diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 3225f6a458..70926ba17d 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -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 = TypedContract::new(); +const CREATE_CONTRACT: TypedContract = TypedContract::new(); +const DELETE_CONTRACT: TypedContract = TypedContract::new(); +const FROM_BODIES_CONTRACT: TypedContract = TypedContract::new(); +const LIST_CONTRACT: TypedContract = TypedContract::new(); +const PROPERTIES_CONTRACT: TypedContract = TypedContract::new(); +const RENAME_CONTRACT: TypedContract = TypedContract::new(); +const UNLOCK_CONTRACT: TypedContract = TypedContract::new(); const EDIT_IN_PLACE_CONTRACT: TypedContract = TypedContract::new(); const FINISH_EDIT_CONTRACT: TypedContract = TypedContract::new(); const MOVE_FEATURES_CONTRACT: TypedContract = 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::) .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::) .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::) + .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::) + .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::) .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::) + .params("component, name"), + CommandSpec::new("component.delete", "Delete Component", crate::checked!("component.delete", delete)) + .icon("delete") + .contract(&DELETE_CONTRACT) + .output(output_schema_for::) + .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::) + .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 = TypedContract::new(); +/// A typed reply as the JSON a handler returns. +pub(super) fn reply(value: T) -> Result { + serde_json::to_value(value).map_err(|e| crate::EngineError::Other(e.to_string())) +} + fn key(v: Option<&Value>) -> Option { 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 { } 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 { @@ -334,7 +385,7 @@ fn activate(s: &mut Session, p: &Value) -> Result { 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 { @@ -342,17 +393,20 @@ fn list(s: &mut Session, p: &Value) -> Result { 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 { 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 = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect(); - let children: Vec = 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 = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect(); + let children: Vec = 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::>()})) + 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 { @@ -411,7 +485,7 @@ fn rename(s: &mut Session, p: &Value) -> Result { // 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 { @@ -428,7 +502,7 @@ fn delete(s: &mut Session, p: &Value) -> Result { 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 { @@ -448,7 +522,7 @@ fn unlock(s: &mut Session, p: &Value) -> Result { } 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 { diff --git a/crates/engine/src/cmd/component_properties.rs b/crates/engine/src/cmd/component_properties.rs index 42fd9bc244..1ae78dca89 100644 --- a/crates/engine/src/cmd/component_properties.rs +++ b/crates/engine/src/cmd/component_properties.rs @@ -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 { @@ -84,10 +85,8 @@ pub(super) fn component(s: &mut Session, p: &Value) -> Result { } 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 { @@ -200,7 +199,12 @@ fn measure_placed(body: &solidcraft_doc::ModelBody, transform: Mat) -> Result, cmd: &str) -> Result { + super::reply(measure(s, doc, selected, descendants, about, cmd)?) +} + +fn measure(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option, cmd: &str) -> Result { 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 diff --git a/crates/engine/src/cmd/component_transfer.rs b/crates/engine/src/cmd/component_transfer.rs index 8d742cd053..e4dc022460 100644 --- a/crates/engine/src/cmd/component_transfer.rs +++ b/crates/engine/src/cmd/component_transfer.rs @@ -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 { 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, diff --git a/crates/engine/src/schema/component.rs b/crates/engine/src/schema/component.rs index c84878fea3..28905a778f 100644 --- a/crates/engine/src/schema/component.rs +++ b/crates/engine/src/schema/component.rs @@ -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, } +// --------------------------------------------------------------------------------------------- +// 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, 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, D::Error> { + flag(d, "grounded must be true or false") +} + +fn descendants_flag<'de, D: Deserializer<'de>>(d: D) -> Result, 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); + +impl<'de> Deserialize<'de> for ParentPath { + fn deserialize>(d: D) -> Result { + Value::deserialize(d)? + .as_array() + .filter(|a| a.len() < 64) + .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) + .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, D::Error> { + component_key(d, "parent") +} + +fn component<'de, D: Deserializer<'de>>(d: D) -> Result, 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, + /// 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, + /// 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, + /// Make it the active component. Default: `true`. + #[serde(default)] + pub activate: Option, + /// 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, +} + +/// 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, + /// 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, +} + +/// `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); + +impl<'de> Deserialize<'de> for DefinitionBodies { + fn deserialize>(d: D) -> Result { + let names = Value::deserialize(d)? + .as_array() + .and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect::>>()) + .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::>().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, +} + +/// 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, + /// 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, +} + +/// 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: D) -> Result { + 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, + /// 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, + /// 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, +} + +/// `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: D) -> Result { + 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, +} + +/// 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, + /// With `compact`: body names, not their count. Read only with `compact: true`. Default: + /// `false`. + #[serde(default)] + pub bodies: Option, +} + +/// `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: D) -> Result { + 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), + 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, + /// 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, + /// The placements it holds. + pub children: Vec, +} + +/// 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, + /// The occurrences with a move not captured yet, by id. + pub pending_moves: Vec, + /// 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, +} + +/// `about`, a point in mm or `null`, with the handler's message for anything else. +fn about<'de, D: Deserializer<'de>>(d: D) -> Result, 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, + /// 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, + /// 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")] + pub about: Option, +} + +/// 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, + /// Its material's name. + pub material: String, + /// Whether it is closed and consistently oriented. + pub closed: bool, + pub density_g_cm3: Option, + pub mass_g: Option, + pub volume_mm3: Option, + pub area_mm2: f64, + /// Its centre of mass, in world coordinates (mm). + #[schemars(with = "Option<[f64; 3]>")] + pub center_of_mass: Option, + /// 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, + /// 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, + 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, +} + +/// 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, + /// 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, + 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, +} + +/// 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: D) -> Result { + 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, + /// The new name. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "ComponentName")] + pub name: Option, +} + +/// 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, +} + +/// 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, +} + +/// What `component.delete` returns. +#[derive(Debug, Serialize, JsonSchema)] +pub struct DeleteResult { + /// The components removed, by id: it and everything inside it. + pub deleted: Vec, + /// The features that were inside them, by id. + pub features: Vec, + /// 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, +} + +/// 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, +} + +/// 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, + /// 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, +} + #[cfg(test)] #[path = "component_tests.rs"] mod tests; diff --git a/crates/engine/src/schema/component_link.rs b/crates/engine/src/schema/component_link.rs index 724856d41f..6aa290fc97 100644 --- a/crates/engine/src/schema/component_link.rs +++ b/crates/engine/src/schema/component_link.rs @@ -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, D::Error> { +pub(super) fn component_key<'de, D: Deserializer<'de>>(d: D, key: &str) -> Result, 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) { diff --git a/crates/engine/src/schema/component_tests.rs b/crates/engine/src/schema/component_tests.rs index c03d84f302..9ec8eb131b 100644 --- a/crates/engine/src/schema/component_tests.rs +++ b/crates/engine/src/schema/component_tests.rs @@ -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::(), &["component", "features"]); assert_keys(input_schema_for::(), &["sketches"]); assert_keys(input_schema_for::(), &["bodies", "component", "component_path", "features", "occurrences"]); + assert_keys(input_schema_for::(), &["activate", "grounded", "name", "parent", "parent_path"]); + assert_keys(input_schema_for::(), &["bodies"]); + assert_keys(input_schema_for::(), &["component", "occurrence", "path"]); + assert_keys(input_schema_for::(), &["bodies", "compact"]); + assert_keys(input_schema_for::(), &["about", "component", "descendants"]); + assert_keys(input_schema_for::(), &["component", "name"]); + assert_keys(input_schema_for::(), &["component"]); + assert_keys(input_schema_for::(), &["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::(), + &[ + (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::(), + &[ + (json!({"bodies": ["A", "B"]}), true), + (json!({}), false), + (json!({"bodies": []}), false), + (json!({"bodies": ["A", "A"]}), false), + (json!({"bodies": "A"}), false), + ], + ); + takes( + input_schema_for::(), + &[ + (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::(), + &[(json!({}), true), (json!({"compact": true, "bodies": true}), true), (json!({"compact": "yes"}), false)], + ); + takes( + input_schema_for::(), + &[ + (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::(), + &[(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::(); + assert_eq!(rename.as_value()["$defs"]["ComponentName"]["pattern"], crate::schema::types::not_blank_pattern()); + takes(input_schema_for::(), &[(json!({"component": 2}), true), (json!({}), false)]); + takes(input_schema_for::(), &[(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::>(); + let none = Vec::::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 = 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::(), "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::(), "component.create", &hand); + let at = run("component.create", json!({"name": "Thumb", "parent": hand["parent"], "parent_path": [arm["occurrence"]]})); + matches(output_schema_for::(), "component.create", &at); + let top = run("component.create", json!({"name": "Top", "parent": 0, "parent_path": []})); + matches(output_schema_for::(), "component.create", &top); + + let active = run("component.activate", json!({"component": "root"})); + assert_eq!(active["active"], 0); + matches(output_schema_for::(), "component.activate", &active); + let active = run("component.activate", json!({"component": arm["component"]})); + matches(output_schema_for::(), "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::(), "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::(), "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::(), "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::(), "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::(), "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::(), "component.rename", &renamed); + let renamed = run("component.rename", json!({"component": 0, "name": "Whole"})); + matches(output_schema_for::(), "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::(), "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::(), "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::()), + ("component.properties", json!({"descendents": false}), "descendents", output_schema_for::()), + ("component.rename", json!({"component": 0, "nmae": "x", "name": "Again"}), "nmae", output_schema_for::()), + ]; + 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] diff --git a/crates/engine/src/schema/unconverted.txt b/crates/engine/src/schema/unconverted.txt index 375d3c8abf..cdd7e2aefb 100644 --- a/crates/engine/src/schema/unconverted.txt +++ b/crates/engine/src/schema/unconverted.txt @@ -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