Agents and scripts: the component commands describe their exact parameters and reply (#12504) (#12597)

component.activate, create, delete, from_bodies, list, properties, rename
and unlock now publish typed input and output schemas. Each parameter is
read the way its handler reads it today and refused with the handler's own
message, and each reply is built from the typed value and serializes to the
same JSON as before. Their lines leave unconverted.txt. Part of #2829.
This commit is contained in:
PeppaWutz
2026-11-07 19:58:18 +00:00
committed by GitHub
parent beaa43cb40
commit fcd4ccd09a
7 changed files with 1158 additions and 71 deletions
+108 -34
View File
@@ -13,12 +13,23 @@ use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list, vec3};
use crate::schema::component::{
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, MoveFeaturesParams, MoveFeaturesResult, MoveSketchesToPartsParams,
MoveSketchesToPartsResult, OccurrenceAlignParams, OccurrenceAlignResult, ReparentItemsParams, ReparentItemsResult,
ActivateParams, ActivateResult, ChildEntry, CompactPlacement, ComponentNode, CreateParams, CreateResult, DeleteParams, DeleteResult,
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, FromBodiesParams, FromBodiesResult, ListParams, ListResult,
MoveFeaturesParams, MoveFeaturesResult, MoveSketchesToPartsParams, MoveSketchesToPartsResult, NamesOrCount, OccurrenceAlignParams,
OccurrenceAlignResult, PlacementEntry, PlacementInfo, PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams,
ReparentItemsResult, UnlockParams, UnlockResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const ACTIVATE_CONTRACT: TypedContract<ActivateParams> = TypedContract::new();
const CREATE_CONTRACT: TypedContract<CreateParams> = TypedContract::new();
const DELETE_CONTRACT: TypedContract<DeleteParams> = TypedContract::new();
const FROM_BODIES_CONTRACT: TypedContract<FromBodiesParams> = TypedContract::new();
const LIST_CONTRACT: TypedContract<ListParams> = TypedContract::new();
const PROPERTIES_CONTRACT: TypedContract<PropertiesParams> = TypedContract::new();
const RENAME_CONTRACT: TypedContract<RenameParams> = TypedContract::new();
const UNLOCK_CONTRACT: TypedContract<UnlockParams> = TypedContract::new();
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
@@ -67,14 +78,23 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.show_history", "Show Component History", show_history)
.icon("eye").doc_only()
.params("component: id or name; puts a component's features back in the History bar after Hide Component History (#10393)"),
CommandSpec::new("component.create", "New Component", new_component)
CommandSpec::new("component.create", "New Component", crate::checked!("component.create", new_component))
.at("SOLID", "ASSEMBLE")
.icon("component")
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<CreateResult>)
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true); grounded?: bool (default: true for the design's first part, false otherwise)"),
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
CommandSpec::new("component.from_bodies", "Create Components from Bodies", crate::checked!("component.from_bodies", from_bodies))
.icon("component")
.contract(&FROM_BODIES_CONTRACT)
.output(output_schema_for::<FromBodiesResult>)
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).icon("activate").noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
CommandSpec::new("component.activate", "Activate Component", crate::checked!("component.activate", activate))
.icon("activate")
.noundo()
.contract(&ACTIVATE_CONTRACT)
.output(output_schema_for::<ActivateResult>)
.params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
CommandSpec::new("component.edit_in_place", "Edit in Place", crate::checked!("component.edit_in_place", edit_in_place))
.icon("edit")
.noundo()
@@ -99,22 +119,41 @@ pub static COMMANDS: &[CommandSpec] = &[
fails while the design has unsaved changes (run file.save first) or something is open in it; asked during the agent's \
turn it finishes once the turn ends (queued: true); elsewhere nothing changes",
),
CommandSpec::new("component.list", "List Components", list).icon("list").noundo().params(
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
),
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
CommandSpec::new("component.list", "List Components", crate::checked!("component.list", list))
.icon("list")
.noundo()
.contract(&LIST_CONTRACT)
.output(output_schema_for::<ListResult>)
.params(
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
),
CommandSpec::new("component.properties", "Component Physical Properties", crate::checked!("component.properties", properties::component))
.icon("measure").noundo()
.contract(&PROPERTIES_CONTRACT)
.output(output_schema_for::<PropertiesResult>)
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
.icon("measure").noundo()
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
move and take joints one by one → joints (the ids removed)",
),
CommandSpec::new("component.rename", "Rename Component", crate::checked!("component.rename", rename))
.icon("rename")
.contract(&RENAME_CONTRACT)
.output(output_schema_for::<RenameResult>)
.params("component, name"),
CommandSpec::new("component.delete", "Delete Component", crate::checked!("component.delete", delete))
.icon("delete")
.contract(&DELETE_CONTRACT)
.output(output_schema_for::<DeleteResult>)
.params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", crate::checked!("component.unlock", unlock))
.icon("rigid_group")
.contract(&UNLOCK_CONTRACT)
.output(output_schema_for::<UnlockResult>)
.params(
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
move and take joints one by one → joints (the ids removed)",
),
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).icon("move").params(
"bodies: [names], component; component_path?: its placement to place them by (default: its only one, else the active \
one; a component placed more than once gets them in every instance); from_path?: the placement of their component they \
@@ -203,6 +242,11 @@ pub static COMMANDS: &[CommandSpec] = &[
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
/// A typed reply as the JSON a handler returns.
pub(super) fn reply<T: serde::Serialize>(value: T) -> Result<Value> {
serde_json::to_value(value).map_err(|e| crate::EngineError::Other(e.to_string()))
}
fn key(v: Option<&Value>) -> Option<String> {
match v {
Some(Value::Number(n)) => Some(n.to_string()),
@@ -288,7 +332,14 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
}
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
let occ = s.doc.occurrence_of(id);
Ok(json!({"component": id, "name": name, "parent": parent, "occurrence": occ.map(|o| o.id), "grounded": occ.is_some_and(|o| o.grounded)}))
reply(CreateResult {
component: id,
name,
parent,
occurrence: occ.map(|o| o.id),
grounded: occ.is_some_and(|o| o.grounded),
param_warnings: Vec::new(),
})
}
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
@@ -334,7 +385,7 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
s.active_component = id;
s.active_occurrence_path = path;
s.revision += 1;
Ok(json!({ "active": id }))
reply(ActivateResult { active: id, param_warnings: Vec::new() })
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
@@ -342,17 +393,20 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc;
// Compact: what a large assembly needs to be found around in, without every name and matrix.
let compact = bool_(p, "compact").unwrap_or(false);
let names = |v: Vec<&str>| if compact { json!(v.len()) } else { json!(v) };
let names = |v: Vec<&str>| if compact { NamesOrCount::Count(v.len()) } else { NamesOrCount::Names(v.into_iter().map(str::to_string).collect()) };
let body_names = !compact || bool_(p, "bodies").unwrap_or(false);
let placement = |o: &Occurrence| {
if !compact {
return json!(o);
return PlacementEntry::Full(PlacementInfo::from(o));
}
let mut v = json!({"id": o.id, "name": o.name, "parent": o.parent, "grounded": o.grounded});
if o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12) {
v["transform"] = json!(o.transform);
}
v
let moved = o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12);
PlacementEntry::Compact(CompactPlacement {
id: o.id,
name: o.name.clone(),
parent: o.parent,
grounded: o.grounded,
transform: moved.then_some(o.transform),
})
};
// Each component's bodies, features, occurrences and children, grouped once rather than
// searched for every component (thousands of each in a design with many components).
@@ -376,17 +430,37 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
let sketches = named(|k| matches!(k, FeatureKind::Sketch { .. }));
let construction = named(|k| matches!(k, FeatureKind::ConstructionPlane { .. }));
let features = named(|k| !matches!(k, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. }));
let occurrences: Vec<Value> = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect();
let children: Vec<Value> = children_of.get(&id).into_iter().flatten().map(|o| if compact { json!(o.id) } else { json!(o) }).collect();
let bodies = if body_names { json!(bodies) } else { json!(bodies.len()) };
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": names(features), "sketches": names(sketches),
"construction": names(construction), "occurrences": occurrences, "children": children})
let occurrences: Vec<PlacementEntry> = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect();
let children: Vec<ChildEntry> = children_of
.get(&id)
.into_iter()
.flatten()
.map(|o| if compact { ChildEntry::Id(o.id) } else { ChildEntry::Full(PlacementInfo::from(*o)) })
.collect();
let bodies =
if body_names { NamesOrCount::Names(bodies.into_iter().map(str::to_string).collect()) } else { NamesOrCount::Count(bodies.len()) };
ComponentNode {
id,
name: name.to_string(),
parent,
bodies,
features: names(features),
sketches: names(sketches),
construction: names(construction),
occurrences,
children,
}
};
let mut out = vec![node(0, &doc.name, None)];
for c in &doc.components {
out.push(node(c.id, &c.name, Some(c.parent)));
}
Ok(json!({"active": s.active_component, "components": out, "pending_moves": s.pending_moves.keys().collect::<Vec<_>>()}))
reply(ListResult {
active: s.active_component,
components: out,
pending_moves: s.pending_moves.keys().copied().collect(),
param_warnings: Vec::new(),
})
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
@@ -411,7 +485,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
// A sketch projecting a body of one of those placements keeps it.
super::sketch_project::follow_placed_renames(d, &st, &placed, None);
}
Ok(json!({"component": id, "name": name}))
reply(RenameResult { component: id, name: name.to_string(), param_warnings: Vec::new() })
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -428,7 +502,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if gone.contains(&s.active_component) {
s.active_component = 0;
}
Ok(json!({"deleted": gone, "features": removed.features}))
reply(DeleteResult { deleted: gone, features: removed.features, param_warnings: Vec::new() })
}
fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
@@ -448,7 +522,7 @@ fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
}
doc.assembly.links.retain(|l| !gone.contains(&l.a) && !gone.contains(&l.b));
s.visibility.hidden_joints.retain(|h| !gone.contains(h));
Ok(json!({"component": id, "joints": gone}))
reply(UnlockResult { component: id, joints: gone, param_warnings: Vec::new() })
}
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
+50 -22
View File
@@ -9,6 +9,7 @@ use solidcraft_geom::Aabb3;
use solidcraft_geom::inertia::{self, Tensor3};
use crate::params::bad;
use crate::schema::component::{BodyProperties, BoundingBox, InertiaAbout, PhysicalProperties, PropertiesResult, TotalProperties};
use crate::{Result, Session};
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
@@ -84,10 +85,8 @@ pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
} else {
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
out["component"] = json!(id);
out["name"] = json!(name);
Ok(out)
let properties = measure(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
super::reply(PropertiesResult { component: id, name, properties, param_warnings: Vec::new() })
}
pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
@@ -200,7 +199,12 @@ fn measure_placed(body: &solidcraft_doc::ModelBody, transform: Mat) -> Result<Pl
})
}
/// [`measure`] as the JSON `occurrence.properties` adds its own keys to.
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<Value> {
super::reply(measure(s, doc, selected, descendants, about, cmd)?)
}
fn measure(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<PhysicalProperties> {
let state = s.model.state();
let mut bodies = Vec::new();
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
@@ -292,13 +296,20 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
faces += measured.faces;
edges += measured.edges;
vertices += measured.vertices;
bodies.push(json!({"name": body.name, "component": component, "path": path,
"material": doc.body_material(&body.name),
"closed": closed, "density_g_cm3": closed.then_some(density),
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(measured.volume),
"area_mm2": measured.area, "center_of_mass": closed.then_some(measured.centroid),
"inertia_g_mm2": body_inertia,
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
bodies.push(BodyProperties {
name: body.name.to_string(),
component,
path: path.clone(),
material: doc.body_material(&body.name),
closed,
density_g_cm3: closed.then_some(density),
mass_g: closed.then_some(body_mass),
volume_mm3: closed.then_some(measured.volume),
area_mm2: measured.area,
center_of_mass: closed.then_some(measured.centroid),
inertia_g_mm2: body_inertia,
bbox: BoundingBox { min: measured.bbox.min, max: measured.bbox.max },
});
}
}
if let Some(w) = &watch {
@@ -321,19 +332,36 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
});
let at_centre = at_centre.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
let principal = at_centre.and_then(|i| inertia::principal(&i, EQUAL_MOMENTS));
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": bounds.min, "max": bounds.max}) };
let mut out = json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
"total": {"open_bodies_skipped": skipped, "volume_mm3": volume, "area_mm2": area, "mass_g": mass,
"center_of_mass": centroid, "first_moment_g_mm": moment, "inertia_g_mm2": at_centre,
"principal_moments_g_mm2": principal.map(|(moments, _)| moments), "principal_axes": principal.map(|(_, axes)| axes),
"bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}});
if let Some(point) = about {
let bbox = (!bodies.is_empty()).then_some(BoundingBox { min: bounds.min, max: bounds.max });
let inertia_about = about.map(|point| {
let shifted =
centroid.zip(at_centre).map(|(c, i)| inertia::shifted(&i, mass, point - c)).filter(|i| i.iter().flatten().all(|x| x.is_finite()));
out["total"]["inertia_about"] = json!({"point": point, "inertia_g_mm2": shifted});
}
Ok(out)
InertiaAbout { point, inertia_g_mm2: shifted }
});
Ok(PhysicalProperties {
path: selected.to_vec(),
coordinate_frame: "world".to_string(),
descendants,
placement_count,
body_count: bodies.len(),
bodies,
total: TotalProperties {
open_bodies_skipped: skipped,
volume_mm3: volume,
area_mm2: area,
mass_g: mass,
center_of_mass: centroid,
first_moment_g_mm: moment,
inertia_g_mm2: at_centre,
principal_moments_g_mm2: principal.map(|(moments, _)| moments),
principal_axes: principal.map(|(_, axes)| axes),
bbox,
faces,
edges,
vertices,
inertia_about,
},
})
}
/// The tessellating work a properties command did on this thread, for tests: placed copies
+3 -2
View File
@@ -1,5 +1,6 @@
//! Atomic timeline ownership changes, preserving the selected world geometry.
use super::*;
use crate::schema::component::{BodyComponent, FromBodiesResult};
use solidcraft_doc::BodyName;
use std::collections::{BTreeMap, BTreeSet};
@@ -128,10 +129,10 @@ pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
for (name, component) in bodies.into_iter().zip(targets) {
let feature = transfer(&mut scratch, &name, component, &frames, cmd)?;
features.push(feature);
made.push(json!({"component":component,"body":name,"feature":feature}));
made.push(BodyComponent { component, body: name, feature });
}
finish(s, &mut scratch, &features, cmd)?;
Ok(json!({"components":made}))
super::reply(FromBodiesResult { components: made, param_warnings: Vec::new() })
}
/// A placement path parameter (`component_path`: the target's placement to place by,
+657
View File
@@ -1,12 +1,17 @@
//! Contracts and result types for the commands in `cmd/component.rs`.
use std::borrow::Cow;
use std::collections::BTreeSet;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use solidcore_record::Vec3;
use solidcraft_doc::Occurrence;
use super::check::{located, whole};
use super::component_link::{ComponentRef, component_key, require};
use super::types::{IdOrName, Point3, PointExpr3, not_blank_pattern, present};
use crate::params;
@@ -473,6 +478,658 @@ pub struct ReparentItemsResult {
pub param_warnings: Vec<String>,
}
// ---------------------------------------------------------------------------------------------
// component.create, from_bodies, activate, list, properties, rename, delete and unlock (#12504).
//
// The handlers read their parameters from the raw JSON, so each key here reads as its handler
// reads it and fails with the handler's message. Whether a component, an occurrence or a body
// exists stays the handler's to check. A key the handler refuses when it is left out is optional
// in the type and required in the schema, so that the handler's message, not the check's "is
// required", is what a caller sees.
// ---------------------------------------------------------------------------------------------
/// `message`, the handler's, for a value that is not a boolean (`null` included).
fn flag<'de, D: Deserializer<'de>>(d: D, message: &str) -> Result<Option<bool>, D::Error> {
match Value::deserialize(d)? {
Value::Bool(b) => Ok(Some(b)),
_ => Err(D::Error::custom(whole(message))),
}
}
fn grounded_flag<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag(d, "grounded must be true or false")
}
fn descendants_flag<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag(d, "`descendants` must be a boolean")
}
/// A placement path as `component.create` reads `parent_path`: up to 63 occurrence ids (none
/// is the design itself).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParentPath(pub Vec<u64>);
impl<'de> Deserialize<'de> for ParentPath {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?
.as_array()
.filter(|a| a.len() < 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.map(ParentPath)
.ok_or_else(|| D::Error::custom(whole("parent_path must list up to 63 occurrence ids")))
}
}
impl JsonSchema for ParentPath {
fn schema_name() -> Cow<'static, str> {
"ParentPath".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "An exact placement path: up to 63 occurrence ids from the root down; empty for the design itself.",
"type": "array",
"items": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
"maxItems": 63
})
}
}
fn parent<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ComponentRef>, D::Error> {
component_key(d, "parent")
}
fn component<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ComponentRef>, D::Error> {
component_key(d, "component")
}
fn component_required(schema: &mut Schema) {
require(schema, "component");
}
/// Make a new component, in the active component unless `parent` says another, and make it the
/// active one unless `activate` is false. The design's first part is grounded; the others are
/// not. Bodies are not moved here (`component.move_bodies`, `component.from_bodies`).
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct CreateParams {
/// The new component's name. Default: a name made from the design's naming.
#[serde(default)]
pub name: Option<String>,
/// The component to put it in, by id or name. Default: the active one.
#[serde(default, deserialize_with = "parent", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub parent: Option<ComponentRef>,
/// The exact placement of the parent to place it by, as occurrence ids from the top of the
/// design (up to 63; none is the design itself). It must belong to the parent. Default: the
/// active placement when the parent is the active component, else the parent's first.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ParentPath")]
pub parent_path: Option<ParentPath>,
/// Make it the active component. Default: `true`.
#[serde(default)]
pub activate: Option<bool>,
/// Ground its placement. Default: `true` for the design's first part, `false` otherwise.
#[serde(default, deserialize_with = "grounded_flag", skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub grounded: Option<bool>,
}
/// What `component.create` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CreateResult {
/// The new component, by id.
pub component: u64,
/// Its name.
pub name: String,
/// The component it is in, by id (0 is the design).
pub parent: u64,
/// Its placement, by id.
pub occurrence: Option<u64>,
/// Whether its placement is grounded.
pub grounded: bool,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// `component.from_bodies`' `bodies`: a list of body names, each once, as `body_names` reads
/// it. The handler also looks each name up in the design.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DefinitionBodies(pub Vec<String>);
impl<'de> Deserialize<'de> for DefinitionBodies {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let names = Value::deserialize(d)?
.as_array()
.and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect::<Option<Vec<_>>>())
.ok_or_else(|| D::Error::custom(whole("bodies must be an array of body names")))?;
if names.is_empty() || names.len() > 1000 || names.iter().collect::<BTreeSet<_>>().len() != names.len() {
return Err(D::Error::custom(whole("bodies must list 1..1000 distinct existing definition bodies")));
}
Ok(DefinitionBodies(names))
}
}
impl JsonSchema for DefinitionBodies {
fn schema_name() -> Cow<'static, str> {
"DefinitionBodies".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "1 to 1000 different body names.",
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 1000,
"uniqueItems": true
})
}
}
fn bodies_required(schema: &mut Schema) {
require(schema, "bodies");
}
/// Make one new component per body, in the active component and named after the body, and move
/// each body into its component, keeping it where it is in the world. All or nothing, one undo
/// step; refused while the timeline is rolled back.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = bodies_required)]
pub struct FromBodiesParams {
// Optional in the type and required in the schema (`bodies_required`).
/// The bodies, by name.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "DefinitionBodies")]
pub bodies: Option<DefinitionBodies>,
}
/// One component `component.from_bodies` made.
#[derive(Debug, Serialize, JsonSchema)]
pub struct BodyComponent {
/// The new component, by id.
pub component: u64,
/// The body it holds, by name.
pub body: String,
/// The Body Transfer feature that moved the body, by id.
pub feature: u64,
}
/// What `component.from_bodies` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FromBodiesResult {
/// The new components, in the order of `bodies`.
pub components: Vec<BodyComponent>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// An occurrence by its id (a number) or its name, as `occurrence` reads it.
#[derive(Clone, Debug, PartialEq)]
pub struct OccurrenceRef(pub String);
impl<'de> Deserialize<'de> for OccurrenceRef {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::Number(n) => Ok(OccurrenceRef(n.to_string())),
Value::String(s) => Ok(OccurrenceRef(s)),
_ => Err(D::Error::custom(whole("`occurrence` must be an id or name"))),
}
}
}
impl JsonSchema for OccurrenceRef {
fn schema_name() -> Cow<'static, str> {
"OccurrenceRef".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "An occurrence's id (a number) or its name.", "type": ["number", "string"]})
}
}
fn activate_schema(schema: &mut Schema) {
require(schema, "component");
schema.insert("dependentRequired".into(), json!({"path": ["occurrence"]}));
}
/// Make a component the active one: new sketches and features go into it, authored in its
/// frame. With `occurrence` (and `path`), the placement that shared geometry is authored
/// through. A change of what is active, not an undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = activate_schema)]
pub struct ActivateKeys {
// Optional in the type and required in the schema (`activate_schema`).
/// The component to make active, by id or name (0 or `"root"` for the design).
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub component: Option<ComponentRef>,
/// The placement the component is authored in, by id or name, for a component placed more
/// than once. Needed when `path` is given.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrenceRef")]
pub occurrence: Option<OccurrenceRef>,
/// The occurrence's exact placement, as occurrence ids from the top of the design (1 to 64),
/// ending in `occurrence`, and a placement of the component. Default: the occurrence's
/// first placement.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrencePath")]
pub path: Option<OccurrencePath>,
}
/// `component.activate`'s parameters: [`ActivateKeys`], with `path` asked to come with
/// `occurrence`, as the handler asks.
#[derive(Debug)]
pub struct ActivateParams(pub ActivateKeys);
impl<'de> Deserialize<'de> for ActivateParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let value = Value::deserialize(d)?;
let keys: ActivateKeys = serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))?;
if keys.path.is_some() && keys.occurrence.is_none() {
return Err(D::Error::custom(whole("`occurrence` must be an id or name")));
}
Ok(ActivateParams(keys))
}
}
impl JsonSchema for ActivateParams {
fn schema_name() -> Cow<'static, str> {
"ActivateParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
ActivateKeys::json_schema(generator)
}
}
/// What `component.activate` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ActivateResult {
/// The active component now, by id.
pub active: u64,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// List the components as a tree: each with its bodies, features, sketches, construction planes,
/// placements and children, the active component and the pending moves. The design is not
/// changed.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ListKeys {
/// Counts of bodies, features, sketches and construction planes instead of their names;
/// placements leave out identity transforms; children as occurrence ids. Default: `false`.
#[serde(default)]
pub compact: Option<bool>,
/// With `compact`: body names, not their count. Read only with `compact: true`. Default:
/// `false`.
#[serde(default)]
pub bodies: Option<bool>,
}
/// `component.list`'s parameters: [`ListKeys`], with `bodies` read only when `compact` is true,
/// as the handler reads it.
#[derive(Debug)]
pub struct ListParams(pub ListKeys);
impl<'de> Deserialize<'de> for ListParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
if let Some(keys) = value.as_object_mut()
&& keys.get("compact").and_then(Value::as_bool) != Some(true)
{
keys.remove("bodies");
}
serde_path_to_error::deserialize(&value).map(ListParams).map_err(|e| D::Error::custom(located(&e)))
}
}
impl JsonSchema for ListParams {
fn schema_name() -> Cow<'static, str> {
"ListParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
ListKeys::json_schema(generator)
}
}
/// A placement as `component.list` shows it, in full.
#[derive(Debug, Serialize, JsonSchema)]
pub struct PlacementInfo {
/// The occurrence, by id.
pub id: u64,
/// The component it places, by id.
pub component: u64,
/// The component it sits in, by id (0 is the design).
pub parent: u64,
pub name: String,
/// Its placement in its parent, as a 4×4 matrix.
pub transform: [[f64; 4]; 4],
/// Whether it is grounded.
pub grounded: bool,
}
impl From<&Occurrence> for PlacementInfo {
fn from(o: &Occurrence) -> Self {
PlacementInfo { id: o.id, component: o.component, parent: o.parent, name: o.name.clone(), transform: o.transform, grounded: o.grounded }
}
}
/// A placement as `compact` `component.list` shows it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CompactPlacement {
/// The occurrence, by id.
pub id: u64,
pub name: String,
/// The component it sits in, by id (0 is the design).
pub parent: u64,
/// Whether it is grounded.
pub grounded: bool,
/// Its placement in its parent, as a 4×4 matrix; left out when it is the identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transform: Option<[[f64; 4]; 4]>,
}
/// A placement in a component's `occurrences`: in full, or compact.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum PlacementEntry {
Full(PlacementInfo),
Compact(CompactPlacement),
}
/// A placement in a component's `children`: in full, or with `compact` its id.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum ChildEntry {
Full(PlacementInfo),
Id(u64),
}
/// Names, or with `compact` how many there are.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum NamesOrCount {
Names(Vec<String>),
Count(usize),
}
/// One component of `component.list`; the design itself is the first, with id 0.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ComponentNode {
/// The component, by id (0 is the design).
pub id: u64,
pub name: String,
/// The component it is in; null for the design.
pub parent: Option<u64>,
/// Its bodies by name; with `compact` their count unless `bodies` is true.
pub bodies: NamesOrCount,
/// Its features other than sketches and construction planes, by name; with `compact` their count.
pub features: NamesOrCount,
/// Its sketches, by name; with `compact` their count.
pub sketches: NamesOrCount,
/// Its construction planes, by name; with `compact` their count.
pub construction: NamesOrCount,
/// Where it is placed.
pub occurrences: Vec<PlacementEntry>,
/// The placements it holds.
pub children: Vec<ChildEntry>,
}
/// What `component.list` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListResult {
/// The active component, by id.
pub active: u64,
/// The design and its components.
pub components: Vec<ComponentNode>,
/// The occurrences with a move not captured yet, by id.
pub pending_moves: Vec<u64>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// `about`, a point in mm or `null`, with the handler's message for anything else.
fn about<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Point3>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
v => params::vec3(&v).map(|p| Some(Point3(p))).ok_or_else(|| D::Error::custom(whole("`about` must be a point [x, y, z] in mm"))),
}
}
/// Measure a component where its first placement puts it (every placement for the design):
/// material-aware mass and centre of mass in world coordinates, volume, density, and inertia
/// for closed, consistently oriented bodies, with totals and principal moments. Pending moves
/// are included. The design is not changed.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PropertiesParams {
/// The component, by id or name. Default: the active component.
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub component: Option<ComponentRef>,
/// Include the components and placements inside it. Default: `true`.
#[serde(default, deserialize_with = "descendants_flag", skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub descendants: Option<bool>,
/// A point in mm, also given the total inertia about it (`total.inertia_about`).
#[serde(default, deserialize_with = "about", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<Point3>")]
pub about: Option<Point3>,
}
/// A body's box, in world coordinates (mm).
#[derive(Debug, Serialize, JsonSchema)]
pub struct BoundingBox {
#[schemars(with = "[f64; 3]")]
pub min: Vec3,
#[schemars(with = "[f64; 3]")]
pub max: Vec3,
}
/// One body's measurements at one placement. Volume, density, mass, centre of mass and inertia
/// are null for a body that is not closed.
#[derive(Debug, Serialize, JsonSchema)]
pub struct BodyProperties {
pub name: String,
/// The component it belongs to, by id.
pub component: u64,
/// The placement it is measured at, as occurrence ids from the top.
pub path: Vec<u64>,
/// Its material's name.
pub material: String,
/// Whether it is closed and consistently oriented.
pub closed: bool,
pub density_g_cm3: Option<f64>,
pub mass_g: Option<f64>,
pub volume_mm3: Option<f64>,
pub area_mm2: f64,
/// Its centre of mass, in world coordinates (mm).
#[schemars(with = "Option<[f64; 3]>")]
pub center_of_mass: Option<Vec3>,
/// The inertia tensor about its centre of mass, in world axes (g·mm²).
pub inertia_g_mm2: Option<[[f64; 3]; 3]>,
pub bbox: BoundingBox,
}
/// The inertia about the point `about` asked for.
#[derive(Debug, Serialize, JsonSchema)]
pub struct InertiaAbout {
/// The point, in mm.
#[schemars(with = "[f64; 3]")]
pub point: Vec3,
/// The total inertia tensor about it (g·mm²); null when it is not known.
pub inertia_g_mm2: Option<[[f64; 3]; 3]>,
}
/// The totals of a physical-properties measurement, over the closed bodies unless said.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TotalProperties {
/// How many open bodies are left out of the volume and mass totals.
pub open_bodies_skipped: usize,
pub volume_mm3: f64,
/// The area of all bodies, closed or not.
pub area_mm2: f64,
pub mass_g: f64,
/// The centre of mass (mm); null when the mass is zero.
#[schemars(with = "Option<[f64; 3]>")]
pub center_of_mass: Option<Vec3>,
/// Mass times centre of mass (g·mm).
#[schemars(with = "[f64; 3]")]
pub first_moment_g_mm: Vec3,
/// The inertia tensor about the centre of mass, in world axes (g·mm²); null when it is not known.
pub inertia_g_mm2: Option<[[f64; 3]; 3]>,
/// The principal moments, smallest first (g·mm²).
pub principal_moments_g_mm2: Option<[f64; 3]>,
/// The principal axes: unit vectors, right-handed.
#[schemars(with = "Option<[[f64; 3]; 3]>")]
pub principal_axes: Option<[Vec3; 3]>,
/// The box around all bodies; null when there are none.
pub bbox: Option<BoundingBox>,
pub faces: usize,
pub edges: usize,
pub vertices: usize,
/// With `about`: the inertia about that point.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inertia_about: Option<InertiaAbout>,
}
/// Physical properties of a placement and what it holds, in world coordinates.
#[derive(Debug, Serialize, JsonSchema)]
pub struct PhysicalProperties {
/// The placement measured, as occurrence ids from the top; empty for the design.
pub path: Vec<u64>,
/// Always `world`.
pub coordinate_frame: String,
/// Whether what is inside the placement is included.
pub descendants: bool,
pub placement_count: usize,
pub body_count: usize,
pub bodies: Vec<BodyProperties>,
pub total: TotalProperties,
}
/// What `component.properties` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct PropertiesResult {
/// The component, by id.
pub component: u64,
pub name: String,
#[serde(flatten)]
pub properties: PhysicalProperties,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// The new name `component.rename` reads: text of 1 to 128 bytes once trimmed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ComponentName(pub String);
impl<'de> Deserialize<'de> for ComponentName {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.trim().len() <= 128 => Ok(ComponentName(s)),
_ => Err(D::Error::custom(whole("`name` must be 1…128 characters"))),
}
}
}
impl JsonSchema for ComponentName {
fn schema_name() -> Cow<'static, str> {
"ComponentName".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Text with something in it; once surrounding blanks are cut, at most 128 bytes.",
"type": "string",
"pattern": not_blank_pattern()
})
}
}
fn rename_schema(schema: &mut Schema) {
require(schema, "component");
require(schema, "name");
}
/// Rename a component, or the design itself (id 0). Placements still named after it follow.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = rename_schema)]
pub struct RenameParams {
// Optional in the type and required in the schema (`rename_schema`).
/// The component, by id or name (0 or `"root"` for the design).
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub component: Option<ComponentRef>,
/// The new name.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentName")]
pub name: Option<ComponentName>,
}
/// What `component.rename` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RenameResult {
/// The component, by id.
pub component: u64,
/// Its name now, trimmed.
pub name: String,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Delete a component with its subcomponents, placements and features. Refused for the design
/// itself, and for a tree that supplies a body transfer recorded outside it.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = component_required)]
pub struct DeleteParams {
// Optional in the type and required in the schema (`component_required`).
/// The component, by id or name.
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub component: Option<ComponentRef>,
}
/// What `component.delete` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DeleteResult {
/// The components removed, by id: it and everything inside it.
pub deleted: Vec<u64>,
/// The features that were inside them, by id.
pub features: Vec<u64>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Remove the rigid joints that hold a component's contents together (an imported assembly's
/// rigid group), so its parts move and take joints one by one. Refused for the design and when
/// nothing holds the contents together.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = component_required)]
pub struct UnlockParams {
// Optional in the type and required in the schema (`component_required`).
/// The component, by id or name.
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub component: Option<ComponentRef>,
}
/// What `component.unlock` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct UnlockResult {
/// The component, by id.
pub component: u64,
/// The joints removed, by id.
pub joints: Vec<u64>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "component_tests.rs"]
mod tests;
+2 -2
View File
@@ -52,7 +52,7 @@ impl JsonSchema for ComponentRef {
/// `key`, a component's id or name, with the handler's message for anything else, `null`
/// included.
fn component_key<'de, D: Deserializer<'de>>(d: D, key: &str) -> Result<Option<ComponentRef>, D::Error> {
pub(super) fn component_key<'de, D: Deserializer<'de>>(d: D, key: &str) -> Result<Option<ComponentRef>, D::Error> {
match Value::deserialize(d)? {
Value::Number(n) => Ok(Some(ComponentRef(n.to_string()))),
Value::String(s) => Ok(Some(ComponentRef(s))),
@@ -95,7 +95,7 @@ fn path_schema(_: &mut SchemaGenerator) -> Schema {
/// Adds `key` to the schema's `required` list: for a key the type has optional so that the
/// handler, not the check, refuses it when it's left out.
fn require(schema: &mut Schema, key: &str) {
pub(super) fn require(schema: &mut Schema, key: &str) {
match schema.get_mut("required") {
Some(Value::Array(required)) => {
if !required.iter().any(|k| k == key) {
+338 -3
View File
@@ -4,11 +4,12 @@ use std::sync::Arc;
use serde_json::{Value, json};
use super::{
EditInPlaceParams, FinishEditParams, MoveFeaturesParams, MoveSketchesToPartsParams, OccurrenceAlignParams, OccurrenceAlignResult,
ReparentItemsParams,
ActivateParams, ActivateResult, CreateParams, CreateResult, DeleteParams, DeleteResult, EditInPlaceParams, FinishEditParams, FromBodiesParams,
FromBodiesResult, ListParams, ListResult, MoveFeaturesParams, MoveSketchesToPartsParams, OccurrenceAlignParams, OccurrenceAlignResult,
PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams, UnlockParams, UnlockResult,
};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
@@ -36,6 +37,340 @@ fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<MoveFeaturesParams>(), &["component", "features"]);
assert_keys(input_schema_for::<MoveSketchesToPartsParams>(), &["sketches"]);
assert_keys(input_schema_for::<ReparentItemsParams>(), &["bodies", "component", "component_path", "features", "occurrences"]);
assert_keys(input_schema_for::<CreateParams>(), &["activate", "grounded", "name", "parent", "parent_path"]);
assert_keys(input_schema_for::<FromBodiesParams>(), &["bodies"]);
assert_keys(input_schema_for::<ActivateParams>(), &["component", "occurrence", "path"]);
assert_keys(input_schema_for::<ListParams>(), &["bodies", "compact"]);
assert_keys(input_schema_for::<PropertiesParams>(), &["about", "component", "descendants"]);
assert_keys(input_schema_for::<RenameParams>(), &["component", "name"]);
assert_keys(input_schema_for::<DeleteParams>(), &["component"]);
assert_keys(input_schema_for::<UnlockParams>(), &["component"]);
}
/// The calls these commands already refuse (the handlers' own messages), refused alike in strict.
/// An unknown key to `component.create` is left out: its handler lists the accepted parameters,
/// where strict gives the check's `unknown key` with a suggestion (refused either way).
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("component.create", json!({"parent": null})),
("component.create", json!({"parent": true})),
("component.create", json!({"parent": [1]})),
("component.create", json!({"parent": "Nope"})),
("component.create", json!({"parent_path": "1"})),
("component.create", json!({"parent_path": null})),
("component.create", json!({"parent_path": [1, "a"]})),
("component.create", json!({"parent_path": vec![1; 64]})),
("component.create", json!({"grounded": "yes"})),
("component.create", json!({"grounded": null})),
("component.from_bodies", json!({})),
("component.from_bodies", json!({"bodies": null})),
("component.from_bodies", json!({"bodies": "Box"})),
("component.from_bodies", json!({"bodies": [1]})),
("component.from_bodies", json!({"bodies": []})),
("component.from_bodies", json!({"bodies": ["A", "A"]})),
("component.from_bodies", json!({"bodies": vec!["A"; 1001]})),
("component.from_bodies", json!({"bodies": ["Nope"]})),
("component.activate", json!({})),
("component.activate", json!({"component": null})),
("component.activate", json!({"component": [1]})),
("component.activate", json!({"component": "Nope"})),
("component.activate", json!({"component": 0, "path": [1]})),
("component.activate", json!({"component": 0, "occurrence": null})),
("component.activate", json!({"component": 0, "occurrence": true})),
("component.properties", json!({"component": null})),
("component.properties", json!({"component": true})),
("component.properties", json!({"descendants": "yes"})),
("component.properties", json!({"descendants": null})),
("component.properties", json!({"about": [1, 2]})),
("component.properties", json!({"about": "origin"})),
("component.properties", json!({"about": [1, 2, 1e9]})),
("component.rename", json!({})),
("component.rename", json!({"component": 0})),
("component.rename", json!({"name": "A"})),
("component.rename", json!({"component": null, "name": "A"})),
("component.rename", json!({"component": 0, "name": ""})),
("component.rename", json!({"component": 0, "name": " \t "})),
("component.rename", json!({"component": 0, "name": 5})),
("component.rename", json!({"component": 0, "name": null})),
("component.rename", json!({"component": 0, "name": "x".repeat(129)})),
("component.delete", json!({})),
("component.delete", json!({"component": false})),
("component.delete", json!({"component": 0})),
("component.unlock", json!({})),
("component.unlock", json!({"component": [0]})),
("component.unlock", json!({"component": 0})),
]);
}
/// A name of 128 bytes is taken once trimmed, and one of 129 is not, as the handler counts.
#[test]
fn a_name_is_counted_as_the_handler_counts_it() {
let issues = |p: Value| crate::schema::issues("component.rename", &p).len();
assert_eq!(issues(json!({"component": 0, "name": format!(" {} ", "x".repeat(128))})), 0);
assert_eq!(issues(json!({"component": 0, "name": "x".repeat(129)})), 1);
// 64 two-byte characters are 128 bytes.
assert_eq!(issues(json!({"component": 0, "name": "é".repeat(64)})), 0);
assert_eq!(issues(json!({"component": 0, "name": "é".repeat(65)})), 1);
}
/// The contracts say what the handlers need: a key a handler refuses without is required in the
/// schema, though the type leaves it to the handler's message.
#[test]
fn the_schemas_take_what_the_handlers_read() {
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
let schema = schema.as_value();
for (p, takes) in cases {
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
}
};
takes(
input_schema_for::<CreateParams>(),
&[
(json!({}), true),
(json!({"name": "Arm", "parent": "Frame", "parent_path": [1, 2], "activate": false, "grounded": true}), true),
(json!({"parent": 3, "parent_path": []}), true),
(json!({"parent": true}), false),
(json!({"parent_path": vec![1; 64]}), false),
(json!({"grounded": "yes"}), false),
(json!({"bodies": ["A"]}), false),
],
);
takes(
input_schema_for::<FromBodiesParams>(),
&[
(json!({"bodies": ["A", "B"]}), true),
(json!({}), false),
(json!({"bodies": []}), false),
(json!({"bodies": ["A", "A"]}), false),
(json!({"bodies": "A"}), false),
],
);
takes(
input_schema_for::<ActivateParams>(),
&[
(json!({"component": 0}), true),
(json!({"component": "root"}), true),
(json!({"component": "Arm", "occurrence": "Arm:2", "path": [1, 2]}), true),
(json!({"component": "Arm", "occurrence": 2}), true),
(json!({}), false),
(json!({"component": true}), false),
(json!({"component": 1, "occurrence": true}), false),
(json!({"component": 1, "path": []}), false),
],
);
takes(
input_schema_for::<ListParams>(),
&[(json!({}), true), (json!({"compact": true, "bodies": true}), true), (json!({"compact": "yes"}), false)],
);
takes(
input_schema_for::<PropertiesParams>(),
&[
(json!({}), true),
(json!({"component": "Arm", "descendants": false, "about": [1, 2, 3]}), true),
(json!({"about": {"x": 1, "y": 2, "z": 3}}), true),
(json!({"about": [1, 2]}), false),
(json!({"descendants": 1}), false),
],
);
takes(
input_schema_for::<RenameParams>(),
&[(json!({"component": "Arm", "name": "Bar"}), true), (json!({"component": "Arm"}), false), (json!({"name": "Bar"}), false)],
);
// `conforms` doesn't read patterns: pin the text that keeps blank names out.
let rename = input_schema_for::<RenameParams>();
assert_eq!(rename.as_value()["$defs"]["ComponentName"]["pattern"], crate::schema::types::not_blank_pattern());
takes(input_schema_for::<DeleteParams>(), &[(json!({"component": 2}), true), (json!({}), false)]);
takes(input_schema_for::<UnlockParams>(), &[(json!({"component": "rig"}), true), (json!({}), false)]);
}
/// Keys the handlers read only in some cases are read only in those cases: `bodies` of
/// `component.list` with `compact`, `path` of `component.activate` with `occurrence`. And the
/// warnings say what is wrong with a key.
#[test]
fn the_check_reads_keys_as_the_handlers_do() {
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
let none = Vec::<String>::new();
assert_eq!(issues("component.list", json!({"bodies": "x"})), none);
assert_eq!(issues("component.list", json!({"compact": false, "bodies": 3})), none);
assert_eq!(issues("component.list", json!({"compact": true, "bodies": true})), none);
assert_eq!(issues("component.list", json!({"compact": true, "bodies": "x"})).len(), 1);
assert_eq!(issues("component.list", json!({"compact": "x"})).len(), 1);
assert_eq!(issues("component.list", json!({"compat": true})), ["unknown key `compat`; did you mean `compact`?"]);
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": 2})), none);
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": 2, "path": [1, 2]})), none);
assert_eq!(issues("component.activate", json!({"component": 1, "path": [2]})), ["`occurrence` must be an id or name"]);
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": 2, "path": []})), ["`path` must list 1..64 occurrence ids"]);
assert_eq!(issues("component.activate", json!({"component": 1, "occurrence": null})), ["`occurrence` must be an id or name"]);
assert_eq!(issues("component.create", json!({"parent": true})), ["`parent` must be a component id or name"]);
assert_eq!(issues("component.create", json!({"parent_path": [1, null]})), ["parent_path must list up to 63 occurrence ids"]);
assert_eq!(issues("component.create", json!({"grounded": 1})), ["grounded must be true or false"]);
assert_eq!(issues("component.create", json!({"parent_path": vec![1; 63]})), none);
assert_eq!(issues("component.create", json!({"parent_path": vec![1; 64]})), ["parent_path must list up to 63 occurrence ids"]);
assert_eq!(issues("component.create", json!({"activate": "no"})).len(), 1);
assert_eq!(issues("component.from_bodies", json!({"bodies": [1]})), ["bodies must be an array of body names"]);
assert_eq!(issues("component.from_bodies", json!({"bodies": ["a", "a"]})), ["bodies must list 1..1000 distinct existing definition bodies"]);
assert_eq!(issues("component.properties", json!({"about": [1]})), ["`about` must be a point [x, y, z] in mm"]);
assert_eq!(issues("component.properties", json!({"about": null})), none);
assert_eq!(issues("component.properties", json!({"descendants": null})), ["`descendants` must be a boolean"]);
assert_eq!(issues("component.properties", json!({"component": null})), ["`component` must be a component id or name"]);
assert_eq!(issues("component.rename", json!({"component": 0, "name": ""})), ["`name` must be 1…128 characters"]);
}
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
}
/// A glTF file whose node `rig` holds a `Part` (one cube): imported, it is a component held
/// together by a rigid group that `component.unlock` releases.
fn rig_file(name: &str) -> String {
let side = 0.01f32;
let points: Vec<[f32; 3]> = (0..8).map(|i| [(i & 1) as f32 * side, ((i >> 1) & 1) as f32 * side, ((i >> 2) & 1) as f32 * side]).collect();
let tris: [u16; 36] = [0, 2, 1, 1, 2, 3, 4, 5, 6, 5, 7, 6, 0, 1, 4, 1, 5, 4, 2, 6, 3, 3, 6, 7, 0, 4, 2, 2, 4, 6, 1, 3, 5, 3, 7, 5];
let mut bin: Vec<u8> = points.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
bin.extend(tris.iter().flat_map(|v| v.to_le_bytes()));
let doc = json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [{"name": "Base", "mesh": 0, "children": [1]}, {"name": "Arm", "mesh": 1, "translation": [0.0, 0.05, 0.0]}],
"meshes": [{"name": "Block", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
{"name": "Peg", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 8, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [side, side, side]},
{"bufferView": 1, "componentType": 5123, "count": 36, "type": "SCALAR"},
],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 96}, {"buffer": 0, "byteOffset": 96, "byteLength": 72}],
"buffers": [{"byteLength": bin.len()}],
});
let path = std::env::temp_dir().join(format!("solidcraft-component-schema-{}-{name}.glb", std::process::id()));
std::fs::write(&path, solidcraft_io::glb_container(doc.to_string().as_bytes(), &bin)).unwrap_or_else(|e| panic!("{e}"));
path.to_string_lossy().to_string()
}
/// Every command, in each form it takes, in Warn: the real reply matches its output schema and
/// carries no `param_warnings` (Warn runs a call the contract refuses anyway, so the replies
/// are also checked for warnings), and a key spelt wrongly on purpose is reported.
#[test]
fn replies_match_the_output_schemas_of_the_component_commands() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
let arm = run("component.create", json!({"name": "Arm"}));
assert_eq!(arm["name"], "Arm");
assert_eq!(arm["grounded"], true, "the design's first part is grounded: {arm}");
matches(output_schema_for::<CreateResult>(), "component.create", &arm);
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "ArmBox"}));
let hand = run("component.create", json!({"name": "Hand", "parent": "Arm", "activate": false, "grounded": false}));
assert!(hand["parent"] == arm["component"] && hand["grounded"] == false, "{hand}");
matches(output_schema_for::<CreateResult>(), "component.create", &hand);
let at = run("component.create", json!({"name": "Thumb", "parent": hand["parent"], "parent_path": [arm["occurrence"]]}));
matches(output_schema_for::<CreateResult>(), "component.create", &at);
let top = run("component.create", json!({"name": "Top", "parent": 0, "parent_path": []}));
matches(output_schema_for::<CreateResult>(), "component.create", &top);
let active = run("component.activate", json!({"component": "root"}));
assert_eq!(active["active"], 0);
matches(output_schema_for::<ActivateResult>(), "component.activate", &active);
let active = run("component.activate", json!({"component": arm["component"]}));
matches(output_schema_for::<ActivateResult>(), "component.activate", &active);
let active = run("component.activate", json!({"component": "Arm", "occurrence": arm["occurrence"], "path": [arm["occurrence"]]}));
assert_eq!(active["active"], arm["component"], "{active}");
matches(output_schema_for::<ActivateResult>(), "component.activate", &active);
run("solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "RootBox"}));
run("solid.box", json!({"length": 6, "width": 6, "height": 6, "body_name": "RootBox2", "operation": "new"}));
let made = run("component.from_bodies", json!({"bodies": ["RootBox", "RootBox2"]}));
assert_eq!(made["components"].as_array().map(Vec::len), Some(2), "{made}");
matches(output_schema_for::<FromBodiesResult>(), "component.from_bodies", &made);
for p in [
json!({}),
json!({"compact": true}),
json!({"compact": true, "bodies": true}),
json!({"compact": false, "bodies": "ignored"}),
json!({"bodies": true}),
] {
let listed = run("component.list", p.clone());
matches(output_schema_for::<ListResult>(), "component.list", &listed);
assert!(listed["components"].as_array().is_some_and(|c| c.len() >= 5), "{p}: {listed}");
}
let moved = run("occurrence.move", json!({"occurrence": hand["occurrence"], "translate": [5, 0, 0], "capture": false, "move_grounded": true}));
assert_eq!(moved["pending"], true, "{moved}");
run("occurrence.move", json!({"occurrence": top["occurrence"], "translate": [0, 7, 0], "move_grounded": true}));
let listed = run("component.list", json!({"compact": true}));
assert!(listed["pending_moves"].as_array().is_some_and(|m| !m.is_empty()), "{listed}");
// Compact placements carry `transform` only when it is not the identity.
let placements: Vec<&Value> =
listed["components"].as_array().into_iter().flatten().flat_map(|c| c["occurrences"].as_array().into_iter().flatten()).collect();
assert!(placements.iter().any(|o| o.get("transform").is_some()) && placements.iter().any(|o| o.get("transform").is_none()), "{listed}");
matches(output_schema_for::<ListResult>(), "component.list", &listed);
for p in [
json!({}),
json!({"component": "Arm"}),
json!({"component": 0, "descendants": false}),
json!({"component": "root", "about": [10, 0, 0]}),
json!({"about": {"x": 1, "y": 2, "z": 3}, "descendants": true}),
json!({"about": null}),
json!({"component": "Top"}),
] {
let measured = run("component.properties", p.clone());
matches(output_schema_for::<PropertiesResult>(), "component.properties", &measured);
assert_eq!(measured["coordinate_frame"], "world", "{p}");
assert_eq!(measured["total"].get("inertia_about").is_some(), p.get("about").is_some_and(|a| !a.is_null()), "{p}: {measured}");
}
let measured = run("component.properties", json!({"component": 0}));
assert!(measured["body_count"].as_u64().is_some_and(|n| n >= 3) && measured["total"]["bbox"].is_object(), "{measured}");
let empty = run("component.properties", json!({"component": "Top"}));
assert!(empty["total"]["bbox"].is_null() && empty["total"]["center_of_mass"].is_null(), "{empty}");
let renamed = run("component.rename", json!({"component": "Hand", "name": " Palm "}));
assert_eq!(renamed["name"], "Palm");
matches(output_schema_for::<RenameResult>(), "component.rename", &renamed);
let renamed = run("component.rename", json!({"component": 0, "name": "Whole"}));
matches(output_schema_for::<RenameResult>(), "component.rename", &renamed);
let deleted = run("component.delete", json!({"component": "Arm"}));
assert!(deleted["deleted"].as_array().is_some_and(|d| d.len() >= 3), "{deleted}");
matches(output_schema_for::<DeleteResult>(), "component.delete", &deleted);
// Unlock: a glTF node tree is held together by a rigid group.
let inserted = run("file.insert_mesh", json!({"path": rig_file("rig"), "up": "z"}));
let rig = inserted["component"].clone();
let unlocked = run("component.unlock", json!({"component": rig}));
assert!(unlocked["joints"].as_array().is_some_and(|j| !j.is_empty()), "{unlocked}");
matches(output_schema_for::<UnlockResult>(), "component.unlock", &unlocked);
// Misspelt keys still run in Warn, say so, and the reply still matches.
let cases: [(&str, Value, &str, schemars::Schema); 3] = [
("component.list", json!({"compat": true}), "compat", output_schema_for::<ListResult>()),
("component.properties", json!({"descendents": false}), "descendents", output_schema_for::<PropertiesResult>()),
("component.rename", json!({"component": 0, "nmae": "x", "name": "Again"}), "nmae", output_schema_for::<RenameResult>()),
];
for (id, p, key, schema) in cases {
let warned = run(id, p);
let warnings = warned["param_warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.len() == 1 && warnings[0].as_str().is_some_and(|w| w.contains(key)), "{id}: {warned}");
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{id}: {warned}");
}
}
/// An unknown key is refused by `component.create` as before (its own message lists the
/// accepted parameters), at every check level but strict, where the check names the key first.
#[test]
fn create_refuses_an_unknown_key_as_before() {
for level in ParamCheck::ALL {
let mut s = Session { param_check: level, ..Session::default() };
let e = s.execute("component.create", &json!({"nme": "A"})).unwrap_err().to_string();
assert!(e.contains("unknown parameter `nme`") ^ (level == ParamCheck::Strict), "{level:?}: {e}");
assert!(e.contains("nme"), "{e}");
}
let mut s = Session::default();
let e = s.execute("component.create", &json!({"bodies": ["A"]})).unwrap_err().to_string();
assert!(e.contains("use component.move_bodies or component.from_bodies"), "{e}");
}
#[test]
-8
View File
@@ -2,19 +2,11 @@
# (coverage_tests.rs). This list only shrinks: when you give a command its contract and output
# type, remove its line. Never add one: a new command comes with both schemas. Sorted, one id
# per line.
component.activate
component.capture_position
component.create
component.delete
component.from_bodies
component.list
component.move_bodies
component.move_sketches
component.paste_new
component.properties
component.rename
component.revert_position
component.unlock
doc.open
doc.recover
doc.recovery_discard