Files
OneCAD-Draft/src/scene/model/wire_model.rs
T
Karim Jerbi ca0ee37d26 feat(snap): independent 3D object-snap system with six modes
Grid snap locks to SNAPUNIT instead of magnetizing; snapped 3D points keep elevation instead of flattening to z=0. New SnapTypes Vertex/EdgeMidpoint/FaceCenter/Knot/FacePerpendicular/NearestFace with their own F4 master toggle and mode set, tiers, and markers, fully independent of the 2D master. Solid B-rep corners/edge-midpoints/face-centers from mesh edges and kernel faces, spline knot points, and live perpendicular/nearest-to-face on mesh triangles merged priority-aware in move/click paths. Drafting Settings 3D Object Snap tab is fully live (was dead placeholders); Nearest-to-face off by default. TDD: behavioral LINE-to-solid tests plus engine/scene unit tests.
2026-09-16 16:43:37 +01:00

1100 lines
45 KiB
Rust

/// Tag for pre-baked snap candidates stored inside a WireModel.
/// Kept separate from `snap::SnapType` to avoid circular module dependencies.
#[derive(Clone, Copy, Debug)]
pub enum SnapHint {
/// Geometric center of a circle, arc, or ellipse.
Center,
/// Point entity location.
Node,
/// 0 / 90 / 180 / 270 ° point on a circle/arc (within arc span).
Quadrant,
/// Insertion point of text or block.
Insertion,
/// Midpoint of a curve that has one well-defined midpoint (an arc's
/// arc-length centre, a spline's `t = 0.5`). Lines / polylines do
/// not use this — their midpoints are derived from `key_vertices`.
Midpoint,
/// An end of a curve that is not a chain of straight segments — an arc,
/// an elliptical arc, a spline.
///
/// Such a curve cannot put its ends in `key_vertices`: consecutive
/// entries there are taken to be joined by a straight segment, so an
/// arc's two ends would also produce a midpoint snap out on the chord,
/// which is not on the geometry.
Endpoint,
/// B-rep corner of a 3D solid. Maps to the separate 3D snap system
/// (`SnapType::Vertex`), never to the 2D modes.
Vertex,
/// Centre of a 3D solid B-rep edge. Maps to the separate 3D snap system
/// (`SnapType::EdgeMidpoint`), never to the 2D modes.
EdgeMidpoint,
/// Centre of a 3D solid B-rep face. Maps to the separate 3D snap system
/// (`SnapType::FaceCenter`), never to the 2D modes.
FaceCenter,
/// NURBS knot location on a spline entity. Maps to the separate 3D snap
/// system (`SnapType::Knot`), never to the 2D modes.
Knot,
}
/// Geometric primitive used by the tangent-snap engine.
#[derive(Clone, Debug)]
pub enum TangentGeom {
/// Infinite line through these two world-space points.
Line { p1: [f32; 3], p2: [f32; 3] },
/// Complete circle.
Circle { center: [f32; 3], radius: f32 },
/// Complete circle in an arbitrary world-space plane.
PlanarCircle {
center: [f64; 3],
axis_x: [f64; 3],
axis_y: [f64; 3],
radius: f64,
},
/// Bounded circular arc in its world-space plane.
Arc {
center: [f64; 3],
axis_x: [f64; 3],
axis_y: [f64; 3],
radius: f64,
start_angle: f64,
end_angle: f64,
},
/// Ellipse or elliptical arc in an arbitrary world-space plane.
PlanarEllipse {
center: [f64; 3],
major_axis: [f64; 3],
normal: [f64; 3],
minor_axis_ratio: f64,
start_param: f64,
end_param: f64,
},
}
impl TangentGeom {
pub fn translated(&self, delta: glam::DVec3) -> Self {
match self {
Self::Line { p1, p2 } => Self::Line {
p1: [p1[0] + delta.x as f32, p1[1] + delta.y as f32, p1[2] + delta.z as f32],
p2: [p2[0] + delta.x as f32, p2[1] + delta.y as f32, p2[2] + delta.z as f32],
},
Self::Circle { center, radius } => Self::Circle {
center: [center[0] + delta.x as f32, center[1] + delta.y as f32, center[2] + delta.z as f32],
radius: *radius,
},
Self::PlanarCircle { center, axis_x, axis_y, radius } => Self::PlanarCircle {
center: [center[0] + delta.x, center[1] + delta.y, center[2] + delta.z],
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius,
},
Self::Arc { center, axis_x, axis_y, radius, start_angle, end_angle } => Self::Arc {
center: [center[0] + delta.x, center[1] + delta.y, center[2] + delta.z],
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius,
start_angle: *start_angle,
end_angle: *end_angle,
},
Self::PlanarEllipse { center, major_axis, normal, minor_axis_ratio, start_param, end_param } => Self::PlanarEllipse {
center: [center[0] + delta.x, center[1] + delta.y, center[2] + delta.z],
major_axis: *major_axis,
normal: *normal,
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
},
}
}
pub fn rotated(&self, center: glam::DVec3, axis: glam::DVec3, angle_rad: f64) -> Self {
let quat = glam::DQuat::from_axis_angle(axis.normalize_or(glam::DVec3::Z), angle_rad);
let rot_pt = |p: [f64; 3]| (center + quat * (glam::DVec3::from_array(p) - center)).to_array();
let rot_vec = |v: [f64; 3]| (quat * glam::DVec3::from_array(v)).to_array();
match self {
Self::Line { p1, p2 } => {
let p1_d = [p1[0] as f64, p1[1] as f64, p1[2] as f64];
let p2_d = [p2[0] as f64, p2[1] as f64, p2[2] as f64];
let r1 = rot_pt(p1_d);
let r2 = rot_pt(p2_d);
Self::Line {
p1: [r1[0] as f32, r1[1] as f32, r1[2] as f32],
p2: [r2[0] as f32, r2[1] as f32, r2[2] as f32],
}
}
Self::Circle { center: c, radius } => {
let c_d = [c[0] as f64, c[1] as f64, c[2] as f64];
let rc = rot_pt(c_d);
Self::Circle {
center: [rc[0] as f32, rc[1] as f32, rc[2] as f32],
radius: *radius,
}
}
Self::PlanarCircle { center: c, axis_x, axis_y, radius } => Self::PlanarCircle {
center: rot_pt(*c),
axis_x: rot_vec(*axis_x),
axis_y: rot_vec(*axis_y),
radius: *radius,
},
Self::Arc { center: c, axis_x, axis_y, radius, start_angle, end_angle } => Self::Arc {
center: rot_pt(*c),
axis_x: rot_vec(*axis_x),
axis_y: rot_vec(*axis_y),
radius: *radius,
start_angle: *start_angle,
end_angle: *end_angle,
},
Self::PlanarEllipse { center: c, major_axis, normal, minor_axis_ratio, start_param, end_param } => Self::PlanarEllipse {
center: rot_pt(*c),
major_axis: rot_vec(*major_axis),
normal: rot_vec(*normal),
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
},
}
}
pub fn scaled(&self, center: glam::DVec3, factor: f64) -> Self {
let scale_pt = |p: [f64; 3]| (center + (glam::DVec3::from_array(p) - center) * factor).to_array();
match self {
Self::Line { p1, p2 } => {
let p1_d = [p1[0] as f64, p1[1] as f64, p1[2] as f64];
let p2_d = [p2[0] as f64, p2[1] as f64, p2[2] as f64];
let s1 = scale_pt(p1_d);
let s2 = scale_pt(p2_d);
Self::Line {
p1: [s1[0] as f32, s1[1] as f32, s1[2] as f32],
p2: [s2[0] as f32, s2[1] as f32, s2[2] as f32],
}
}
Self::Circle { center: c, radius } => {
let c_d = [c[0] as f64, c[1] as f64, c[2] as f64];
let sc = scale_pt(c_d);
Self::Circle {
center: [sc[0] as f32, sc[1] as f32, sc[2] as f32],
radius: *radius * factor.abs() as f32,
}
}
Self::PlanarCircle { center: c, axis_x, axis_y, radius } => Self::PlanarCircle {
center: scale_pt(*c),
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius * factor.abs(),
},
Self::Arc { center: c, axis_x, axis_y, radius, start_angle, end_angle } => Self::Arc {
center: scale_pt(*c),
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius * factor.abs(),
start_angle: *start_angle,
end_angle: *end_angle,
},
Self::PlanarEllipse { center: c, major_axis, normal, minor_axis_ratio, start_param, end_param } => Self::PlanarEllipse {
center: scale_pt(*c),
major_axis: (glam::DVec3::from_array(*major_axis) * factor.abs()).to_array(),
normal: *normal,
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
},
}
}
pub fn mirrored(&self, p1: glam::DVec3, normal: glam::DVec3) -> Self {
let refl_pt = |p: [f64; 3]| {
let pt = glam::DVec3::from_array(p);
(pt - 2.0 * normal.dot(pt - p1) * normal).to_array()
};
let refl_vec = |v: [f64; 3]| {
let vec = glam::DVec3::from_array(v);
(vec - 2.0 * normal.dot(vec) * normal).to_array()
};
match self {
Self::Line { p1: lp1, p2: lp2 } => {
let p1_d = [lp1[0] as f64, lp1[1] as f64, lp1[2] as f64];
let p2_d = [lp2[0] as f64, lp2[1] as f64, lp2[2] as f64];
let m1 = refl_pt(p1_d);
let m2 = refl_pt(p2_d);
Self::Line {
p1: [m1[0] as f32, m1[1] as f32, m1[2] as f32],
p2: [m2[0] as f32, m2[1] as f32, m2[2] as f32],
}
}
Self::Circle { center: c, radius } => {
let c_d = [c[0] as f64, c[1] as f64, c[2] as f64];
let mc = refl_pt(c_d);
Self::Circle {
center: [mc[0] as f32, mc[1] as f32, mc[2] as f32],
radius: *radius,
}
}
Self::PlanarCircle { center: c, axis_x, axis_y, radius } => {
let ax = refl_vec(*axis_x);
let ay = refl_vec(*axis_y);
Self::PlanarCircle {
center: refl_pt(*c),
axis_x: ax,
axis_y: [-ay[0], -ay[1], -ay[2]],
radius: *radius,
}
}
Self::Arc { center: c, axis_x, axis_y, radius, start_angle, end_angle } => {
let ax = refl_vec(*axis_x);
let ay = refl_vec(*axis_y);
Self::Arc {
center: refl_pt(*c),
axis_x: ax,
axis_y: [-ay[0], -ay[1], -ay[2]],
radius: *radius,
start_angle: -end_angle,
end_angle: -start_angle,
}
}
Self::PlanarEllipse { center: c, major_axis, normal: n, minor_axis_ratio, start_param, end_param } => {
let norm = refl_vec(*n);
Self::PlanarEllipse {
center: refl_pt(*c),
major_axis: refl_vec(*major_axis),
normal: [-norm[0], -norm[1], -norm[2]],
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
}
}
}
}
}
/// Viewport-relative display transform for a point marker.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PointMarker {
pub origin: glam::DVec3,
pub normal: glam::DVec3,
pub axis_x: glam::DVec3,
pub axis_y: glam::DVec3,
pub viewport_percent: f32,
}
#[derive(Clone, Copy, Debug)]
pub struct PatternStationPiece {
pub source_segment: u32,
pub source_distances: [f32; 2],
pub segment_distances: [f32; 2],
pub vector: [f32; 3],
}
#[derive(Clone, Debug)]
pub(crate) struct PatternStationMap {
pub point_segments: Vec<i32>,
pub pieces: Vec<PatternStationPiece>,
}
pub(crate) fn pattern_station_values(data: &[f32], point_count: usize) -> Option<(&[f32], f32)> {
(data.len() >= point_count + 1).then(|| (&data[..point_count], data[point_count]))
}
pub(crate) fn encode_pattern_stations(
mut stations: Vec<f32>,
source_length: f32,
point_segments: &[i32],
pieces: &[PatternStationPiece],
) -> Vec<f32> {
let point_count = stations.len();
stations.push(source_length);
if point_segments.len() != point_count || pieces.is_empty() {
return stations;
}
stations.extend(point_segments.iter().map(|segment| *segment as f32));
for piece in pieces {
stations.push(piece.source_segment as f32);
stations.extend(piece.source_distances);
stations.extend(piece.segment_distances);
stations.extend(piece.vector);
}
stations
}
pub(crate) fn decode_pattern_station_map(
data: &[f32],
point_count: usize,
) -> Option<PatternStationMap> {
let metadata_start = point_count.checked_mul(2)?.checked_add(1)?;
if data.len() < metadata_start || (data.len() - metadata_start) % 8 != 0 {
return None;
}
let point_segments = data[point_count + 1..metadata_start]
.iter()
.map(|value| *value as i32)
.collect();
let pieces = data[metadata_start..]
.chunks_exact(8)
.map(|values| PatternStationPiece {
source_segment: values[0] as u32,
source_distances: [values[1], values[2]],
segment_distances: [values[3], values[4]],
vector: [values[5], values[6], values[7]],
})
.collect();
Some(PatternStationMap {
point_segments,
pieces,
})
}
impl PointMarker {
pub fn resolve(self, point: glam::DVec3, view_height: f64) -> glam::DVec3 {
let normal = self.normal.normalize_or(glam::DVec3::Z);
let delta = point - self.origin;
let axial = normal * delta.dot(normal);
let scale = self.viewport_percent.max(0.0) as f64 * 0.01 * view_height.max(0.0);
self.origin + axial + (delta - axial) * scale
}
}
/// A 1-D entity (line, arc, polyline) represented as an ordered set of
/// world-space points rendered as a quad strip (TriangleList).
///
/// Linetype is encoded as a GPU-side dash pattern so the CPU never needs to
/// split wires into per-dash segments. `pattern_length = 0.0` means solid.
/// Everything needed to resolve a wire's display colour against a background,
/// kept so the resolution can be redone for another background without
/// re-tessellating.
///
/// `None` — the default — means the wire did not record its inputs and a
/// resolution pass must leave it alone. Only `Batches::finalize` fills it in.
pub type BgAdapt = Option<Box<BgAdaptInputs>>;
/// The inputs `Batches::finalize` consumes when it resolves a colour.
///
/// The flags are independent — a wire can paint the background itself while
/// its glyphs still adapt — so this mirrors that code's branches rather than
/// collapsing them into one enum.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BgAdaptInputs {
/// Colour before adaptation. Resolution always recomputes from here rather
/// than from `WireModel::color`, so it is idempotent and safe over a set
/// mixing memo hits with freshly tessellated wires. `adapt_to_bg` is
/// **not** re-appliable: a near-white colour snaps to pure black on a
/// light background, and pure black comes back pure *white* on a dark one,
/// losing the tint.
pub raw_color: [f32; 4],
/// Glyph colours before adaptation, parallel to `WireModel::text_verts`.
/// Empty when the wire has no glyphs or their colour never adapts.
pub text_raw_colors: Vec<[f32; 4]>,
/// Adapt against this instead of the live background: an MTEXT carrying
/// its own background fill.
pub contrast_bg: Option<[f32; 4]>,
/// The wire paints the background; its colour *is* the live background.
pub canvas_color: bool,
/// Authored colour. The background must not touch it, glyphs included.
pub preserve_color: bool,
}
#[derive(Clone, Debug)]
pub struct WireModel {
/// Unique identifier — the handle value as a decimal string.
pub name: String,
/// Ordered world-space positions forming a strip of quads. Each entry is
/// the "high" half of a double-single f32 pair; [`points_low`] carries the
/// matching residual so the shader can reconstruct the f64 source.
pub points: Vec<[f32; 3]>,
/// Low-bit residual paired index-for-index with [`points`]. Empty means
/// "all-zero residual" (interactive draw / preview wires whose coordinates
/// don't need sub-f32 precision). Tessellation from CAD f64 fills it.
pub points_low: Vec<[f32; 3]>,
/// Point-marker scaling shared by the GPU, picking, and plotting paths.
pub point_marker: Option<PointMarker>,
/// RGBA colour in [0, 1].
pub color: [f32; 4],
/// How `color` and `text_verts` follow the background, or `None` when this
/// wire's colour must not be re-resolved.
pub bg_adapt: BgAdapt,
/// Whether this wire is currently selected.
#[allow(dead_code)]
pub selected: bool,
/// Total length of one pattern repeat (world units). 0 = solid line.
pub pattern_length: f32,
/// Up to 8 pattern elements: positive = dash length, negative = gap length.
/// Unused slots must be 0.0 (acts as end-of-pattern sentinel in shader).
pub pattern: [f32; 8],
/// Rendered line width in screen pixels (half-width = line_weight_px / 2).
pub line_weight_px: f32,
/// World-space band width (drawing units). Positive values create a wide
/// polyline band that scales with zoom. Zero uses `line_weight_px` and the
/// drawing's lineweight display setting. A negative value encodes a fixed
/// screen-pixel width whose absolute value bypasses that display setting.
pub world_width: f32,
/// Per-point full band width (drawing units), aligned index-for-index with
/// [`points`], for a polyline whose width VARIES (a taper). Empty = a
/// constant band of `world_width`. The wire shader reads the two endpoint
/// widths of each segment and interpolates, so the band tapers smoothly.
pub taper_widths: Vec<f32>,
/// Centreline stations and optional source-piece transform metadata.
pub pattern_stations: Vec<f32>,
/// ACI color index (1-255). 0 means true-color or unknown (no CTB lookup).
pub aci: u8,
/// Pre-baked snap candidates (Center, Node, Quadrant, Insertion).
pub snap_pts: Vec<(glam::DVec3, SnapHint)>,
/// Per-segment tangent geometry for Tangent snap.
/// Line/Arc entities: 1 entry. LwPolyline: 1 entry per segment.
pub tangent_geoms: Vec<TangentGeom>,
/// True polyline vertices used for Endpoint/Midpoint snap.
/// Non-empty only for entities with distinct vertex positions (Line, LwPolyline).
/// Empty for tessellated curves (Circle, Arc, Ellipse) which use snap_pts instead.
pub key_vertices: Vec<[f64; 3]>,
/// World-space 2-D bounding box [min_x, min_y, max_x, max_y].
/// Set from acadrust `bounding_box()` in `tessellate_entity()`.
/// Preview / interim wires use `UNBOUNDED_AABB` so they are never pre-rejected
/// by the snap world-space filter.
pub aabb: [f32; 4],
/// When false the linetype pattern restarts at each NaN-separated segment
/// (DXF PLINEGEN=0). When true the pattern runs continuously (PLINEGEN=1).
pub plinegen: bool,
/// DGN line-style marker. When false (every standard linetype) the dash
/// pattern uses the normal phase: A-type end alignment for dash-first
/// patterns, else centred. When true (DGN pipe walls) the pattern is drawn
/// from the START vertex with continuous phase and no A-type end forcing —
/// DGN line styles are not end-aligned.
pub dash_from_start: bool,
/// Shared "A"-type end-dash length for MLINE elements. `Some(len)` makes the
/// dash shader use `len` as the begin/end solid-dash length for EVERY
/// parallel element (derived once from the multiline's centre-line length),
/// while `align_total` stays each element's own length — so all elements
/// share one interior phase (perpendicular dashes line up) yet each still
/// ends on a dash. `None` (the default) = per-wire A-type / from-start.
pub dash_align_end: Option<f32>,
/// Pre-triangulated solid fill: flat vertex list, 3 per triangle (world-offset applied).
/// Non-empty only for PolyfaceMesh / PolygonMesh entities.
pub fill_tris: Vec<[f32; 3]>,
/// Low residual paired with [`fill_tris`] (double-single). Empty = all-zero;
/// tessellation from CAD f64 fills it so fills stay precise at UTM scale.
pub fill_tris_low: Vec<[f32; 3]>,
/// Pre-triangulated pick-only geometry: flat vertex list, 3 per triangle.
/// Hit-testing treats these as solid; this wire's own draw never uses them.
///
/// Carries the interior of things whose surface the cursor would otherwise
/// fall through, because [`points`] only bounds them:
///
/// - An entity extruded by a DXF thickness (code 39) — the swept wall
/// between each base segment and its extruded copy. Drawn as its four
/// wireframe edges, with nothing in between to pick.
/// - A wide polyline (codes 43 / 40 / 41) — the solid band. It *is* drawn,
/// but by the hatch pipeline off `wide_fills`, so the wire that carries
/// its centreline has no fill of its own to hit-test.
///
/// Separate from [`fill_tris`] because that channel reaches the GPU: a
/// thickness wall put there would render shaded and change how every such
/// drawing looks, and a polyline band would be drawn a second time on top
/// of the hatch that already draws it.
pub pick_tris: Vec<[f32; 3]>,
/// Low residual paired with [`pick_tris`] (double-single). Empty = all-zero.
pub pick_tris_low: Vec<[f32; 3]>,
/// SDF glyph quads for this entity's text (TEXT / MTEXT / dimension text /
/// block-internal text). Non-empty only when SDF text is enabled and this
/// wire carries a text run. Rides with the wire so it is cached by the
/// tess memo, cloned on hit, and transformed by the block-expand loop
/// exactly like `points` — no separate collector pass. The renderer
/// gathers these across all wires into the text vertex buffer.
pub text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
/// Block-local composed draw-order offset in (-1, 1) for a wire that must
/// order against its *siblings inside the block* — currently wide-polyline
/// bands, whose solid area would otherwise cover later-drawn siblings.
/// `None` (all other wires) = the whole-insert depth resolved from `name`.
/// `Some(local)` composes at draw time: `insert_depth + local * insert_half`,
/// mirroring how exploded block fills seed their depth, so bands and fills
/// from the same block interleave by the block's internal draw order.
pub depth_override: Option<f32>,
/// Whether this wire is drawn in the normal viewport pass.
pub display_visible: bool,
/// Whether this wire is included in plotted output.
pub plot_visible: bool,
/// `true` when [`fill_tris`] is a real 3-D surface (PolyfaceMesh /
/// PolygonMesh face) that must render with hidden-surface depth and only in
/// shaded modes. `false` for a flat 2-D overlay fill (SOLID arrowhead,
/// dimension / MultiLeader text background, greek-LOD text) that draws in
/// every view mode. The render pass can't infer this from `fill_tris_low`
/// alone — a 2-D fill at UTM scale carries a low residual too.
pub fill_is_3d: bool,
/// `true` only for a planar SOLID entity's interior. Wireframe 3D omits
/// this fill while preserving its perimeter and every other 2-D overlay.
pub fill_is_2d_solid: bool,
/// Shared block-render source and this placement's translation.
pub render_instance: Option<super::instance_model::RenderInstance>,
}
impl WireModel {
pub const WHITE: [f32; 4] = [1.00, 1.00, 1.00, 1.0];
pub const CYAN: [f32; 4] = [0.25, 0.85, 1.00, 1.0];
pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0];
/// Rollover (hover) highlight — orange, distinct from the blue selection.
pub const HOVER: [f32; 4] = [0.95, 0.55, 0.10, 1.0];
/// Sentinel AABB that never rejects any snap query.
pub const UNBOUNDED_AABB: [f32; 4] = [
f32::NEG_INFINITY,
f32::NEG_INFINITY,
f32::INFINITY,
f32::INFINITY,
];
/// Double-single split: `high + low ≈ v` to ~f64 precision in two f32s.
/// Matches the renderer's relative-to-eye reconstruction.
#[inline]
pub fn split_ds(v: f64) -> (f32, f32) {
let high = v as f32;
(high, (v - high as f64) as f32)
}
/// Create a solid preview wire from f64 points, filling the double-single
/// `points_low` buffer so the line stays precise at UTM-scale coordinates.
/// Rubber-band previews built straight from f32 absolute points jitter
/// ~0.5 m at UTM because the wire pass is relative-to-eye and expects the
/// low residual; this keeps the preview glued to the cursor.
pub fn solid_f64(name: String, points: Vec<[f64; 3]>, color: [f32; 4], selected: bool) -> Self {
let mut hi = Vec::with_capacity(points.len());
let mut lo = Vec::with_capacity(points.len());
for [x, y, z] in points {
let (hx, lx) = Self::split_ds(x);
let (hy, ly) = Self::split_ds(y);
let (hz, lz) = Self::split_ds(z);
hi.push([hx, hy, hz]);
lo.push([lx, ly, lz]);
}
let mut w = Self::solid(name, hi, color, selected);
w.points_low = lo;
w
}
pub fn set_fixed_screen_width(&mut self, width_px: f32) {
let width_px = width_px.max(1.0);
self.line_weight_px = width_px;
self.world_width = -width_px;
}
/// Create a solid wire (no dash pattern, 1px weight).
pub fn solid(name: String, points: Vec<[f32; 3]>, color: [f32; 4], selected: bool) -> Self {
Self {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
world_width: 0.0,
depth_override: None,
display_visible: true,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
render_instance: None,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
text_verts: Vec::new(),
name,
points,
points_low: Vec::new(),
color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: Self::UNBOUNDED_AABB,
plinegen: true,
dash_from_start: false,
dash_align_end: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
}
}
/// Return a clone with every point translated by `delta`.
pub fn translated(&self, delta: glam::Vec3) -> Self {
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
map_points(&mut out.points, &mut out.points_low, |point| {
point + delta.as_dvec3()
});
if let Some(marker) = &mut out.point_marker {
marker.origin += delta.as_dvec3();
}
if !out.text_verts.is_empty() {
let (dx, dy, dz) = (delta.x as f64, delta.y as f64, delta.z as f64);
out.text_verts =
map_text_verts(&self.text_verts, |x, y, z| (x + dx, y + dy, z + dz));
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.translated(delta.as_dvec3()))
.collect();
out
}
/// Return a clone with every point rotated around `center` by `angle_rad`.
pub fn rotated(&self, center: glam::Vec3, angle_rad: f32) -> Self {
self.rotated_about_axis(center, glam::Vec3::Z, angle_rad)
}
/// Return a clone rotated about an arbitrary world-space axis.
pub fn rotated_about_axis(
&self,
center: glam::Vec3,
axis: glam::Vec3,
angle_rad: f32,
) -> Self {
let rotation = glam::Quat::from_axis_angle(axis.normalize_or_zero(), angle_rad);
let (s, c) = angle_rad.sin_cos();
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
for p in &mut out.points {
let mapped = center + rotation * (glam::Vec3::from_array(*p) - center);
*p = mapped.to_array();
}
for p in &mut out.points_low {
*p = (rotation * glam::Vec3::from_array(*p)).to_array();
}
if let Some(marker) = &mut out.point_marker {
let center = center.as_dvec3();
let rotation = glam::DQuat::from_axis_angle(
axis.as_dvec3().normalize_or(glam::DVec3::Z),
angle_rad as f64,
);
marker.origin = center + rotation * (marker.origin - center);
marker.normal = (rotation * marker.normal).normalize_or(glam::DVec3::Z);
marker.axis_x = (rotation * marker.axis_x).normalize_or(glam::DVec3::X);
marker.axis_y = (rotation * marker.axis_y).normalize_or(glam::DVec3::Y);
}
if !out.text_verts.is_empty() {
let center = center.as_dvec3();
let axis = axis.as_dvec3().normalize_or_zero();
let (s, c) = (s as f64, c as f64);
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let v = glam::DVec3::new(x, y, z) - center;
let mapped = center
+ v * c
+ axis.cross(v) * s
+ axis * axis.dot(v) * (1.0 - c);
(mapped.x, mapped.y, mapped.z)
});
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.rotated(center.as_dvec3(), axis.as_dvec3(), angle_rad as f64))
.collect();
out
}
/// Return a clone with every point uniformly scaled from `center` by `factor`.
pub fn scaled(&self, center: glam::Vec3, factor: f32) -> Self {
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
if let Some(marker) = out.point_marker {
let center = center.as_dvec3();
let normal = marker.normal.normalize_or(glam::DVec3::Z);
let target_origin = center + (marker.origin - center) * factor as f64;
map_points(&mut out.points, &mut out.points_low, |point| {
let delta = point - marker.origin;
let axial = normal * delta.dot(normal);
target_origin + (delta - axial) + axial * factor as f64
});
if let Some(target) = &mut out.point_marker {
target.origin = target_origin;
}
} else {
let center = center.as_dvec3();
map_points(&mut out.points, &mut out.points_low, |point| {
center + (point - center) * factor as f64
});
}
if !out.text_verts.is_empty() {
let (cx, cy, cz) = (center.x as f64, center.y as f64, center.z as f64);
let f = factor as f64;
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
(cx + (x - cx) * f, cy + (y - cy) * f, cz + (z - cz) * f)
});
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.scaled(center.as_dvec3(), factor as f64))
.collect();
out
}
/// Return a clone for a stretch preview: every point whose XY lies inside
/// the crossing window `[win_min, win_max]` is translated by `delta`; points
/// outside stay put. Exact for line/polyline vertices (the primary stretch
/// targets); curve tessellation points may deform where a window edge cuts
/// through them, matching the per-vertex nature of the operation.
pub fn stretched(
&self,
win_min: glam::Vec3,
win_max: glam::Vec3,
delta: glam::Vec3,
) -> Self {
self.stretched_windows(&[(win_min, win_max)], delta)
}
/// Return a clone for a multi-window STRETCH preview. A point moves exactly
/// once when it lies inside any of the crossing windows.
pub fn stretched_windows(
&self,
windows: &[(glam::Vec3, glam::Vec3)],
delta: glam::Vec3,
) -> Self {
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
let inside = |x: f32, y: f32| {
windows.iter().any(|(win_min, win_max)| {
x >= win_min.x
&& x <= win_max.x
&& y >= win_min.y
&& y <= win_max.y
})
};
if let Some(marker) = out.point_marker {
if inside(marker.origin.x as f32, marker.origin.y as f32) {
map_points(&mut out.points, &mut out.points_low, |point| {
point + delta.as_dvec3()
});
if let Some(target) = &mut out.point_marker {
target.origin += delta.as_dvec3();
}
}
} else {
for p in &mut out.points {
if inside(p[0], p[1]) {
p[0] += delta.x;
p[1] += delta.y;
p[2] += delta.z;
}
}
}
if !out.text_verts.is_empty() {
let (dx, dy, dz) =
(delta.x as f64, delta.y as f64, delta.z as f64);
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let inside = windows.iter().any(|(win_min, win_max)| {
x >= win_min.x as f64
&& x <= win_max.x as f64
&& y >= win_min.y as f64
&& y <= win_max.y as f64
});
if inside {
(x + dx, y + dy, z + dz)
} else {
(x, y, z)
}
});
}
out
}
/// Return a clone mirrored across the line through `p1`→`p2`.
pub fn mirrored(&self, p1: glam::Vec3, p2: glam::Vec3) -> Self {
self.mirrored_in_plane(p1, p2, glam::Vec3::Z)
}
/// Return a clone reflected through the plane containing the picked line
/// and the supplied working-plane normal.
pub fn mirrored_in_plane(
&self,
p1: glam::Vec3,
p2: glam::Vec3,
working_normal: glam::Vec3,
) -> Self {
let plane_normal = (p2 - p1)
.cross(working_normal.normalize_or_zero())
.normalize_or_zero();
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
if plane_normal.length_squared() < 1e-12 {
return out;
}
for p in &mut out.points {
let point = glam::Vec3::from_array(*p);
*p = (point - 2.0 * plane_normal.dot(point - p1) * plane_normal).to_array();
}
// World position is the double-single sum `points + points_low` (text /
// UTM wires split it), so the residual must reflect too — as a direction
// (linear reflection about the axis, no `p1` offset).
for p in &mut out.points_low {
let point = glam::Vec3::from_array(*p);
*p = (point - 2.0 * plane_normal.dot(point) * plane_normal).to_array();
}
if let Some(marker) = &mut out.point_marker {
let p1 = p1.as_dvec3();
let normal = plane_normal.as_dvec3();
marker.origin -= 2.0 * normal.dot(marker.origin - p1) * normal;
marker.normal =
(marker.normal - 2.0 * normal.dot(marker.normal) * normal)
.normalize_or(glam::DVec3::Z);
marker.axis_x =
(marker.axis_x - 2.0 * normal.dot(marker.axis_x) * normal)
.normalize_or(glam::DVec3::X);
marker.axis_y =
(marker.axis_y - 2.0 * normal.dot(marker.axis_y) * normal)
.normalize_or(glam::DVec3::Y);
}
// Glyph quads reflect wholesale (true mirror) — the caller only routes
// text through here for MIRRTEXT-on; MIRRTEXT-off relocates via
// `translated` so glyphs stay readable.
if !out.text_verts.is_empty() {
let p1 = p1.as_dvec3();
let normal = plane_normal.as_dvec3();
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let point = glam::DVec3::new(x, y, z);
let mapped = point - 2.0 * normal.dot(point - p1) * normal;
(mapped.x, mapped.y, mapped.z)
});
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.mirrored(p1.as_dvec3(), plane_normal.as_dvec3()))
.collect();
out
}
/// Total arc-length of this wire (sum of segment lengths).
#[allow(dead_code)]
pub fn length(&self) -> f32 {
self.points
.windows(2)
.map(|w| {
let dx = w[1][0] - w[0][0];
let dy = w[1][1] - w[0][1];
let dz = w[1][2] - w[0][2];
(dx * dx + dy * dy + dz * dz).sqrt()
})
.sum()
}
pub fn point_world(&self, index: usize, view_height: f64) -> glam::DVec3 {
let high = self.points[index];
let low = self.points_low.get(index).copied().unwrap_or([0.0; 3]);
let point = glam::DVec3::new(
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
high[2] as f64 + low[2] as f64,
);
self.point_marker
.map_or(point, |marker| marker.resolve(point, view_height))
}
}
fn map_points(
points: &mut [[f32; 3]],
points_low: &mut Vec<[f32; 3]>,
map: impl Fn(glam::DVec3) -> glam::DVec3,
) {
let old_low = std::mem::take(points_low);
points_low.reserve(points.len());
for (index, high) in points.iter_mut().enumerate() {
if !high[0].is_finite() || !high[1].is_finite() || !high[2].is_finite() {
points_low.push([0.0; 3]);
continue;
}
let low = old_low.get(index).copied().unwrap_or([0.0; 3]);
let mapped = map(glam::DVec3::new(
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
high[2] as f64 + low[2] as f64,
));
let (hx, lx) = WireModel::split_ds(mapped.x);
let (hy, ly) = WireModel::split_ds(mapped.y);
let (hz, lz) = WireModel::split_ds(mapped.z);
*high = [hx, hy, hz];
points_low.push([lx, ly, lz]);
}
}
/// Map every glyph vertex's double-single world position through `f`, re-
/// splitting the result. The preview transforms above move `points`, but SDF
/// glyph quads live in `text_verts` (absolute-world double-single) — so a text
/// ghost (MOVE / COPY / ROTATE / SCALE / STRETCH / MIRROR preview) must carry
/// these along or the dragged text renders frozen at its source (issue #316).
/// Paper-space viewport projection uses it for the same reason (issue #385).
pub(crate) fn map_text_verts(
verts: &[crate::scene::pipeline::text_gpu::TextVertex],
f: impl Fn(f64, f64, f64) -> (f64, f64, f64),
) -> Vec<crate::scene::pipeline::text_gpu::TextVertex> {
use crate::scene::pipeline::text_gpu::split_ds;
verts
.iter()
.map(|v| {
let (nx, ny, nz) = f(
v.pos[0] as f64 + v.pos_low[0] as f64,
v.pos[1] as f64 + v.pos_low[1] as f64,
v.pos[2] as f64 + v.pos_low[2] as f64,
);
let (xh, xl) = split_ds(nx);
let (yh, yl) = split_ds(ny);
let (zh, zl) = split_ds(nz);
crate::scene::pipeline::text_gpu::TextVertex {
pos: [xh, yh, zh],
pos_low: [xl, yl, zl],
..*v
}
})
.collect()
}
impl Default for WireModel {
fn default() -> Self {
Self {
point_marker: None,
text_verts: Vec::new(),
name: String::new(),
points: Vec::new(),
points_low: Vec::new(),
color: Self::WHITE,
bg_adapt: None,
selected: false,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
world_width: 0.0,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
aci: 0,
snap_pts: Vec::new(),
tangent_geoms: Vec::new(),
key_vertices: Vec::new(),
aabb: Self::UNBOUNDED_AABB,
plinegen: true,
dash_from_start: false,
dash_align_end: None,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
depth_override: None,
display_visible: true,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
render_instance: None,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_transformations_preserve_and_update_tangent_geoms() {
let mut wire = WireModel::default();
wire.tangent_geoms.push(TangentGeom::PlanarCircle {
center: [10.0, 20.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
});
wire.tangent_geoms.push(TangentGeom::Arc {
center: [10.0, 20.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
start_angle: 0.0,
end_angle: std::f64::consts::FRAC_PI_2,
});
wire.tangent_geoms.push(TangentGeom::PlanarEllipse {
center: [10.0, 20.0, 0.0],
major_axis: [10.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.5,
start_param: 0.0,
end_param: std::f64::consts::PI,
});
// Test translated
let trans = wire.translated(glam::Vec3::new(5.0, -5.0, 1.0));
assert_eq!(trans.tangent_geoms.len(), 3);
if let TangentGeom::PlanarCircle { center, radius, .. } = trans.tangent_geoms[0] {
assert!((center[0] - 15.0).abs() < 1e-5);
assert!((center[1] - 15.0).abs() < 1e-5);
assert!((center[2] - 1.0).abs() < 1e-5);
assert!((radius - 5.0).abs() < 1e-5);
} else {
panic!("Expected PlanarCircle");
}
if let TangentGeom::Arc { center, radius, start_angle, end_angle, .. } = trans.tangent_geoms[1] {
assert!((center[0] - 15.0).abs() < 1e-5);
assert!((center[1] - 15.0).abs() < 1e-5);
assert!((center[2] - 1.0).abs() < 1e-5);
assert!((radius - 5.0).abs() < 1e-5);
assert_eq!(start_angle, 0.0);
assert_eq!(end_angle, std::f64::consts::FRAC_PI_2);
} else {
panic!("Expected Arc");
}
if let TangentGeom::PlanarEllipse { center, major_axis, minor_axis_ratio, .. } = trans.tangent_geoms[2] {
assert!((center[0] - 15.0).abs() < 1e-5);
assert!((center[1] - 15.0).abs() < 1e-5);
assert!((center[2] - 1.0).abs() < 1e-5);
assert_eq!(major_axis, [10.0, 0.0, 0.0]);
assert_eq!(minor_axis_ratio, 0.5);
} else {
panic!("Expected PlanarEllipse");
}
// Test scaled
let scaled = wire.scaled(glam::Vec3::new(10.0, 20.0, 0.0), 2.0);
if let TangentGeom::PlanarCircle { radius, .. } = scaled.tangent_geoms[0] {
assert!((radius - 10.0).abs() < 1e-5);
} else {
panic!("Expected PlanarCircle");
}
if let TangentGeom::Arc { radius, .. } = scaled.tangent_geoms[1] {
assert!((radius - 10.0).abs() < 1e-5);
} else {
panic!("Expected Arc");
}
if let TangentGeom::PlanarEllipse { major_axis, .. } = scaled.tangent_geoms[2] {
assert!((major_axis[0] - 20.0).abs() < 1e-5);
} else {
panic!("Expected PlanarEllipse");
}
// Test rotated
let rot = wire.rotated(glam::Vec3::ZERO, std::f32::consts::FRAC_PI_2);
if let TangentGeom::PlanarCircle { center, axis_x, axis_y, .. } = rot.tangent_geoms[0] {
assert!((center[0] - -20.0).abs() < 1e-4);
assert!((center[1] - 10.0).abs() < 1e-4);
assert!((axis_x[1] - 1.0).abs() < 1e-4);
assert!((axis_y[0] - -1.0).abs() < 1e-4);
} else {
panic!("Expected PlanarCircle");
}
// Test mirrored across Y=0 plane (normal = Y)
let mirr = wire.mirrored(glam::Vec3::new(0.0, 0.0, 0.0), glam::Vec3::new(1.0, 0.0, 0.0));
if let TangentGeom::PlanarCircle { center, .. } = mirr.tangent_geoms[0] {
assert!((center[0] - 10.0).abs() < 1e-4);
assert!((center[1] - -20.0).abs() < 1e-4);
} else {
panic!("Expected PlanarCircle");
}
}
}