Files
OneCAD-Draft/src/entities/raster_image.rs
T
ramox81andClaude Opus 5.5 a0a83ad681 ADJUST: images and underlays, -IMAGEADJUST and PDF/DWF/DGNADJUST
ADJUST gathers raster images or PDF/DWF/DGN underlays (other objects are
dropped silently, pickfirst is used, a mixed set is refused), then asks the
image option (Brightness default) or the underlay option (last used), with
value prompts that offer the image's own value or the session's last
underlay fade/contrast, and Monochrome Yes/No. Invalid values re-prompt.
One undo step. -IMAGEADJUST, PDFADJUST, DWFADJUST and DGNADJUST take one
kind each. Raster image Brightness/Contrast/Fade move to an Image Adjust
section as whole numbers with 0-100 validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:53:20 +03:00

885 lines
34 KiB
Rust

use crate::t;
use codec::entities::{RasterImage, Wipeout};
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_angle_deg, parse_f64,
ro_prop as ro, square_grip,
};
use crate::entities::text_support::{resolve_text_style, text_local_bounds};
use crate::entities::traits::{Grippable, PropertyEditable, RenderConvertible, Transformable};
use crate::scene::convert::acad_to_render::{GlyphRun, RenderEntity, RenderObject, TextStroke};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::SnapHint;
use crate::scene::text::lff;
// ── 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: &codec::types::Vector3,
u: &codec::types::Vector3,
v: &codec::types::Vector3,
w: f64,
h: f64,
) -> [[f64; 3]; 4] {
let ox = origin.x;
let oy = origin.y;
let oz = origin.z;
let ux = u.x * w;
let uy = u.y * w;
let uz = u.z * w;
let vx = v.x * h;
let vy = v.y * h;
let vz = v.z * h;
[
[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: [[f64; 3]; 4], with_x: bool) -> Vec<[f64; 3]> {
let [p0, p1, p2, p3] = corners;
let mut pts = vec![p0, p1, p2, p3, p0];
if with_x {
pts.push([f64::NAN; 3]);
pts.push(p0);
pts.push(p2);
pts.push([f64::NAN; 3]);
pts.push(p1);
pts.push(p3);
}
pts
}
/// The clip boundary of a raster image in drawing pixel units (x along the
/// u-vector, y along the v-vector, 0 at the insertion corner), or `None`
/// when the image shows unclipped. The stored boundary is in raster space:
/// pixel centres at whole numbers, row 0 at the top — the frame edge is at
/// -0.5, so a stored `(x, y)` sits at `(x + 0.5, h - y - 0.5)`. Clipping
/// shows only while both the clip state and the display flag are on (the
/// OFF option clears the flag and keeps the state).
pub(crate) fn image_clip_polygon(img: &RasterImage) -> Option<Vec<[f64; 2]>> {
use codec::entities::{ClipType, ImageDisplayFlags};
if !img.clipping_enabled || !img.flags.contains(ImageDisplayFlags::USE_CLIPPING_BOUNDARY) {
return None;
}
let h = img.size.y;
let at = |x: f64, y: f64| [x + 0.5, h - y - 0.5];
let cb = &img.clip_boundary;
match cb.clip_type {
ClipType::Rectangular if cb.vertices.len() >= 2 => {
let (a, b) = (cb.vertices[0], cb.vertices[1]);
Some(vec![at(a.x, a.y), at(b.x, a.y), at(b.x, b.y), at(a.x, b.y)])
}
ClipType::Polygonal if cb.vertices.len() >= 3 => {
let mut poly: Vec<[f64; 2]> = cb.vertices.iter().map(|v| at(v.x, v.y)).collect();
// The stored polygon repeats its first vertex at the end.
if poly.len() > 3 && poly.first() == poly.last() {
poly.pop();
}
Some(poly)
}
_ => None,
}
}
/// World point → raster clip space (the inverse of `image_clip_polygon`'s
/// mapping), for the clip commands.
pub(crate) fn image_world_to_clip(img: &RasterImage, p: [f64; 3]) -> codec::types::Vector2 {
let (u, v) = (&img.u_vector, &img.v_vector);
let d = [p[0] - img.insertion_point.x, p[1] - img.insertion_point.y];
// Solve d = a·u + b·v in the image plane (x/y components).
let det = u.x * v.y - u.y * v.x;
let (a, b) = if det.abs() > 1e-300 {
(
(d[0] * v.y - d[1] * v.x) / det,
(u.x * d[1] - u.y * d[0]) / det,
)
} else {
(0.0, 0.0)
};
codec::types::Vector2::new(a - 0.5, img.size.y - b - 0.5)
}
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 RenderConvertible for RasterImage {
fn to_render(&self, document: &codec::CadDocument) -> Option<RenderEntity> {
let corners = image_corners(
&self.insertion_point,
&self.u_vector,
&self.v_vector,
self.size.x,
self.size.y,
);
// Helper: pixel-space → world-space point.
let ox = self.insertion_point.x;
let oy = self.insertion_point.y;
let oz = self.insertion_point.z;
let px_to_world = |px: f64, py: f64| -> [f64; 3] {
[
ox + self.u_vector.x * px + self.v_vector.x * py,
oy + self.u_vector.y * px + self.v_vector.y * py,
oz + self.u_vector.z * px + self.v_vector.z * py,
]
};
// Diagonals are the BROKEN-reference placeholder; a resolvable image
// draws its pixels inside the frame, so the X would scribble over it.
// One probe for the whole conversion: the outline below and the
// placeholder branch further down must agree on a single answer.
let path = self.file_path.trim();
let resolvable =
path.is_empty() || crate::scene::model::image_model::resolve_image(path).is_some();
// Outline: the clip boundary, or — when it hides its inside — the
// frame with the boundary as a hole. Must match the raster's own clip
// triangulation in `ImageModel` so outline and pixels align.
let pts = match image_clip_polygon(self) {
Some(poly) => {
let mut ring: Vec<[f64; 3]> =
poly.iter().map(|p| px_to_world(p[0], p[1])).collect();
ring.push(ring[0]);
if self.clip_boundary.clip_mode == codec::entities::ClipMode::Inside {
let mut frame = image_wire(corners, false);
frame.push([f64::NAN; 3]);
frame.extend(ring);
frame
} else {
ring
}
}
None => image_wire(corners, !resolvable),
};
// A raster OCS can display renders its pixels (built separately) inside
// this frame — just draw the frame/clip outline. A reference it cannot
// resolve (an offline/broken URL, a missing or renamed file) gets the
// usual broken-reference treatment: the frame plus the saved path drawn
// as text, so the user sees WHICH reference is unresolved instead of an
// empty box. `resolve_image` is memoised and shared with the raster
// loader, so a URL that fetches online is treated as resolvable (no
// placeholder — the image shows) while an offline one falls back to the
// path text, and neither is fetched twice. The scene's text-cache
// classifier runs the same probe, so erasing this placeholder drops its
// glyphs from the text buffer instead of leaving them on screen.
if resolvable {
return Some(RenderEntity {
// Picked on its frame only (shown or hidden), like the other
// framed objects: a click inside the picture selects nothing.
pick_tris: vec![],
object: RenderObject::Lines(pts),
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: corners.to_vec(),
fill_tris: vec![],
});
}
// ── Unresolved reference: frame outline + path text, image colour ──
let ins = [self.insertion_point.x, self.insertion_point.y];
// Boundary → run-less stroke groups (local to `ins`), split on the
// NaN gaps the wire uses to separate disjoint segments.
let mut boundary: Vec<Vec<[f32; 2]>> = Vec::new();
let mut seg: Vec<[f32; 2]> = Vec::new();
for p in &pts {
if p[0].is_nan() {
if seg.len() >= 2 {
boundary.push(std::mem::take(&mut seg));
} else {
seg.clear();
}
} else {
seg.push([(p[0] - ins[0]) as f32, (p[1] - ins[1]) as f32]);
}
}
if seg.len() >= 2 {
boundary.push(seg);
}
let mut groups: Vec<TextStroke> = vec![TextStroke {
strokes: boundary,
origin: ins,
color: None,
fill_tris: vec![],
plane: None,
run: None,
}];
// Place the path text centred in the frame, sized to span ~90% of the
// frame width (capped so it also fits vertically).
let c0 = corners[0];
let c2 = corners[2];
let uw = [corners[1][0] - c0[0], corners[1][1] - c0[1]];
let vh = [corners[3][0] - c0[0], corners[3][1] - c0[1]];
let frame_w = (uw[0] * uw[0] + uw[1] * uw[1]).sqrt();
let frame_h = (vh[0] * vh[0] + vh[1] * vh[1]).sqrt();
if frame_w > 1e-9 && frame_h > 1e-9 {
let u_hat = [uw[0] / frame_w, uw[1] / frame_w];
let v_hat = [vh[0] / frame_h, vh[1] / frame_h];
let rotation = (u_hat[1] as f32).atan2(u_hat[0] as f32);
let font = resolve_text_style("", document).font_name;
// advance at height 1.0 → width per unit height.
let adv = text_local_bounds(&font, path, 1.0, 1.0, 0.0)
.map(|b| b.advance)
.filter(|a| *a > 1e-3)
.unwrap_or(0.6 * path.chars().count().max(1) as f32);
let height = ((frame_w as f32 * 0.9) / adv)
.min(frame_h as f32 * 0.5)
.max(frame_h as f32 * 0.02);
let text_w = (adv * height) as f64;
let center = [(c0[0] + c2[0]) * 0.5, (c0[1] + c2[1]) * 0.5];
// Shift the baseline-left origin so the run is centred both ways.
let origin = [
center[0] - u_hat[0] * text_w * 0.5 - v_hat[0] * height as f64 * 0.35,
center[1] - u_hat[1] * text_w * 0.5 - v_hat[1] * height as f64 * 0.35,
];
let (strokes, fill_tris) =
lff::tessellate_text_ex([0.0, 0.0], height, rotation, 1.0, 0.0, &font, path);
groups.push(TextStroke {
strokes,
origin,
color: None,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: path.to_string(),
font,
height,
rotation,
width_factor: 1.0,
oblique: 0.0,
tracking: 1.0,
bold: false,
}),
});
}
Some(RenderEntity {
pick_tris: vec![],
object: RenderObject::Text(groups),
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: corners.to_vec(),
fill_tris: 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, glam::DVec3::from(corners[0])),
center_grip(1, glam::DVec3::from(corners[1])),
center_grip(2, glam::DVec3::from(corners[2])),
center_grip(3, glam::DVec3::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]) -> Vec<PropSection> {
let rotation_deg = self.u_vector.y.atan2(self.u_vector.x).to_degrees();
let scale = self.u_vector.length();
let show_image = self
.flags
.contains(codec::entities::ImageDisplayFlags::SHOW_IMAGE);
let show_clipped = self
.flags
.contains(codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY);
let clip_inverted = self.clip_boundary.clip_mode == codec::entities::ClipMode::Inside;
let transparency = self.common.transparency.to_string();
let whole = |label: &str, field: &'static str, value: u8| Property {
label: t!(label).into_owned(),
field,
value: PropValue::EditText(value.to_string()),
};
vec![
PropSection {
title: t!("Image Adjust").into_owned(),
props: vec![
whole("Brightness", "ri_bright", self.brightness),
whole("Contrast", "ri_contrast", self.contrast),
whole("Fade", "ri_fade", self.fade),
],
},
PropSection {
title: t!("Geometry").into_owned(),
props: vec![
edit(t!("Position X").as_ref(), "ri_ox", self.insertion_point.x),
edit(t!("Position Y").as_ref(), "ri_oy", self.insertion_point.y),
edit(t!("Position Z").as_ref(), "ri_oz", self.insertion_point.z),
ro(
t!("Rotation").as_ref(),
"ri_rotation",
crate::entities::common::format_angle(rotation_deg.to_radians()),
),
ro(
t!("Width").as_ref(),
"ri_width",
crate::entities::common::format_length(self.width()),
),
ro(
t!("Height").as_ref(),
"ri_height",
crate::entities::common::format_length(self.height()),
),
ro(t!("Scale").as_ref(), "ri_scale", format!("{:.4}", scale)),
],
},
PropSection {
title: t!("Misc").into_owned(),
props: vec![
ro(t!("Name").as_ref(), "ri_name", self.file_name().to_string()),
ro(t!("Transparency").as_ref(), "ri_transparency", transparency),
Property {
label: t!("Show image").into_owned(),
field: "ri_show_image",
value: PropValue::BoolToggle {
field: "ri_show_image",
value: show_image,
},
},
Property {
label: t!("Show clipped").into_owned(),
field: "ri_show_clipped",
value: PropValue::BoolToggle {
field: "ri_show_clipped",
value: show_clipped,
},
},
Property {
label: t!("Clip inverted").into_owned(),
field: "ri_clip_inverted",
value: PropValue::BoolToggle {
field: "ri_clip_inverted",
value: clip_inverted,
},
},
],
},
]
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
match field {
"ri_show_image" => {
let on = if value == "toggle" {
!self
.flags
.contains(codec::entities::ImageDisplayFlags::SHOW_IMAGE)
} else {
value == "true"
};
self.set_visible(on);
return;
}
"ri_show_clipped" => {
let on = if value == "toggle" {
!self
.flags
.contains(codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY)
} else {
value == "true"
};
if on {
self.flags |= codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY;
} else {
self.flags &= !codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY;
}
return;
}
"ri_clip_inverted" => {
let on = if value == "toggle" {
self.clip_boundary.clip_mode != codec::entities::ClipMode::Inside
} else {
value == "true"
};
self.clip_boundary.clip_mode = if on {
codec::entities::ClipMode::Inside
} else {
codec::entities::ClipMode::Outside
};
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,
// Out-of-range values are refused before they get here.
"ri_bright" if (0.0..=100.0).contains(&v) => self.brightness = v as u8,
"ri_contrast" if (0.0..=100.0).contains(&v) => self.contrast = v as u8,
"ri_fade" if (0.0..=100.0).contains(&v) => self.fade = v as u8,
_ => {}
}
}
}
impl Transformable for RasterImage {
fn apply_transform(&mut self, t: &EntityTransform) {
crate::scene::view::transform::apply_standard_entity_transform(
self,
t,
|entity, p1, p2| {
crate::scene::view::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 ───────────────────────────────────────────────────────────────────
fn wipeout_is_polygonal(wipeout: &Wipeout) -> bool {
wipeout.clipping_enabled
&& wipeout.clip_boundary_vertices.len() >= 3
&& matches!(
wipeout.clip_type,
codec::entities::WipeoutClipType::Polygonal
)
}
fn wipeout_clip_to_world(wipeout: &Wipeout, point: &codec::types::Vector2) -> [f64; 3] {
let x = point.x + wipeout.size.x * 0.5;
let y = wipeout.size.y * 0.5 - point.y;
wipeout_plane(wipeout).point_at([x, y])
}
fn wipeout_plane(wipeout: &Wipeout) -> kernel::space::Plane {
kernel::space::Plane::from_axes(
[
wipeout.insertion_point.x,
wipeout.insertion_point.y,
wipeout.insertion_point.z,
],
[wipeout.u_vector.x, wipeout.u_vector.y, wipeout.u_vector.z],
[wipeout.v_vector.x, wipeout.v_vector.y, wipeout.v_vector.z],
)
}
fn wipeout_boundary(wipeout: &Wipeout) -> Vec<[f64; 3]> {
if wipeout_is_polygonal(wipeout) {
wipeout
.clip_boundary_vertices
.iter()
.map(|point| wipeout_clip_to_world(wipeout, point))
.collect()
} else {
image_corners(
&wipeout.insertion_point,
&wipeout.u_vector,
&wipeout.v_vector,
wipeout.size.x,
wipeout.size.y,
)
.to_vec()
}
}
fn wipeout_axis_length(axis: &codec::types::Vector3, size: f64) -> f64 {
(axis.x * axis.x + axis.y * axis.y + axis.z * axis.z).sqrt() * size.abs()
}
fn wipeout_world_to_clip(wipeout: &Wipeout, world: [f64; 3]) -> Option<codec::types::Vector2> {
let [x, y] = wipeout_plane(wipeout).project(world)?;
Some(codec::types::Vector2::new(
x - wipeout.size.x * 0.5,
wipeout.size.y * 0.5 - y,
))
}
impl RenderConvertible for Wipeout {
fn to_render(&self, _document: &codec::CadDocument) -> Option<RenderEntity> {
let boundary = wipeout_boundary(self);
let mut pts = boundary.clone();
if let Some(&first) = pts.first() {
pts.push(first);
}
Some(RenderEntity {
// Picked on its frame only; the masked area selects nothing.
pick_tris: vec![],
object: RenderObject::Lines(pts),
snap_pts: boundary
.iter()
.map(|point| (glam::DVec3::from(*point), SnapHint::Endpoint))
.collect(),
tangent_geoms: vec![],
key_vertices: boundary,
fill_tris: vec![],
})
}
}
impl Grippable for Wipeout {
fn grips(&self) -> Vec<GripDef> {
if wipeout_is_polygonal(self) {
wipeout_boundary(self)
.into_iter()
.enumerate()
.map(|(i, point)| {
if i == 0 {
square_grip(i, glam::DVec3::from(point))
} else {
center_grip(i, glam::DVec3::from(point))
}
})
.collect()
} else {
let corners = image_corners(
&self.insertion_point,
&self.u_vector,
&self.v_vector,
self.size.x,
self.size.y,
);
vec![
square_grip(0, glam::DVec3::from(corners[0])),
center_grip(1, glam::DVec3::from(corners[1])),
center_grip(2, glam::DVec3::from(corners[2])),
center_grip(3, glam::DVec3::from(corners[3])),
]
}
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
if wipeout_is_polygonal(self) {
if let Some(current) = self.clip_boundary_vertices.get(grip_id).cloned() {
let current_world = wipeout_clip_to_world(self, &current);
let new_w = match apply {
GripApply::Translate(d) => [
current_world[0] + d.x as f64,
current_world[1] + d.y as f64,
current_world[2] + d.z as f64,
],
GripApply::Absolute(p) => [p.x as f64, p.y as f64, p.z as f64],
};
if let Some(clip) = wipeout_world_to_clip(self, new_w) {
self.clip_boundary_vertices[grip_id] = clip;
}
}
return;
}
let corners = wipeout_boundary(self);
let Some(current) = corners.get(grip_id).copied() else {
return;
};
let target = match apply {
GripApply::Translate(delta) => glam::DVec3::from(current) + delta,
GripApply::Absolute(point) => point,
};
let u_hat =
glam::DVec3::new(self.u_vector.x, self.u_vector.y, self.u_vector.z).normalize_or_zero();
let v_hat =
glam::DVec3::new(self.v_vector.x, self.v_vector.y, self.v_vector.z).normalize_or_zero();
if u_hat == glam::DVec3::ZERO || v_hat == glam::DVec3::ZERO {
return;
}
let resize_plane =
kernel::space::Plane::from_axes([0.0; 3], u_hat.to_array(), v_hat.to_array());
let (width, height, insertion) = match grip_id {
0 => {
let fixed = glam::DVec3::from(corners[2]);
let Some([width, height]) =
resize_plane.project_vector((fixed - target).to_array())
else {
return;
};
(width, height, target)
}
1 => {
let fixed = glam::DVec3::from(corners[3]);
let Some([width, neg_height]) =
resize_plane.project_vector((target - fixed).to_array())
else {
return;
};
let height = -neg_height;
(width, height, fixed - v_hat * height)
}
2 => {
let fixed = glam::DVec3::from(corners[0]);
let Some([width, height]) =
resize_plane.project_vector((target - fixed).to_array())
else {
return;
};
(width, height, fixed)
}
3 => {
let fixed = glam::DVec3::from(corners[1]);
let Some([neg_width, height]) =
resize_plane.project_vector((target - fixed).to_array())
else {
return;
};
let width = -neg_width;
(width, height, fixed - u_hat * width)
}
_ => return,
};
if width.abs() > 1e-9 && height.abs() > 1e-9 {
self.insertion_point =
codec::types::Vector3::new(insertion.x, insertion.y, insertion.z);
let sx = self.size.x.abs().max(1e-9);
let sy = self.size.y.abs().max(1e-9);
self.u_vector = codec::types::Vector3::new(
u_hat.x * width / sx,
u_hat.y * width / sx,
u_hat.z * width / sx,
);
self.v_vector = codec::types::Vector3::new(
v_hat.x * height / sy,
v_hat.y * height / sy,
v_hat.z * height / sy,
);
}
}
}
impl PropertyEditable for Wipeout {
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
let show_image = self
.flags
.contains(codec::entities::WipeoutDisplayFlags::SHOW_IMAGE);
let show_clipped = self
.flags
.contains(codec::entities::WipeoutDisplayFlags::USE_CLIPPING_BOUNDARY);
let bg_transparency = self
.flags
.contains(codec::entities::WipeoutDisplayFlags::TRANSPARENCY_ON);
let width = wipeout_axis_length(&self.u_vector, self.size.x);
let height = wipeout_axis_length(&self.v_vector, self.size.y);
let rotation = self.u_vector.y.atan2(self.u_vector.x).to_degrees();
vec![
PropSection {
title: t!("Image Adjust").into_owned(),
props: vec![
ro(
t!("Brightness").as_ref(),
"wo_brightness",
self.brightness.to_string(),
),
ro(
t!("Contrast").as_ref(),
"wo_contrast",
self.contrast.to_string(),
),
ro(t!("Fade").as_ref(), "wo_fade", self.fade.to_string()),
],
},
PropSection {
title: t!("Geometry").into_owned(),
props: vec![
edit(t!("Position X").as_ref(), "wo_ox", self.insertion_point.x),
edit(t!("Position Y").as_ref(), "wo_oy", self.insertion_point.y),
edit(t!("Position Z").as_ref(), "wo_oz", self.insertion_point.z),
edit_angle(t!("Rotation").as_ref(), "wo_rotation", rotation),
edit(t!("Width").as_ref(), "wo_width", width),
edit(t!("Height").as_ref(), "wo_height", height),
ro(t!("Scale").as_ref(), "wo_scale", "1"),
],
},
PropSection {
title: t!("Misc").into_owned(),
props: vec![
ro(
t!("Show image").as_ref(),
"wo_show_image",
if show_image { t!("Yes") } else { t!("No") },
),
ro(
t!("Show clipped").as_ref(),
"wo_show_clipped",
if show_clipped { t!("Yes") } else { t!("No") },
),
ro(
t!("Background transparency").as_ref(),
"wo_bg_transparency",
if bg_transparency { t!("Yes") } else { t!("No") },
),
],
},
]
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
if field == "wo_rotation" {
let Some(target_degrees) = parse_angle_deg(value).filter(|value| value.is_finite())
else {
return;
};
let u = glam::DVec3::new(self.u_vector.x, self.u_vector.y, self.u_vector.z);
let v = glam::DVec3::new(self.v_vector.x, self.v_vector.y, self.v_vector.z);
let normal = u.cross(v).normalize_or_zero();
if normal.length_squared() <= 1e-18 || normal.z.abs() <= 1e-12 {
return;
}
let target_radians = target_degrees.to_radians();
let horizontal = glam::DVec3::new(target_radians.cos(), target_radians.sin(), 0.0);
let target = (normal.z * horizontal - normal.dot(horizontal) * glam::DVec3::Z)
* normal.z.signum();
let direction = u.normalize_or_zero();
let target = target.normalize_or_zero();
if direction == glam::DVec3::ZERO || target == glam::DVec3::ZERO {
return;
}
let angle = normal
.dot(direction.cross(target))
.atan2(direction.dot(target));
let rotation = glam::DQuat::from_axis_angle(normal, angle);
let u = rotation * u;
let v = rotation * v;
self.u_vector = codec::types::Vector3::new(u.x, u.y, u.z);
self.v_vector = codec::types::Vector3::new(v.x, v.y, v.z);
return;
}
let Some(v) = parse_f64(value).filter(|value| value.is_finite()) 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_width" if v > 0.0 => {
let current = wipeout_axis_length(&self.u_vector, self.size.x);
if current > 1e-9 {
let scale = v / current;
self.u_vector.x *= scale;
self.u_vector.y *= scale;
self.u_vector.z *= scale;
}
}
"wo_height" if v > 0.0 => {
let current = wipeout_axis_length(&self.v_vector, self.size.y);
if current > 1e-9 {
let scale = v / current;
self.v_vector.x *= scale;
self.v_vector.y *= scale;
self.v_vector.z *= scale;
}
}
_ => {}
}
}
}
impl Transformable for Wipeout {
fn apply_transform(&mut self, t: &EntityTransform) {
crate::scene::view::transform::apply_standard_entity_transform(
self,
t,
|entity, p1, p2| {
crate::scene::view::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);
}
},
);
}
}
#[cfg(test)]
mod wipeout_property_tests {
use super::*;
#[test]
fn rotation_reaches_the_requested_angle_for_mirrored_and_tilted_frames() {
for (u, v, target) in [
([1.0, 0.0, 0.0], [0.0, -1.0, 0.0], 90.0),
([1.0, 0.0, 1.0], [0.0, 1.0, 0.0], 45.0),
] {
let mut wipeout = Wipeout::new();
wipeout.u_vector = codec::types::Vector3::new(u[0], u[1], u[2]);
wipeout.v_vector = codec::types::Vector3::new(v[0], v[1], v[2]);
let lengths = (wipeout.u_vector.length(), wipeout.v_vector.length());
wipeout.apply_geom_prop("wo_rotation", &target.to_string());
let angle = wipeout
.u_vector
.y
.atan2(wipeout.u_vector.x)
.to_degrees()
.rem_euclid(360.0);
assert!(
(angle - target).abs() < 1e-9,
"target={target} actual={angle}"
);
assert!((wipeout.u_vector.length() - lengths.0).abs() < 1e-12);
assert!((wipeout.v_vector.length() - lengths.1).abs() < 1e-12);
}
}
}