Files
OneCAD-Draft/src/entities/raster_image.rs
T
PanosandClaude Opus 5 7e12dab89e fix(image): erasing a broken raster reference clears its placeholder text
A raster reference that cannot be resolved is the only framed entity whose
to_render emits a RenderObject::Text: the frame and its diagonals are lines,
but the saved file path becomes a real glyph run in the SDF text buffer.

gather_text_verts reuses the whole previous vertex buffer when
text_unchanged(epoch) says no text-bearing entity moved, and that asks
entity_affects_text_cache -- which enumerated Text, MText, Dimension,
MultiLeader, Leader, Table, Tolerance, attributes and Insert, but not
RasterImage. So an erase never raised CACHE_CATEGORY_TEXT, the stale buffer
was taken as valid, and the path kept drawing over empty space. The reuse
branch then re-stamps that buffer with the current epoch, which froze it
until a full rebuild. CACHE_CATEGORY_IMAGE did not cover the gap: it is set
from images.contains_key(handle), and a missing reference has no ImageModel
at all.

The same gap broke three more flows from the Added/Modified side: ATTACH of
a missing reference showed no text, MOVE and grip-drag left the text behind
while the frame followed, and UNDO after an erase did not bring it back.

entity_affects_text_cache now counts a RasterImage whose reference does not
resolve, reusing the converter's memoised resolve_image probe so the two
cannot disagree. Kept a probe rather than a blanket type match so a healthy
image does not rebuild every glyph in the drawing on each mouse move of a
grip-drag.

Four regression tests, each confirmed to fail without the fix. The main one
is end-to-end on the buffer the GPU draws: before, the erased placeholder
left 300 glyph vertices on screen (50 chars x 6).

Also: to_render ran the same resolve probe twice; now once, shared by the
outline and placeholder branches. Moved text_unchanged's doc comment off
draw_ranks_stable, where it had drifted, and recorded the rule that every
entity able to emit a GlyphRun must be declared there.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 17:42:38 +03:00

834 lines
33 KiB
Rust

use codec::entities::{RasterImage, Wipeout};
use crate::t;
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, Transformable, RenderConvertible};
use crate::scene::convert::acad_to_render::{GlyphRun, TextStroke, RenderEntity, RenderObject};
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();
vec![
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()),
edit(t!("Brightness").as_ref(), "ri_bright", self.brightness as f64),
edit(t!("Contrast").as_ref(), "ri_contrast", self.contrast as f64),
edit(t!("Fade").as_ref(), "ri_fade", self.fade as f64),
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,
"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::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);
}
}
}