Files
OneCAD-Draft/src/scene/model/wire_model.rs
T
ramox81andClaude Opus 5.5 507a6ab43c DWF/DGN underlays: draw content as vector wires
A DWF sheet or DGN model is drawn as world-space wires (one line wire per
colour and width, one fill wire per colour) instead of a raster rescaled
to the view, so it stays sharp while zooming instead of showing pixels
until the raster is made again. Colours go through the colour table
mapping, contrast, fade and monochrome; clip boundaries clip the wires.
The wires are drawn and plotted but neither picked nor snapped; the
underlay's own snap geometry still serves object snaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:43:20 +03:00

1133 lines
46 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 opencadcodec `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,
/// Geometry that is never picked: the content of an underlay. Hidden
/// (`display_visible` false) it is what object snaps find; drawn, it is
/// display only and snaps skip it too ([`Self::is_display_only`]).
pub snap_only: 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];
/// Drawn underlay content: shown and plotted, never picked or snapped.
pub fn is_display_only(&self) -> bool {
self.snap_only && self.display_visible
}
/// 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,
snap_only: false,
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 preview clone with every point mapped through `map` (an
/// INSERT-style placement: per-axis scale, rotation, translation).
/// Tangent geometry is dropped; a preview is not snapped to.
pub fn mapped(&self, map: impl Fn(glam::DVec3) -> glam::DVec3) -> 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, &map);
if let Some(marker) = &mut out.point_marker {
marker.origin = map(marker.origin);
}
if !out.text_verts.is_empty() {
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let p = map(glam::DVec3::new(x, y, z));
(p.x, p.y, p.z)
});
}
out.tangent_geoms.clear();
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,
snap_only: false,
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");
}
}
}