Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
069ba1223f | ||
|
|
4c09fa5355 | ||
|
|
626fe6fdee | ||
|
|
01f2c78cd1 | ||
|
|
9ca7d307a4 |
No files matched your search
Generated
+14
-14
@@ -3281,7 +3281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3299,7 +3299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3308,7 +3308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3323,7 +3323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3339,7 +3339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3348,7 +3348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pollster 0.4.0",
|
||||
@@ -3360,7 +3360,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"quick-xml",
|
||||
"serde",
|
||||
@@ -3375,7 +3375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -3393,7 +3393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3401,7 +3401,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3411,7 +3411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3422,7 +3422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3439,7 +3439,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4862,7 +4862,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+14
-2
@@ -59,6 +59,14 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
|
||||
## Physical material data
|
||||
|
||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||
thermal data, per-property sources/conditions and isotropic/orthotropic stability
|
||||
checks through read-only `material.properties.validate`. It does not assign or
|
||||
persist properties, provide a solver or enable rendering; see
|
||||
[the contract and staged follow-ups](docs/physical-material-cards.md).
|
||||
|
||||
## Library foundations (#785, #786, #787)
|
||||
|
||||
- Proposed phases cover offline bundled/project/user packs, physical materials and
|
||||
@@ -270,8 +278,12 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
|
||||
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
|
||||
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
|
||||
certificate and refine within bounded node limits while retaining existing IDs. Singular,
|
||||
self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
|
||||
certificate and refine within bounded node limits while retaining existing IDs.
|
||||
At the node limit, geometric certificate subdivisions accommodate unequal spline parameter
|
||||
speeds without changing stored nodes or relaxing the distance tolerance (#971).
|
||||
Associated offset fit nodes stay hidden from display, picking and snapping; dragging the
|
||||
editable source updates the derived boundary live. Break Association restores point editing.
|
||||
Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
|
||||
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
|
||||
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
|
||||
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
|
||||
|
||||
@@ -956,5 +956,6 @@
|
||||
"Letter Spacing": "Letter Spacing",
|
||||
"Line Spacing": "Line Spacing",
|
||||
"Path": "Path",
|
||||
"sketch_text.on_path": "The text follows its path: the curve places it"
|
||||
"sketch_text.on_path": "The text follows its path: the curve places it",
|
||||
"cmd.material_properties_validate":"Validate Physical Material Data"
|
||||
}
|
||||
@@ -956,5 +956,6 @@
|
||||
"Letter Spacing": "文字間隔",
|
||||
"Line Spacing": "行間",
|
||||
"Path": "パス",
|
||||
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります"
|
||||
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります",
|
||||
"cmd.material_properties_validate":"物理材料データを検証"
|
||||
}
|
||||
@@ -956,5 +956,6 @@
|
||||
"Path": "路径",
|
||||
"sketch_text.on_path": "文字沿路径排列:由曲线放置",
|
||||
"ui.analysis": "分析",
|
||||
"ui.edit_section_analysis": "编辑截面分析"
|
||||
"ui.edit_section_analysis": "编辑截面分析",
|
||||
"cmd.material_properties_validate":"验证物理材料数据"
|
||||
}
|
||||
@@ -31,6 +31,9 @@ pub struct ModelBody {
|
||||
/// The display plan (none when the body has no plan: a mesh body, or one the planner
|
||||
/// refuses), shared like the mesh. A GPU viewport draws it.
|
||||
plan: Arc<OnceLock<Option<Arc<TessPlan>>>>,
|
||||
/// The plan evaluated on the CPU (none without a plan), shared like the mesh: the triangles a
|
||||
/// GPU viewport draws, so a highlight over a face lies exactly on it.
|
||||
plan_mesh: Arc<OnceLock<Option<Arc<Mesh>>>>,
|
||||
/// Identity of this body's content for the evaluation cache: the key of the evaluation
|
||||
/// that produced it (kept while later features pass it through unchanged).
|
||||
key: u64,
|
||||
@@ -59,6 +62,7 @@ impl ModelBody {
|
||||
feature,
|
||||
mesh: Arc::new(OnceLock::new()),
|
||||
plan: Arc::new(OnceLock::new()),
|
||||
plan_mesh: Arc::new(OnceLock::new()),
|
||||
key: 0,
|
||||
names: Default::default(),
|
||||
made: Default::default(),
|
||||
@@ -98,6 +102,14 @@ impl ModelBody {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The display plan's mesh, evaluated on the CPU once: the same triangles a GPU viewport
|
||||
/// evaluates and draws for this body. Highlights over a face drawn from the plan use it, since
|
||||
/// the reference mesh ([`ModelBody::mesh`]) triangulates the face differently and the two
|
||||
/// would fight at equal depth. `None` when the body has no plan.
|
||||
pub fn plan_mesh(&self) -> Option<Arc<Mesh>> {
|
||||
self.plan_mesh.get_or_init(|| self.display_plan().and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new)).clone()
|
||||
}
|
||||
|
||||
/// The display mesh's chord tolerance: 1/1000 of the body's size.
|
||||
fn display_tol(&self) -> f64 {
|
||||
(self.body.size() * 1e-3).max(1e-3)
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod motion;
|
||||
mod name_lookup;
|
||||
pub mod naming;
|
||||
mod params;
|
||||
pub mod physical_material;
|
||||
mod piece_names;
|
||||
mod profile_refs;
|
||||
pub mod progress;
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Candidate engineering data, separate from assigned density and visual appearance.
|
||||
//! Validation establishes numerical consistency, not certification or solver support.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MaterialCard {
|
||||
pub schema_version: u32,
|
||||
pub designation: String,
|
||||
pub family: Family,
|
||||
pub sources: Vec<Source>,
|
||||
pub properties: Vec<Property>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Family {
|
||||
Wood,
|
||||
Polymer,
|
||||
Glass,
|
||||
Metal,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Source {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub kind: SourceKind,
|
||||
/// A caller's claim, never an approval to redistribute or proof of authenticity.
|
||||
pub license: Option<String>,
|
||||
pub reference: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SourceKind {
|
||||
UserSupplied,
|
||||
Measurement,
|
||||
PublicReference,
|
||||
Illustrative,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Property {
|
||||
pub data: PropertyData,
|
||||
pub source_id: String,
|
||||
#[serde(default)]
|
||||
pub conditions: Conditions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Conditions {
|
||||
pub temperature_k: Option<f64>,
|
||||
/// kg water / kg dry material; unlike wet-basis fraction, may exceed one.
|
||||
pub moisture_dry_basis_kg_kg: Option<f64>,
|
||||
pub strain_rate_per_s: Option<f64>,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MaterialAxes {
|
||||
Material123,
|
||||
WoodLrt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PropertyData {
|
||||
Density { kg_m3: f64 },
|
||||
Elasticity { model: Elasticity },
|
||||
TensileStrength { pa: f64 },
|
||||
CompressiveStrength { pa: f64 },
|
||||
YieldStrength { pa: f64 },
|
||||
ShearStrength { pa: f64 },
|
||||
ThermalConductivity { w_m_k: [f64; 3], axes: MaterialAxes },
|
||||
ThermalExpansion { per_k: [f64; 3], axes: MaterialAxes },
|
||||
SpecificHeat { j_kg_k: f64 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "symmetry", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Elasticity {
|
||||
Isotropic {
|
||||
young_modulus_pa: f64,
|
||||
poisson_ratio: f64,
|
||||
},
|
||||
/// E1,E2,E3; G12,G23,G31; nu12,nu23,nu31, with nu_ij = -eps_j/eps_i
|
||||
/// under uniaxial stress in i. Reciprocal ratios follow nu_ij/Ei = nu_ji/Ej.
|
||||
Orthotropic {
|
||||
axes: MaterialAxes,
|
||||
young_moduli_pa: [f64; 3],
|
||||
shear_moduli_pa: [f64; 3],
|
||||
poisson_ratios: [f64; 3],
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct IsotropicModuli {
|
||||
pub shear_pa: f64,
|
||||
pub bulk_pa: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Validation {
|
||||
pub schema_version: u32,
|
||||
pub property_kinds: Vec<String>,
|
||||
pub isotropic_moduli: Option<IsotropicModuli>,
|
||||
pub unspecified_conditions: Vec<String>,
|
||||
pub source_review_required: Vec<String>,
|
||||
pub analysis_solver_available: bool,
|
||||
pub rendering_properties_available: bool,
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> DocError {
|
||||
DocError::Invalid(message.into())
|
||||
}
|
||||
fn text(value: &str, limit: usize, field: &str) -> Result<()> {
|
||||
if value.trim().is_empty() || value.len() > limit || value.chars().any(char::is_control) {
|
||||
return Err(invalid(format!("{field}: expected nonempty text of at most {limit} UTF-8 bytes without controls")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn number(value: f64, low: f64, high: f64, field: &str) -> Result<()> {
|
||||
if !value.is_finite() || value < low || value > high {
|
||||
return Err(invalid(format!("{field}: expected a finite value in [{low}, {high}]")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn positive(value: f64, high: f64, field: &str) -> Result<()> {
|
||||
number(value, 0., high, field)?;
|
||||
if value == 0. {
|
||||
return Err(invalid(format!("{field}: must be positive")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Elasticity {
|
||||
pub fn validate(&self) -> Result<Option<IsotropicModuli>> {
|
||||
match self {
|
||||
Self::Isotropic { young_modulus_pa: e, poisson_ratio: nu } => {
|
||||
positive(*e, 1e15, "young_modulus_pa")?;
|
||||
if !e.recip().is_finite() {
|
||||
return Err(invalid("young_modulus_pa produces unrepresentable compliance"));
|
||||
}
|
||||
if !nu.is_finite() || *nu <= -1. || *nu >= 0.5 {
|
||||
return Err(invalid("poisson_ratio: isotropic stability requires -1 < nu < 0.5"));
|
||||
}
|
||||
let result = IsotropicModuli { shear_pa: e / (2. * (1. + nu)), bulk_pa: e / (3. * (1. - 2. * nu)) };
|
||||
if !result.shear_pa.is_finite()
|
||||
|| !result.bulk_pa.is_finite()
|
||||
|| result.shear_pa <= 0.
|
||||
|| result.bulk_pa <= 0.
|
||||
|| !result.shear_pa.recip().is_finite()
|
||||
|| !result.bulk_pa.recip().is_finite()
|
||||
{
|
||||
return Err(invalid("elastic moduli exceed numeric limits"));
|
||||
}
|
||||
Ok(Some(result))
|
||||
}
|
||||
Self::Orthotropic { young_moduli_pa: e, shear_moduli_pa: g, poisson_ratios: nu, .. } => {
|
||||
for value in e.iter().chain(g) {
|
||||
positive(*value, 1e15, "orthotropic modulus_pa")?;
|
||||
if !value.recip().is_finite() {
|
||||
return Err(invalid("orthotropic modulus produces unrepresentable compliance"));
|
||||
}
|
||||
}
|
||||
for value in nu {
|
||||
number(*value, -1e6, 1e6, "orthotropic poisson_ratio")?;
|
||||
}
|
||||
// Diagonal scaling of the reciprocal, symmetric normal compliance
|
||||
// gives unit diagonal and these off-diagonal entries. Sylvester's
|
||||
// criterion proves positive definiteness without dimensional cutoffs.
|
||||
let [e1, e2, e3] = *e;
|
||||
let [n12, n23, n31] = *nu;
|
||||
let [a, b, c] = [-n12 * (e2.sqrt() / e1.sqrt()), -n23 * (e3.sqrt() / e2.sqrt()), -n31 * (e1.sqrt() / e3.sqrt())];
|
||||
let determinant = 1. + 2. * a * b * c - a * a - b * b - c * c;
|
||||
if ![a, b, c, determinant].iter().all(|v| v.is_finite()) || 1. - a * a <= 0. || determinant <= 0. {
|
||||
return Err(invalid("orthotropic compliance must be finite and positive definite"));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PropertyData {
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Density { .. } => "density",
|
||||
Self::Elasticity { .. } => "elasticity",
|
||||
Self::TensileStrength { .. } => "tensile_strength",
|
||||
Self::CompressiveStrength { .. } => "compressive_strength",
|
||||
Self::YieldStrength { .. } => "yield_strength",
|
||||
Self::ShearStrength { .. } => "shear_strength",
|
||||
Self::ThermalConductivity { .. } => "thermal_conductivity",
|
||||
Self::ThermalExpansion { .. } => "thermal_expansion",
|
||||
Self::SpecificHeat { .. } => "specific_heat",
|
||||
}
|
||||
}
|
||||
fn validate(&self) -> Result<Option<IsotropicModuli>> {
|
||||
match self {
|
||||
Self::Density { kg_m3 } => positive(*kg_m3, 1e6, "density.kg_m3")?,
|
||||
Self::Elasticity { model } => return model.validate(),
|
||||
Self::TensileStrength { pa } | Self::CompressiveStrength { pa } | Self::YieldStrength { pa } | Self::ShearStrength { pa } => {
|
||||
positive(*pa, 1e15, self.kind())?
|
||||
}
|
||||
Self::ThermalConductivity { w_m_k, .. } => {
|
||||
for k in w_m_k {
|
||||
positive(*k, 1e9, "thermal_conductivity.w_m_k")?;
|
||||
}
|
||||
}
|
||||
Self::ThermalExpansion { per_k, .. } => {
|
||||
for a in per_k {
|
||||
number(*a, -1., 1., "thermal_expansion.per_k")?;
|
||||
}
|
||||
}
|
||||
Self::SpecificHeat { j_kg_k } => positive(*j_kg_k, 1e9, "specific_heat.j_kg_k")?,
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl MaterialCard {
|
||||
pub fn validate(&self) -> Result<Validation> {
|
||||
if self.schema_version != 1 {
|
||||
return Err(invalid("unsupported material card schema_version (expected 1)"));
|
||||
}
|
||||
text(&self.designation, 256, "designation")?;
|
||||
if self.sources.len() > 32 || self.properties.len() > 16 {
|
||||
return Err(invalid("material card exceeds 32 sources or 16 properties"));
|
||||
}
|
||||
let mut sources = BTreeSet::new();
|
||||
for source in &self.sources {
|
||||
text(&source.id, 128, "source.id")?;
|
||||
text(&source.title, 256, "source.title")?;
|
||||
if let Some(value) = &source.license {
|
||||
text(value, 512, "source.license")?;
|
||||
}
|
||||
if let Some(value) = &source.reference {
|
||||
text(value, 2048, "source.reference")?;
|
||||
}
|
||||
if !sources.insert(source.id.clone()) {
|
||||
return Err(invalid("duplicate source id"));
|
||||
}
|
||||
}
|
||||
let mut kinds = BTreeSet::new();
|
||||
let mut used_sources = BTreeSet::new();
|
||||
let mut unspecified = Vec::new();
|
||||
let mut isotropic_moduli = None;
|
||||
for property in &self.properties {
|
||||
let kind = property.data.kind();
|
||||
if !kinds.insert(kind.to_string()) {
|
||||
return Err(invalid(format!("duplicate property kind: {kind}")));
|
||||
}
|
||||
if !sources.contains(&property.source_id) {
|
||||
return Err(invalid(format!("{kind}: unknown source_id")));
|
||||
}
|
||||
used_sources.insert(property.source_id.clone());
|
||||
let conditions = &property.conditions;
|
||||
if let Some(t) = conditions.temperature_k {
|
||||
positive(t, 20000., "temperature_k")?;
|
||||
}
|
||||
if let Some(m) = conditions.moisture_dry_basis_kg_kg {
|
||||
number(m, 0., 1e6, "moisture_dry_basis_kg_kg")?;
|
||||
}
|
||||
if let Some(r) = conditions.strain_rate_per_s {
|
||||
positive(r, 1e12, "strain_rate_per_s")?;
|
||||
}
|
||||
if let Some(state) = &conditions.state {
|
||||
text(state, 1024, "conditions.state")?;
|
||||
}
|
||||
if conditions.temperature_k.is_none()
|
||||
|| conditions.state.is_none()
|
||||
|| (self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none())
|
||||
|| (self.family == Family::Polymer && conditions.strain_rate_per_s.is_none())
|
||||
{
|
||||
unspecified.push(kind.to_string());
|
||||
}
|
||||
if let Some(moduli) = property.data.validate()? {
|
||||
isotropic_moduli = Some(moduli);
|
||||
}
|
||||
}
|
||||
Ok(Validation {
|
||||
schema_version: 1,
|
||||
property_kinds: kinds.into_iter().collect(),
|
||||
isotropic_moduli,
|
||||
unspecified_conditions: unspecified,
|
||||
source_review_required: used_sources.into_iter().collect(),
|
||||
analysis_solver_available: false,
|
||||
rendering_properties_available: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound JSON traversal before cloning/deserializing it. Metadata and numeric
|
||||
/// bounds are admission limits, not claims about a real material's valid range.
|
||||
pub fn validate_card(value: &Value) -> Result<Validation> {
|
||||
let mut pending = vec![(value, 0usize)];
|
||||
let (mut nodes, mut bytes) = (0usize, 0usize);
|
||||
while let Some((value, depth)) = pending.pop() {
|
||||
nodes += 1;
|
||||
bytes += 32;
|
||||
if nodes > 2048 || depth > 12 {
|
||||
return Err(invalid("material card exceeds JSON node/depth budget"));
|
||||
}
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if s.len() > 256 * 1024 {
|
||||
return Err(invalid("material card string exceeds admission budget"));
|
||||
}
|
||||
bytes += s.len();
|
||||
}
|
||||
Value::Array(a) => {
|
||||
if a.len() > 32 {
|
||||
return Err(invalid("material card array exceeds 32 entries"));
|
||||
}
|
||||
pending.extend(a.iter().map(|v| (v, depth + 1)));
|
||||
}
|
||||
Value::Object(o) => {
|
||||
if o.len() > 32 {
|
||||
return Err(invalid("material card object exceeds 32 fields"));
|
||||
}
|
||||
for (key, v) in o {
|
||||
if key.len() > 256 * 1024 {
|
||||
return Err(invalid("material card key exceeds admission budget"));
|
||||
}
|
||||
bytes += key.len();
|
||||
pending.push((v, depth + 1));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if bytes > 256 * 1024 {
|
||||
return Err(invalid("material card exceeds 256 KiB admission budget"));
|
||||
}
|
||||
}
|
||||
let card: MaterialCard = serde_json::from_value(value.clone()).map_err(|e| invalid(format!("material card: {e}")))?;
|
||||
card.validate()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "physical_material_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,115 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn card(data: Value) -> Value {
|
||||
json!({"schema_version":1,"designation":"Original synthetic fixture","family":"wood",
|
||||
"sources":[{"id":"test","title":"Analytic synthetic data, not a material specification","kind":"illustrative","license":"MIT OR Apache-2.0"}],
|
||||
"properties":[{"data":data,"source_id":"test"}]})
|
||||
}
|
||||
fn isotropic(e: f64, nu: f64) -> Value {
|
||||
json!({"kind":"elasticity","model":{"symmetry":"isotropic","young_modulus_pa":e,"poisson_ratio":nu}})
|
||||
}
|
||||
fn orthotropic(e: [f64; 3], nu: [f64; 3]) -> Value {
|
||||
json!({"kind":"elasticity","model":{"symmetry":"orthotropic","axes":"wood_lrt",
|
||||
"young_moduli_pa":e,"shear_moduli_pa":[4e9,4e9,4e9],"poisson_ratios":nu}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isotropic_moduli_match_closed_form_and_auxetic_values_are_valid() {
|
||||
let r = validate_card(&card(isotropic(12e9, 0.25))).unwrap();
|
||||
assert_eq!(r.isotropic_moduli, Some(IsotropicModuli { shear_pa: 4.8e9, bulk_pa: 8e9 }));
|
||||
let r = validate_card(&card(isotropic(12e9, -0.5))).unwrap();
|
||||
assert_eq!(r.isotropic_moduli, Some(IsotropicModuli { shear_pa: 12e9, bulk_pa: 2e9 }));
|
||||
for nu in [-2., -1., 0.5, 0.6] {
|
||||
assert!(validate_card(&card(isotropic(12e9, nu))).is_err());
|
||||
}
|
||||
for e in [0., -1., 1e16] {
|
||||
assert!(validate_card(&card(isotropic(e, 0.25))).is_err());
|
||||
}
|
||||
assert!(Elasticity::Isotropic { young_modulus_pa: f64::NAN, poisson_ratio: 0.25 }.validate().is_err());
|
||||
assert!(Elasticity::Isotropic { young_modulus_pa: 1e-309, poisson_ratio: 0.25 }.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orthotropy_uses_reciprocity_and_full_positive_definiteness() {
|
||||
// With equal E and nu=.25 this is the same normal compliance as isotropy.
|
||||
assert!(validate_card(&card(orthotropic([12e9; 3], [0.25; 3]))).is_ok());
|
||||
// A general orthotropic ratio may exceed .5: reciprocal nu21 is .08 here.
|
||||
assert!(validate_card(&card(orthotropic([10e9, 1e9, 1e9], [0.8, 0., 0.]))).is_ok());
|
||||
// Every 2x2 principal minor is positive; the full determinant is negative.
|
||||
assert!(validate_card(&card(orthotropic([12e9; 3], [0.6; 3]))).is_err());
|
||||
assert!(validate_card(&card(orthotropic([10e9, 1e9, 1e9], [4., 0., 0.]))).is_err());
|
||||
assert!(validate_card(&card(orthotropic([0., 1e9, 1e9], [0.; 3]))).is_err());
|
||||
let mut bad = card(orthotropic([12e9; 3], [0.25; 3]));
|
||||
bad["properties"][0]["data"]["model"]["shear_moduli_pa"] = json!([4e9, 0., 4e9]);
|
||||
assert!(validate_card(&bad).is_err());
|
||||
// Scaling all moduli changes units of compliance, not its stability verdict.
|
||||
assert!(validate_card(&card(orthotropic([12.; 3], [0.25; 3]))).is_ok());
|
||||
assert!(validate_card(&card(orthotropic([1e-300, 1e15, 1.], [0.; 3]))).is_ok());
|
||||
assert!(validate_card(&card(orthotropic([1e-309, 1., 1.], [0.; 3]))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditions_missing_values_and_source_claims_remain_explicit() {
|
||||
let mut value = card(json!({"kind":"density","kg_m3":600.}));
|
||||
let r = validate_card(&value).unwrap();
|
||||
assert_eq!(r.unspecified_conditions, vec!["density"]);
|
||||
assert_eq!(r.source_review_required, vec!["test"]); // Even an explicit license is unverified.
|
||||
assert!(!r.analysis_solver_available && !r.rendering_properties_available);
|
||||
assert!(r.isotropic_moduli.is_none());
|
||||
value["properties"][0]["conditions"] = json!({"temperature_k":293.15,"moisture_dry_basis_kg_kg":1.5,"state":"synthetic wet specimen"});
|
||||
assert!(validate_card(&value).unwrap().unspecified_conditions.is_empty());
|
||||
value["family"] = json!("polymer");
|
||||
value["properties"][0]["conditions"]["moisture_dry_basis_kg_kg"] = json!(150.);
|
||||
value["properties"][0]["conditions"]["strain_rate_per_s"] = json!(1.);
|
||||
assert!(validate_card(&value).unwrap().unspecified_conditions.is_empty());
|
||||
value["properties"] = json!([]);
|
||||
let r = validate_card(&value).unwrap();
|
||||
assert!(r.property_kinds.is_empty() && r.isotropic_moduli.is_none());
|
||||
let parsed: MaterialCard = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::from_value::<MaterialCard>(serde_json::to_value(&parsed).unwrap()).unwrap(), parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thermal_properties_accept_negative_expansion_but_not_negative_conductivity() {
|
||||
assert!(validate_card(&card(json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}))).is_ok());
|
||||
assert!(validate_card(&card(json!({"kind":"thermal_conductivity","w_m_k":[1.,0.,1.],"axes":"material123"}))).is_err());
|
||||
assert!(validate_card(&card(json!({"kind":"specific_heat","j_kg_k":500.}))).is_ok());
|
||||
for kind in ["tensile_strength", "compressive_strength", "yield_strength", "shear_strength"] {
|
||||
assert!(validate_card(&card(json!({"kind":kind,"pa":1e8}))).is_ok());
|
||||
assert!(validate_card(&card(json!({"kind":kind,"pa":-1.}))).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_units_sources_fields_and_budgets_are_rejected() {
|
||||
let valid = card(isotropic(12e9, 0.25));
|
||||
let mut bad = valid.clone();
|
||||
bad["properties"][0]["data"]["model"]["young_modulus_mpa"] = json!(12.);
|
||||
assert!(validate_card(&bad).is_err());
|
||||
let mut bad = valid.clone();
|
||||
bad["properties"][0]["source_id"] = json!("unknown");
|
||||
assert!(validate_card(&bad).is_err());
|
||||
let mut bad = valid.clone();
|
||||
bad["properties"] = json!([valid["properties"][0], valid["properties"][0]]);
|
||||
assert!(validate_card(&bad).is_err());
|
||||
let mut bad = valid.clone();
|
||||
bad["sources"] = json!([valid["sources"][0], valid["sources"][0]]);
|
||||
assert!(validate_card(&bad).is_err());
|
||||
let mut bad = valid.clone();
|
||||
bad["schema_version"] = json!(2);
|
||||
assert!(validate_card(&bad).is_err());
|
||||
let mut bad = valid.clone();
|
||||
bad["designation"] = json!("x".repeat(257));
|
||||
assert!(validate_card(&bad).is_err());
|
||||
let mut bad = valid.clone();
|
||||
bad["properties"][0]["conditions"] = json!({"moisture_dry_basis_kg_kg":-1.});
|
||||
assert!(validate_card(&bad).is_err());
|
||||
assert!(validate_card(&json!(vec![Value::Null; 33])).is_err());
|
||||
assert!(validate_card(&json!("x".repeat(256 * 1024 + 1))).is_err());
|
||||
let mut deep = Value::Null;
|
||||
for _ in 0..14 {
|
||||
deep = json!([deep]);
|
||||
}
|
||||
assert!(validate_card(&deep).is_err());
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Read-only validation of candidate engineering data; no assignment or simulation.
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", validate)
|
||||
.icon("material")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("card: schema_version 1 engineering material card in SI units. Returns numerical consistency, derived isotropic moduli, missing conditions and sources needing review. Read-only; does not assign/save properties or provide an analysis solver. See docs/physical-material-cards.md")];
|
||||
|
||||
fn validate(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
let object = params
|
||||
.as_object()
|
||||
.filter(|o| o.len() == 1 && o.contains_key("card"))
|
||||
.ok_or_else(|| bad("material.properties.validate", "expected {card: ...}"))?;
|
||||
let card = object.get("card").ok_or_else(|| bad("material.properties.validate", "missing card"))?;
|
||||
let result = solidcraft_doc::physical_material::validate_card(card).map_err(|e| bad("material.properties.validate", e.to_string()))?;
|
||||
Ok(json!({"valid":true,"validation":result}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn command_reports_analytic_moduli_and_refuses_unstable_data() {
|
||||
let mut s = Session::default();
|
||||
let mut card = json!({"schema_version":1,"designation":"Synthetic elastic fixture","family":"other",
|
||||
"sources":[{"id":"fixture","title":"Original analytic fixture","kind":"illustrative"}],
|
||||
"properties":[{"source_id":"fixture","data":{"kind":"elasticity","model":{
|
||||
"symmetry":"isotropic","young_modulus_pa":12e9,"poisson_ratio":0.25}}}]});
|
||||
let result = s.execute("material.properties.validate", &json!({"card":card})).unwrap();
|
||||
assert_eq!(result["validation"]["isotropic_moduli"], json!({"shear_pa":4.8e9,"bulk_pa":8e9}));
|
||||
assert_eq!(result["validation"]["source_review_required"], json!(["fixture"]));
|
||||
assert_eq!(result["validation"]["rendering_properties_available"], false);
|
||||
card["properties"][0]["data"]["model"]["poisson_ratio"] = json!(0.6);
|
||||
assert!(s.execute("material.properties.validate", &json!({"card":card})).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn validation_preserves_document_history_selection_and_evaluation() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":20,"height":5})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let doc = s.doc.clone();
|
||||
let undo = s.undo.clone();
|
||||
let redo = s.redo.clone();
|
||||
let before = (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale(), s.camera, s.selection.clone());
|
||||
let card = json!({"schema_version":1,"designation":"Synthetic empty card","family":"other","sources":[],"properties":[]});
|
||||
let result = s.execute("material.properties.validate", &json!({"card":card})).unwrap();
|
||||
assert_eq!(result["validation"]["analysis_solver_available"], false);
|
||||
for p in [json!({}), json!({"card":card,"assign":true}), json!({"card":{"schema_version":9}})] {
|
||||
assert!(s.execute("material.properties.validate", &p).is_err());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale(), s.camera, s.selection.clone()));
|
||||
for (a, b) in [(&undo, &s.undo), (&redo, &s.redo)] {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b) {
|
||||
assert!(Arc::ptr_eq(&a.doc, &b.doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_does_not_settle_live_geometry() {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane":"XY"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[25,20]})),
|
||||
("sketch.dimension", json!({"entities":["l1"],"value":20})),
|
||||
("sketch.dimension", json!({"entities":["l2"],"value":15})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance":5})),
|
||||
("sketch.edit", json!({"sketch":"Sketch1"})),
|
||||
("sketch.drag", json!({"entity":"l1.start","from":[5,5],"to":[4,5],"live":true})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap();
|
||||
}
|
||||
assert!(s.model.is_stale());
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.evaluations, s.active_sketch, s.undo.len(), s.redo.len());
|
||||
let card = json!({"schema_version":1,"designation":"Synthetic","family":"other","sources":[],"properties":[]});
|
||||
s.execute("material.properties.validate", &json!({"card":card})).unwrap();
|
||||
assert!(s.execute("material.properties.validate", &json!({"card":false})).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert!(s.model.is_stale());
|
||||
assert_eq!(before, (s.revision, s.evaluations, s.active_sketch, s.undo.len(), s.redo.len()));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ mod config;
|
||||
mod file;
|
||||
mod inspect;
|
||||
pub(crate) mod joints;
|
||||
mod material_properties;
|
||||
mod materials;
|
||||
mod measure_sel;
|
||||
mod mesh;
|
||||
@@ -248,6 +249,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(materials::COMMANDS.iter());
|
||||
v.extend(material_properties::COMMANDS.iter());
|
||||
v.extend(section::COMMANDS.iter());
|
||||
v.extend(view_navigation::COMMANDS.iter());
|
||||
v.extend(diagnostics::COMMANDS.iter());
|
||||
|
||||
@@ -723,6 +723,66 @@ fn offset_free_form_curves() {
|
||||
assert_eq!(ids(&r["curves"]).len(), 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irregular_closed_spline_offset_certificate_edits_undoes_and_reopens() {
|
||||
// Public-safe reproduction of #971: the fit uses chord-length parameters,
|
||||
// while its exact offset's speed varies across eight unequal source spans.
|
||||
let mut s = new_sketch();
|
||||
run(
|
||||
&mut s,
|
||||
"sketch.spline.fit_point",
|
||||
json!({
|
||||
"points":[[-17,15],[0,8],[17,13],[15,0],[5,-7],[-1,-14],[-9,-1],[-21,-1]],
|
||||
"periodic":true
|
||||
}),
|
||||
);
|
||||
let original = sketch(&s);
|
||||
let before = s.doc.to_json();
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":["s1"],"distance":2.0,"chain":true}));
|
||||
let output = result["curves"][0].as_str().unwrap().to_string();
|
||||
let parameter = result["param"].as_str().unwrap().to_string();
|
||||
let point_ids = sketch(&s).curves[1].kind.point_ids();
|
||||
let verify = |s: &Session, distance: f64| {
|
||||
let state = s.world_state();
|
||||
let sk = &state.sketch(1).unwrap().sketch;
|
||||
assert_eq!(sk.curves[0], original.curves[0]);
|
||||
assert_eq!(&sk.points[..original.points.len()], &original.points);
|
||||
let index = sk.curve_index(&output).unwrap();
|
||||
assert_eq!(sk.curves[index].kind.point_ids(), point_ids);
|
||||
assert!(matches!(sk.curves[index].kind, CurveKind::Spline { periodic: true, .. }));
|
||||
let exact = sk.exact_segs(0).unwrap();
|
||||
let fitted = sk.exact_segs(index).unwrap();
|
||||
let mut max_error = 0.0_f64;
|
||||
for curve in &fitted {
|
||||
for j in 0..=8 {
|
||||
let p = curve.point_at(j as f64 / 8.0);
|
||||
let normal_distance = exact.iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min);
|
||||
max_error = max_error.max((normal_distance - distance).abs());
|
||||
}
|
||||
}
|
||||
assert!(max_error < solidcraft_sketch::OFFSET_TOLERANCE, "normal-distance error {max_error}");
|
||||
eprintln!("#971 offset {distance} mm: maximum sampled normal-distance error {max_error} mm");
|
||||
assert_eq!(sk.freeform_offsets.len(), 1);
|
||||
assert_eq!(sk.freeform_offsets[0].divisions, 32);
|
||||
sk.validate_freeform_offsets().unwrap();
|
||||
sk.validate_periodic_splines().unwrap();
|
||||
assert_eq!(state.sketch(1).unwrap().profiles.len(), 2);
|
||||
};
|
||||
verify(&s, 2.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
verify(&s, 2.0);
|
||||
run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"}));
|
||||
verify(&s, 3.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
verify(&s, 2.0);
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
verify(&reopened, 2.0);
|
||||
run(&mut reopened, "parameters.change", json!({"name":parameter,"expression":"3 mm"}));
|
||||
verify(&reopened, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_offset_follows_dimension_drag_reopen_break_and_undo() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
@@ -169,13 +169,41 @@ pub fn sketch_curve_visible(sk: &Sketch, i: usize) -> bool {
|
||||
!(c.construction && sk.view.hide_construction)
|
||||
}
|
||||
|
||||
/// Should sketch point `i` be drawn? Text outlines' points are not (they are not picked or
|
||||
/// constrained individually).
|
||||
pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
|
||||
match sk.points.get(i).and_then(|p| p.link.as_deref()) {
|
||||
Some(l) => sk.link(l).is_none_or(|l| l.kind != solidcraft_sketch::LinkKind::Text),
|
||||
None => true,
|
||||
/// Point roles shown and picked individually. Associated offsets keep their generated
|
||||
/// fit nodes private until their association is broken. Build once per sketch/frame.
|
||||
pub struct SketchPointVisibility<'a> {
|
||||
hidden: std::collections::HashSet<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> SketchPointVisibility<'a> {
|
||||
pub fn new(sk: &'a Sketch) -> Self {
|
||||
let outputs: std::collections::HashSet<&str> = sk.freeform_offsets.iter().map(|o| o.output.as_str()).collect();
|
||||
// A repeated link ID names its first link, as Sketch::link does.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let text: std::collections::HashSet<&str> = sk
|
||||
.links
|
||||
.iter()
|
||||
.filter(|l| seen.insert(l.id.as_str()))
|
||||
.filter(|l| l.kind == solidcraft_sketch::LinkKind::Text)
|
||||
.map(|l| l.id.as_str())
|
||||
.collect();
|
||||
let mut hidden: std::collections::HashSet<&str> =
|
||||
sk.points.iter().filter(|p| p.link.as_deref().is_some_and(|l| text.contains(l))).map(|p| p.id.as_str()).collect();
|
||||
for curve in sk.curves.iter().filter(|c| outputs.contains(c.id.as_str())) {
|
||||
hidden.extend(curve.kind.point_ids().into_iter().filter_map(|i| sk.points.get(i).map(|p| p.id.as_str())));
|
||||
}
|
||||
Self { hidden }
|
||||
}
|
||||
|
||||
pub fn takes(&self, point: &solidcraft_sketch::SPoint) -> bool {
|
||||
!self.hidden.contains(point.id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Should sketch point `i` be drawn or picked individually? Text outlines and derived
|
||||
/// offset fit nodes are private. Batch callers use [`SketchPointVisibility`].
|
||||
pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
|
||||
sk.points.get(i).is_some_and(|p| SketchPointVisibility::new(sk).takes(p))
|
||||
}
|
||||
|
||||
/// The scene for a headless render, cut by the session's section.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Persistent free-form offsets with a whole-span normal-distance error certificate.
|
||||
//! The certificate bounds the second derivative of the exact normal offset and of
|
||||
//! the actual cubic interpolant (natural for legacy output, periodic for new closed output). The endpoint interpolation theorem then
|
||||
//! bounds their distance everywhere, rather than just at selected samples.
|
||||
//! bounds their distance everywhere, rather than just at selected samples. At the
|
||||
//! node limit, interior splits align the curves geometrically instead of requiring
|
||||
//! identical parameter speeds; stored nodes and interpolation remain unchanged.
|
||||
use crate::{CurveKind, Sketch, SketchError, fit_beziers_mode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::{Seg2, Vec2};
|
||||
@@ -14,6 +16,11 @@ struct Fit {
|
||||
tolerance: f64,
|
||||
periodic: bool,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Certificate {
|
||||
tolerance: f64,
|
||||
align_midpoints: bool,
|
||||
}
|
||||
type Result<T> = std::result::Result<T, SketchError>;
|
||||
fn bad(message: &str) -> SketchError {
|
||||
SketchError::Invalid(message.into())
|
||||
@@ -202,7 +209,8 @@ impl H {
|
||||
/// Subdivision tightens the proof, not the stored geometry. Endpoint discrepancies
|
||||
/// are measured against the exact rational normal, then the second derivative
|
||||
/// interpolation bound certifies the full intervening interval.
|
||||
fn certify(h: &H, cubic: &[Vec2; 4], distance: f64, tolerance: f64, depth: u8, budget: &mut usize) -> Result<()> {
|
||||
fn certify(h: &H, cubic: &[Vec2; 4], distance: f64, proof: Certificate, depth: u8, budget: &mut usize) -> Result<()> {
|
||||
let tolerance = proof.tolerance;
|
||||
if *budget == 0 {
|
||||
return Err(bad("offset certificate work limit exceeded"));
|
||||
}
|
||||
@@ -220,13 +228,24 @@ fn certify(h: &H, cubic: &[Vec2; 4], distance: f64, tolerance: f64, depth: u8, b
|
||||
}
|
||||
let (left, right) = h.split(0.5);
|
||||
let curve = Seg2::Cubic { p0: cubic[0], p1: cubic[1], p2: cubic[2], p3: cubic[3] };
|
||||
let (a, b) = curve.split_half();
|
||||
// A chord-length fit need not have the source's parameter speed. When node
|
||||
// refinement is exhausted, pair the exact offset midpoint with a point on
|
||||
// the fitted cubic instead of assuming its parameter is 0.5. Each interior
|
||||
// split defines an increasing, piecewise-affine correspondence. The same
|
||||
// endpoint/second-derivative theorem applies on every pair of restricted
|
||||
// spans, proving a whole-curve distance bound in both directions. Projection
|
||||
// only chooses the split; its accuracy is checked by the certificate itself.
|
||||
let split = if proof.align_midpoints { curve.closest_param(left.normal_end(true, distance)?) } else { 0.5 };
|
||||
if !split.is_finite() || !(1e-9..=1.0 - 1e-9).contains(&split) {
|
||||
return Err(bad("offset spline has no interior certificate correspondence"));
|
||||
}
|
||||
let (a, b) = curve.split_at(split);
|
||||
let controls = |s: Seg2| match s {
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => Ok([p0, p1, p2, p3]),
|
||||
_ => Err(bad("invalid cubic certificate")),
|
||||
};
|
||||
certify(&left, &controls(a)?, distance, tolerance, depth + 1, budget)?;
|
||||
certify(&right, &controls(b)?, distance, tolerance, depth + 1, budget)
|
||||
certify(&left, &controls(a)?, distance, proof, depth + 1, budget)?;
|
||||
certify(&right, &controls(b)?, distance, proof, depth + 1, budget)
|
||||
}
|
||||
|
||||
fn normal_point(seg: &Seg2, t: f64, d: f64) -> Result<Vec2> {
|
||||
@@ -345,7 +364,12 @@ fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize,
|
||||
let cubics = fit_beziers_mode(&points, periodic && closed)?;
|
||||
let allowance = (*work).min(65_536);
|
||||
let mut budget = allowance;
|
||||
let result = restricted.iter().zip(&cubics).try_for_each(|(h, c)| certify(h, c, d, tolerance, 0, &mut budget));
|
||||
let mut result =
|
||||
restricted.iter().zip(&cubics).try_for_each(|(h, c)| certify(h, c, d, Certificate { tolerance, align_midpoints: false }, 0, &mut budget));
|
||||
if result.is_err() && (divisions == 256 || pieces.len().saturating_mul(divisions.saturating_mul(2)) >= crate::MAX_SPLINE_POINTS) {
|
||||
result =
|
||||
restricted.iter().zip(&cubics).try_for_each(|(h, c)| certify(h, c, d, Certificate { tolerance, align_midpoints: true }, 0, &mut budget));
|
||||
}
|
||||
*work -= allowance - budget;
|
||||
result?;
|
||||
verify_simple(&points, &cubics, closed)?;
|
||||
@@ -567,6 +591,66 @@ impl Sketch {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn certified_offset_irregular_closed_spline_at_node_limit() {
|
||||
let mut sk = Sketch::new();
|
||||
let pts = [(-17.0, 15.0), (0.0, 8.0), (17.0, 13.0), (15.0, 0.0), (5.0, -7.0), (-1.0, -14.0), (-9.0, -1.0), (-21.0, -1.0), (-17.0, 15.0)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap())
|
||||
.collect();
|
||||
let source = sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap();
|
||||
let original = sk.clone();
|
||||
for (forward, distance) in [(true, 2.0), (false, -2.0)] {
|
||||
let mut sk = original.clone();
|
||||
let output = sk.add_freeform_offset(source, forward, distance).unwrap();
|
||||
assert_eq!(sk.freeform_offsets[0].divisions, 32);
|
||||
assert_eq!(&sk.points[..original.points.len()], &original.points);
|
||||
assert_eq!(sk.curves[source], original.curves[source]);
|
||||
let exact = sk.exact_segs(source).unwrap();
|
||||
let fitted = sk.exact_segs(output).unwrap();
|
||||
assert_eq!(fitted.first().unwrap().start(), fitted.last().unwrap().end());
|
||||
let mut max_error = 0.0_f64;
|
||||
for (i, piece) in exact.iter().enumerate() {
|
||||
for j in 0..=128 {
|
||||
let t = j as f64 / 128.0;
|
||||
let want = piece.point_at(t) + piece.derivative(t).normalized().unwrap().perp() * 2.0;
|
||||
let span = i * 32 + (j / 4).min(31);
|
||||
let span = if forward { span } else { fitted.len() - 1 - span };
|
||||
let curve = fitted[span];
|
||||
max_error = max_error.max(curve.point_at(curve.closest_param(want)).dist(want));
|
||||
}
|
||||
}
|
||||
assert!(max_error < OFFSET_TOLERANCE, "normal-offset error {max_error}");
|
||||
for curve in &fitted {
|
||||
for j in 0..=8 {
|
||||
let p = curve.point_at(j as f64 / 8.0);
|
||||
let distance = exact.iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min);
|
||||
assert!((distance - 2.0).abs() < OFFSET_TOLERANCE, "normal distance {distance}");
|
||||
}
|
||||
}
|
||||
let saved = serde_json::to_string(&sk).unwrap();
|
||||
let mut reopened: Sketch = serde_json::from_str(&saved).unwrap();
|
||||
reopened.refresh_offsets().unwrap();
|
||||
assert_eq!(reopened, sk);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometric_certificate_aligns_speed_but_keeps_error_regularity_and_work_limits() {
|
||||
let source = H::from_seg(&Seg2::Line { a: Vec2::ZERO, b: Vec2::X }).unwrap();
|
||||
// Analytically identical straight lines, traversed at different speeds.
|
||||
let cubic = [Vec2::new(0.0, 2.0), Vec2::new(0.1, 2.0), Vec2::new(0.2, 2.0), Vec2::new(1.0, 2.0)];
|
||||
let mut budget = 65_536;
|
||||
assert!(certify(&source, &cubic, 2.0, Certificate { tolerance: OFFSET_TOLERANCE, align_midpoints: false }, 0, &mut budget).is_err());
|
||||
let proof = Certificate { tolerance: OFFSET_TOLERANCE, align_midpoints: true };
|
||||
certify(&source, &cubic, 2.0, proof, 0, &mut budget).unwrap();
|
||||
assert!(certify(&source, &cubic, 2.0, proof, 0, &mut 0).is_err());
|
||||
let bowed = [cubic[0], cubic[1] + Vec2::Y, cubic[2] + Vec2::Y, cubic[3]];
|
||||
assert!(certify(&source, &bowed, 2.0, proof, 0, &mut budget).is_err());
|
||||
let stationary = H::from_seg(&Seg2::Line { a: Vec2::ZERO, b: Vec2::ZERO }).unwrap();
|
||||
assert!(certify(&stationary, &cubic, 2.0, proof, 0, &mut budget).is_err());
|
||||
}
|
||||
|
||||
fn ellipse() -> (Sketch, usize) {
|
||||
let mut sketch = Sketch::new();
|
||||
let center = sketch.add_point(Vec2::ZERO, None).unwrap();
|
||||
|
||||
@@ -905,7 +905,7 @@ pub(crate) fn snaps_to_points(accept: Accept) -> bool {
|
||||
}
|
||||
|
||||
/// Which of a sketch's points a face place snaps to (the Single hole's): the points of geometry
|
||||
/// the sketch shows. Not a text outline's points, which are never drawn or picked on their own
|
||||
/// the sketch shows. Not generated offset nodes or a text outline's points, which are never drawn or picked on their own
|
||||
/// (as `view::sketch_point_visible` has it), nor projected points while the sketch hides
|
||||
/// projected geometry (Show Projected off), whose curves aren't picked either. Hidden sketches
|
||||
/// offer no points at all. Show Points doesn't count: it only hides the markers of the sketch
|
||||
@@ -913,35 +913,17 @@ pub(crate) fn snaps_to_points(accept: Accept) -> bool {
|
||||
pub(crate) struct SnapPoints<'a> {
|
||||
/// Show Projected is off: no linked point is taken.
|
||||
hide_linked: bool,
|
||||
/// The text sources' link ids (looked up once, however many points and links there are).
|
||||
text: std::collections::HashSet<&'a str>,
|
||||
shown: solidcraft_engine::view::SketchPointVisibility<'a>,
|
||||
}
|
||||
|
||||
impl<'a> SnapPoints<'a> {
|
||||
pub(crate) fn of(sk: &'a solidcraft_engine::sketch::Sketch) -> SnapPoints<'a> {
|
||||
let hide_linked = sk.view.hide_projected;
|
||||
let text = if hide_linked {
|
||||
Default::default()
|
||||
} else {
|
||||
// An id names its first link, as `Sketch::link` finds it.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
sk.links
|
||||
.iter()
|
||||
.filter(|l| seen.insert(l.id.as_str()))
|
||||
.filter(|l| l.kind == solidcraft_engine::sketch::LinkKind::Text)
|
||||
.map(|l| l.id.as_str())
|
||||
.collect()
|
||||
};
|
||||
SnapPoints { hide_linked, text }
|
||||
pub(crate) fn of(sk: &'a solidcraft_engine::sketch::Sketch) -> Self {
|
||||
Self { hide_linked: sk.view.hide_projected, shown: solidcraft_engine::view::SketchPointVisibility::new(sk) }
|
||||
}
|
||||
|
||||
/// Does a face place snap to point `p` of the sketch?
|
||||
pub(crate) fn takes(&self, p: &solidcraft_engine::sketch::SPoint) -> bool {
|
||||
match p.link.as_deref() {
|
||||
None => true,
|
||||
Some(_) if self.hide_linked => false,
|
||||
Some(l) => !self.text.contains(l),
|
||||
}
|
||||
self.shown.takes(p) && !(self.hide_linked && p.link.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,9 @@ pub fn find(sk: &Sketch, moving: &[(usize, Vec2)], exclude: &[usize], mm_px: f64
|
||||
return keep(None);
|
||||
}
|
||||
let id = |i: usize| sk.points.get(i).map(|p| p.id.clone()).unwrap_or_default();
|
||||
let targets: Vec<(usize, Vec2)> = sk.points.iter().enumerate().filter(|(i, _)| !exclude.contains(i)).map(|(i, p)| (i, p.pos)).collect();
|
||||
let visible = solidcraft_engine::view::SketchPointVisibility::new(sk);
|
||||
let targets: Vec<(usize, Vec2)> =
|
||||
sk.points.iter().enumerate().filter(|(i, p)| !exclude.contains(i) && visible.takes(p)).map(|(i, p)| (i, p.pos)).collect();
|
||||
let free_curves: Vec<usize> =
|
||||
(0..sk.curves.len()).filter(|&c| sk.curves.get(c).is_some_and(|cu| !exclude.iter().any(|&p| cu.kind.uses(p)))).collect();
|
||||
let mut best: Option<(f64, Snap)> = None;
|
||||
|
||||
@@ -642,7 +642,7 @@ impl Resources {
|
||||
access_bgl,
|
||||
access_bind,
|
||||
access_version: 0,
|
||||
tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), highlight_depth(t.depth), alpha),
|
||||
trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
glass_far: glass("sc_glass_far", wgpu::Face::Front),
|
||||
glass_near: glass("sc_glass_near", wgpu::Face::Back),
|
||||
@@ -662,6 +662,24 @@ impl Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// Depth state of faces lit over the model (hover, selection): tested, not written, and pulled a
|
||||
/// little toward the eye. They lie on the model's own faces, but the GPU evaluates a body's plan
|
||||
/// in f32 while the highlight's copy of the same triangles is evaluated on the CPU, so the two
|
||||
/// depths differ by rounding and an unbiased equal test lights the face in speckles and wedges.
|
||||
/// The bias is a few depth steps plus the face's own slope: far less than any real gap.
|
||||
fn highlight_depth(format: Option<wgpu::TextureFormat>) -> Option<wgpu::DepthStencilState> {
|
||||
format.map(|format| wgpu::DepthStencilState {
|
||||
format,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: HIGHLIGHT_BIAS,
|
||||
})
|
||||
}
|
||||
|
||||
/// See [`highlight_depth`]. Negative: toward the eye (depth grows away from it).
|
||||
const HIGHLIGHT_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: -8, slope_scale: -1.0, clamp: 0.0 };
|
||||
|
||||
fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option<Batch> {
|
||||
upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX)
|
||||
}
|
||||
@@ -895,8 +913,17 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
/// Render `scene` offscreen on a device of its own, through the viewport's real resources and
|
||||
/// frame callback (`prepare`, then `paint`), and read the pixels back: straight RGBA, `size` ×
|
||||
/// `size`. `None` without a GPU adapter. With `tess`, planned bodies are evaluated on the GPU.
|
||||
/// `highlight` is drawn as the viewport's highlight scene (hover and selection).
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn render_offscreen(scene: GpuScene, view_proj: [[f32; 4]; 4], back: [f32; 3], eye: [f32; 4], size: u32, tess: bool) -> Option<Vec<u8>> {
|
||||
pub(crate) fn render_offscreen(
|
||||
scene: GpuScene,
|
||||
highlight: Option<GpuScene>,
|
||||
view_proj: [[f32; 4]; 4],
|
||||
back: [f32; 3],
|
||||
eye: [f32; 4],
|
||||
size: u32,
|
||||
tess: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
@@ -915,8 +942,8 @@ pub(crate) fn render_offscreen(scene: GpuScene, view_proj: [[f32; 4]; 4], back:
|
||||
slot: Arc::new(Mutex::new(Some(scene))),
|
||||
sketch_key: 0,
|
||||
sketch_slot: none(),
|
||||
hl_key: 0,
|
||||
hl_slot: none(),
|
||||
hl_key: u64::from(highlight.is_some()),
|
||||
hl_slot: Arc::new(Mutex::new(highlight)),
|
||||
pv_key: 0,
|
||||
pv_slot: none(),
|
||||
view_proj,
|
||||
|
||||
@@ -151,3 +151,96 @@ fn a_dragged_line_that_stops_short_stays_under_the_cursor_when_it_can_follow_aga
|
||||
assert!(start.dist(Vec2::new(x + 2.0, y)) < 0.1, "{start:?}");
|
||||
assert!(!h.app.session.model.is_stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn associated_spline_offset_hides_fit_nodes_and_follows_a_source_drag_live() {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
run(&mut h, "sketch.create", json!({"plane":"XY"}));
|
||||
run(
|
||||
&mut h,
|
||||
"sketch.spline.fit_point",
|
||||
json!({
|
||||
"points":[[-17,15],[0,8],[17,13],[15,0],[5,-7],[-1,-14],[-9,-1],[-21,-1]],
|
||||
"periodic":true
|
||||
}),
|
||||
);
|
||||
let output = h.app.run("sketch.offset", json!({"curves":["s1"],"distance":2,"chain":true})).unwrap()["curves"][0].as_str().unwrap().to_owned();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 65.0;
|
||||
h.frames(3);
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let original = h.app.session.world_state().sketch(sid).unwrap().sketch.clone();
|
||||
let source = original.curve_index("s1").unwrap();
|
||||
let derived = original.curve_index(&output).unwrap();
|
||||
let source_points = original.curves[source].kind.point_ids();
|
||||
let derived_points = original.curves[derived].kind.point_ids();
|
||||
assert!(derived_points.len() > 100, "exercise the crowded generated fit nodes");
|
||||
for &i in &source_points {
|
||||
assert!(solidcraft_engine::view::sketch_point_visible(&original, i));
|
||||
}
|
||||
let snaps = crate::dialogs::SnapPoints::of(&original);
|
||||
for &i in &derived_points {
|
||||
assert!(!solidcraft_engine::view::sketch_point_visible(&original, i));
|
||||
assert!(!snaps.takes(&original.points[i]));
|
||||
}
|
||||
let p = original.points[derived_points[32]].pos;
|
||||
let pos = screen(&mut h, p.x, p.y);
|
||||
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
|
||||
let hits = crate::viewport::pick_all(&h.app, &proj, pos);
|
||||
assert!(hits.iter().any(|hit| matches!(hit, crate::viewport::Hit::SketchCurve {id,..} if id == &output)));
|
||||
assert!(
|
||||
!hits
|
||||
.iter()
|
||||
.any(|hit| matches!(hit, crate::viewport::Hit::SketchPoint {id,..} if derived_points.iter().any(|i| original.points[*i].id == *id)))
|
||||
);
|
||||
let snap = crate::drag_snap::find(&original, &[(source_points[1], p)], &source_points, 0.01);
|
||||
assert!(
|
||||
!matches!(snap.map(|s| s.target), Some(crate::drag_snap::Target::Point(id)) if derived_points.iter().any(|i| original.points[*i].id == id))
|
||||
);
|
||||
crate::drag_snap::clear();
|
||||
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let a = screen(&mut h, 0.0, 8.0);
|
||||
let b = screen(&mut h, 1.0, 9.0);
|
||||
let c = screen(&mut h, 2.0, 10.0);
|
||||
let mut path = vec![a];
|
||||
path.extend(steps(a, b, 4));
|
||||
press_and_move(&mut h, &path);
|
||||
for (pos, target) in [(b, Vec2::new(1.0, 9.0)), (c, Vec2::new(2.0, 10.0))] {
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
play(&mut h);
|
||||
assert!(h.app.viewport.point_drag.is_some());
|
||||
assert!(h.app.session.model.is_stale(), "the drag has not been released");
|
||||
let state = h.app.session.world_state();
|
||||
let sk = &state.sketch(sid).unwrap().sketch;
|
||||
assert!(sk.points[source_points[1]].pos.dist(target) < 0.1, "source follows the held pointer: {:?}", sk.points[source_points[1]].pos);
|
||||
assert_ne!(sk.exact_segs(derived), original.exact_segs(derived), "the offset follows before release");
|
||||
assert_eq!(sk.freeform_offsets[0].distance, 2.0);
|
||||
sk.validate_freeform_offsets().unwrap();
|
||||
let points = sk.curves[derived].kind.point_ids();
|
||||
assert!(points.iter().all(|i| !solidcraft_engine::view::sketch_point_visible(sk, *i)));
|
||||
for piece in sk.exact_segs(derived).unwrap() {
|
||||
for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
let p = piece.point_at(t);
|
||||
let distance = sk.exact_segs(source).unwrap().iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min);
|
||||
assert!((distance - 2.0).abs() < 0.002, "live offset normal distance {distance}");
|
||||
}
|
||||
}
|
||||
}
|
||||
release(&mut h, c);
|
||||
assert!(!h.app.session.model.is_stale());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
run(&mut h, "edit.undo", json!({}));
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
run(&mut h, "edit.redo", json!({}));
|
||||
run(&mut h, "sketch.offset.break", json!({"curve":output}));
|
||||
let state = h.app.session.world_state();
|
||||
let sk = &state.sketch(sid).unwrap().sketch;
|
||||
assert!(sk.curves[derived].kind.point_ids().iter().all(|i| solidcraft_engine::view::sketch_point_visible(sk, *i)));
|
||||
run(&mut h, "edit.undo", json!({}));
|
||||
let state = h.app.session.world_state();
|
||||
let sk = &state.sketch(sid).unwrap().sketch;
|
||||
assert!(sk.curves[derived].kind.point_ids().iter().all(|i| !solidcraft_engine::view::sketch_point_visible(sk, *i)));
|
||||
}
|
||||
+125
-21
@@ -305,6 +305,51 @@ fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// With a component active, bodies outside it are drawn faded.
|
||||
fn body_faded(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody, active_bodies: &Option<Vec<String>>) -> bool {
|
||||
let s = &app.session;
|
||||
if let Some(names) = active_bodies {
|
||||
!names.contains(&b.name)
|
||||
} else {
|
||||
s.active_component != 0 && !s.doc.component_within(b.component, s.active_component)
|
||||
}
|
||||
}
|
||||
|
||||
/// The display plan the model scene draws `b` from on the GPU, or `None` when its faces are built
|
||||
/// here from the reference mesh. Opaque shaded bodies (`opaque`: not faded, and no see-through
|
||||
/// body or face) are drawn from their plans where the device evaluates them; faded and
|
||||
/// see-through ones (sorted and blended per triangle) are built here.
|
||||
fn drawn_plan(
|
||||
app: &SolveApp,
|
||||
b: &solidcraft_engine::doc::ModelBody,
|
||||
opaque: bool,
|
||||
) -> Option<std::sync::Arc<solidcraft_engine::geom::tess::TessPlan>> {
|
||||
let shaded = app.ui.visual_style != 2;
|
||||
(shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess))
|
||||
.then(|| b.display_plan())
|
||||
.flatten()
|
||||
.filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH)
|
||||
}
|
||||
|
||||
/// The mesh whose triangles the model scene shows for `b`'s faces: the plan's (evaluated on the
|
||||
/// CPU, the same triangles the GPU draws) or the reference mesh. A face highlighted over the model
|
||||
/// must use the same triangles: another triangulation of the same face fights it at equal depth
|
||||
/// and shimmers.
|
||||
fn shown_mesh(
|
||||
app: &SolveApp,
|
||||
b: &solidcraft_engine::doc::ModelBody,
|
||||
active_bodies: &Option<Vec<String>>,
|
||||
) -> std::sync::Arc<solidcraft_engine::geom::Mesh> {
|
||||
let doc = &app.session.doc;
|
||||
let opaque =
|
||||
|| !body_faded(app, b, active_bodies) && doc.model_body_opacity(b) >= 0.999 && doc.face_colors(b).iter().all(|(_, l)| l.opacity >= 0.999);
|
||||
(app.viewport.gpu.is_some_and(|g| g.tess) && opaque())
|
||||
.then(|| drawn_plan(app, b, true))
|
||||
.flatten()
|
||||
.and_then(|_| b.plan_mesh())
|
||||
.unwrap_or_else(|| b.mesh())
|
||||
}
|
||||
|
||||
fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
let tk = Tokens::get();
|
||||
let mut sc = GpuScene::default();
|
||||
@@ -316,12 +361,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) {
|
||||
continue;
|
||||
}
|
||||
// With a component active, bodies outside it are drawn faded.
|
||||
let faded = if let Some(names) = &active_bodies {
|
||||
!names.contains(&b.name)
|
||||
} else {
|
||||
s.active_component != 0 && !s.doc.component_within(b.component, s.active_component)
|
||||
};
|
||||
let faded = body_faded(app, b, &active_bodies);
|
||||
let base = body_color(&s.doc, b).unwrap_or(tk.body);
|
||||
let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) };
|
||||
let m = b.mesh();
|
||||
@@ -338,13 +378,8 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
let opacity = s.doc.model_body_opacity(b);
|
||||
let face_looks: std::collections::HashMap<usize, solidcraft_engine::doc::appearance::Look> =
|
||||
if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
|
||||
// Opaque bodies are drawn from their display plans on the GPU where the device evaluates
|
||||
// them; faded and see-through ones (sorted and blended per triangle) are built here.
|
||||
let opaque = !faded && opacity >= 0.999 && face_looks.values().all(|l| l.opacity >= 0.999);
|
||||
let planned = (shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess))
|
||||
.then(|| b.display_plan())
|
||||
.flatten()
|
||||
.filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH);
|
||||
let planned = drawn_plan(app, b, opaque);
|
||||
let on_gpu = planned.is_some();
|
||||
if let Some(plan) = planned {
|
||||
let face_colors = (0..b.body.face_count())
|
||||
@@ -853,8 +888,9 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
|
||||
for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) {
|
||||
let sid = ss.feature;
|
||||
let shown = snapping.then(|| crate::dialogs::SnapPoints::of(&ss.sketch));
|
||||
let visible = solidcraft_engine::view::SketchPointVisibility::new(&ss.sketch);
|
||||
for p in &ss.sketch.points {
|
||||
if shown.as_ref().is_some_and(|f| !f.takes(p)) {
|
||||
if !visible.takes(p) || shown.as_ref().is_some_and(|f| !f.takes(p)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) {
|
||||
@@ -1399,6 +1435,10 @@ fn highlight_key(app: &SolveApp) -> u64 {
|
||||
format!("{:?}", app.viewport.hover).hash(&mut h);
|
||||
app.viewport.hover_feature.hash(&mut h);
|
||||
app.viewport.hover_bodies.hash(&mut h);
|
||||
// Which mesh lights a face follows how the model draws its body (see `shown_mesh`).
|
||||
app.session.active_component.hash(&mut h);
|
||||
app.session.active_occurrence_path.hash(&mut h);
|
||||
app.ui.visual_style.hash(&mut h);
|
||||
app.ui.pick_bodies.hash(&mut h);
|
||||
crate::theme::is_dark().hash(&mut h);
|
||||
app.preview.replaced.hash(&mut h);
|
||||
@@ -1417,6 +1457,10 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
let t = Tokens::get();
|
||||
let mut sc = GpuScene::default();
|
||||
let st = app.session.world_state();
|
||||
let s = &app.session;
|
||||
let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok());
|
||||
// Faces lit over the model use the triangles the model shows them with.
|
||||
let shown = |b: &solidcraft_engine::doc::ModelBody| shown_mesh(app, b, &active_bodies);
|
||||
let mut sel = Vec::new();
|
||||
for item in app.highlighted() {
|
||||
for piece in displayed_selection(app, &item) {
|
||||
@@ -1490,7 +1534,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
Sel::Face { body, index, .. } => {
|
||||
// A live preview stands in for the body: its old faces would fight with it.
|
||||
if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.replaced.contains(body)) {
|
||||
face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false);
|
||||
face_tris(&mut sc, &shown(b), *index, c4(t.sel_face), false);
|
||||
hidden_face_outline(&mut sc, &b.mesh(), *index, t.sel_edge);
|
||||
}
|
||||
}
|
||||
@@ -1498,7 +1542,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
if let Some(b) = st.body(name)
|
||||
&& app.session.body_visible(name)
|
||||
{
|
||||
let m = b.mesh();
|
||||
let m = shown(b);
|
||||
for f in 0..b.body.face_count() {
|
||||
face_tris(&mut sc, &m, f, c4(t.body_selected), true);
|
||||
}
|
||||
@@ -1572,11 +1616,12 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
let k = t.hover_face_lift;
|
||||
let c = body_rgb(app, &b.name);
|
||||
let m = b.mesh();
|
||||
let lit = shown(b);
|
||||
for &f in &faces {
|
||||
if !sel.iter().any(|x| {
|
||||
matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name)
|
||||
}) {
|
||||
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||||
face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||||
}
|
||||
}
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
@@ -1592,7 +1637,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
if let Some(b) = st.body(body) {
|
||||
let k = t.hover_face_lift;
|
||||
let c = body_rgb(app, body);
|
||||
let m = b.mesh();
|
||||
let m = shown(b);
|
||||
for f in 0..b.body.face_count() {
|
||||
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||||
}
|
||||
@@ -1607,8 +1652,9 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
let c = body_rgb(app, body);
|
||||
let m = b.mesh();
|
||||
let patch = m.surface_patch(*index);
|
||||
let lit = shown(b);
|
||||
for &f in &patch {
|
||||
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||||
face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||||
}
|
||||
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
|
||||
if let Some(p) = m.edges.get(e)
|
||||
@@ -2586,10 +2632,11 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
||||
out.push(Sel::SketchCurve { id: c.id.clone() });
|
||||
}
|
||||
}
|
||||
let visible = solidcraft_engine::view::SketchPointVisibility::new(&ss.sketch);
|
||||
for (i, p) in ss.sketch.points.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_points")) {
|
||||
if accepts.is_none_or(|a| a & crate::selection::SKETCH_POINTS != 0)
|
||||
&& i != 0
|
||||
&& solidcraft_engine::view::sketch_point_visible(&ss.sketch, i)
|
||||
&& visible.takes(p)
|
||||
&& bx.takes(&to2(&[ss.plane.to_world(p.pos)]))
|
||||
{
|
||||
out.push(Sel::SketchPoint { id: p.id.clone() });
|
||||
@@ -2763,8 +2810,9 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let sk = &ss.sketch;
|
||||
let locked = sk.locked_points();
|
||||
let virtual_corners: Vec<usize> = sk.virtual_corners().into_iter().map(|(i, _)| i).collect();
|
||||
let visible = solidcraft_engine::view::SketchPointVisibility::new(sk);
|
||||
for (i, p) in sk.points.iter().enumerate() {
|
||||
if !solidcraft_engine::view::sketch_point_visible(sk, i) {
|
||||
if !visible.takes(p) {
|
||||
continue;
|
||||
}
|
||||
let hidden = (sk.view.hide_points && i != 0) || (sk.view.hide_projected && p.link.is_some());
|
||||
@@ -3269,7 +3317,7 @@ mod tests {
|
||||
let e = proj.cam.eye().to_f32();
|
||||
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
|
||||
let planned = scene.planned.len();
|
||||
(planned, scene.tris.len(), crate::gpu::render_offscreen(scene, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess))
|
||||
(planned, scene.tris.len(), crate::gpu::render_offscreen(scene, None, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess))
|
||||
};
|
||||
let (planned, gpu_tris, gpu) = render(true);
|
||||
let (none, cpu_tris, cpu) = render(false);
|
||||
@@ -3287,6 +3335,62 @@ mod tests {
|
||||
assert!(differ * 100 < drawn, "{differ} of {drawn} model pixels differ");
|
||||
}
|
||||
|
||||
/// A hovered face lights solidly over a body drawn from its display plan on the GPU: the
|
||||
/// highlight uses the plan's triangles, not the reference mesh's other triangulation of the
|
||||
/// same face, which fought the model at equal depth and shimmered in wedges. Every face of a
|
||||
/// box with a pocket and a round boss, hovered, changes the same pixels as on the CPU path (where model and
|
||||
/// highlight share the reference mesh). Skipped without a GPU adapter.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_hovered_face_lights_whole_over_a_body_drawn_from_its_plan() {
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 40, "height": 40, "corner": [0, 0, 0], "operation": "new"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 16, "width": 16, "height": 20, "corner": [12, 12, 20], "operation": "cut"})).unwrap();
|
||||
s.execute("solid.cylinder", &json!({"base": [33, 33, 40], "diameter": 10, "height": 10, "operation": "join"})).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
app.fit_view();
|
||||
const SIZE: u32 = 256;
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32));
|
||||
let body = app.session.world_state().bodies[0].clone();
|
||||
let faces = body.body.face_count();
|
||||
let mut render = |tess: bool, hover: Option<usize>| {
|
||||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
|
||||
app.viewport.hover = hover.map(|index| Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO });
|
||||
let (scene, highlight) = (build_scene(&app), build_highlight(&app));
|
||||
assert_eq!(!scene.planned.is_empty(), tess, "the box is drawn from its plan exactly on the GPU path");
|
||||
let proj = projection(&app, rect);
|
||||
let e = proj.cam.eye().to_f32();
|
||||
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
|
||||
crate::gpu::render_offscreen(scene, Some(highlight), proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess)
|
||||
};
|
||||
let lit = |plain: &[u8], hovered: &[u8]| -> Vec<bool> { plain.chunks(4).zip(hovered.chunks(4)).map(|(a, b)| a != b).collect() };
|
||||
let (Some(gpu_plain), Some(cpu_plain)) = (render(true, None), render(false, None)) else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let mut checked = 0;
|
||||
for f in 0..faces {
|
||||
let (Some(gpu), Some(cpu)) = (render(true, Some(f)), render(false, Some(f))) else { return };
|
||||
let (on_gpu, on_cpu) = (lit(&gpu_plain, &gpu), lit(&cpu_plain, &cpu));
|
||||
let area = on_cpu.iter().filter(|x| **x).count();
|
||||
if area < 400 {
|
||||
continue; // facing away or hidden
|
||||
}
|
||||
// Inside the face, two pixels in from its outline: a curved face's outline follows
|
||||
// its own facets, which the plan and the reference mesh cut differently.
|
||||
let n = SIZE as i32;
|
||||
let inside = |i: usize| {
|
||||
let (x, y) = (i as i32 % n, i as i32 / n);
|
||||
(-2..=2).all(|dy| (-2..=2).all(|dx| on_cpu.get(((y + dy).clamp(0, n - 1) * n + (x + dx).clamp(0, n - 1)) as usize) == Some(&true)))
|
||||
};
|
||||
let missed = (0..on_cpu.len()).filter(|&i| inside(i) && !on_gpu[i]).count();
|
||||
assert!(missed * 200 < area, "face {f}: {missed} of its {area} hovered pixels do not light (the highlight fights the model)");
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked >= 3, "{checked} faces seen lit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_drawing_and_hover_overlays_preserve_visible_gaps() {
|
||||
fn length(shape: &Shape) -> f32 {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{"note": "#971: a 2 mm offset of this closed irregular fit spline used to exhaust its node limit with an endpoint certificate error. Preview must succeed, Cancel must preserve the source, and OK must commit the preview."},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.spline.fit_point", "params": {"points": [[-17,15],[0,8],[17,13],[15,0],[5,-7],[-1,-14],[-9,-1],[-21,-1]], "periodic": true}}},
|
||||
{"call": "ui.view", "params": {"view": "top"}},
|
||||
{"start": "sketch.offset"},
|
||||
{"click": {"sketch": [0,8]}},
|
||||
{"until": {"count": {"name": "sketch_preview_curves", "value": 1}}},
|
||||
{"expect": {"dialog": "Offset", "sketch_curves": {"own": 1}, "errors": 0}},
|
||||
{"key": "Escape"},
|
||||
{"expect": {"dialog": null, "sketch_curves": {"own": 1}, "errors": 0}},
|
||||
{"start": "sketch.offset"},
|
||||
{"click": {"sketch": [0,8]}},
|
||||
{"until": {"count": {"name": "sketch_preview_curves", "value": 1}}},
|
||||
{"key": "Enter"},
|
||||
{"expect": {"dialog": null, "sketch_curves": {"own": 2}, "errors": 0}},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 1, "periodic"], "value": true}}},
|
||||
{"call": "engine.execute", "params": {"command": "edit.undo", "params": {}}},
|
||||
{"expect": {"sketch_curves": {"own": 1}, "errors": 0}},
|
||||
{"call": "engine.execute", "params": {"command": "edit.redo", "params": {}}},
|
||||
{"expect": {"sketch_curves": {"own": 2}, "errors": 0}}
|
||||
]
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Features
|
||||
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **343 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **344 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
|
||||
## Sketch (99)
|
||||
|
||||
@@ -432,7 +432,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
|---|---|---|
|
||||
| Delete Face | `face.delete` | SOLID › MODIFY |
|
||||
|
||||
## `material.*` (5)
|
||||
## `material.*` (6)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
@@ -441,6 +441,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| New Physical Material | `material.create` | – |
|
||||
| Edit Physical Material | `material.edit` | – |
|
||||
| Delete Physical Material | `material.delete` | – |
|
||||
| Validate Physical Material Data | `material.properties.validate` | – |
|
||||
|
||||
## `mesh.*` (1)
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Physical material data cards
|
||||
|
||||
`material.properties.validate {card}` checks a caller-supplied candidate record,
|
||||
without assigning it, saving it in the design, reading files, fetching sources or
|
||||
changing history, selection, camera or live geometry. This is phase 1 of #923/#922,
|
||||
building towards #785. Existing `material.create/edit` and their density/appearance
|
||||
behavior are unchanged. The design format is unchanged.
|
||||
|
||||
Validation means numerical and structural consistency. It does not authenticate
|
||||
sources, approve redistribution, certify design values, provide a solver or enable
|
||||
advanced rendering. The result reports `analysis_solver_available: false` and
|
||||
`rendering_properties_available: false`. Missing properties remain unknown.
|
||||
|
||||
## Contract (standalone card schema 1)
|
||||
|
||||
A card has `schema_version: 1`, `designation`, `family` (`wood`, `polymer`, `glass`,
|
||||
`metal`, `other`), `sources` and `properties`. Source IDs are exact, case-sensitive
|
||||
local references. Each source has `id`, `title`, `kind` (`user_supplied`,
|
||||
`measurement`, `public_reference`, `illustrative`), optional `license` and
|
||||
`reference` strings. These strings are metadata supplied by the caller; even a
|
||||
declared open license remains in `source_review_required`. Nothing is downloaded.
|
||||
|
||||
Every property has `data`, `source_id` and optional `conditions`. Conditions are
|
||||
`temperature_k`, `moisture_dry_basis_kg_kg`, `strain_rate_per_s` and a free-text
|
||||
`state` (grade, temper, processing or specimen condition). Conditions belong to
|
||||
the property, not an assumed global room temperature. An elastic block represents
|
||||
constants measured under the same conditions and source; mixed-condition curves
|
||||
and independently sourced constants need a later representation.
|
||||
|
||||
| `data.kind` | Explicit SI fields |
|
||||
|---|---|
|
||||
| `density` | `kg_m3` |
|
||||
| `elasticity` | `model`, described below |
|
||||
| `tensile_strength`, `compressive_strength`, `yield_strength`, `shear_strength` | `pa` |
|
||||
| `thermal_conductivity` | `w_m_k: [k1,k2,k3]`, `axes` |
|
||||
| `thermal_expansion` | `per_k: [a1,a2,a3]`, `axes` |
|
||||
| `specific_heat` | `j_kg_k` |
|
||||
|
||||
Directional values use `axes: material123` (material-local 1/2/3) or `wood_lrt`
|
||||
(longitudinal/radial/tangential). These labels do not assign a frame to a body or
|
||||
infer mechanical orientation from texture rotation. Equal directional values can
|
||||
describe an isotropic thermal property. Thermal expansion may be zero or negative.
|
||||
|
||||
An isotropic elastic model has `symmetry: isotropic`, `young_modulus_pa` and
|
||||
`poisson_ratio`. Stability requires E > 0 and -1 < nu < 0.5. Valid auxetic values
|
||||
are accepted. The result derives G = E/[2(1+nu)] and K = E/[3(1-2nu)], in Pa.
|
||||
|
||||
An orthotropic model has `symmetry: orthotropic`, `axes`,
|
||||
`young_moduli_pa: [E1,E2,E3]`, `shear_moduli_pa: [G12,G23,G31]` and
|
||||
`poisson_ratios: [nu12,nu23,nu31]`. Here nu_ij = -eps_j/eps_i under stress along i;
|
||||
reciprocity supplies nu_ij/Ei = nu_ji/Ej. The normal compliance is symmetric with
|
||||
diagonal 1/Ei and off-diagonal -nu_ij/Ei. Diagonal scaling produces unit diagonal;
|
||||
Sylvester's criterion checks its positive definiteness. All three shear moduli
|
||||
must be positive. General orthotropic Poisson ratios are not capped at 0.5.
|
||||
Moduli whose reciprocals cannot be represented finitely are rejected; the
|
||||
isotropic derived response and its reciprocals must also remain finite/positive.
|
||||
|
||||
Moisture is kg water / kg dry material and can exceed one. It is not a wet-mass
|
||||
fraction. Unspecified temperature/state, wood moisture or polymer strain rate are
|
||||
reported in `unspecified_conditions`; they are not guessed or replaced by zero.
|
||||
The family label does not certify that a chosen constitutive model fits a material.
|
||||
Scalar strengths are individual measurements, not a three-dimensional failure
|
||||
envelope. Typed strength directions and test methods remain a separate extension.
|
||||
|
||||
## Admission limits
|
||||
|
||||
Before cloning/deserializing JSON: at most 2,048 nodes, depth 12, 32 array entries
|
||||
or object fields and a 256 KiB conservative accounting budget (32 bytes per node
|
||||
plus string/key bytes). The typed record allows 32 sources and 16 properties;
|
||||
duplicate source IDs or property kinds and unknown fields are rejected.
|
||||
|
||||
Text limits in UTF-8 bytes: designation/title 256, source ID 128, license 512,
|
||||
reference 2,048 and state 1,024; controls and blank text are rejected. All numeric
|
||||
values must be finite. Positive-value upper admission limits are density 1e6
|
||||
kg/m³, moduli/strengths 1e15 Pa, conductivity 1e9 W/(m K), heat capacity 1e9
|
||||
J/(kg K), temperature 20,000 K and strain rate 1e12 /s. Expansion is within ±1 /K,
|
||||
dry-basis moisture within [0,1e6] (also accommodating highly hydrated polymers),
|
||||
orthotropic Poisson ratios within ±1e6 in addition
|
||||
to the compliance stability test. These are generous technical admission limits,
|
||||
not measured applicability ranges or a conditioning guarantee for a future solver.
|
||||
|
||||
## Original synthetic example
|
||||
|
||||
These deliberately invented constants demonstrate validation, not a real grade:
|
||||
|
||||
```json
|
||||
{
|
||||
"card": {
|
||||
"schema_version": 1,
|
||||
"designation": "Synthetic elastic example — not design data",
|
||||
"family": "other",
|
||||
"sources": [{
|
||||
"id": "example",
|
||||
"title": "Original synthetic example",
|
||||
"kind": "illustrative",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
}],
|
||||
"properties": [{
|
||||
"data": {
|
||||
"kind": "elasticity",
|
||||
"model": {"symmetry": "isotropic", "young_modulus_pa": 12000000000, "poisson_ratio": 0.25}
|
||||
},
|
||||
"source_id": "example"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The result has G = 4.8e9 Pa, K = 8e9 Pa, unspecified conditions and source review
|
||||
still required. No assigned mass, saved material or appearance changes.
|
||||
|
||||
## Next phases and references
|
||||
|
||||
Persistence/library integration needs a separately reviewed document migration,
|
||||
atomic edits, undo/redo and offline reopening, coordinated with #863. Curves,
|
||||
uncertainty, hardness scales, viscoelasticity and failure criteria remain later work.
|
||||
Visual PBR/texture/environment consumption uses the renderer contract requested
|
||||
in #813, with #909/#837 input utilities; accepting physical data advertises none
|
||||
of those capabilities.
|
||||
|
||||
The [USDA Wood Handbook](https://research.fs.usda.gov/fpl/wood-handbook), including
|
||||
[chapter 4's dry-mass moisture definition](https://research.fs.usda.gov/download/treesearch/62243.pdf), is a public
|
||||
scientific reference for moisture, mechanical behavior and the distinction between
|
||||
specimen properties and graded design values. No handbook data/table is bundled.
|
||||
The [Khronos PBR material model](https://www.khronos.org/gltf/pbr) is a reference for
|
||||
the separate optical track. No third-party code, engineering dataset or asset is
|
||||
added by this implementation.
|
||||
Reference in new issue
Block a user