From 5c5516aae0c0a30f12e399e33f5ca81729f1cafb Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:42:41 -0400 Subject: [PATCH] engine: a typed parameter schema per command (CommandSpec::form) Field, Kind, Dim and Collection describe a command's JSON parameters: numbers with unit dimensions, choices, vectors, references to model items, groups, one-of variants and rows. prefill, switch_variant and problems serve forms; json_schema gives the same as JSON Schema. No command is described yet (form: None). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/block.rs | 1 + crates/engine/src/cmd/contact.rs | 5 + crates/engine/src/cmd/deck.rs | 1 + crates/engine/src/cmd/define.rs | 20 +- crates/engine/src/cmd/edit.rs | 2 + crates/engine/src/cmd/file.rs | 7 + crates/engine/src/cmd/geometry.rs | 4 + crates/engine/src/cmd/hex.rs | 4 + crates/engine/src/cmd/mesh.rs | 18 +- crates/engine/src/cmd/model.rs | 7 +- crates/engine/src/cmd/octree.rs | 1 + crates/engine/src/cmd/results.rs | 7 + crates/engine/src/cmd/solve.rs | 9 + crates/engine/src/cmd/tags.rs | 3 +- crates/engine/src/lib.rs | 4 + crates/engine/src/schema.rs | 733 ++++++++++++++++++++++++++++++ 16 files changed, 816 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/schema.rs diff --git a/crates/engine/src/cmd/block.rs b/crates/engine/src/cmd/block.rs index 854ab27..67994d2 100644 --- a/crates/engine/src/cmd/block.rs +++ b/crates/engine/src/cmd/block.rs @@ -18,6 +18,7 @@ pub const BLOCK: CommandSpec = CommandSpec { id: "mesh.block", label: "Mesh Block", params: r#"{"body": "Plate", "kind": "hex8|hex20|tet4|tet10|wedge6", "corners": [[x, y, z] × 8] (hex8 order: 1-2-3-4 counter-clockwise seen from 5-6-7-8), "divisions": [n12, n14, n15] (elements along 1→2, 1→4, 1→5), "bias": [1, 1, 1] (optional: last over first element size along each direction), "edges": [{"corners": [1, 2], "arc": {"center": [x, y, z]}} | {"corners": [2, 3], "ellipse": {"center": [x, y, z], "axes": [[a], [b]] (perpendicular)}} | {"corners": [5, 6], "name": "tip_edge"}] (optional: curved edges, the shorter arc between the corners, and edge names), "faces": {"1": "bottom", "4": "tip"} (optional: names of block faces, numbered as hex8 faces: 1 = 1-2-3-4, 2 = 5-6-7-8, 3 = 1-2-6-5, 4 = 2-3-7-6, 5 = 3-4-8-7, 6 = 4-1-5-8), "vertices": {"1": "A"} (optional: names of corners), "merge": distance (optional, default 1e-6 of the block's diagonal; 0 never joins)} — adds a mapped mesh of the block (transfinite interpolation of its edges; quadratic nodes on the curves), uses existing nodes within `merge` of new ones so blocks that share a face join (the shared face must have the same elements on both sides, or nothing changes), and tags the elements with the body and element faces, edges and nodes with the named faces, edges and vertices. 2D: "kind": "quad4|quad8|tri3|tri6", four "corners" at z = 0 (counter-clockwise from +z), "divisions": [n12, n14], "bias": [r, r], "face": "area" (the face of the body the elements fill, default `area`), edges and vertices numbered among the four corners; for plane stress, plane strain and axisymmetric sections"#, + form: None, kind: CommandKind::Model, run: block_mesh, }; diff --git a/crates/engine/src/cmd/contact.rs b/crates/engine/src/cmd/contact.rs index 2a31216..3b887fb 100644 --- a/crates/engine/src/cmd/contact.rs +++ b/crates/engine/src/cmd/contact.rs @@ -43,6 +43,7 @@ pub const PAIR: CommandSpec = CommandSpec { r#""friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb, μ = μk + (μs − μk) e^(−decay × slip rate); default frictionless), "penalty_scale": 1 (0 < scale ≤ 1: a factor on the stiffest penalty that leaves the time increment alone), "initial_penetration": "adjust" (move nodes that start inside onto the surface, strain-free, and report them) | "refuse" (refuse the analysis, naming the first)"#, r#"} — contact between two surfaces, each one's nodes kept out of the other's faces (explicit dynamics; fails if the name is taken)"# ), + form: None, kind: CommandKind::Model, run: |s: &mut Session, p: &Value| { let n = name(p)?; @@ -60,6 +61,7 @@ pub const GENERAL: CommandSpec = CommandSpec { r#""friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb, μ = μk + (μs − μk) e^(−decay × slip rate); default frictionless), "penalty_scale": 1 (0 < scale ≤ 1: a factor on the stiffest penalty that leaves the time increment alone), "initial_penetration": "adjust" (move nodes that start inside onto the surface, strain-free, and report them) | "refuse" (refuse the analysis, naming the first)"#, r#"} — single-surface contact of everything that may touch (explicit dynamics; fails if the name is taken)"# ), + form: None, kind: CommandKind::Model, run: |s: &mut Session, p: &Value| { let n = name(p)?; @@ -76,6 +78,7 @@ pub const EDIT: CommandSpec = CommandSpec { id: "interaction.contact.edit", label: "Edit Contact", params: r#"{"name": "impact", "surfaces": {"type": "pair", "main": "plate", "secondary": "ball"} | {"type": "general", "surface": "skin" (optional)}, "friction": {…}, "penalty_scale": 1, "initial_penetration": "adjust|refuse"} — replaces the contact of that name (the model's form; see interaction.contact.pair)"#, + form: None, kind: CommandKind::Model, run: |s: &mut Session, p: &Value| { let n = name(p)?; @@ -91,6 +94,7 @@ pub const DELETE: CommandSpec = CommandSpec { id: "interaction.contact.delete", label: "Delete Contact", params: r#"{"name": "impact"}"#, + form: None, kind: CommandKind::Model, run: |s: &mut Session, p: &Value| { s.model.remove_contact(name(p)?)?; @@ -105,6 +109,7 @@ pub const FIND: CommandSpec = CommandSpec { r#"{"tolerance": 0.1 (distance within which surfaces touch; default a tenth of the mean exterior face size), "create": false (true: add two face sets and a contact pair per touching pair of parts, named find-N-main, find-N-secondary and find-N), "#, r#""friction", "penalty_scale", "initial_penetration" (of the created pairs, as interaction.contact.pair)} — the parts of the mesh (solid elements connected through their nodes) whose exterior faces come within the tolerance of each other"# ), + form: None, kind: CommandKind::Model, run: find, }; diff --git a/crates/engine/src/cmd/deck.rs b/crates/engine/src/cmd/deck.rs index 6067c16..f285f7e 100644 --- a/crates/engine/src/cmd/deck.rs +++ b/crates/engine/src/cmd/deck.rs @@ -12,6 +12,7 @@ pub const WRITE: CommandSpec = CommandSpec { id: "deck.write", label: "Write Solver Deck", params: r#"{"format": "abaqus|nastran|lsdyna", "path": "part.inp", "steps": ["Load"]} — writes the model as a solver deck (formats: fieldcraft_decks::FORMATS); `steps` (optional) writes only those steps, as a Nastran run needs (static or frequency steps, not both); LS-DYNA writes several steps as *CASE analyses (run it with CASE); fails naming every error of the model check and everything the format cannot express"#, + form: None, kind: CommandKind::Session, run: write, }; diff --git a/crates/engine/src/cmd/define.rs b/crates/engine/src/cmd/define.rs index 841fe96..8f6deac 100644 --- a/crates/engine/src/cmd/define.rs +++ b/crates/engine/src/cmd/define.rs @@ -31,28 +31,31 @@ macro_rules! definition_commands { id: concat!("model.", $id, ".create"), label: concat!("New ", $label), params: concat!($doc, " — fails if the name is taken"), + form: None, kind: CommandKind::Model, run: |s: &mut Session, p: &Value| { let n = name(p)?; - s.model.$add(n, fields::<$ty>(p, $what)?)?; - Ok(json!({"name": n})) + s.model.$add(n, fields::<$ty>(p, $what)?)?; + Ok(json!({"name": n})) }, }; pub const $EDIT: CommandSpec = CommandSpec { id: concat!("model.", $id, ".edit"), label: concat!("Edit ", $label), params: concat!($doc, " — replaces the existing definition of that name"), + form: None, kind: CommandKind::Model, run: |s: &mut Session, p: &Value| { - let n = name(p)?; + let n = name(p)?; s.model.$replace(n, fields::<$ty>(p, $what)?)?; - Ok(json!({"name": n})) + Ok(json!({"name": n})) }, }; pub const $DELETE: CommandSpec = CommandSpec { id: concat!("model.", $id, ".delete"), label: concat!("Delete ", $label), params: r#"{"name": "…"} — refused while another definition refers to it"#, + form: None, kind: CommandKind::Model, run: |s: &mut Session, p: &Value| { s.model.$remove(name(p)?)?; @@ -103,6 +106,7 @@ pub const MATERIAL_PLASTICITY: CommandSpec = CommandSpec { id: "model.material.plasticity", label: "Plasticity", params: r#"{"name": "Steel", "plastic": {"type": "bilinear", "yield_stress": 250, "hardening_modulus": 1000, "hardening": "isotropic|kinematic"} | {"type": "table", "points": [{"strain": 0, "stress": 250}, {"strain": 0.1, "stress": 300}]} | {"type": "johnson_cook", "a": 792, "b": 510, "n": 0.26, "c": 0.014, "m": 1.03, "reference_strain_rate": 1, "room_temperature": 294, "melt_temperature": 1793, "specific_heat": 4.77e8, "taylor_quinney": 0.9} | null} — von Mises plasticity of an isotropic material (null removes it); explicit dynamics uses it, the linear solver the elastic constants alone"#, + form: None, kind: CommandKind::Model, run: |s: &mut Session, p: &Value| { change_material(s, p, |m, p| { @@ -116,6 +120,7 @@ pub const MATERIAL_DAMAGE: CommandSpec = CommandSpec { id: "model.material.damage", label: "Damage", params: r#"{"name": "Steel", "damage": {"type": "johnson_cook", "d1": 0.05, "d2": 3.44, "d3": -2.12, "d4": 0.002, "d5": 0.61} | null, "erosion": {"plastic_strain": 1.0} | null} — damage to failure and element erosion of a plastic material (each key optional; null removes it); a failed element is eroded in explicit dynamics"#, + form: None, kind: CommandKind::Model, run: |s: &mut Session, p: &Value| { change_material(s, p, |m, p| { @@ -251,6 +256,7 @@ pub const FLUID_BOUNDARY: CommandSpec = CommandSpec { id: "model.fluid_domain.boundary", label: "Inlet, Outlet or Wall", params: r#"{"domain": "Flow", "at": "x_min" (a box face) | {"body": "Body1", "face": "F2:top"} (an obstacle face), "condition": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"} | {"type": "pressure", "pressure": 0} | {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w]} | {"type": "slip"} | {"type": "periodic"}} — sets one boundary's condition (obstacle faces take walls and moving walls)"#, + form: None, kind: CommandKind::Model, run: fluid_boundary, }; @@ -267,6 +273,7 @@ pub const STEP_CREATE: CommandSpec = CommandSpec { id: "model.step.create", label: "New Step", params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#, + form: None, kind: CommandKind::Model, run: step_create, }; @@ -275,6 +282,7 @@ pub const STEP_EDIT: CommandSpec = CommandSpec { id: "model.step.edit", label: "Edit Step", params: r#"{"name": "Bend", "procedure", "output", "boundary_conditions", "loads"} — as model.step.create; what is left out keeps its value"#, + form: None, kind: CommandKind::Model, run: step_edit, }; @@ -283,6 +291,7 @@ pub const STEP_DELETE: CommandSpec = CommandSpec { id: "model.step.delete", label: "Delete Step", params: r#"{"name": "Bend"}"#, + form: None, kind: CommandKind::Model, run: |s, p| s.model.remove_step(name(p)?).map(|_| Value::Null).map_err(Into::into), }; @@ -291,12 +300,13 @@ pub const STEP_ACTIVATE: CommandSpec = CommandSpec { id: "model.step.activate", label: "Activate in Step", params: r#"{"step": "Bend", "boundary_conditions": [names], "loads": [names], "active": true} — switches the listed items on (or off with false) in that step only"#, + form: None, kind: CommandKind::Model, run: step_activate, }; pub const ACTIVITY: CommandSpec = - CommandSpec { id: "model.activity", label: "Step Table", params: "{}", kind: CommandKind::Model, run: |s, _| Ok(activity(&s.model)) }; + CommandSpec { id: "model.activity", label: "Step Table", params: "{}", form: None, kind: CommandKind::Model, run: |s, _| Ok(activity(&s.model)) }; fn step_create(s: &mut Session, p: &Value) -> Result { let n = name(p)?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8714584..468b1dc 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -36,6 +36,7 @@ pub const UNDO: CommandSpec = CommandSpec { id: "edit.undo", label: "Undo", params: r#"{"count"?: 1} — takes back the last command that changed the model (it leaves the journal and can be redone); `count` takes back that many, as far as the history reaches"#, + form: None, kind: CommandKind::Session, run: |s, p| repeat(s, p, Session::undo), }; @@ -44,6 +45,7 @@ pub const REDO: CommandSpec = CommandSpec { id: "edit.redo", label: "Redo", params: r#"{"count"?: 1} — runs the last undone command's change again; `count` redoes that many, as far as there are undone commands"#, + form: None, kind: CommandKind::Session, run: |s, p| repeat(s, p, Session::redo), }; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2fc0087..b995c39 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -23,6 +23,7 @@ pub const OPEN: CommandSpec = CommandSpec { id: "file.open", label: "Open Model", params: r#"{"path": "bracket.fieldcraft"} — opens the model, its mesh and its journal, and opens its design and imported meshes again from their files (one that is missing or changed is listed in `warnings`; the model still opens); unsaved changes of the current model are discarded and the undo history starts empty"#, + form: None, kind: CommandKind::Session, run: open, }; @@ -31,6 +32,7 @@ pub const SAVE: CommandSpec = CommandSpec { id: "file.save", label: "Save Model", params: r#"{"path"?: "bracket.fieldcraft"} — saves to `path`, else to the file the model was opened from or last saved to; written atomically, the previous version kept as `.bak`"#, + form: None, kind: CommandKind::Session, run: save, }; @@ -39,6 +41,7 @@ pub const SAVE_AS: CommandSpec = CommandSpec { id: "file.save_as", label: "Save Model As", params: r#"{"path": "bracket.fieldcraft"} — saves to a new file, which becomes the model's file"#, + form: None, kind: CommandKind::Session, run: save_as, }; @@ -47,6 +50,7 @@ pub const RECOVERY_LIST: CommandSpec = CommandSpec { id: "file.recovery.list", label: "Recoverable Models", params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first (default folder: FIELDCRAFT_RECOVERY_DIR or the per-user data folder)"#, + form: None, kind: CommandKind::Session, run: recovery_list, }; @@ -55,6 +59,7 @@ pub const RECOVER: CommandSpec = CommandSpec { id: "file.recover", label: "Recover Model", params: r#"{"id": "…", "dir"?: "…"} — opens an autosaved model (an id from file.recovery.list) with its original file path, as unsaved changes; the autosave stays until file.recovery.discard"#, + form: None, kind: CommandKind::Session, run: recover, }; @@ -63,6 +68,7 @@ pub const RECOVERY_DISCARD: CommandSpec = CommandSpec { id: "file.recovery.discard", label: "Discard Recovered Model", params: r#"{"id": "…"} or {"all": true}; "dir"?: "…" — deletes autosaves of apps that are no longer running"#, + form: None, kind: CommandKind::Session, run: recovery_discard, }; @@ -71,6 +77,7 @@ pub const SAMPLE: CommandSpec = CommandSpec { id: "file.sample", label: "Open Sample", params: r#"{"name": "parts|cantilever|impact"} — opens a sample model (fieldcraft_engine::sample) in place of the current one, unsaved changes discarded; `cantilever` and `impact` are ready to solve (solve.run)"#, + form: None, kind: CommandKind::Session, run: open_sample, }; diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index 8447df7..8b70a7b 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -12,6 +12,7 @@ pub const OPEN: CommandSpec = CommandSpec { id: "geometry.open", label: "Open Design", params: r#"{"path": "part.solidcraft"} — rebuilds the SolidCraft design and makes its bodies the geometry; opening it again after an edit picks up the change"#, + form: None, kind: CommandKind::Model, run: open, }; @@ -20,6 +21,7 @@ pub const BODIES: CommandSpec = CommandSpec { id: "geometry.bodies", label: "Bodies", params: "{} — the open design's bodies: measures and persistent face names (by face index)", + form: None, kind: CommandKind::Model, run: bodies, }; @@ -28,6 +30,7 @@ pub const TESSELLATE: CommandSpec = CommandSpec { id: "geometry.tessellate", label: "Tessellate Body", params: r#"{"body": "Body1", "tolerance": 0.05} — triangulates the body's boundary within the chordal tolerance (mm); reports triangles per face"#, + form: None, kind: CommandKind::Model, run: tessellate, }; @@ -36,6 +39,7 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec { id: "geometry.import.mesh", label: "Import Tagged Mesh", params: r#"{"path": "part.3mf"} — adds each build item of a 3MF package as a surface; triangles keep their B-rep face tags (docs/formats/tagged-mesh.md)"#, + form: None, kind: CommandKind::Model, run: import_mesh, }; diff --git a/crates/engine/src/cmd/hex.rs b/crates/engine/src/cmd/hex.rs index 94ea3da..18a3d23 100644 --- a/crates/engine/src/cmd/hex.rs +++ b/crates/engine/src/cmd/hex.rs @@ -15,6 +15,7 @@ pub const HEX_GENERATE: CommandSpec = CommandSpec { id: "mesh.hex.generate", label: "Generate Hex Mesh", params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the mesh with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#, + form: None, kind: CommandKind::Model, run: hex_generate, }; @@ -117,6 +118,7 @@ pub const HEX_SWEEP: CommandSpec = CommandSpec { id: "mesh.hex.sweep", label: "Sweep Hex Mesh", params: r#"the parameters of mesh.hex.generate, with "method": "swept" (default: extrude, sweep or revolve, whichever fits first) | "extrude" | "sweep" | "revolve" — mesh.hex.generate limited to the sweeping methods"#, + form: None, kind: CommandKind::Model, run: hex_sweep, }; @@ -125,6 +127,7 @@ pub const HEX_MAPPED: CommandSpec = CommandSpec { id: "mesh.hex.mapped", label: "Mapped Hex Mesh", params: r#"the parameters of mesh.hex.generate without "method" — mesh.hex.generate with the mapped method: a six-sided block (six faces of four edges, three edges at each of eight corners)"#, + form: None, kind: CommandKind::Model, run: hex_mapped, }; @@ -133,6 +136,7 @@ pub const HEX_BLOCKS: CommandSpec = CommandSpec { id: "mesh.hex.blocks", label: "Block Hex Mesh", params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the mesh with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#, + form: None, kind: CommandKind::Model, run: hex_blocks, }; diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index d97fab8..1a63f06 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -19,6 +19,7 @@ pub const NODE_ADD: CommandSpec = CommandSpec { id: "mesh.node.add", label: "Add Node", params: r#"{"label": 1, "xyz": [x, y, z]} — adds the node, or moves it if the label exists"#, + form: None, kind: CommandKind::Model, run: node_add, }; @@ -27,6 +28,7 @@ pub const NODE_REMOVE: CommandSpec = CommandSpec { id: "mesh.node.remove", label: "Remove Node", params: r#"{"label": 1} — only a node no element uses"#, + form: None, kind: CommandKind::Model, run: node_remove, }; @@ -35,17 +37,25 @@ pub const ELEMENT_ADD: CommandSpec = CommandSpec { id: "mesh.element.add", label: "Add Element", params: r#"{"label": 1, "kind": "rod2|beam2|tri3|tri6|quad4|quad8|tet4|tet10|wedge6|hex8|hex20", "nodes": [labels]} — adds or replaces"#, + form: None, kind: CommandKind::Model, run: element_add, }; -pub const ELEMENT_REMOVE: CommandSpec = - CommandSpec { id: "mesh.element.remove", label: "Remove Element", params: r#"{"label": 1}"#, kind: CommandKind::Model, run: element_remove }; +pub const ELEMENT_REMOVE: CommandSpec = CommandSpec { + id: "mesh.element.remove", + label: "Remove Element", + params: r#"{"label": 1}"#, + form: None, + kind: CommandKind::Model, + run: element_remove, +}; pub const QUALITY: CommandSpec = CommandSpec { id: "mesh.quality", label: "Mesh Quality", params: r#"{"elements": [labels] (optional, default all), "bins": 10 (1..=1000)} — certified validity (det J > 0 everywhere), volume, and min/max/mean/worst/histogram per metric (angles in degrees; histograms over each metric's fixed range, out-of-range values in the end bins)"#, + form: None, kind: CommandKind::Model, run: quality_report, }; @@ -54,6 +64,7 @@ pub const DELAUNAY_POINTS: CommandSpec = CommandSpec { id: "mesh.delaunay.points", label: "Delaunay of Points", params: r#"{"points": [[x, y, z], ...] (at most 20000)} — the exact Delaunay tetrahedralization (a debugging report; the model is not changed): {"dimension", "vertices", "duplicates": [[point, kept point], ...], "tets": [[i, j, k, l], ...]} with 0-based point indices in tet4 order; collinear or coplanar points give no tets"#, + form: None, kind: CommandKind::Model, run: delaunay_points, }; @@ -79,6 +90,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec { id: "mesh.surface.check", label: "Check Boundary Surface", params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, optional "weld": 1e-9 (0 or 1e-15..=1e-3 of the bounding-box diagonal), "weld_distance": mm (default: the surface's own, twice SolidCraft's linear tolerance for a tessellated body, 0 for an imported mesh), "feature_angle": 45 (degrees, (0, 180]) — welds the tessellated body or imported surface and checks it can bound a volume mesh: closed, manifold, outward, no self-intersections; reports shells, genus, volume and feature edges, or the located defects"#, + form: None, kind: CommandKind::Model, run: surface_check, }; @@ -87,6 +99,7 @@ pub const TET_GENERATE: CommandSpec = CommandSpec { id: "mesh.tet.generate", label: "Generate Tet Mesh", params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Reports the totals and, under `bodies`, each body's counts, size and quality report"#, + form: None, kind: CommandKind::Model, run: tet_generate, }; @@ -335,6 +348,7 @@ pub const PLANAR: CommandSpec = CommandSpec { id: "mesh.planar", label: "2D Mesh", params: r#"{"size": 1.0, "kind": "quad4|quad8|tri3|tri6" (default quad4), and either "polygon": [[x, y], ...] with optional "holes": [[[x, y], ...], ...] and "name": "Plane" (the body name its tags use), or "body": "Body1", "face": "F2:top", "tolerance": 0.05 (a planar face of an open design, parallel to XY); optional "max_elements": 1000000 (1..=4000000)} — meshes the planar region with 2D elements in the XY plane (a face at z = c is meshed at z = 0) for plane stress, plane strain or axisymmetric sections (x the radius, Y the axis): free quadrilaterals (or constrained Delaunay triangles) of about `size`, mid-side nodes on the boundary for quadratic kinds. Adds nodes and elements after the largest existing labels and tags them: the elements with the face (polygon: face `area` of body `name`), the boundary element edges with edges named after the neighbouring face of the body (polygon: `e1`, `e2`, … side by side, the outer loop then each hole, and vertices `v1`, … at the corners), so sets on those edges carry loads and boundary conditions"#, + form: None, kind: CommandKind::Model, run: planar, }; diff --git a/crates/engine/src/cmd/model.rs b/crates/engine/src/cmd/model.rs index 592da36..46a3738 100644 --- a/crates/engine/src/cmd/model.rs +++ b/crates/engine/src/cmd/model.rs @@ -4,22 +4,25 @@ use serde_json::{Value, json}; use super::{listed, name}; use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; -pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", kind: CommandKind::Model, run: info }; +pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", form: None, kind: CommandKind::Model, run: info }; pub const UNITS: CommandSpec = CommandSpec { id: "model.units", label: "Unit System", params: r#"{"system": "m-kg-s-N-Pa|mm-t-s-N-MPa|mm-kg-ms-kN-GPa|mm-g-ms-N-MPa|in-lbf-s-psi|unspecified"} — labels the system the model's numbers are in; nothing is converted"#, + form: None, kind: CommandKind::Model, run: units, }; -pub const CHECK: CommandSpec = CommandSpec { id: "model.check", label: "Check Model", params: "{}", kind: CommandKind::Model, run: check }; +pub const CHECK: CommandSpec = + CommandSpec { id: "model.check", label: "Check Model", params: "{}", form: None, kind: CommandKind::Model, run: check }; pub const SET_RESOLVE: CommandSpec = CommandSpec { id: "model.set.resolve", label: "Resolve Set", params: r#"{"name": "root"} — the set's nodes, elements and element faces on the current mesh (the first 100 of each); fails naming every member that is not on the mesh"#, + form: None, kind: CommandKind::Model, run: set_resolve, }; diff --git a/crates/engine/src/cmd/octree.rs b/crates/engine/src/cmd/octree.rs index c784bfb..1cd66dc 100644 --- a/crates/engine/src/cmd/octree.rs +++ b/crates/engine/src/cmd/octree.rs @@ -15,6 +15,7 @@ pub const HEX_OCTREE: CommandSpec = CommandSpec { id: "mesh.hex.octree", label: "Octree Hex Mesh", params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the mesh with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#, + form: None, kind: CommandKind::Model, run: hex_octree, }; diff --git a/crates/engine/src/cmd/results.rs b/crates/engine/src/cmd/results.rs index 975c368..1a381f8 100644 --- a/crates/engine/src/cmd/results.rs +++ b/crates/engine/src/cmd/results.rs @@ -12,6 +12,7 @@ pub const DEFORMED: CommandSpec = CommandSpec { id: "results.deformed", label: "Deformed Shape", params: r#"{"step": "Load", "show": true, "scale": "auto"} — shows a solved step's deformed shape (`show: false` the undeformed mesh); `scale` is a factor (0 draws the undeformed shape, so a sequence from 0 swings it out) or "auto" (the largest displacement drawn a tenth of the model's size); `step` defaults to the step shown or the first solved one"#, + form: None, kind: CommandKind::Session, run: deformed, }; @@ -20,6 +21,7 @@ pub const CONTOUR: CommandSpec = CommandSpec { id: "results.contour", label: "Contours", params: r#"{"step": "Load", "field": "von_mises", "bands": 12} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps); "none" turns them off; 2 to 24 bands"#, + form: None, kind: CommandKind::Session, run: contour, }; @@ -28,6 +30,7 @@ pub const MODES: CommandSpec = CommandSpec { id: "results.modes", label: "Mode Shapes", params: r#"{"step": "Modes", "mode": 1} — shows a mode shape of a solved frequency step (from 1) and lists every mode's frequency"#, + form: None, kind: CommandKind::Session, run: modes, }; @@ -36,6 +39,7 @@ pub const ANIMATE: CommandSpec = CommandSpec { id: "results.animate", label: "Animate", params: r#"{"on": true} — animates the deformed shape: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed"#, + form: None, kind: CommandKind::Session, run: animate, }; @@ -44,6 +48,7 @@ pub const FRAME: CommandSpec = CommandSpec { id: "results.frame", label: "Field Frame", params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit step, by index from 0 or the frame nearest a time (neither: the last), or of a nonlinear static step (one per converged increment, `time` its load factor; the deformed shape follows it, its contours show the final state); the deformed shape and contours follow it"#, + form: None, kind: CommandKind::Session, run: frame, }; @@ -52,6 +57,7 @@ pub const REACTIONS: CommandSpec = CommandSpec { id: "results.reactions", label: "Reaction Sums", params: r#"{"step": "Load", "sets": ["root"], "about": [0, 0, 0]} — reaction force and moment (about the point) summed over each node set, and over the whole model; `sets` defaults to the sets the step's boundary conditions hold"#, + form: None, kind: CommandKind::Session, run: reactions, }; @@ -60,6 +66,7 @@ pub const CLEAR: CommandSpec = CommandSpec { id: "results.clear", label: "Hide Results", params: "{} — back to the model: hides the deformed shape, contours and modes", + form: None, kind: CommandKind::Session, run: clear, }; diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index 9eea63d..b671216 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -19,6 +19,7 @@ pub const RUN: CommandSpec = CommandSpec { id: "solve.run", label: "Solve All Steps", params: r#"{} — solves every step in order with its procedure's solver (solve.static, solve.frequency, solve.explicit, solve.fluid with their default caps) and keeps the results; stops at the first step that fails, naming it"#, + form: None, kind: CommandKind::Model, run: run_all, }; @@ -27,6 +28,7 @@ pub const STATIC: CommandSpec = CommandSpec { id: "solve.static", label: "Solve Static Step", params: r#"{"step": "Load", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — solves a linear static or coupled temperature–displacement step (the only one when `step` is left out) and keeps the result in the session; the caps may only be lowered; `threads` (1 to 256, by default the session's: `FIELDCRAFT_THREADS` or the cores) changes only the time, never the result"#, + form: None, kind: CommandKind::Model, run: run_static, }; @@ -35,6 +37,7 @@ pub const NONLINEAR: CommandSpec = CommandSpec { id: "solve.nonlinear", label: "Solve Nonlinear Static Step", params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains (small strains without nonlinear geometry), the equivalent plastic strain of plastic materials (the largest in the summary), every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment; the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#, + form: None, kind: CommandKind::Model, run: run_nonlinear, }; @@ -43,6 +46,7 @@ pub const HEAT: CommandSpec = CommandSpec { id: "solve.heat", label: "Solve Heat Transfer Step", params: r#"{"step": "Heat", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — steady or transient heat conduction of a heat transfer step (the only one when `step` is left out): temperatures, heat flux and the heat flow at prescribed temperatures, kept in the session; the caps may only be lowered; `threads` as for solve.static"#, + form: None, kind: CommandKind::Model, run: run_heat, }; @@ -51,6 +55,7 @@ pub const FREQUENCY: CommandSpec = CommandSpec { id: "solve.frequency", label: "Solve Frequency Step", params: r#"{"step": "Modes", "mass": "consistent|lumped", "shift": 0.0, "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — natural frequencies and mode shapes of a frequency step (its modes and range); `max_iterations` caps the Lanczos vectors; the result stays in the session; `threads` as for solve.static"#, + form: None, kind: CommandKind::Model, run: run_frequency, }; @@ -59,6 +64,7 @@ pub const EXPLICIT: CommandSpec = CommandSpec { id: "solve.explicit", label: "Solve Explicit Dynamic Step", params: r#"{"step": "Impact", "max_dofs": 6000000, "max_increments": 1000000000, "max_output_values": 50000000, "max_contact_faces": 10000000, "threads": 8} — transient response of an explicit dynamic step (the only one when `step` is left out) by central differences, with the model's contacts (interaction.contact.*) and the step's rigid walls; keeps its histories and field frames in the session and reports each contact's peak force, first and last contact, deepest penetration, friction dissipated and the nodes adjusted at the start; the caps may only be lowered (the step's own increment cap applies too); `threads` as for solve.static"#, + form: None, kind: CommandKind::Model, run: run_explicit, }; @@ -67,6 +73,7 @@ pub const HISTORY: CommandSpec = CommandSpec { id: "solve.history", label: "Result Histories", params: r#"{"step": "Impact", "every": 1} — the time histories of a solved explicit step: time, increment, energies, momentum, constraint force, the normal force across each contact (in `contacts` order) and the history nodes' displacement and velocity; `every` keeps one sample in so many (by default enough to return at most 10000)"#, + form: None, kind: CommandKind::Model, run: history, }; @@ -75,6 +82,7 @@ pub const FLUID: CommandSpec = CommandSpec { id: "solve.fluid", label: "Solve Fluid Step", params: r#"{"step": "Flow", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass and the final force on every wall; the caps may only be lowered; `threads` as for solve.static"#, + form: None, kind: CommandKind::Model, run: run_fluid, }; @@ -83,6 +91,7 @@ pub const PROBE: CommandSpec = CommandSpec { id: "solve.probe", label: "Probe Results", params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#, + form: None, kind: CommandKind::Model, run: probe, }; diff --git a/crates/engine/src/cmd/tags.rs b/crates/engine/src/cmd/tags.rs index ef85de9..600c283 100644 --- a/crates/engine/src/cmd/tags.rs +++ b/crates/engine/src/cmd/tags.rs @@ -11,12 +11,13 @@ pub const TAG: CommandSpec = CommandSpec { id: "mesh.tag", label: "Tag Mesh with Geometry", params: r#"{"geometry": {"body": "Body1"} | {"body", "face"} | {"body", "edge"} | {"body", "vertex"}, "elements": [labels] (filling a body, face or edge), "faces": [{"element": 1, "face": 3}] (on a face), "edges": [{"element": 1, "edge": 2}] (on an edge), "nodes": [labels] (on a vertex)} — records where the mesh lies on the geometry, so sets named by geometry resolve"#, + form: None, kind: CommandKind::Model, run: tag, }; pub const TAG_CLEAR: CommandSpec = - CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", kind: CommandKind::Model, run: tag_clear }; + CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", form: None, kind: CommandKind::Model, run: tag_clear }; #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e02656d..d409f2b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub use cmd::analytic_surfaces; pub mod history; pub mod results; pub mod sample; +pub mod schema; pub mod view; use std::collections::BTreeMap; @@ -108,6 +109,9 @@ pub struct CommandSpec { pub label: &'static str, /// The JSON parameters, documented for scripts, `fieldcraft-cli commands` and agents. pub params: &'static str, + /// The parameters field by field, for forms and JSON Schema; `None` when not described yet + /// (the form is then the JSON text alone). + pub form: Option<&'static [schema::Field]>, pub kind: CommandKind, pub run: fn(&mut Session, &Value) -> Result, } diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs new file mode 100644 index 0000000..b0d3245 --- /dev/null +++ b/crates/engine/src/schema.rs @@ -0,0 +1,733 @@ +//! Typed parameter schemas: what a command's JSON parameters are, field by field. +//! +//! A command's [`CommandSpec::form`](crate::CommandSpec::form) lists its [`Field`]s: name, kind, +//! default, whether it is required, its unit dimension, its choices, the model collection it +//! names, nested groups and one-of variants. The schema describes the JSON the command already +//! takes; it never changes it. The UI draws its command forms from it, `fieldcraft-cli commands +//! --json` and the MCP `list_commands` tool give it as JSON Schema ([`json_schema`]). +//! +//! The schema is descriptive: the command's `run` stays the judge of its parameters. What the +//! schema cannot express (a mass given per direction, a table of load-path points) stays reachable +//! through the form's JSON view. + +use fieldcraft_model::UnitSystem; +use serde_json::{Map, Value, json}; + +use crate::Session; + +/// Rows a list field holds at most in a form. +pub const MAX_ROWS: usize = 1000; +/// Names a reference list offers at most. +pub const MAX_NAMES: usize = 10_000; + +/// One parameter of a command. +#[derive(Debug, Clone, Copy)] +pub struct Field { + /// Its JSON key. Empty for [`Kind::Variants`] whose tag and fields sit in the parent object + /// (an internally tagged definition such as a load: `{"name", "type": "force", "vector"}`). + pub key: &'static str, + pub label: &'static str, + pub kind: Kind, + pub required: bool, + /// The value the form starts with, as JSON text; `None` leaves the field empty. + pub default: Option<&'static str>, + /// What an empty field means ("every body", "1/25 of the diagonal"), or a short note. + pub hint: &'static str, +} + +/// What a field holds. +#[derive(Debug, Clone, Copy)] +pub enum Kind { + /// A number in the model's unit system (or a fixed unit such as degrees). + Number { + dim: Dim, + min: Option, + max: Option, + }, + /// A whole number in `min..=max`. + Integer { + min: i64, + max: i64, + }, + Text, + /// A file path; `save` when the command writes it. + Path { + save: bool, + }, + Bool, + /// One of these strings: (value, label). + Choice(&'static [(&'static str, &'static str)]), + /// A fixed-length list of numbers, one per label (`["x", "y", "z"]`); `nullable` entries may + /// be null (a free degree of freedom). + Vector { + dim: Dim, + labels: &'static [&'static str], + nullable: bool, + }, + /// The name of an item of the model or the open design. + Ref(Collection), + /// A list of such names. + RefList(Collection), + /// A nested object. + Group(&'static [Field]), + /// A nested object (or, with an empty key, the parent object) tagged by `tag`: the tag's value + /// picks the variant whose fields apply. + Variants { + tag: &'static str, + variants: &'static [Variant], + }, + /// An object with exactly one of these keys (`{"set": …}` or `{"reference_point": …}`), or a + /// bare string for a [`Kind::Unit`] option. + Either(&'static [Field]), + /// An option of [`Kind::Either`] written as its key alone (`"line"`). + Unit, + /// A list of objects with these fields, at most `max` long. + Rows { + fields: &'static [Field], + max: usize, + }, + /// Any JSON: what the schema does not describe. + Json, +} + +/// One variant of [`Kind::Variants`]. +#[derive(Debug, Clone, Copy)] +pub struct Variant { + pub tag: &'static str, + pub label: &'static str, + pub fields: &'static [Field], +} + +/// Where a reference field's names come from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Collection { + Set, + Material, + Section, + ReferencePoint, + Coupling, + Contact, + Mass, + Spring, + BoundaryCondition, + Load, + FluidDomain, + Step, + /// A solved step. + SolvedStep, + /// A body of the open design. + Body, +} + +impl Collection { + pub fn label(self) -> &'static str { + match self { + Collection::Set => "set", + Collection::Material => "material", + Collection::Section => "section", + Collection::ReferencePoint => "reference point", + Collection::Coupling => "coupling", + Collection::Contact => "contact", + Collection::Mass => "point mass", + Collection::Spring => "spring", + Collection::BoundaryCondition => "boundary condition", + Collection::Load => "load", + Collection::FluidDomain => "fluid domain", + Collection::Step => "step", + Collection::SolvedStep => "solved step", + Collection::Body => "body", + } + } +} + +impl Session { + /// The names a reference field may take, in order (at most [`MAX_NAMES`]). + pub fn names(&self, c: Collection) -> Vec { + let m = &self.model; + let names: Box> = match c { + Collection::Set => Box::new(m.sets().keys().map(String::as_str)), + Collection::Material => Box::new(m.materials().keys().map(String::as_str)), + Collection::Section => Box::new(m.sections().keys().map(String::as_str)), + Collection::ReferencePoint => Box::new(m.reference_points().keys().map(String::as_str)), + Collection::Coupling => Box::new(m.couplings().keys().map(String::as_str)), + Collection::Contact => Box::new(m.contacts().keys().map(String::as_str)), + Collection::Mass => Box::new(m.masses().keys().map(String::as_str)), + Collection::Spring => Box::new(m.springs().keys().map(String::as_str)), + Collection::BoundaryCondition => Box::new(m.boundary_conditions().keys().map(String::as_str)), + Collection::Load => Box::new(m.loads().keys().map(String::as_str)), + Collection::FluidDomain => Box::new(m.fluid_domains().keys().map(String::as_str)), + Collection::Step => Box::new(m.steps().iter().map(|s| s.name.as_str())), + Collection::SolvedStep => Box::new(self.solved_steps()), + Collection::Body => Box::new(self.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.as_str()))), + }; + names.take(MAX_NAMES).map(str::to_string).collect() + } +} + +/// A physical dimension: its unit follows the model's unit system. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dim { + /// No unit (a ratio, a count, a factor). + None, + Length, + Area, + Stress, + Density, + Force, + Moment, + Mass, + Inertia, + Time, + Velocity, + Acceleration, + AngularVelocity, + Stiffness, + Damping, + Viscosity, + ForcePerVolume, + Power, + HeatFlux, + FilmCoefficient, + Conductivity, + SpecificHeat, + Expansion, + Temperature, + /// Always millimetres (geometry from SolidCraft). + Millimetre, + Degree, + Hertz, +} + +impl Dim { + /// Its unit in `system`, or `None` (no unit, or the system is unspecified). + pub fn unit(self, system: UnitSystem) -> Option { + let fixed = match self { + Dim::Millimetre => Some("mm"), + Dim::Degree => Some("°"), + Dim::Hertz => Some("Hz"), + Dim::Expansion => Some("1/K"), + _ => None, + }; + if let Some(f) = fixed { + return Some(f.into()); + } + let (l, m, t, f, s) = match system { + UnitSystem::Unspecified => return None, + UnitSystem::MKgS => ("m", "kg", "s", "N", "Pa"), + UnitSystem::MmTonneS => ("mm", "t", "s", "N", "MPa"), + UnitSystem::MmKgMs => ("mm", "kg", "ms", "kN", "GPa"), + UnitSystem::MmGMs => ("mm", "g", "ms", "N", "MPa"), + UnitSystem::InLbfS => ("in", "lbf·s²/in", "s", "lbf", "psi"), + }; + let inch = system == UnitSystem::InLbfS; + Some(match self { + Dim::Length => l.into(), + Dim::Area => format!("{l}²"), + Dim::Stress => s.into(), + Dim::Density if inch => "lbf·s²/in⁴".into(), + Dim::Density => format!("{m}/{l}³"), + Dim::Force => f.into(), + Dim::Moment => format!("{f}·{l}"), + Dim::Mass => m.into(), + Dim::Inertia if inch => "lbf·s²·in".into(), + Dim::Inertia => format!("{m}·{l}²"), + Dim::Time => t.into(), + Dim::Velocity => format!("{l}/{t}"), + Dim::Acceleration => format!("{l}/{t}²"), + Dim::AngularVelocity => format!("rad/{t}"), + Dim::Stiffness => format!("{f}/{l}"), + Dim::Damping => format!("{f}·{t}/{l}"), + Dim::Viscosity => format!("{s}·{t}"), + Dim::ForcePerVolume => format!("{f}/{l}³"), + Dim::Power => format!("{f}·{l}/{t}"), + Dim::HeatFlux => format!("{f}/({l}·{t})"), + Dim::FilmCoefficient => format!("{f}/({l}·{t}·K)"), + Dim::Conductivity => format!("{f}/({t}·K)"), + Dim::SpecificHeat => format!("{l}²/({t}²·K)"), + Dim::None | Dim::Temperature | Dim::Millimetre | Dim::Degree | Dim::Hertz | Dim::Expansion => return None, + }) + } +} + +// ---- Building fields (const, so schemas are static tables) ---- + +const fn field(key: &'static str, label: &'static str, kind: Kind) -> Field { + Field { key, label, kind, required: false, default: None, hint: "" } +} + +impl Field { + /// Required. + pub const fn req(self) -> Field { + Field { required: true, ..self } + } + /// Starts with this JSON value. + pub const fn def(self, json: &'static str) -> Field { + Field { default: Some(json), ..self } + } + /// What an empty field means. + pub const fn hint(self, hint: &'static str) -> Field { + Field { hint, ..self } + } +} + +pub const fn num(key: &'static str, label: &'static str, dim: Dim) -> Field { + field(key, label, Kind::Number { dim, min: None, max: None }) +} +pub const fn range(key: &'static str, label: &'static str, dim: Dim, min: f64, max: f64) -> Field { + field(key, label, Kind::Number { dim, min: Some(min), max: Some(max) }) +} +pub const fn positive(key: &'static str, label: &'static str, dim: Dim) -> Field { + field(key, label, Kind::Number { dim, min: Some(0.0), max: None }) +} +pub const fn int(key: &'static str, label: &'static str, min: i64, max: i64) -> Field { + field(key, label, Kind::Integer { min, max }) +} +pub const fn text(key: &'static str, label: &'static str) -> Field { + field(key, label, Kind::Text) +} +pub const fn path(key: &'static str, label: &'static str, save: bool) -> Field { + field(key, label, Kind::Path { save }) +} +pub const fn flag(key: &'static str, label: &'static str) -> Field { + field(key, label, Kind::Bool) +} +pub const fn choice(key: &'static str, label: &'static str, options: &'static [(&'static str, &'static str)]) -> Field { + field(key, label, Kind::Choice(options)) +} +pub const fn xyz(key: &'static str, label: &'static str, dim: Dim) -> Field { + field(key, label, Kind::Vector { dim, labels: &["x", "y", "z"], nullable: false }) +} +/// A vector whose entries may be null (left free). +pub const fn free_vector(key: &'static str, label: &'static str, dim: Dim, labels: &'static [&'static str]) -> Field { + field(key, label, Kind::Vector { dim, labels, nullable: true }) +} +pub const fn vector(key: &'static str, label: &'static str, dim: Dim, labels: &'static [&'static str]) -> Field { + field(key, label, Kind::Vector { dim, labels, nullable: false }) +} +pub const fn reference(key: &'static str, label: &'static str, c: Collection) -> Field { + field(key, label, Kind::Ref(c)) +} +pub const fn references(key: &'static str, label: &'static str, c: Collection) -> Field { + field(key, label, Kind::RefList(c)) +} +pub const fn group(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field { + field(key, label, Kind::Group(fields)) +} +pub const fn variants(key: &'static str, label: &'static str, variants: &'static [Variant]) -> Field { + field(key, label, Kind::Variants { tag: "type", variants }) +} +pub const fn either(key: &'static str, label: &'static str, options: &'static [Field]) -> Field { + field(key, label, Kind::Either(options)) +} +pub const fn unit(key: &'static str, label: &'static str) -> Field { + field(key, label, Kind::Unit) +} +pub const fn rows(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field { + field(key, label, Kind::Rows { fields, max: MAX_ROWS }) +} +pub const fn json(key: &'static str, label: &'static str) -> Field { + field(key, label, Kind::Json) +} +pub const fn variant(tag: &'static str, label: &'static str, fields: &'static [Field]) -> Variant { + Variant { tag, label, fields } +} + +/// The `name` of a new or edited definition. +pub const NAME: Field = text("name", "Name").req(); + +impl Kind { + /// The variant of `tag`, for [`Kind::Variants`]. + pub fn variant(&self, tag: &str) -> Option<&'static Variant> { + match self { + Kind::Variants { variants, .. } => variants.iter().find(|v| v.tag == tag), + _ => None, + } + } +} + +// ---- Prefilling, switching variants, checking ---- + +/// `params` with the defaults of the fields it leaves out filled in (required groups created, +/// the active variant's defaults too). What it already has is kept. +pub fn prefill(fields: &[Field], params: &Value) -> Value { + let mut obj = params.as_object().cloned().unwrap_or_default(); + fill(fields, &mut obj, 0); + Value::Object(obj) +} + +/// Nesting deeper than this is not followed (schemas are shallow; this bounds the recursion). +pub const MAX_DEPTH: usize = 8; + +fn fill(fields: &[Field], obj: &mut Map, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for f in fields { + match f.kind { + Kind::Variants { tag, .. } if f.key.is_empty() => { + if !obj.contains_key(tag) + && let Some(d) = parse_default(f) + { + obj.insert(tag.into(), d); + } + if let Some(v) = obj.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) { + fill(v.fields, obj, depth + 1); + } + } + Kind::Group(_) | Kind::Variants { .. } => { + if !obj.contains_key(f.key) { + match parse_default(f) { + Some(d) => { + obj.insert(f.key.into(), d); + } + None if f.required => { + obj.insert(f.key.into(), Value::Object(Map::new())); + } + None => continue, + } + } + if let Some(Value::Object(child)) = obj.get_mut(f.key) { + fill_nested(f, child, depth + 1); + } + } + _ => { + if !obj.contains_key(f.key) + && let Some(d) = parse_default(f) + { + obj.insert(f.key.into(), d); + } + } + } + } +} + +/// Fill a group's or a (keyed) variant field's own object. +fn fill_nested(f: &Field, child: &mut Map, depth: usize) { + match f.kind { + Kind::Group(fields) => fill(fields, child, depth), + Kind::Variants { tag, variants } => { + if !child.contains_key(tag) + && let Some(first) = variants.first() + { + child.insert(tag.into(), json!(first.tag)); + } + if let Some(v) = child.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) { + fill(v.fields, child, depth); + } + } + _ => {} + } +} + +fn parse_default(f: &Field) -> Option { + f.default.and_then(|d| serde_json::from_str(d).ok()) +} + +/// The object for variant `tag` of variants field `f`, from `old`: the tag, the values of `old` +/// the new variant (or `common`, the fields beside the variants) also has, and the new variant's +/// defaults. +pub fn switch_variant(f: &Field, common: &[Field], old: &Map, tag: &str) -> Map { + let Kind::Variants { tag: tag_key, .. } = f.kind else { return old.clone() }; + let Some(v) = f.kind.variant(tag) else { return old.clone() }; + let keep = |k: &str| v.fields.iter().chain(common).any(|g| g.key == k && g.key != tag_key); + let mut out: Map = old.iter().filter(|(k, _)| keep(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + out.insert(tag_key.into(), json!(tag)); + fill(v.fields, &mut out, 0); + out +} + +/// A problem with a field's value, for the form to show beside it. +#[derive(Debug, Clone, PartialEq)] +pub struct Problem { + /// The JSON path of the field (`elastic/young`). + pub path: String, + pub message: String, +} + +/// What is missing or out of range in `params`: required fields left out, numbers outside +/// their range, values of the wrong kind. An empty list does not promise the command accepts +/// them (it checks more); a problem means it would not. +pub fn problems(fields: &[Field], params: &Value) -> Vec { + match params.as_object() { + Some(obj) => object_problems(fields, obj), + None => vec![Problem { path: String::new(), message: "the parameters must be a JSON object".into() }], + } +} + +/// [`problems`] of an object's fields. +pub fn object_problems(fields: &[Field], obj: &Map) -> Vec { + let mut out = Vec::new(); + check(fields, obj, "", &mut out, 0); + out +} + +/// The path of field `key` inside `prefix` (`elastic/young`). +pub fn join(prefix: &str, key: &str) -> String { + if prefix.is_empty() { + key.to_string() + } else if key.is_empty() { + prefix.to_string() + } else { + format!("{prefix}/{key}") + } +} + +fn check(fields: &[Field], obj: &Map, prefix: &str, out: &mut Vec, depth: usize) { + if depth > MAX_DEPTH || out.len() > MAX_ROWS { + return; + } + for f in fields { + if let Kind::Variants { tag, .. } = f.kind + && f.key.is_empty() + { + match obj.get(tag).and_then(Value::as_str) { + Some(t) => match f.kind.variant(t) { + Some(v) => check(v.fields, obj, prefix, out, depth + 1), + None => out.push(Problem { path: join(prefix, tag), message: format!("`{t}` is not a {}", f.label.to_lowercase()) }), + }, + None if f.required => out.push(Problem { path: join(prefix, tag), message: "required".into() }), + None => {} + } + continue; + } + let path = join(prefix, f.key); + match obj.get(f.key) { + None | Some(Value::Null) if f.required => out.push(Problem { path, message: "required".into() }), + None | Some(Value::Null) => {} + Some(v) => { + if let Some(message) = value_problem(f, v, &path, out, depth) { + out.push(Problem { path, message }); + } + } + } + } +} + +/// What is wrong with `v` as the value of `f`; nested problems go to `out`. +fn value_problem(f: &Field, v: &Value, path: &str, out: &mut Vec, depth: usize) -> Option { + match f.kind { + Kind::Number { min, max, .. } => match (v.as_f64().filter(|x| x.is_finite()), min, max) { + (None, ..) => Some("a number".into()), + (Some(x), Some(lo), _) if x < lo => Some(format!("at least {lo}")), + (Some(x), _, Some(hi)) if x > hi => Some(format!("at most {hi}")), + _ => None, + }, + Kind::Integer { min, max } => match v.as_i64() { + Some(x) if (min..=max).contains(&x) => None, + _ => Some(format!("a whole number in {min}..={max}")), + }, + Kind::Text | Kind::Path { .. } | Kind::Ref(_) => match v.as_str() { + Some(s) if f.required && s.trim().is_empty() => Some("required".into()), + Some(_) => None, + None => Some("text".into()), + }, + Kind::Bool => (!v.is_boolean()).then(|| "true or false".into()), + Kind::Choice(options) => match v.as_str() { + Some(s) if options.iter().any(|(o, _)| *o == s) => None, + _ => Some(format!("one of {}", options.iter().map(|(o, _)| *o).collect::>().join(", "))), + }, + Kind::Vector { labels, nullable, .. } => match v.as_array() { + Some(a) if a.len() == labels.len() && a.iter().all(|x| x.as_f64().is_some_and(f64::is_finite) || (nullable && x.is_null())) => None, + _ => Some(format!("{} numbers", labels.len())), + }, + Kind::RefList(_) => (!v.as_array().is_some_and(|a| a.iter().all(Value::is_string))).then(|| "a list of names".into()), + Kind::Group(fields) => match v.as_object() { + Some(o) => { + check(fields, o, path, out, depth + 1); + None + } + None => Some("an object".into()), + }, + Kind::Variants { tag, .. } => match v.as_object() { + Some(o) => match o.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) { + Some(var) => { + check(var.fields, o, path, out, depth + 1); + None + } + None => Some(format!("needs a known `{tag}`")), + }, + None => Some("an object".into()), + }, + Kind::Either(options) => { + let ok = match v { + Value::String(s) => options.iter().any(|o| o.key == s && matches!(o.kind, Kind::Unit)), + Value::Object(o) if o.len() == 1 => o.iter().all(|(k, inner)| { + options.iter().find(|opt| opt.key == k).is_some_and(|opt| value_problem(opt, inner, path, out, depth + 1).is_none()) + }), + _ => false, + }; + (!ok).then(|| format!("one of {}", options.iter().map(|o| o.key).collect::>().join(", "))) + } + Kind::Unit | Kind::Json => None, + Kind::Rows { fields, max } => match v.as_array() { + Some(a) if a.len() <= max => { + for (i, row) in a.iter().enumerate() { + match row.as_object() { + Some(o) => check(fields, o, &format!("{path}/{i}"), out, depth + 1), + None => out.push(Problem { path: format!("{path}/{i}"), message: "an object".into() }), + } + } + None + } + _ => Some(format!("a list of at most {max} rows")), + }, + } +} + +// ---- JSON Schema ---- + +/// The fields as a JSON Schema (draft 2020-12) object schema, units and hints in descriptions. +pub fn json_schema(fields: &[Field]) -> Value { + object_schema(fields, 0) +} + +fn object_schema(fields: &[Field], depth: usize) -> Value { + let mut properties = Map::new(); + let mut required = Vec::new(); + let mut one_of = None; + for f in fields { + if let Kind::Variants { tag, variants } = f.kind + && f.key.is_empty() + { + one_of = Some(variant_schemas(tag, variants, depth)); + if f.required { + required.push(json!(tag)); + } + continue; + } + properties.insert(f.key.into(), field_schema(f, depth)); + if f.required { + required.push(json!(f.key)); + } + } + let mut s = json!({"type": "object", "properties": properties}); + if !required.is_empty() { + s["required"] = Value::Array(required); + } + if let Some(o) = one_of { + s["oneOf"] = o; + } + s +} + +fn variant_schemas(tag: &str, variants: &[Variant], depth: usize) -> Value { + Value::Array( + variants + .iter() + .map(|v| { + let mut s = if depth > MAX_DEPTH { json!({"type": "object"}) } else { object_schema(v.fields, depth + 1) }; + s["properties"][tag] = json!({"const": v.tag, "title": v.label}); + let mut req = s.get("required").and_then(Value::as_array).cloned().unwrap_or_default(); + req.push(json!(tag)); + s["required"] = Value::Array(req); + s + }) + .collect(), + ) +} + +fn field_schema(f: &Field, depth: usize) -> Value { + let mut s = match f.kind { + Kind::Number { dim, min, max } => { + let mut s = json!({"type": "number"}); + if let Some(lo) = min { + s["minimum"] = json!(lo); + } + if let Some(hi) = max { + s["maximum"] = json!(hi); + } + if dim != Dim::None { + s["x-dimension"] = json!(format!("{dim:?}").to_lowercase()); + } + s + } + Kind::Integer { min, max } => json!({"type": "integer", "minimum": min, "maximum": max}), + Kind::Text | Kind::Path { .. } => json!({"type": "string"}), + Kind::Bool => json!({"type": "boolean"}), + Kind::Choice(options) => json!({"enum": options.iter().map(|(o, _)| *o).collect::>()}), + Kind::Vector { labels, nullable, .. } => { + let item = if nullable { json!({"type": ["number", "null"]}) } else { json!({"type": "number"}) }; + json!({"type": "array", "items": item, "minItems": labels.len(), "maxItems": labels.len()}) + } + Kind::Ref(c) => json!({"type": "string", "x-references": c.label()}), + Kind::RefList(c) => json!({"type": "array", "items": {"type": "string", "x-references": c.label()}}), + Kind::Group(fields) if depth <= MAX_DEPTH => object_schema(fields, depth + 1), + Kind::Variants { tag, variants } if depth <= MAX_DEPTH => json!({"oneOf": variant_schemas(tag, variants, depth + 1)}), + Kind::Either(options) if depth <= MAX_DEPTH => json!({"oneOf": options.iter().map(|o| match o.kind { + Kind::Unit => json!({"const": o.key}), + _ => json!({"type": "object", "properties": {o.key: field_schema(o, depth + 1)}, "required": [o.key], "additionalProperties": false}), + }).collect::>()}), + Kind::Rows { fields, max } if depth <= MAX_DEPTH => json!({"type": "array", "items": object_schema(fields, depth + 1), "maxItems": max}), + Kind::Unit => json!({"const": f.key}), + _ => json!({}), + }; + s["title"] = json!(f.label); + if !f.hint.is_empty() { + s["description"] = json!(f.hint); + } + if let Some(d) = parse_default(f) { + s["default"] = d; + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn units_follow_the_system() { + assert_eq!(Dim::Stress.unit(UnitSystem::MmTonneS).as_deref(), Some("MPa")); + assert_eq!(Dim::Density.unit(UnitSystem::MmTonneS).as_deref(), Some("t/mm³")); + assert_eq!(Dim::Moment.unit(UnitSystem::MKgS).as_deref(), Some("N·m")); + assert_eq!(Dim::Density.unit(UnitSystem::InLbfS).as_deref(), Some("lbf·s²/in⁴")); + assert_eq!(Dim::Stress.unit(UnitSystem::Unspecified), None); + assert_eq!(Dim::Millimetre.unit(UnitSystem::Unspecified).as_deref(), Some("mm")); + assert_eq!(Dim::None.unit(UnitSystem::MKgS), None); + } + + const LOADISH: &[Field] = &[ + NAME, + variants( + "", + "Type", + &[ + variant( + "force", + "Force", + &[xyz("vector", "Vector", Dim::Force).req(), choice("distribution", "Spread", &[("total", "Total")]).def("\"total\"")], + ), + variant("pressure", "Pressure", &[num("magnitude", "Magnitude", Dim::Stress).req(), reference("set", "Set", Collection::Set).req()]), + ], + ) + .req() + .def("\"force\""), + ]; + + #[test] + fn prefill_switch_and_check() { + let p = prefill(LOADISH, &json!({})); + assert_eq!(p, json!({"type": "force", "distribution": "total"})); + let problems = problems(LOADISH, &p); + let paths: Vec<&str> = problems.iter().map(|p| p.path.as_str()).collect(); + assert_eq!(paths, ["name", "vector"]); + let root = &LOADISH[1]; + let switched = switch_variant( + root, + LOADISH, + &json!({"name": "a", "type": "force", "vector": [1, 2, 3]}).as_object().cloned().unwrap_or_default(), + "pressure", + ); + assert_eq!(Value::Object(switched), json!({"name": "a", "type": "pressure"})); + assert!(super::problems(LOADISH, &json!({"name": "a", "type": "force", "vector": [1, "x", 3]})).iter().any(|p| p.path == "vector")); + assert!(super::problems(LOADISH, &json!([1])).len() == 1); + } + + #[test] + fn json_schema_has_variants_and_requirements() { + let s = json_schema(LOADISH); + assert_eq!(s["required"], json!(["name", "type"])); + assert_eq!(s["oneOf"][1]["properties"]["type"]["const"], "pressure"); + assert_eq!(s["oneOf"][1]["required"], json!(["magnitude", "set", "type"])); + } +}