Files
OneCAD-Draft/src/app/control/entities.rs
T
Hakan Seven 207684efb0 Merge pull request #1694 from YsDirector/pr/d8-entities-validation
Validate entities_create fields and geometry strictly

# Conflicts:
#	src/app/control/entities.rs
2026-10-11 23:28:35 +03:00

1108 lines
45 KiB
Rust

//! 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<T> = Result<T, Value>;
/// 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]`.
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()
}
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<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}")))
}
pub(super) fn hex_handles(req: &Value, key: &str) -> Parsed<Vec<codec::Handle>> {
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<String> = 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<Option<String>> {
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<codec::EntityType> {
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<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(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<Task<Message>, Value> {
let list = req["entities"]
.as_array()
.filter(|v| !v.is_empty())
.ok_or_else(|| failure("entities_required", "Supply entities:[{type:…},…]"))?;
let built: Vec<codec::EntityType> = {
let document = &self.tabs[self.active_tab].scene.document;
list.iter()
.map(|spec| build_entity(spec, document))
.collect::<Parsed<_>>()?
};
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<String> = {
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<Task<Message>, 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<Task<Message>, 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<EntityTransform> {
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<String> = 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<Task<Message>, 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<Value> = handles
.iter()
.filter_map(|handle| {
let entity = document.get_entity(*handle)?;
let extended = &entity.common().extended_data;
let records: serde_json::Map<String, Value> = 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::<Vec<_>>()),
)
})
.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<Task<Message>, 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<Task<Message>, 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<Task<Message>, 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<codec::Handle> = 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<Option<Vec<codec::xdata::XDataValue>>> {
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::<Result<Vec<u8>, 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::<String>()
}),
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),
}
}