//! Entity-level automation operations — create, erase, transform, XData and //! block-definition operations on the drawing database. All operations are atomic (one undo step), validate //! every input before touching the document, and address entities by the //! hexadecimal handles that `query`/`records` return. use super::*; use crate::command::EntityTransform; type Parsed = Result; /// 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 { 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]`. fn point_field(req: &Value, key: &str) -> Parsed { point_value(req.get(key), key, "invalid_point") } fn point_list_field( spec: &Value, key: &str, minimum: usize, ) -> Parsed> { 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> { 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() } fn deg_field(req: &Value, key: &str) -> f64 { req[key].as_f64().unwrap_or(0.0).to_radians() } /// Reject fields that the typed builder would otherwise silently ignore. fn validate_entity_fields(spec: &Value, kind: &str) -> Parsed<()> { let Some(object) = spec.as_object() else { return Ok(()); }; for field in object.keys() { let allowed = match kind { "LINE" => matches!( field.as_str(), "type" | "start" | "end" | "thickness" | "layer" | "color" ), "CIRCLE" => matches!(field.as_str(), "type" | "center" | "radius" | "layer" | "color"), "ARC" => matches!( field.as_str(), "type" | "center" | "radius" | "start_angle_deg" | "end_angle_deg" | "layer" | "color" ), // Kept here so the schema remains forward-compatible with the // Spline entity API when that builder lands separately. "SPLINE" => matches!( field.as_str(), "type" | "degree" | "knots" | "control_points" | "fit_points" | "weights" | "closed" | "periodic" | "rational" | "planar" | "linear" | "layer" | "color" ), "LWPOLYLINE" | "POLYLINE" => matches!( field.as_str(), "type" | "vertices" | "closed" | "constant_width" | "layer" | "color" ), "POINT" => matches!(field.as_str(), "type" | "location" | "layer" | "color"), "TEXT" => matches!( field.as_str(), "type" | "value" | "position" | "height" | "rotation_deg" | "style" | "layer" | "color" ), "MTEXT" => matches!( field.as_str(), "type" | "value" | "position" | "height" | "width" | "rotation_deg" | "style" | "layer" | "color" ), "INSERT" => matches!( field.as_str(), "type" | "block" | "position" | "scale" | "rotation_deg" | "layer" | "color" ), "SOLID" => matches!(field.as_str(), "type" | "corners" | "layer" | "color"), "HATCH" => matches!( field.as_str(), "type" | "boundary" | "solid" | "pattern" | "pattern_scale" | "pattern_angle_deg" | "layer" | "color" ), _ => true, }; if !allowed { return Err(failure( "unknown_entity_field", format!("Unknown field '{field}' for {kind} entity"), )); } } Ok(()) } /// Required finite angle in degrees, converted to radians without a default. fn finite_angle_field(spec: &Value, key: &str) -> Parsed { 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}"))) } pub(super) fn hex_handles(req: &Value, key: &str) -> Parsed> { let list = req[key] .as_array() .filter(|v| !v.is_empty()) .ok_or_else(|| { failure( "handles_required", format!("Supply a non-empty {key}:[…] list"), ) })?; list.iter() .map(|v| { u64::from_str_radix( v.as_str() .unwrap_or("") .trim_start_matches("0x") .trim_start_matches("0X"), 16, ) .map(codec::Handle::new) .map_err(|_| { failure( "invalid_handle", format!("Expected a hexadecimal handle, got {v}"), ) }) }) .collect() } /// Every handle must exist in the document; otherwise `entity_absent` naming /// the missing ones so the caller can refresh instead of guessing. pub(super) fn require_existing( document: &codec::CadDocument, handles: &[codec::Handle], ) -> Parsed<()> { let missing: Vec = handles .iter() .filter(|h| document.get_entity(**h).is_none()) .map(|h| format!("{:X}", h.value())) .collect(); if missing.is_empty() { Ok(()) } else { Err(failure( "entity_absent", format!("Handles not found: {}", missing.join(", ")), )) } } 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_i64().and_then(|v| i16::try_from(v).ok()) { entity.common_mut().color = codec::types::Color::from_index(index); } 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: &codec::CadDocument) -> Parsed> { let Some(style) = spec["style"].as_str().filter(|s| !s.is_empty()) else { return Ok(None); }; if !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. fn build_entity(spec: &Value, document: &codec::CadDocument) -> Parsed { use codec::entities::*; use codec::types::Vector3; let kind = spec["type"].as_str().unwrap_or("").to_ascii_uppercase(); validate_entity_fields(spec, &kind)?; 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( "invalid_sweep", "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_i64() .filter(|degree| (1..=10).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( "invalid_spline_values", 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( "invalid_spline_values", 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( "invalid_spline_values", 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(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 = 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::>()?; 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(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)?; Ok(entity) } impl OpenCADStudio { /// `entities_create` — build a batch of typed entities, auto-create the /// layers they name, and commit everything as one undoable step. All definitions are validated before the first /// entity is added, so the batch is all-or-nothing. pub(super) fn control_entities_create(&mut self, req: &Value) -> Result, Value> { let list = req["entities"] .as_array() .filter(|v| !v.is_empty()) .ok_or_else(|| failure("entities_required", "Supply entities:[{type:…},…]"))?; let built: Vec = { let document = &self.tabs[self.active_tab].scene.document; list.iter() .map(|spec| build_entity(spec, document)) .collect::>()? }; let i = self.active_tab; self.push_undo_snapshot(i, "ENTITIESCREATE"); // Auto-create missing layers (Catalog §1.4: "create layer if missing") // after the undo snapshot, so undo removes them together with the // entities. let mut layers_created = Vec::new(); { let document = &mut self.tabs[i].scene.document; for entity in &built { let layer = entity.common().layer.clone(); if layer.is_empty() || document.layers.contains(&layer) { continue; } let mut layer_record = codec::tables::Layer::new(layer.clone()); layer_record.handle = document.allocate_handle(); document .layers .add(layer_record) .map_err(|e| failure("layer_failed", e))?; layers_created.push(layer); } } let handles: Vec = { let scene = &mut self.tabs[i].scene; built .into_iter() .map(|entity| format!("{:X}", scene.add_entity(entity).value())) .collect() }; self.post_ref_op(i); let created = handles.len(); self.set_control_result(json!({ "handles": handles, "created": created, "layers_created": layers_created, })); Ok(Task::none()) } /// `entities_delete` — erase by handles. Every handle is /// checked up front; nothing is erased when one is missing. pub(super) fn control_entities_delete(&mut self, req: &Value) -> Result, Value> { let handles = hex_handles(req, "handles")?; let i = self.active_tab; require_existing(&self.tabs[i].scene.document, &handles)?; self.push_undo_snapshot(i, "ENTITIESDELETE"); self.tabs[i].scene.erase_entities(&handles); self.post_ref_op(i); let erased = handles.len(); self.set_control_result(json!({ "erased": erased })); Ok(Task::none()) } /// `entities_transform` — move/copy/rotate/scale/mirror/array by handles /// Angles are degrees. `copy`, mirror with /// `"copy":true` and `array` return the newly created handles. pub(super) fn control_entities_transform( &mut self, req: &Value, ) -> Result, Value> { let handles = hex_handles(req, "handles")?; let action = req["action"] .as_str() .unwrap_or("move") .trim() .to_ascii_lowercase(); let i = self.active_tab; require_existing(&self.tabs[i].scene.document, &handles)?; if !matches!( action.as_str(), "move" | "copy" | "rotate" | "scale" | "mirror" | "array" ) { return Err(failure( "invalid_action", format!( "Unknown transform action '{action}'. Use move, copy, rotate, scale, mirror or array" ), )); } let transform = |req: &Value| -> Parsed { Ok(match action.as_str() { "move" | "copy" => EntityTransform::Translate(glam::DVec3::new( req["vector"][0].as_f64().unwrap_or(0.0), req["vector"][1].as_f64().unwrap_or(0.0), req["vector"][2].as_f64().unwrap_or(0.0), )), "rotate" => EntityTransform::Rotate { center: glam::DVec3::new( req["center"][0].as_f64().unwrap_or(0.0), req["center"][1].as_f64().unwrap_or(0.0), req["center"][2].as_f64().unwrap_or(0.0), ), axis: glam::DVec3::Z, angle_rad: deg_field(req, "angle_deg"), }, "scale" => { let factor = req["factor"] .as_f64() .filter(|f| f.is_finite() && *f != 0.0) .ok_or_else(|| { failure("invalid_factor", "scale needs a nonzero finite factor") })?; EntityTransform::Scale { center: glam::DVec3::new( req["center"][0].as_f64().unwrap_or(0.0), req["center"][1].as_f64().unwrap_or(0.0), req["center"][2].as_f64().unwrap_or(0.0), ), factor, } } "mirror" => { let axis = req["axis"] .as_array() .filter(|v| v.len() == 2) .ok_or_else(|| { failure("invalid_axis", "mirror needs axis:[[x1,y1],[x2,y2]]") })?; let end = |k: usize| { axis[k] .as_array() .filter(|v| v.len() >= 2) .ok_or_else(|| { failure("invalid_axis", "mirror needs axis:[[x1,y1],[x2,y2]]") }) }; let (a, b) = (end(0)?, end(1)?); let coord = |v: &[Value], k: usize| { v[k].as_f64() .filter(|f| f.is_finite()) .ok_or_else(|| failure("invalid_axis", "mirror axis points must be finite")) }; let p1 = glam::DVec3::new(coord(&a, 0)?, coord(&a, 1)?, 0.0); let p2 = glam::DVec3::new(coord(&b, 0)?, coord(&b, 1)?, 0.0); if p1 == p2 { return Err(failure( "invalid_axis", "mirror axis points must differ", )); } EntityTransform::Mirror { p1, p2, working_normal: glam::DVec3::Z, } } other => { return Err(failure( "invalid_action", format!( "Unknown transform action '{other}'. Use move, copy, rotate, scale, mirror or array" ), )) } }) }; self.push_undo_snapshot(i, "ENTITIESTRANSFORM"); let scene = &mut self.tabs[i].scene; let mut created: Vec = Vec::new(); let affected; match action.as_str() { "move" | "rotate" | "scale" => { let t = transform(req)?; scene.transform_entities(&handles, &t); affected = handles.len(); } "mirror" => { let t = transform(req)?; if req["copy"].as_bool().unwrap_or(false) { created = scene .copy_entities(&handles, &t) .into_iter() .map(|h| format!("{:X}", h.value())) .collect(); } else { scene.transform_entities(&handles, &t); } affected = handles.len(); } "copy" => { let t = transform(req)?; created = scene .copy_entities(&handles, &t) .into_iter() .map(|h| format!("{:X}", h.value())) .collect(); affected = handles.len(); } "array" => { let rows = req["rows"].as_u64().unwrap_or(1).clamp(1, 100) as i32; let columns = req["columns"].as_u64().unwrap_or(1).clamp(1, 100) as i32; let row_spacing = req["row_spacing"].as_f64().unwrap_or(0.0); let column_spacing = req["column_spacing"].as_f64().unwrap_or(0.0); for row in 0..rows { for column in 0..columns { if row == 0 && column == 0 { continue; } let t = EntityTransform::Translate(glam::DVec3::new( column as f64 * column_spacing, row as f64 * row_spacing, 0.0, )); created.extend( scene .copy_entities(&handles, &t) .into_iter() .map(|h| format!("{:X}", h.value())), ); } } affected = handles.len(); } other => { return Err(failure( "invalid_action", format!( "Unknown transform action '{other}'. Use move, copy, rotate, scale, mirror or array" ), )) } } self.post_ref_op(i); self.set_control_result(json!({ "affected": affected, "created": created, })); Ok(Task::none()) } /// `xdata_set` — replace one application's extended-data record on every /// listed handle. The APPID table entry is /// registered implicitly; an empty (or absent) `data` list removes the /// application's record. pub(super) fn control_xdata_set(&mut self, req: &Value) -> Result, Value> { let handles = hex_handles(req, "handles")?; let app = string(req, "app")?.to_owned(); if app.is_empty() { return Err(failure("app_required", "Supply an application name")); } let values = parse_xdata_values(req)?; let i = self.active_tab; require_existing(&self.tabs[i].scene.document, &handles)?; self.push_undo_snapshot(i, "XDATASET"); { let document = &mut self.tabs[i].scene.document; for handle in &handles { crate::scene::view::dispatch::set_entity_xdata( document, *handle, &app, values.clone(), ); } } let updated = handles.len(); self.set_control_result(json!({ "updated": updated, "app": app })); Ok(Task::none()) } /// `xdata_get` — read extended data back, optionally for one application. /// A read: dispatched through the query path, no request_id needed. pub(super) fn xdata_read(&self, req: &Value) -> Value { let handles = match hex_handles(req, "handles") { Ok(handles) => handles, Err(error) => return error, }; let app = req["app"].as_str().unwrap_or(""); let i = self.active_tab; if let Err(error) = require_existing(&self.tabs[i].scene.document, &handles) { return error; } let document = &self.tabs[i].scene.document; let items: Vec = handles .iter() .filter_map(|handle| { let entity = document.get_entity(*handle)?; let extended = &entity.common().extended_data; let records: serde_json::Map = extended .records() .iter() .filter(|record| app.is_empty() || record.application_name == app) .map(|record| { ( record.application_name.clone(), json!(record .values .iter() .map(xdata_value_json) .collect::>()), ) }) .collect(); Some(json!({ "handle": format!("{:X}", handle.value()), "xdata": records, })) }) .collect(); json!({ "ok": true, "items": items }) } /// `block_define` — turn the listed entities into a new block definition /// (block-definition semantics, as the BLOCK command: the sources become /// the definition's content and an Insert is placed at `insert_at`, /// defaulting to `base`, so the drawing looks unchanged). pub(super) fn control_block_define(&mut self, req: &Value) -> Result, Value> { let name = string(req, "name")?.to_owned(); if name.is_empty() { return Err(failure("name_required", "Supply a block name")); } let handles = hex_handles(req, "handles")?; let base = point_field(req, "base")?; let insert_at = if req.get("insert_at").is_some() { point_field(req, "insert_at")? } else { base }; let i = self.active_tab; require_existing(&self.tabs[i].scene.document, &handles)?; // World → block space translates `base` onto the block origin; the // inverse places the Insert so entities keep their world position. let world_to_block = codec::types::Transform::from_translation(codec::types::Vector3::new( -base.x, -base.y, -base.z, )); let block_to_world = codec::types::Transform::from_translation(insert_at); self.push_undo_snapshot(i, "BLOCKDEFINE"); // CF-01.3 (barcode re-import): `replace:true` drops the previous // definition — its children, markers and inserts — inside the same // undo step, so the new definition can take the name. if req["replace"].as_bool().unwrap_or(false) { erase_block_definition(&mut self.tabs[i].scene, &name); } let insert = self.tabs[i] .scene .create_block_from_entities(&handles, &name, &world_to_block, &block_to_world) .map_err(|e| failure("block_failed", e))?; self.post_ref_op(i); self.set_control_result(json!({ "block": name, "insert": format!("{:X}", insert.value()), })); Ok(Task::none()) } /// `block_delete` — remove a block definition together with its child /// entities, the Block/BlockEnd markers and every Insert referencing it. pub(super) fn control_block_delete(&mut self, req: &Value) -> Result, Value> { let name = string(req, "name")?.to_owned(); if name.starts_with('*') { return Err(failure( "block_protected", "Layout block records cannot be deleted", )); } let i = self.active_tab; if self.tabs[i] .scene .document .block_records .get(&name) .is_none() { return Err(failure( "block_missing", format!("Block '{name}' does not exist"), )); } self.push_undo_snapshot(i, "BLOCKDELETE"); let erased = erase_block_definition(&mut self.tabs[i].scene, &name); self.post_ref_op(i); self.set_control_result(json!({ "block": name, "erased": erased })); Ok(Task::none()) } /// `view_focus` — fit the listed entities into the view and highlight /// them (Catalog §1.3). GUI sessions only; headless servers answer /// `gui_required`. #[cfg(not(target_arch = "wasm32"))] pub(super) fn control_view_focus(&mut self, req: &Value) -> Result, Value> { if self.main_window.is_none() { return Err(failure( "gui_required", "view_focus needs the editor window", )); } let handles = hex_handles(req, "handles")?; let i = self.active_tab; require_existing(&self.tabs[i].scene.document, &handles)?; if req["highlight"].as_bool().unwrap_or(true) { let scene = &mut self.tabs[i].scene; scene.deselect_all(); for handle in &handles { scene.select_entity(*handle, false); } } let zoomed = self.tabs[i].scene.zoom_to_entities(&handles); let selected = self.tabs[i].scene.selected_entities().len(); self.set_control_result(json!({ "zoomed": zoomed, "selected": selected })); Ok(Task::none()) } } /// Remove one block definition from the scene: every entity owned by the /// block record, every Insert referencing the name, the Block/BlockEnd /// markers (which the entity iterator skips) and the table record itself. /// Silent no-op for an unknown name (callers validate first); /// `*`-prefixed layout records are rejected by the callers. Returns the /// number of entities erased. pub(crate) fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str) -> usize { let Some(record) = scene.document.block_records.get(name) else { return 0; }; let owner = record.handle; let block_markers = [record.block_entity_handle, record.block_end_handle]; let mut victims: Vec = scene .document .entities() .filter(|entity| { entity.common().owner_handle == owner || matches!( entity, codec::EntityType::Insert(insert) if insert.block_name.eq_ignore_ascii_case(name) ) }) .map(|entity| entity.common().handle) .collect(); victims.extend_from_slice(&block_markers); scene.erase_entities(&victims); scene.document.block_records.remove(name); victims.len() } /// `data:[{code,value},…]` → typed XData values; an absent or empty list /// means "remove this application's record". fn parse_xdata_values(req: &Value) -> Parsed>> { use codec::xdata::XDataValue; let Some(list) = req["data"].as_array() else { return Ok(None); }; if list.is_empty() { return Ok(None); } let mut values = Vec::with_capacity(list.len()); for item in list { let code = item["code"] .as_i64() .ok_or_else(|| failure("invalid_xdata", "data entries need an integer code"))?; let value = &item["value"]; let parsed = match code { 1000 => XDataValue::String( value.as_str().ok_or_else(|| { failure("invalid_xdata", "code 1000 needs a string value") })? .to_string(), ), 1003 => XDataValue::LayerName( value.as_str().ok_or_else(|| { failure("invalid_xdata", "code 1003 needs a layer name") })? .to_string(), ), 1004 => { let hex = value.as_str().ok_or_else(|| { failure("invalid_xdata", "code 1004 needs a hex byte string") })?; ((0..hex.len()).step_by(2)) .map(|k| { u8::from_str_radix(&hex[k..(k + 2).min(hex.len())], 16).map_err(|_| { failure("invalid_xdata", "code 1004 needs a valid hex byte string") }) }) .collect::, Value>>() .map(XDataValue::BinaryData)? } 1005 => { let raw = value.as_str().ok_or_else(|| { failure("invalid_xdata", "code 1005 needs a hex handle") })?; let raw = raw.trim_start_matches("0x").trim_start_matches("0X"); let handle = u64::from_str_radix(raw, 16) .map_err(|_| failure("invalid_xdata", "code 1005 needs a valid hex handle"))?; XDataValue::Handle(codec::Handle::new(handle)) } 1010..=1013 => { let point = value.as_array().filter(|v| !v.is_empty()).ok_or_else(|| { failure("invalid_xdata", "codes 1010-1013 need [x,y,z]") })?; let coord = |k: usize| point.get(k).and_then(Value::as_f64).unwrap_or(0.0); let point = codec::types::Vector3::new(coord(0), coord(1), coord(2)); match code { 1010 => XDataValue::Point3D(point), 1011 => XDataValue::Position3D(point), 1012 => XDataValue::Displacement3D(point), _ => XDataValue::Direction3D(point), } } 1040 => XDataValue::Real(value.as_f64().unwrap_or(0.0)), 1041 => XDataValue::Distance(value.as_f64().unwrap_or(0.0)), 1042 => XDataValue::ScaleFactor(value.as_f64().unwrap_or(0.0)), 1070 => XDataValue::Integer16( value .as_i64() .and_then(|v| i16::try_from(v).ok()) .ok_or_else(|| failure("invalid_xdata", "code 1070 needs a 16-bit integer"))?, ), 1071 => XDataValue::Integer32( value .as_i64() .and_then(|v| i32::try_from(v).ok()) .ok_or_else(|| failure("invalid_xdata", "code 1071 needs a 32-bit integer"))?, ), other => { return Err(failure( "invalid_xdata", format!( "Unsupported xdata code {other}. Use 1000, 1003, 1004, 1005, 1010-1013, 1040, 1041, 1042, 1070 or 1071" ), )) } }; values.push(parsed); } Ok(Some(values)) } /// JSON shape for one stored XData value. fn xdata_value_json(value: &codec::xdata::XDataValue) -> Value { use codec::xdata::XDataValue; match value { XDataValue::String(text) | XDataValue::ControlString(text) | XDataValue::LayerName(text) => json!(text), XDataValue::BinaryData(bytes) => json!({ "binary": bytes.iter().map(|b| format!("{b:02X}")).collect::() }), XDataValue::Handle(handle) => json!(format!("{:X}", handle.value())), XDataValue::Point3D(point) | XDataValue::Position3D(point) | XDataValue::Displacement3D(point) | XDataValue::Direction3D(point) => json!([point.x, point.y, point.z]), XDataValue::Real(number) | XDataValue::Distance(number) | XDataValue::ScaleFactor(number) => json!(number), XDataValue::Integer16(number) => json!(number), XDataValue::Integer32(number) => json!(number), } }