Feat: Phase 4.3/4.4/4.5 — RasterImage, Wipeout, AttributeDefinition, AttributeEntity

- raster_image.rs: RasterImage renders as border rectangle + X diagonals
  (pixel placeholder); Wipeout renders border or polygon clip boundary;
  both support insertion_point grip, brightness/contrast/fade properties,
  clipping toggle, and mirror transform (u/v vectors reflected)
- attribute.rs: AttributeDefinition renders default_value (or [TAG] if
  empty) as font-tessellated text; AttributeEntity renders the live value;
  both support insertion grip, position/height/rotation properties,
  and transform operations
- mod.rs + traits.rs: wire up all four new entity types through the full
  dispatch chain (to_truck, grips, geometry_properties, apply_geom_prop,
  apply_grip, apply_transform)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-02 19:00:15 +03:00
commit 27c0e74468
4 changed files with 607 additions and 0 deletions

219
src/entities/attribute.rs Normal file
View file

@ -0,0 +1,219 @@
use acadrust::entities::{AttributeDefinition, AttributeEntity};
use glam::Vec3;
use crate::command::EntityTransform;
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip};
use crate::entities::text_support::resolve_text_style;
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::object::{GripApply, GripDef, PropSection};
use crate::scene::wire_model::SnapHint;
use crate::scene::{cxf, transform};
// ── AttributeDefinition ───────────────────────────────────────────────────────
impl TruckConvertible for AttributeDefinition {
fn to_truck(&self, document: &acadrust::CadDocument) -> Option<TruckEntity> {
let snap_pt = Vec3::new(
self.insertion_point.x as f32,
self.insertion_point.y as f32,
self.insertion_point.z as f32,
);
let resolved = resolve_text_style(&self.text_style, document);
let display = if self.default_value.is_empty() {
format!("[{}]", self.tag)
} else {
self.default_value.clone()
};
let wf = (self.width_factor as f32).max(0.01);
let strokes = cxf::tessellate_text_ex(
[self.insertion_point.x as f32, self.insertion_point.y as f32],
self.height as f32,
self.rotation as f32,
wf * resolved.width_factor.max(0.01),
self.oblique_angle as f32 + resolved.oblique_angle,
&resolved.font_name,
&display,
);
Some(TruckEntity {
object: TruckObject::Text(strokes),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![],
})
}
}
impl Grippable for AttributeDefinition {
fn grips(&self) -> Vec<GripDef> {
vec![square_grip(
0,
Vec3::new(
self.insertion_point.x as f32,
self.insertion_point.y as f32,
self.insertion_point.z as f32,
),
)]
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
if grip_id == 0 {
match apply {
GripApply::Translate(d) => {
self.insertion_point.x += d.x as f64;
self.insertion_point.y += d.y as f64;
self.insertion_point.z += d.z as f64;
}
GripApply::Absolute(p) => {
self.insertion_point.x = p.x as f64;
self.insertion_point.y = p.y as f64;
self.insertion_point.z = p.z as f64;
}
}
}
}
}
impl PropertyEditable for AttributeDefinition {
fn geometry_properties(&self, _text_style_names: &[String]) -> PropSection {
PropSection {
title: "Geometry".into(),
props: vec![
ro("Tag", "att_tag", self.tag.clone()),
ro("Prompt", "att_prompt", self.prompt.clone()),
edit("Default", "att_default", 0.0), // String — handled as text
edit("Insert X", "att_ix", self.insertion_point.x),
edit("Insert Y", "att_iy", self.insertion_point.y),
edit("Insert Z", "att_iz", self.insertion_point.z),
edit("Height", "att_h", self.height),
edit("Rotation", "att_rot", self.rotation.to_degrees()),
],
}
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
let Ok(v) = value.trim().parse::<f64>() else { return };
match field {
"att_ix" => self.insertion_point.x = v,
"att_iy" => self.insertion_point.y = v,
"att_iz" => self.insertion_point.z = v,
"att_h" if v > 0.0 => self.height = v,
"att_rot" => self.rotation = v.to_radians(),
_ => {}
}
}
}
impl Transformable for AttributeDefinition {
fn apply_transform(&mut self, t: &EntityTransform) {
transform::apply_standard_entity_transform(self, t, |entity, p1, p2| {
transform::reflect_xy_point(
&mut entity.insertion_point.x,
&mut entity.insertion_point.y,
p1,
p2,
);
});
}
}
// ── AttributeEntity ───────────────────────────────────────────────────────────
impl TruckConvertible for AttributeEntity {
fn to_truck(&self, document: &acadrust::CadDocument) -> Option<TruckEntity> {
let snap_pt = Vec3::new(
self.insertion_point.x as f32,
self.insertion_point.y as f32,
self.insertion_point.z as f32,
);
let resolved = resolve_text_style(&self.text_style, document);
let wf = (self.width_factor as f32).max(0.01);
let strokes = cxf::tessellate_text_ex(
[self.insertion_point.x as f32, self.insertion_point.y as f32],
self.height as f32,
self.rotation as f32,
wf * resolved.width_factor.max(0.01),
self.oblique_angle as f32 + resolved.oblique_angle,
&resolved.font_name,
&self.value,
);
Some(TruckEntity {
object: TruckObject::Text(strokes),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![],
})
}
}
impl Grippable for AttributeEntity {
fn grips(&self) -> Vec<GripDef> {
vec![square_grip(
0,
Vec3::new(
self.insertion_point.x as f32,
self.insertion_point.y as f32,
self.insertion_point.z as f32,
),
)]
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
if grip_id == 0 {
match apply {
GripApply::Translate(d) => {
self.insertion_point.x += d.x as f64;
self.insertion_point.y += d.y as f64;
self.insertion_point.z += d.z as f64;
}
GripApply::Absolute(p) => {
self.insertion_point.x = p.x as f64;
self.insertion_point.y = p.y as f64;
self.insertion_point.z = p.z as f64;
}
}
}
}
}
impl PropertyEditable for AttributeEntity {
fn geometry_properties(&self, _text_style_names: &[String]) -> PropSection {
PropSection {
title: "Geometry".into(),
props: vec![
ro("Tag", "atte_tag", self.tag.clone()),
ro("Value", "atte_val", self.value.clone()),
edit("Insert X", "atte_ix", self.insertion_point.x),
edit("Insert Y", "atte_iy", self.insertion_point.y),
edit("Insert Z", "atte_iz", self.insertion_point.z),
edit("Height", "atte_h", self.height),
edit("Rotation", "atte_rot", self.rotation.to_degrees()),
],
}
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
let Ok(v) = value.trim().parse::<f64>() else { return };
match field {
"atte_ix" => self.insertion_point.x = v,
"atte_iy" => self.insertion_point.y = v,
"atte_iz" => self.insertion_point.z = v,
"atte_h" if v > 0.0 => self.height = v,
"atte_rot" => self.rotation = v.to_radians(),
_ => {}
}
}
}
impl Transformable for AttributeEntity {
fn apply_transform(&mut self, t: &EntityTransform) {
transform::apply_standard_entity_transform(self, t, |entity, p1, p2| {
transform::reflect_xy_point(
&mut entity.insertion_point.x,
&mut entity.insertion_point.y,
p1,
p2,
);
});
}
}

