Files
OneCAD-Draft/src/entity_creation.rs
T
Hakan SevenandClaude Opus 5.5 e4d04ef132 Cargo: codec e023bc3 (acadrust sync), kernel abca4e8, graph c63556f
The codec now holds wide EntityType variants boxed (MText, Helix,
Dimension, Hatch, Viewport, MultiLeader, Surface, Table, Ole2Frame,
Light, Extended). Constructors box their entity; nested patterns reach
a dimension through entities::dimension::as_dimension(_mut). Also
follows BlockAction::action_ids, LineTypeComplexData::source_flags and
the decimal tab stop with an explicit character.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 09:17:01 +03:00

745 lines
31 KiB
Rust

//! Discoverable creation contracts, shared by MCP preflight and the entity builder.
use serde_json::{json, Value};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn published_examples_build_with_the_real_factory() {
let document = codec::CadDocument::new();
for kind in TYPES {
let contract = schema(kind).unwrap();
for example in contract["examples"].as_array().unwrap() {
validate_entity(example, "/entities/0").unwrap();
let built = build_entity(example, Some(&document));
assert!(built.is_ok(), "{kind}: {built:?}");
}
}
for name in ["line", "LINE", "polyline", "Polyline", "LWPOLYLINE"] {
let mut example = schema(name).unwrap()["examples"][0].clone();
example["type"] = json!(name);
validate_entity(&example, "").unwrap();
assert!(build_entity(&example, Some(&document)).is_ok());
}
// JSON Schema integers include numerically integral float notation.
let circle = json!({"type":"Circle","center":[0,0],"radius":2,"color":2.0});
validate_entity(&circle, "").unwrap();
assert_eq!(
build_entity(&circle, Some(&document))
.unwrap()
.common()
.color,
codec::types::Color::from_index(2)
);
let spline = json!({"type":"Spline","degree":2.0,"control_points":[[0,0],[5,10],[10,0]]});
validate_entity(&spline, "").unwrap();
let codec::EntityType::Spline(built) = build_entity(&spline, Some(&document)).unwrap()
else {
panic!("expected spline")
};
assert_eq!(built.degree, 2);
}
#[test]
fn rejects_bad_nested_fields_and_matches_geometry_constraints() {
let cases = [
(
json!({"type":"Line","start":[0,0],"end":[1,"bad"]}),
"/end/1",
),
(json!({"type":"Circle","center":[0,0]}), "/radius"),
(
json!({"type":"Circle","center":[0,0],"radius":-1}),
"/radius",
),
(
json!({"type":"Solid","corners":[[0,0],[1,0],[0,1]]}),
"/corners/0",
),
(
json!({"type":"Point","location":[0,0],"color":32768}),
"/color",
),
(
json!({"type":"Line","start":[0,0],"end":[1,1],"unsupported":true}),
"/unsupported",
),
];
for (spec, path) in cases {
let error = validate_entity(&spec, "/entities/0").unwrap_err();
assert!(error.contains(&format!("/entities/0{path}")), "{error}");
assert!(build_entity(&spec, None).is_err());
}
for spec in [
json!({"type":"Arc","center":[0,0],"radius":5,"start_angle_deg":45,"end_angle_deg":45}),
json!({"type":"Spline","degree":3,"control_points":[[0,0],[1,1]]}),
json!({"type":"Spline","degree":1,"control_points":[[0,0],[1,1]],"weights":[1]}),
json!({"type":"Insert","block":"Example","position":[0,0],"scale":0}),
] {
assert!(validate_entity(&spec, "").is_err());
assert!(build_entity(&spec, None).is_err());
}
// The static path deliberately omits document-dependent style lookup.
let spec = json!({"type":"Text","value":"Label","position":[0,0],"style":"missing_style"});
assert!(validate_entity(&spec, "").is_ok());
assert_eq!(
build_entity(&spec, Some(&codec::CadDocument::new())).unwrap_err()["code"],
"invalid_text_style"
);
}
}
pub const TYPES: &[&str] = &[
"Line",
"Circle",
"Arc",
"Spline",
"LwPolyline",
"Point",
"Text",
"MText",
"Insert",
"Solid",
"Hatch",
];
fn point(min: usize, max: usize) -> Value {
json!({"type":"array","items":{"type":"number"},"minItems":min,"maxItems":max})
}
/// Contracts are built once and shared by discovery, preflight and execution.
pub fn schema(name: &str) -> Result<Value, String> {
use std::sync::OnceLock;
static CONTRACTS: OnceLock<Vec<Value>> = OnceLock::new();
let name = if name.eq_ignore_ascii_case("Polyline") {
"LwPolyline"
} else {
name
};
let index = TYPES.iter().position(|kind| kind.eq_ignore_ascii_case(name))
.ok_or_else(|| format!("/type: unknown entity type '{name}'; request entity_schema without type to list supported types"))?;
let contracts = CONTRACTS.get_or_init(|| {
TYPES
.iter()
.map(|kind| build_schema(kind).expect("known type"))
.collect()
});
Ok(contracts[index].clone())
}
fn build_schema(name: &str) -> Result<Value, String> {
let name = if name.eq_ignore_ascii_case("Polyline") {
"LwPolyline"
} else {
name
};
let canonical = TYPES.iter().find(|t| t.eq_ignore_ascii_case(name))
.ok_or_else(|| format!("/type: unknown entity type '{name}'; request entity_schema without type to list supported types"))?;
let case_pattern = |name: &str| {
name.chars()
.map(|c| format!("[{}{}]", c.to_ascii_uppercase(), c.to_ascii_lowercase()))
.collect::<String>()
};
let pattern = if *canonical == "LwPolyline" {
format!(
"^({}|{})$",
case_pattern(canonical),
case_pattern("Polyline")
)
} else {
format!("^{}$", case_pattern(canonical))
};
let number = json!({"type":"number"});
let string = json!({"type":"string"});
let boolean = json!({"type":"boolean"});
let mut properties = json!({"type":{"type":"string","pattern":pattern},"layer":{"type":"string","description":"Missing layers are created; empty uses the default layer."},"color":{"type":"integer","minimum":-32768,"maximum":32767,"description":"DXF indexed color, including ByLayer/ByBlock sentinels."}});
let (required, example) = match *canonical {
"Line" => {
properties["start"] = point(2, 3);
properties["end"] = point(2, 3);
properties["thickness"] = number.clone();
(
vec!["start", "end"],
json!({"type":"Line","start":[0,0],"end":[10,10]}),
)
}
"Circle" | "Arc" => {
properties["center"] = point(2, 3);
properties["radius"] = if *canonical == "Arc" {
json!({"type":"number","exclusiveMinimum":0})
} else {
json!({"type":"number","minimum":0})
};
if *canonical == "Arc" {
properties["start_angle_deg"] = number.clone();
properties["end_angle_deg"] = number.clone();
properties["end_angle_deg"]["description"] = json!("Together with start_angle_deg, must define a finite, nonzero sweep using the CAD arc constructor.");
(
vec!["center", "radius", "start_angle_deg", "end_angle_deg"],
json!({"type":"Arc","center":[0,0],"radius":5,"start_angle_deg":0,"end_angle_deg":90}),
)
} else {
(
vec!["center", "radius"],
json!({"type":"Circle","center":[0,0],"radius":5}),
)
}
}
"Point" => {
properties["location"] = point(2, 3);
(vec!["location"], json!({"type":"Point","location":[0,0]}))
}
"LwPolyline" => {
properties["vertices"] = json!({"type":"array","minItems":2,"items":point(2,3),"description":"XY coordinates; optional Z is ignored."});
properties["closed"] = boolean.clone();
properties["constant_width"] = number.clone();
(
vec!["vertices"],
json!({"type":"LwPolyline","vertices":[[0,0],[10,0]],"closed":false}),
)
}
"Text" | "MText" => {
properties["value"] = json!({"type":"string","minLength":1});
properties["position"] = point(2, 3);
properties["height"] = json!({"type":"number","default":2.5,"description":"Absolute value, clamped to at least 0.000001."});
properties["rotation_deg"] = number.clone();
properties["style"] = json!({"type":"string","description":"Nonempty style names must exist in the target document."});
if *canonical == "MText" {
properties["width"] = json!({"type":"number","description":"Positive values set rectangle width; otherwise default."});
}
(
vec!["value", "position"],
json!({"type":canonical,"value":"Label","position":[0,0]}),
)
}
"Insert" => {
properties["block"] = json!({"type":"string","minLength":1});
properties["position"] = point(2, 3);
properties["rotation_deg"] = number.clone();
properties["scale"] = json!({"anyOf":[{"type":"number","not":{"const":0}},{"type":"array","minItems":3,"maxItems":3,"items":{"type":"number","not":{"const":0}}}],"default":1,"description":"Nonzero uniform factor or three nonzero axis factors."});
(
vec!["block", "position"],
json!({"type":"Insert","block":"DOOR_90","position":[0,0],"scale":1}),
)
}
"Solid" => {
properties["corners"] =
json!({"type":"array","minItems":3,"maxItems":4,"items":point(3,3)});
(
vec!["corners"],
json!({"type":"Solid","corners":[[0,0,0],[10,0,0],[0,10,0]]}),
)
}
"Hatch" => {
properties["boundary"] = json!({"type":"array","minItems":3,"items":point(2,3),"description":"Closed XY boundary; optional Z is ignored."});
properties["solid"] = boolean.clone();
properties["pattern"] = string;
properties["pattern_scale"] = json!({"type":"number","default":1,"description":"Absolute value, clamped to at least 0.000001."});
properties["pattern_angle_deg"] = number.clone();
(
vec!["boundary"],
json!({"type":"Hatch","boundary":[[0,0],[10,0],[0,10]],"solid":true}),
)
}
"Spline" => {
properties["degree"] = json!({"type":"integer","minimum":1,"maximum":10,"default":3});
for key in ["control_points", "fit_points"] {
properties[key] = json!({"type":"array","minItems":2,"items":point(2,3)});
}
for key in ["knots", "weights"] {
properties[key] = json!({"type":"array","minItems":1,"items":{"type":"number"}});
}
properties["control_points"]["description"] =
json!("Requires more points than degree (default degree 3).");
properties["knots"]["description"] = json!("Requires control_points; exactly control_points count + degree + 1 non-decreasing values.");
properties["weights"]["description"] = json!("Requires control_points; one positive weight per control point. Sets rational=true unless overridden explicitly.");
properties["weights"]["items"]["exclusiveMinimum"] = json!(0);
for key in ["closed", "periodic", "rational", "planar", "linear"] {
properties[key] = boolean.clone();
}
(
vec![],
json!({"type":"Spline","degree":2,"control_points":[[0,0],[5,10],[10,0]]}),
)
}
_ => unreachable!(),
};
let mut required = required;
required.insert(0, "type");
let mut result = json!({"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":properties,"required":required,"additionalProperties":false,"examples":[example],"description":"Creation input, not serialized record properties. Type names are case-insensitive; Polyline aliases LwPolyline. Angles are degrees; coordinates and lengths use drawing units. Additional geometric constraints are checked by preflight and the builder."});
if *canonical == "Spline" {
result["anyOf"] = json!([{"required":["control_points"]},{"required":["fit_points"]}]);
}
Ok(result)
}
pub fn validate_entity(spec: &Value, path: &str) -> Result<(), String> {
let kind = spec["type"]
.as_str()
.ok_or_else(|| format!("{path}/type: expected entity type string"))?;
let contract = schema(kind).map_err(|e| format!("{path}{e}"))?;
crate::schema_validation::validate_shape(&contract, spec, path)?;
build_entity(spec, None).map(|_| ()).map_err(|error| {
format!(
"{path}{}: {}",
error["path"].as_str().unwrap_or(""),
error["error"].as_str().unwrap_or("invalid entity")
)
})
}
type Parsed<T> = Result<T, Value>;
fn failure_at(code: &str, path: &str, error: impl ToString) -> Value {
let mut result = failure(code, error);
result["path"] = json!(path);
result
}
fn failure(code: &str, error: impl ToString) -> Value {
json!({"ok":false,"status":"failed","code":code,"error":error.to_string()})
}
/// Parse `[x,y]` or `[x,y,z]` without silently turning a malformed
/// coordinate into zero.
fn point_value(value: Option<&Value>, label: &str, code: &str) -> Parsed<codec::types::Vector3> {
let values = value
.and_then(Value::as_array)
.filter(|v| (2..=3).contains(&v.len()))
.ok_or_else(|| failure(code, format!("{label} must be [x,y] or [x,y,z]")))?;
let coord = |i: usize| {
values[i]
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(|| failure(code, format!("{label} needs finite coordinates")))
};
let z = values
.get(2)
.map(|value| {
value
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(|| failure(code, format!("{label} needs finite coordinates")))
})
.transpose()?
.unwrap_or(0.0);
Ok(codec::types::Vector3::new(coord(0)?, coord(1)?, z))
}
/// `[x,y]` or `[x,y,z]` (missing z = 0) from `req[key]`.
pub(crate) fn point_field(req: &Value, key: &str) -> Parsed<codec::types::Vector3> {
point_value(req.get(key), key, "invalid_point")
}
fn point_list_field(spec: &Value, key: &str, minimum: usize) -> Parsed<Vec<codec::types::Vector3>> {
let points = spec[key]
.as_array()
.filter(|points| points.len() >= minimum)
.ok_or_else(|| {
failure(
"invalid_spline_points",
format!("{key} needs at least {minimum} points"),
)
})?;
points
.iter()
.enumerate()
.map(|(index, point)| {
point_value(
Some(point),
&format!("{key}[{index}]"),
"invalid_spline_points",
)
})
.collect()
}
fn finite_number_list_field(spec: &Value, key: &str) -> Parsed<Vec<f64>> {
let values = spec[key]
.as_array()
.filter(|values| !values.is_empty())
.ok_or_else(|| {
failure(
"invalid_spline_values",
format!("{key} needs a non-empty array"),
)
})?;
values
.iter()
.enumerate()
.map(|(index, value)| {
value
.as_f64()
.filter(|value| value.is_finite())
.ok_or_else(|| {
failure(
"invalid_spline_values",
format!("{key}[{index}] must be finite"),
)
})
})
.collect()
}
pub(crate) fn deg_field(req: &Value, key: &str) -> f64 {
req[key].as_f64().unwrap_or(0.0).to_radians()
}
/// Required finite angle in degrees, converted to radians without a default.
fn finite_angle_field(spec: &Value, key: &str) -> Parsed<f64> {
let degrees = spec[key]
.as_f64()
.filter(|value| value.is_finite())
.ok_or_else(|| failure("invalid_angle", format!("arc needs a finite {key}")))?;
let radians = degrees.to_radians();
radians
.is_finite()
.then_some(radians)
.ok_or_else(|| failure("invalid_angle", format!("arc needs a finite {key}")))
}
fn apply_common_properties(spec: &Value, entity: &mut codec::EntityType) -> Parsed<()> {
if let Some(layer) = spec["layer"].as_str() {
if !layer.is_empty()
&& !matches!(
entity,
codec::EntityType::Block(_) | codec::EntityType::BlockEnd(_)
)
{
entity.common_mut().layer = layer.to_owned();
}
}
if let Some(index) = spec["color"]
.as_f64()
.filter(|n| n.fract() == 0.0 && (-32768.0..=32767.0).contains(n))
{
entity.common_mut().color = codec::types::Color::from_index(index as i16);
}
Ok(())
}
/// An explicit text style must exist in the current document; otherwise a
/// typo would silently fall back to the default style.
fn text_style_field(spec: &Value, document: Option<&codec::CadDocument>) -> Parsed<Option<String>> {
let Some(style) = spec["style"].as_str().filter(|s| !s.is_empty()) else {
return Ok(None);
};
if document.is_some_and(|document| !document.text_styles.contains(style)) {
return Err(failure(
"invalid_text_style",
format!("Text style '{style}' does not exist in the document"),
));
}
Ok(Some(style.to_owned()))
}
/// One `entities` array entry → a real entity. Geometry validation happens
/// here so a bad definition aborts the whole batch before anything commits.
pub(crate) fn build_entity(
spec: &Value,
document: Option<&codec::CadDocument>,
) -> Parsed<codec::EntityType> {
use codec::entities::*;
use codec::types::Vector3;
let kind = spec["type"].as_str().unwrap_or("").to_ascii_uppercase();
// Preserve the public unknown-field error while deriving allowed keys from
// the published contract, rather than maintaining another field list.
if let (Some(object), Ok(schema)) = (spec.as_object(), schema(&kind)) {
for field in object.keys() {
if schema["properties"].get(field).is_none() {
return Err(failure(
"unknown_entity_field",
format!("Unknown field '{field}' for {kind} entity"),
));
}
}
}
let entity = match kind.as_str() {
"LINE" => {
let mut line = Line::from_points(point_field(spec, "start")?, point_field(spec, "end")?);
if let Some(t) = spec["thickness"].as_f64() {
line.thickness = t;
}
EntityType::Line(line)
}
"CIRCLE" => {
let radius = spec["radius"]
.as_f64()
.filter(|r| r.is_finite() && *r >= 0.0)
.ok_or_else(|| failure("invalid_radius", "circle needs a finite radius >= 0"))?;
EntityType::Circle(Circle::from_center_radius(point_field(spec, "center")?, radius))
}
"ARC" => {
let radius = spec["radius"]
.as_f64()
.filter(|r| r.is_finite() && *r > 0.0)
.ok_or_else(|| failure("invalid_radius", "arc needs a finite radius > 0"))?;
let arc = Arc::from_center_radius_angles(
point_field(spec, "center")?,
radius,
finite_angle_field(spec, "start_angle_deg")?,
finite_angle_field(spec, "end_angle_deg")?,
);
if !arc.sweep_angle().is_finite() || arc.sweep_angle() <= 0.0 {
return Err(failure_at(
"invalid_sweep", "/end_angle_deg",
"arc start_angle_deg and end_angle_deg must define a finite non-zero sweep",
));
}
EntityType::Arc(arc)
}
"SPLINE" => {
let control_points = if spec.get("control_points").is_some() {
point_list_field(spec, "control_points", 2)?
} else {
Vec::new()
};
let fit_points = if spec.get("fit_points").is_some() {
point_list_field(spec, "fit_points", 2)?
} else {
Vec::new()
};
if control_points.is_empty() && fit_points.is_empty() {
return Err(failure(
"invalid_spline_points",
"spline needs control_points or fit_points (at least 2 points)",
));
}
let degree = match spec.get("degree") {
None | Some(Value::Null) => 3,
Some(value) => value
.as_f64()
.filter(|degree| degree.fract() == 0.0 && (1.0..=10.0).contains(degree))
.ok_or_else(|| {
failure("invalid_spline_values", "degree must be an integer 1..10")
})? as i32,
};
let count = control_points.len();
if count > 0 && count <= degree as usize {
return Err(failure_at(
"invalid_spline_values", "/control_points",
format!("a degree {degree} spline needs more than {degree} control_points"),
));
}
let mut spline = if control_points.is_empty() {
let mut spline = Spline::from_fit_points(fit_points.clone());
spline.degree = degree;
spline
} else {
Spline::from_control_points(degree, control_points)
};
if !fit_points.is_empty() {
spline.fit_points = fit_points;
}
if spec.get("knots").is_some() {
if spline.control_points.is_empty() {
return Err(failure(
"invalid_spline_values",
"spline knots require control_points",
));
}
let knots = finite_number_list_field(spec, "knots")?;
if knots.len() != count + degree as usize + 1
|| knots.windows(2).any(|pair| pair[1] < pair[0])
{
return Err(failure_at(
"invalid_spline_values", "/knots",
format!(
"knots must be {} non-decreasing values (control_points + degree + 1)",
count + degree as usize + 1
),
));
}
spline.knots = knots;
}
if spec.get("weights").is_some() {
if spline.control_points.is_empty() {
return Err(failure(
"invalid_spline_values",
"spline weights require control_points",
));
}
let weights = finite_number_list_field(spec, "weights")?;
if weights.len() != count || weights.iter().any(|weight| *weight <= 0.0) {
return Err(failure_at(
"invalid_spline_values", "/weights",
format!("weights must be {count} positive values, one per control point"),
));
}
spline.weights = weights;
spline.flags.rational = true;
}
if let Some(value) = spec["closed"].as_bool() {
spline.flags.closed = value;
}
if let Some(value) = spec["periodic"].as_bool() {
spline.flags.periodic = value;
}
if let Some(value) = spec["rational"].as_bool() {
spline.flags.rational = value;
}
if let Some(value) = spec["planar"].as_bool() {
spline.flags.planar = value;
}
if let Some(value) = spec["linear"].as_bool() {
spline.flags.linear = value;
}
EntityType::Spline(spline)
}
"LWPOLYLINE" | "POLYLINE" => {
let vertices = spec["vertices"]
.as_array()
.filter(|v| v.len() >= 2)
.ok_or_else(|| failure("invalid_vertices", "polyline needs vertices:[[x,y],…]"))?;
let mut pline = LwPolyline::new();
for point in vertices {
let x = point[0].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_vertices", "polyline vertices must be finite [x,y]")
})?;
let y = point[1].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_vertices", "polyline vertices must be finite [x,y]")
})?;
pline.add_point(codec::types::Vector2::new(x, y));
}
if spec["closed"].as_bool().unwrap_or(false) {
pline.close();
}
if let Some(width) = spec["constant_width"].as_f64() {
pline.constant_width = width;
}
EntityType::LwPolyline(pline)
}
"POINT" => EntityType::Point(Point::at(point_field(spec, "location")?)),
"TEXT" => {
let value = spec["value"].as_str().unwrap_or("");
if value.is_empty() {
return Err(failure("invalid_text", "text needs a non-empty value"));
}
let mut text = Text::with_value(value, point_field(spec, "position")?)
.with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6))
.with_rotation(deg_field(spec, "rotation_deg"));
if let Some(style) = text_style_field(spec, document)? {
text.style = style;
}
EntityType::Text(text)
}
"MTEXT" => {
let value = spec["value"].as_str().unwrap_or("");
if value.is_empty() {
return Err(failure("invalid_text", "mtext needs a non-empty value"));
}
let mut mtext = MText::with_value(value, point_field(spec, "position")?)
.with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6));
if let Some(width) = spec["width"].as_f64().filter(|w| *w > 0.0) {
mtext.rectangle_width = width;
}
mtext.rotation = deg_field(spec, "rotation_deg");
if let Some(style) = text_style_field(spec, document)? {
mtext.style = style;
}
EntityType::MText(Box::new(mtext))
}
"INSERT" => {
let block = spec["block"].as_str().unwrap_or("");
if block.is_empty() {
return Err(failure("block_required", "insert needs a block name"));
}
let mut insert = Insert::new(block, point_field(spec, "position")?)
.with_rotation(deg_field(spec, "rotation_deg"));
match &spec["scale"] {
Value::Number(factor) => {
let factor = factor.as_f64().filter(|f| *f != 0.0).ok_or_else(|| {
failure("invalid_scale", "insert scale must be nonzero")
})?;
insert = insert.with_uniform_scale(factor);
}
Value::Array(factors) if factors.len() == 3 => {
let axis = |i: usize| {
factors[i].as_f64().filter(|f| *f != 0.0).ok_or_else(|| {
failure("invalid_scale", "insert scale components must be nonzero")
})
};
insert = insert.with_scale(axis(0)?, axis(1)?, axis(2)?);
}
_ => {}
}
EntityType::Insert(insert)
}
"SOLID" => {
let raw_corners = spec["corners"]
.as_array()
.filter(|v| (3..=4).contains(&v.len()))
.ok_or_else(|| failure("invalid_corners", "solid needs 3 or 4 corners"))?;
let corners: Vec<Vector3> = raw_corners
.iter()
.enumerate()
.map(|(index, value)| {
if value.as_array().is_none_or(|values| values.len() != 3) {
return Err(failure(
"invalid_corners",
format!("corners[{index}] must be [x,y,z]"),
));
}
point_value(
Some(value),
&format!("corners[{index}]"),
"invalid_corners",
)
})
.collect::<Parsed<_>>()?;
let solid = match corners.as_slice() {
[first, second, third] => Solid::triangle(*first, *second, *third),
[first, second, third, fourth] => Solid::new(*first, *second, *third, *fourth),
_ => unreachable!("corner count was validated above"),
};
EntityType::Solid(solid)
}
"HATCH" => {
let boundary = spec["boundary"]
.as_array()
.filter(|v| v.len() >= 3)
.ok_or_else(|| failure("invalid_boundary", "hatch needs boundary:[[x,y],…] (>= 3 points)"))?;
let mut vertices = Vec::with_capacity(boundary.len());
for point in boundary {
let x = point[0].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_boundary", "hatch boundary must be finite [x,y]")
})?;
let y = point[1].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_boundary", "hatch boundary must be finite [x,y]")
})?;
vertices.push(codec::types::Vector2::new(x, y));
}
let mut hatch = Hatch::new();
if spec["solid"].as_bool().unwrap_or(false) {
hatch.is_solid = true;
} else {
hatch.is_solid = false;
hatch.pattern = HatchPattern::new(
spec["pattern"].as_str().unwrap_or("ANSI31"),
);
hatch.pattern_scale = spec["pattern_scale"].as_f64().unwrap_or(1.0).abs().max(1e-6);
hatch.pattern_angle = deg_field(spec, "pattern_angle_deg");
}
let mut path = BoundaryPath::external();
path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(vertices, true)));
hatch.paths.push(path);
EntityType::Hatch(Box::new(hatch))
}
other => {
return Err(failure(
"unknown_entity_type",
format!(
"Unknown entity type '{other}'. Supported: Line, Circle, Arc, Spline, LwPolyline, Point, Text, MText, Insert, Solid, Hatch"
),
))
}
};
let mut entity = entity;
apply_common_properties(spec, &mut entity)?;
let contract = schema(&kind).expect("supported factory type");
crate::schema_validation::validate_shape(&contract, spec, "")
.map_err(|error| failure("invalid_entity_definition", error))?;
Ok(entity)
}