View file

@ -1,4 +1,5 @@
pub mod arc;
pub mod attribute;
pub mod circle;
mod common;
pub mod dimension;
@ -12,6 +13,7 @@ pub mod lwpolyline;
pub mod mtext;
pub mod point;
pub mod polyline;
pub mod raster_image;
pub mod ray;
pub mod spline;
pub mod text;

View file

@ -0,0 +1,350 @@
use acadrust::entities::{RasterImage, Wipeout};
use glam::Vec3;
use crate::command::EntityTransform;
use crate::entities::common::{diamond_grip, edit_prop as edit, ro_prop as ro, square_grip};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::object::{GripApply, GripDef, PropSection, PropValue, Property};
// ── Shared geometry helpers ───────────────────────────────────────────────────
/// Compute the four world-space corners of an image/wipeout from its
/// insertion_point, u_vector, v_vector and pixel size.
///
/// Returns (p0, p1, p2, p3) in counter-clockwise order:
/// p0 = origin
/// p1 = origin + U*W
/// p2 = origin + U*W + V*H
/// p3 = origin + V*H
fn image_corners(
origin: &acadrust::types::Vector3,
u: &acadrust::types::Vector3,
v: &acadrust::types::Vector3,
w: f64,
h: f64,
) -> [[f32; 3]; 4] {
let ox = origin.x as f32;
let oy = origin.y as f32;
let oz = origin.z as f32;
let ux = (u.x * w) as f32;
let uy = (u.y * w) as f32;
let uz = (u.z * w) as f32;
let vx = (v.x * h) as f32;
let vy = (v.y * h) as f32;
let vz = (v.z * h) as f32;
[
[ox, oy, oz],
[ox + ux, oy + uy, oz + uz],
[ox + ux + vx, oy + uy + vy, oz + uz + vz],
[ox + vx, oy + vy, oz + vz],
]
}
/// Rectangle border + X diagonals — used as a placeholder for images.
fn image_wire(corners: [[f32; 3]; 4], with_x: bool) -> Vec<[f32; 3]> {
let [p0, p1, p2, p3] = corners;
let mut pts = vec![p0, p1, p2, p3, p0];
if with_x {
pts.push([f32::NAN; 3]);
pts.push(p0);
pts.push(p2);
pts.push([f32::NAN; 3]);
pts.push(p1);
pts.push(p3);
}
pts
}
fn reflect_vec3(
vx: &mut f64,
vy: &mut f64,
ax: f64,
ay: f64,
len2: f64,
) {
let dot = *vx * ax + *vy * ay;
*vx = 2.0 * dot * ax / len2 - *vx;
*vy = 2.0 * dot * ay / len2 - *vy;
}
// ── RasterImage ───────────────────────────────────────────────────────────────
impl TruckConvertible for RasterImage {
fn to_truck(&self, _document: &acadrust::CadDocument) -> Option<TruckEntity> {
let corners = image_corners(
&self.insertion_point,
&self.u_vector,
&self.v_vector,
self.size.x,
self.size.y,
);
Some(TruckEntity {
object: TruckObject::Lines(image_wire(corners, true)),
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: corners.to_vec(),
})
}
}
impl Grippable for RasterImage {
fn grips(&self) -> Vec<GripDef> {
let corners = image_corners(
&self.insertion_point,
&self.u_vector,
&self.v_vector,
self.size.x,
self.size.y,
);
vec![
square_grip(0, Vec3::from(corners[0])),
diamond_grip(1, Vec3::from(corners[1])),
diamond_grip(2, Vec3::from(corners[2])),
diamond_grip(3, Vec3::from(corners[3])),
]
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
if grip_id == 0 {
match apply {
GripApply::Translate(d) => {
self.insertion_point.x += d.x as f64;
self.insertion_point.y += d.y as f64;
self.insertion_point.z += d.z as f64;
}
GripApply::Absolute(p) => {
self.insertion_point.x = p.x as f64;
self.insertion_point.y = p.y as f64;
self.insertion_point.z = p.z as f64;
}
}
}
// Corner grips 1-3 are display-only (resizing changes u/v vectors,
// which requires careful normalization — deferred).
}
}
impl PropertyEditable for RasterImage {
fn geometry_properties(&self, _text_style_names: &[String]) -> PropSection {
PropSection {
title: "Geometry".into(),
props: vec![
ro("File", "ri_file", self.file_path.clone()),
edit("Insert X", "ri_ox", self.insertion_point.x),
edit("Insert Y", "ri_oy", self.insertion_point.y),
edit("Insert Z", "ri_oz", self.insertion_point.z),
edit("Brightness", "ri_bright", self.brightness as f64),
edit("Contrast", "ri_contrast", self.contrast as f64),
edit("Fade", "ri_fade", self.fade as f64),
Property {
label: "Clipping".into(),
field: "ri_clip",
value: PropValue::BoolToggle {
field: "ri_clip",
value: self.clipping_enabled,
},
},
],
}
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
match field {
"ri_clip" => {
self.clipping_enabled =
if value == "toggle" { !self.clipping_enabled } else { value == "true" };
return;
}
_ => {}
}
let Ok(v) = value.trim().parse::<f64>() else { return };
match field {
"ri_ox" => self.insertion_point.x = v,
"ri_oy" => self.insertion_point.y = v,
"ri_oz" => self.insertion_point.z = v,
"ri_bright" => self.brightness = v.clamp(0.0, 100.0) as u8,
"ri_contrast" => self.contrast = v.clamp(0.0, 100.0) as u8,
"ri_fade" => self.fade = v.clamp(0.0, 100.0) as u8,
_ => {}
}
}
}
impl Transformable for RasterImage {
fn apply_transform(&mut self, t: &EntityTransform) {
crate::scene::transform::apply_standard_entity_transform(self, t, |entity, p1, p2| {
crate::scene::transform::reflect_xy_point(
&mut entity.insertion_point.x,
&mut entity.insertion_point.y,
p1,
p2,
);
let ax = (p2.x - p1.x) as f64;
let ay = (p2.y - p1.y) as f64;
let len2 = ax * ax + ay * ay;
if len2 > 1e-12 {
reflect_vec3(&mut entity.u_vector.x, &mut entity.u_vector.y, ax, ay, len2);
reflect_vec3(&mut entity.v_vector.x, &mut entity.v_vector.y, ax, ay, len2);
}
});
}
}
// ── Wipeout ───────────────────────────────────────────────────────────────────
impl TruckConvertible for Wipeout {
fn to_truck(&self, _document: &acadrust::CadDocument) -> Option<TruckEntity> {
let corners = image_corners(
&self.insertion_point,
&self.u_vector,
&self.v_vector,
self.size.x,
self.size.y,
);
// If clipping is enabled and there's a polygon boundary, show that.
let pts = if self.clipping_enabled
&& self.clip_boundary_vertices.len() >= 3
&& matches!(
self.clip_type,
acadrust::entities::WipeoutClipType::Polygonal
)
{
// Convert pixel-space boundary vertices to world space:
// world = insertion_point + u_vector * v.x * size.x + v_vector * v.y * size.y
let ox = self.insertion_point.x as f32;
let oy = self.insertion_point.y as f32;
let oz = self.insertion_point.z as f32;
let mut poly: Vec<[f32; 3]> = self
.clip_boundary_vertices
.iter()
.map(|v| {
let wx = (self.u_vector.x * v.x * self.size.x
+ self.v_vector.x * v.y * self.size.y) as f32;
let wy = (self.u_vector.y * v.x * self.size.x
+ self.v_vector.y * v.y * self.size.y) as f32;
let wz = (self.u_vector.z * v.x * self.size.x
+ self.v_vector.z * v.y * self.size.y) as f32;
[ox + wx, oy + wy, oz + wz]
})
.collect();
// Close the polygon.
if let Some(&first) = poly.first() {
poly.push(first);
}
poly
} else {
// Rectangular boundary — just the border, no diagonals (mask area).
image_wire(corners, false)
};
Some(TruckEntity {
object: TruckObject::Lines(pts),
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: corners.to_vec(),
})
}
}
impl Grippable for Wipeout {
fn grips(&self) -> Vec<GripDef> {
let corners = image_corners(
&self.insertion_point,
&self.u_vector,
&self.v_vector,
self.size.x,
self.size.y,
);
vec![
square_grip(0, Vec3::from(corners[0])),
diamond_grip(1, Vec3::from(corners[1])),
diamond_grip(2, Vec3::from(corners[2])),
diamond_grip(3, Vec3::from(corners[3])),
]
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
if grip_id == 0 {
match apply {
GripApply::Translate(d) => {
self.insertion_point.x += d.x as f64;
self.insertion_point.y += d.y as f64;
self.insertion_point.z += d.z as f64;
}
GripApply::Absolute(p) => {
self.insertion_point.x = p.x as f64;
self.insertion_point.y = p.y as f64;
self.insertion_point.z = p.z as f64;
}
}
}
}
}
impl PropertyEditable for Wipeout {
fn geometry_properties(&self, _text_style_names: &[String]) -> PropSection {
PropSection {
title: "Geometry".into(),
props: vec![
edit("Insert X", "wo_ox", self.insertion_point.x),
edit("Insert Y", "wo_oy", self.insertion_point.y),
edit("Insert Z", "wo_oz", self.insertion_point.z),
edit("Brightness", "wo_bright", self.brightness as f64),
edit("Contrast", "wo_contrast", self.contrast as f64),
edit("Fade", "wo_fade", self.fade as f64),
Property {
label: "Clipping".into(),
field: "wo_clip",
value: PropValue::BoolToggle {
field: "wo_clip",
value: self.clipping_enabled,
},
},
],
}
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
match field {
"wo_clip" => {
self.clipping_enabled =
if value == "toggle" { !self.clipping_enabled } else { value == "true" };
return;
}
_ => {}
}
let Ok(v) = value.trim().parse::<f64>() else { return };
match field {
"wo_ox" => self.insertion_point.x = v,
"wo_oy" => self.insertion_point.y = v,
"wo_oz" => self.insertion_point.z = v,
"wo_bright" => self.brightness = v.clamp(0.0, 100.0) as u8,
"wo_contrast" => self.contrast = v.clamp(0.0, 100.0) as u8,
"wo_fade" => self.fade = v.clamp(0.0, 100.0) as u8,
_ => {}
}
}
}
impl Transformable for Wipeout {
fn apply_transform(&mut self, t: &EntityTransform) {
crate::scene::transform::apply_standard_entity_transform(self, t, |entity, p1, p2| {
crate::scene::transform::reflect_xy_point(
&mut entity.insertion_point.x,
&mut entity.insertion_point.y,
p1,
p2,
);
let ax = (p2.x - p1.x) as f64;
let ay = (p2.y - p1.y) as f64;
let len2 = ax * ax + ay * ay;
if len2 > 1e-12 {
reflect_vec3(&mut entity.u_vector.x, &mut entity.u_vector.y, ax, ay, len2);
reflect_vec3(&mut entity.v_vector.x, &mut entity.v_vector.y, ax, ay, len2);
}
});
}
}

View file

@ -46,6 +46,10 @@ impl EntityTypeOps for EntityType {
EntityType::Polyline3D(pl) => TruckConvertible::to_truck(pl, document),
EntityType::Ray(ray) => TruckConvertible::to_truck(ray, document),
EntityType::XLine(xl) => TruckConvertible::to_truck(xl, document),
EntityType::RasterImage(img) => TruckConvertible::to_truck(img, document),
EntityType::Wipeout(wo) => TruckConvertible::to_truck(wo, document),
EntityType::AttributeDefinition(a) => TruckConvertible::to_truck(a, document),
EntityType::AttributeEntity(a) => TruckConvertible::to_truck(a, document),
EntityType::Text(text) => TruckConvertible::to_truck(text, document),
EntityType::MText(text) => TruckConvertible::to_truck(text, document),
EntityType::Leader(leader) => TruckConvertible::to_truck(leader, document),
@ -66,6 +70,10 @@ impl EntityTypeOps for EntityType {
EntityType::Polyline3D(pl) => Grippable::grips(pl),
EntityType::Ray(ray) => Grippable::grips(ray),
EntityType::XLine(xl) => Grippable::grips(xl),
EntityType::RasterImage(img) => Grippable::grips(img),
EntityType::Wipeout(wo) => Grippable::grips(wo),
EntityType::AttributeDefinition(a) => Grippable::grips(a),
EntityType::AttributeEntity(a) => Grippable::grips(a),
EntityType::Point(pt) => Grippable::grips(pt),
EntityType::Spline(spline) => Grippable::grips(spline),
EntityType::Text(text) => Grippable::grips(text),
@ -119,6 +127,22 @@ impl EntityTypeOps for EntityType {
xl,
text_style_names,
)),
EntityType::RasterImage(img) => Some(PropertyEditable::geometry_properties(
img,
text_style_names,
)),
EntityType::Wipeout(wo) => Some(PropertyEditable::geometry_properties(
wo,
text_style_names,
)),
EntityType::AttributeDefinition(a) => Some(PropertyEditable::geometry_properties(
a,
text_style_names,
)),
EntityType::AttributeEntity(a) => Some(PropertyEditable::geometry_properties(
a,
text_style_names,
)),
EntityType::Hatch(hatch) => Some(PropertyEditable::geometry_properties(
hatch,
text_style_names,
@ -174,6 +198,10 @@ impl EntityTypeOps for EntityType {
EntityType::Polyline3D(pl) => PropertyEditable::apply_geom_prop(pl, field, value),
EntityType::Ray(ray) => PropertyEditable::apply_geom_prop(ray, field, value),
EntityType::XLine(xl) => PropertyEditable::apply_geom_prop(xl, field, value),
EntityType::RasterImage(img) => PropertyEditable::apply_geom_prop(img, field, value),
EntityType::Wipeout(wo) => PropertyEditable::apply_geom_prop(wo, field, value),
EntityType::AttributeDefinition(a) => PropertyEditable::apply_geom_prop(a, field, value),
EntityType::AttributeEntity(a) => PropertyEditable::apply_geom_prop(a, field, value),
EntityType::Hatch(hatch) => PropertyEditable::apply_geom_prop(hatch, field, value),
EntityType::Point(pt) => PropertyEditable::apply_geom_prop(pt, field, value),
EntityType::Spline(spline) => PropertyEditable::apply_geom_prop(spline, field, value),
@ -200,6 +228,10 @@ impl EntityTypeOps for EntityType {
EntityType::Polyline3D(pl) => Grippable::apply_grip(pl, grip_id, apply),
EntityType::Ray(ray) => Grippable::apply_grip(ray, grip_id, apply),
EntityType::XLine(xl) => Grippable::apply_grip(xl, grip_id, apply),
EntityType::RasterImage(img) => Grippable::apply_grip(img, grip_id, apply),
EntityType::Wipeout(wo) => Grippable::apply_grip(wo, grip_id, apply),
EntityType::AttributeDefinition(a) => Grippable::apply_grip(a, grip_id, apply),
EntityType::AttributeEntity(a) => Grippable::apply_grip(a, grip_id, apply),
EntityType::Point(pt) => Grippable::apply_grip(pt, grip_id, apply),
EntityType::Spline(spline) => Grippable::apply_grip(spline, grip_id, apply),
EntityType::Text(text) => Grippable::apply_grip(text, grip_id, apply),
@ -226,6 +258,10 @@ impl EntityTypeOps for EntityType {
EntityType::Polyline3D(pl) => Transformable::apply_transform(pl, t),
EntityType::Ray(ray) => Transformable::apply_transform(ray, t),
EntityType::XLine(xl) => Transformable::apply_transform(xl, t),
EntityType::RasterImage(img) => Transformable::apply_transform(img, t),
EntityType::Wipeout(wo) => Transformable::apply_transform(wo, t),
EntityType::AttributeDefinition(a) => Transformable::apply_transform(a, t),
EntityType::AttributeEntity(a) => Transformable::apply_transform(a, t),
EntityType::MText(text) => Transformable::apply_transform(text, t),
EntityType::Point(pt) => Transformable::apply_transform(pt, t),
EntityType::Spline(spline) => Transformable::apply_transform(spline, t),