forked from 1b-agent/opencadstudio-upstream
refactor(scene): group modules into role-based subfolders
Move the 30 flat modules under src/scene/ into six folders by role: convert (entity→geometry), text (fonts/shaping), model (render models), pick (hit-test/selection/grips/xclip), view (camera/transform/render), cache (block + property caches). pipeline/ is unchanged. Full path migration — all `scene::X` references become `scene::<group>::X`, no compatibility re-exports. render.rs items that were `pub(super)` (parent was scene) become `pub(in crate::scene)` to keep their original scene-wide visibility. No behavior change; build and tests are green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
cd048165a9
commit
7fe7e0230d
160 files changed
+732
-715
No files matched your search
@@ -0,0 +1,319 @@
|
||||
// HatchModel — CPU-side hatch fill data; rendered entirely on the GPU.
|
||||
use std::sync::Arc;
|
||||
//
|
||||
// The boundary is a closed polygon in world XY coordinates.
|
||||
// The GPU fragment shader performs point-in-polygon and hatch-line tests so
|
||||
// no line geometry is ever tessellated on the CPU.
|
||||
|
||||
pub const MAX_HATCH_BOUNDARY_VERTS: usize = 1024;
|
||||
|
||||
/// One line family from a PAT-format hatch pattern.
|
||||
///
|
||||
/// Format mirrors the standard PAT line format:
|
||||
/// `angle_deg, x0, y0, dx, dy [, dash1, dash2, ...]`
|
||||
///
|
||||
/// The perpendicular spacing between adjacent parallel lines is:
|
||||
/// `| -dx * sin(angle) + dy * cos(angle) |`
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PatFamily {
|
||||
/// Line direction in degrees.
|
||||
pub angle_deg: f32,
|
||||
/// Origin of the first line in this family.
|
||||
pub x0: f32,
|
||||
pub y0: f32,
|
||||
/// Step vector to the next parallel line.
|
||||
pub dx: f32,
|
||||
pub dy: f32,
|
||||
/// Dash/gap sequence: positive = dash length, negative = gap length.
|
||||
/// Empty = solid (no dash pattern).
|
||||
pub dashes: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Hatch fill pattern.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum HatchPattern {
|
||||
/// Opaque solid fill.
|
||||
Solid,
|
||||
/// One or more line families (PAT format).
|
||||
Pattern(Vec<PatFamily>),
|
||||
/// Linear gradient from `color` to `color2` along `angle_deg`.
|
||||
Gradient { angle_deg: f32, color2: [f32; 4] },
|
||||
}
|
||||
|
||||
/// A hatched region defined by a closed polygon boundary.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HatchModel {
|
||||
/// World XY anchor (in the same offset-relative coordinate space as
|
||||
/// the rest of the scene — `world_offset` already subtracted, but
|
||||
/// kept at f64 precision). Boundary vertices are stored as f32
|
||||
/// offsets from this anchor so that:
|
||||
/// 1) hit-test / paper_canvas can still read small-magnitude f32
|
||||
/// coords without precision loss from the f64 → f32 cast that
|
||||
/// would otherwise happen at large drawing extents (UTM, etc.).
|
||||
/// 2) the GPU pipeline can pre-shift the quad in hatch-local
|
||||
/// space (so the fragment shader's `xz` varying stays small)
|
||||
/// and add `world_origin` back inside the view_proj multiply.
|
||||
/// Reconstruct WCS-relative coords as `(world_origin.x + v.x as f64,
|
||||
/// world_origin.y + v.y as f64)`.
|
||||
pub world_origin: [f64; 2],
|
||||
/// World-XY coordinates of the boundary polygon vertices, stored as
|
||||
/// f32 offsets from `world_origin`. NaN-NaN sentinels separate
|
||||
/// disconnected paths and must be preserved un-shifted by consumers.
|
||||
pub boundary: Arc<Vec<[f32; 2]>>,
|
||||
/// Fill pattern.
|
||||
pub pattern: HatchPattern,
|
||||
/// Catalog name for this pattern (e.g. "ANSI31", "SOLID", "LINEAR").
|
||||
/// Stored so `add_hatch()` can write the correct name to the DXF entity.
|
||||
pub name: String,
|
||||
/// RGBA color in [0,1].
|
||||
pub color: [f32; 4],
|
||||
/// Pattern rotation offset in radians (from DXF `pattern_angle`).
|
||||
/// Applied on top of each family's base angle at render time.
|
||||
pub angle_offset: f32,
|
||||
/// Pattern scale multiplier (from DXF `pattern_scale`).
|
||||
pub scale: f32,
|
||||
/// Optional world-space XY rect [x0, y0, x1, y1] for paper-space
|
||||
/// viewport clipping. When `Some`, the pipeline translates it into
|
||||
/// a per-frame pixel scissor and clips this hatch's draw call to it,
|
||||
/// preventing viewport-projected content from bleeding past the
|
||||
/// viewport frame. Mirrors `WireModel.vp_scissor`.
|
||||
pub vp_scissor: Option<[f32; 4]>,
|
||||
/// Normalized draw-order depth in (0,1); higher draws on top. Fed to the
|
||||
/// hatch pipeline as a small clip-z bias so this fill orders correctly
|
||||
/// against other entity types. 0.0 for transient/preview hatches.
|
||||
pub draw_depth: f32,
|
||||
}
|
||||
|
||||
impl HatchModel {
|
||||
/// CPU-side rasteriser for `HatchPattern::Pattern` — produces the line
|
||||
/// segments inside the boundary so non-GPU consumers (PDF export,
|
||||
/// `paper_canvas`, print preview) can draw the actual pattern instead
|
||||
/// of just the outline.
|
||||
///
|
||||
/// Coordinate frame: each emitted segment is in the same
|
||||
/// boundary-relative WCS that callers already use, i.e. `world_origin +
|
||||
/// boundary[i]`. Solid / gradient hatches return an empty vec —
|
||||
/// callers fall back to their solid-fill path.
|
||||
pub fn pattern_segments(&self) -> Vec<[[f32; 2]; 2]> {
|
||||
let HatchPattern::Pattern(families) = &self.pattern else {
|
||||
return Vec::new();
|
||||
};
|
||||
if self.boundary.is_empty() || families.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let ox = self.world_origin[0] as f32;
|
||||
let oy = self.world_origin[1] as f32;
|
||||
|
||||
// ── Build edge list from boundary, splitting on NaN sentinels.
|
||||
// Each sub-path is closed (last → first edge) so even-odd
|
||||
// inside-tests work for islands / holes.
|
||||
let mut edges: Vec<([f32; 2], [f32; 2])> = Vec::new();
|
||||
let mut sub_start: Option<[f32; 2]> = None;
|
||||
let mut prev: Option<[f32; 2]> = None;
|
||||
for &[bx, by] in self.boundary.iter() {
|
||||
if bx.is_nan() || by.is_nan() {
|
||||
if let (Some(s), Some(p)) = (sub_start, prev) {
|
||||
if (s[0] - p[0]).abs() > 1e-6 || (s[1] - p[1]).abs() > 1e-6 {
|
||||
edges.push((p, s));
|
||||
}
|
||||
}
|
||||
sub_start = None;
|
||||
prev = None;
|
||||
continue;
|
||||
}
|
||||
let pt = [bx + ox, by + oy];
|
||||
match (sub_start, prev) {
|
||||
(None, _) => {
|
||||
sub_start = Some(pt);
|
||||
prev = Some(pt);
|
||||
}
|
||||
(Some(_), Some(p)) => {
|
||||
edges.push((p, pt));
|
||||
prev = Some(pt);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(s), Some(p)) = (sub_start, prev) {
|
||||
if (s[0] - p[0]).abs() > 1e-6 || (s[1] - p[1]).abs() > 1e-6 {
|
||||
edges.push((p, s));
|
||||
}
|
||||
}
|
||||
if edges.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// ── AABB of the boundary in world coords.
|
||||
let mut min_x = f32::INFINITY;
|
||||
let mut max_x = f32::NEG_INFINITY;
|
||||
let mut min_y = f32::INFINITY;
|
||||
let mut max_y = f32::NEG_INFINITY;
|
||||
for &(a, b) in &edges {
|
||||
for [x, y] in [a, b] {
|
||||
min_x = min_x.min(x);
|
||||
max_x = max_x.max(x);
|
||||
min_y = min_y.min(y);
|
||||
max_y = max_y.max(y);
|
||||
}
|
||||
}
|
||||
|
||||
let scale = self.scale.max(1e-6);
|
||||
let angle_offset = self.angle_offset;
|
||||
let mut segments: Vec<[[f32; 2]; 2]> = Vec::new();
|
||||
|
||||
// Hard cap to keep pathological patterns / huge boundaries bounded.
|
||||
const MAX_LINES_PER_FAMILY: i32 = 4096;
|
||||
const MAX_SEGMENTS_TOTAL: usize = 200_000;
|
||||
|
||||
let cos_off = angle_offset.cos();
|
||||
let sin_off = angle_offset.sin();
|
||||
for family in families {
|
||||
let angle = family.angle_deg.to_radians() + angle_offset;
|
||||
let cos_a = angle.cos();
|
||||
let sin_a = angle.sin();
|
||||
// PAT local frame: dx = along-line phase, dy = perpendicular
|
||||
// spacing. Lines step in world by k · (dx, dy)_local rotated
|
||||
// into the family's frame.
|
||||
let step_x = (family.dx * cos_a - family.dy * sin_a) * scale;
|
||||
let step_y = (family.dx * sin_a + family.dy * cos_a) * scale;
|
||||
let perp_x = -sin_a;
|
||||
let perp_y = cos_a;
|
||||
let step_perp = step_x * perp_x + step_y * perp_y;
|
||||
if step_perp.abs() < 1e-6 {
|
||||
continue; // degenerate spacing
|
||||
}
|
||||
|
||||
// k range: project AABB corners onto perp direction relative
|
||||
// to the family's origin and divide by signed perp step. The
|
||||
// pattern origin is rotated by `angle_offset` and scaled —
|
||||
// same convention as the GPU shader, so PAT patterns whose
|
||||
// `x0/y0` are non-zero (e.g. brick offsets) line up with the
|
||||
// on-screen render.
|
||||
let origin = [
|
||||
(family.x0 * cos_off - family.y0 * sin_off) * scale,
|
||||
(family.x0 * sin_off + family.y0 * cos_off) * scale,
|
||||
];
|
||||
let mut p_min = f32::INFINITY;
|
||||
let mut p_max = f32::NEG_INFINITY;
|
||||
for &[cx, cy] in &[
|
||||
[min_x, min_y],
|
||||
[max_x, min_y],
|
||||
[min_x, max_y],
|
||||
[max_x, max_y],
|
||||
] {
|
||||
let p = (cx - origin[0]) * perp_x + (cy - origin[1]) * perp_y;
|
||||
p_min = p_min.min(p);
|
||||
p_max = p_max.max(p);
|
||||
}
|
||||
let mut k_lo = (p_min / step_perp).floor() as i32 - 1;
|
||||
let mut k_hi = (p_max / step_perp).ceil() as i32 + 1;
|
||||
if k_lo > k_hi {
|
||||
std::mem::swap(&mut k_lo, &mut k_hi);
|
||||
}
|
||||
k_lo = k_lo.max(-MAX_LINES_PER_FAMILY);
|
||||
k_hi = k_hi.min(MAX_LINES_PER_FAMILY);
|
||||
|
||||
let period: f32 = family.dashes.iter().map(|d| d.abs()).sum::<f32>() * scale;
|
||||
let has_dashes = !family.dashes.is_empty() && period > 1e-6;
|
||||
|
||||
for k in k_lo..=k_hi {
|
||||
if segments.len() >= MAX_SEGMENTS_TOTAL {
|
||||
return segments;
|
||||
}
|
||||
let kf = k as f32;
|
||||
let lx = origin[0] + kf * step_x;
|
||||
let ly = origin[1] + kf * step_y;
|
||||
|
||||
// Intersect line P(t) = L + t·(cos_a, sin_a) against each
|
||||
// boundary edge; collect t-values where the edge actually
|
||||
// crosses (s ∈ [0,1]).
|
||||
let mut ts: Vec<f32> = Vec::with_capacity(8);
|
||||
for &(a, b) in &edges {
|
||||
let ex = b[0] - a[0];
|
||||
let ey = b[1] - a[1];
|
||||
let det = ex * sin_a - ey * cos_a; // = sin_a·ex − cos_a·ey
|
||||
if det.abs() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let rx = a[0] - lx;
|
||||
let ry = a[1] - ly;
|
||||
let t = (ex * ry - ey * rx) / det;
|
||||
let s = (cos_a * ry - sin_a * rx) / det;
|
||||
if s >= 0.0 && s <= 1.0 {
|
||||
ts.push(t);
|
||||
}
|
||||
}
|
||||
if ts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
// De-duplicate near-coincident hits (line clipping a vertex).
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-5);
|
||||
if ts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Even-odd: emit segments between consecutive pairs.
|
||||
for pair in ts.chunks_exact(2) {
|
||||
let t0 = pair[0];
|
||||
let t1 = pair[1];
|
||||
if t1 - t0 < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
if !has_dashes {
|
||||
let p0 = [lx + t0 * cos_a, ly + t0 * sin_a];
|
||||
let p1 = [lx + t1 * cos_a, ly + t1 * sin_a];
|
||||
segments.push([p0, p1]);
|
||||
} else {
|
||||
// Walk the dash sequence along this clipped span with
|
||||
// absolute phase (so the pattern aligns across spans,
|
||||
// matching the GPU shader). Positive entries are
|
||||
// dashes, negative are gaps, and a zero-length entry is
|
||||
// a dot — rendered as a short mark so dot patterns
|
||||
// (e.g. DOTS) are visible instead of drawing nothing.
|
||||
let n = family.dashes.len();
|
||||
let dot_len = (period * 0.06).max(1e-3);
|
||||
let phase = t0.rem_euclid(period);
|
||||
// Start at the period boundary at or before t0; the
|
||||
// span clip below drops anything before t0.
|
||||
let mut seg_t = t0 - phase;
|
||||
let mut idx = 0usize;
|
||||
let max_iters = (((t1 - t0) / period).ceil() as usize + 2) * n + 8;
|
||||
let mut iters = 0usize;
|
||||
while seg_t < t1 && iters < max_iters {
|
||||
let d = family.dashes[idx];
|
||||
let dl = d.abs() * scale;
|
||||
if d > 0.0 {
|
||||
let a = seg_t.max(t0);
|
||||
let b = (seg_t + dl).min(t1);
|
||||
if b > a {
|
||||
segments.push([
|
||||
[lx + a * cos_a, ly + a * sin_a],
|
||||
[lx + b * cos_a, ly + b * sin_a],
|
||||
]);
|
||||
}
|
||||
} else if d == 0.0 && seg_t >= t0 - 1e-6 && seg_t <= t1 + 1e-6 {
|
||||
// Short centered mark for the dot.
|
||||
let a = (seg_t - dot_len * 0.5).max(t0);
|
||||
let b = (seg_t + dot_len * 0.5).min(t1);
|
||||
if b > a {
|
||||
segments.push([
|
||||
[lx + a * cos_a, ly + a * sin_a],
|
||||
[lx + b * cos_a, ly + b * sin_a],
|
||||
]);
|
||||
}
|
||||
}
|
||||
seg_t += dl;
|
||||
idx = (idx + 1) % n;
|
||||
iters += 1;
|
||||
if segments.len() >= MAX_SEGMENTS_TOTAL {
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
segments
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// OpenCADStudio hatch pattern catalog — built from `assets/patterns/OpenCADStudio.pat`.
|
||||
//
|
||||
// Each `PatternEntry` wraps a parsed PAT pattern with:
|
||||
// - `gpu` — `HatchPattern::Pattern(families)` for the shader
|
||||
// - `pat_lines` — exact PAT line definitions used for DXF export
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::patterns::PatLineDef;
|
||||
use crate::scene::model::hatch_model::{HatchPattern, PatFamily};
|
||||
use acadrust::entities::{HatchPattern as DxfPattern, HatchPatternLine};
|
||||
use acadrust::types::Vector2;
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────
|
||||
|
||||
pub struct PatternEntry {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// GPU-ready pattern for the shader.
|
||||
pub gpu: HatchPattern,
|
||||
/// Exact PAT line families (used for DXF export).
|
||||
pub pat_lines: Vec<PatLineDef>,
|
||||
}
|
||||
|
||||
// ── Catalog ───────────────────────────────────────────────────────────────
|
||||
|
||||
static CATALOG: OnceLock<Vec<PatternEntry>> = OnceLock::new();
|
||||
|
||||
pub fn catalog() -> &'static [PatternEntry] {
|
||||
CATALOG.get_or_init(build_catalog)
|
||||
}
|
||||
|
||||
pub fn find(name: &str) -> Option<&'static PatternEntry> {
|
||||
catalog().iter().find(|e| e.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
pub fn names() -> Vec<String> {
|
||||
catalog().iter().map(|e| e.name.clone()).collect()
|
||||
}
|
||||
|
||||
// ── DXF export ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn build_dxf_pattern(entry: &PatternEntry) -> DxfPattern {
|
||||
let mut pat = DxfPattern::new(&entry.name);
|
||||
pat.description = entry.description.clone();
|
||||
for ln in &entry.pat_lines {
|
||||
let angle_rad = (ln.angle_deg as f64).to_radians();
|
||||
pat.lines.push(HatchPatternLine {
|
||||
angle: angle_rad,
|
||||
base_point: Vector2::new(ln.x0 as f64, ln.y0 as f64),
|
||||
offset: Vector2::new(ln.dx as f64, ln.dy as f64),
|
||||
dash_lengths: ln.dashes.iter().map(|&d| d as f64).collect(),
|
||||
});
|
||||
}
|
||||
pat
|
||||
}
|
||||
|
||||
// ── Builder ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_catalog() -> Vec<PatternEntry> {
|
||||
let mut entries = vec![PatternEntry {
|
||||
name: "SOLID".into(),
|
||||
description: "Solid fill".into(),
|
||||
gpu: HatchPattern::Solid,
|
||||
pat_lines: vec![],
|
||||
}];
|
||||
|
||||
for def in crate::patterns::catalog() {
|
||||
entries.push(PatternEntry {
|
||||
name: def.name.clone(),
|
||||
description: def.description.clone(),
|
||||
gpu: HatchPattern::Pattern(def.lines.iter().map(pat_line_to_family).collect()),
|
||||
pat_lines: def.lines.clone(),
|
||||
});
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn pat_line_to_family(ln: &PatLineDef) -> PatFamily {
|
||||
PatFamily {
|
||||
angle_deg: ln.angle_deg,
|
||||
x0: ln.x0,
|
||||
y0: ln.y0,
|
||||
dx: ln.dx,
|
||||
dy: ln.dy,
|
||||
dashes: ln.dashes.clone(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// ImageModel — CPU-side data for a raster image quad.
|
||||
//
|
||||
// Holds decoded RGBA pixel data and the world-space quad geometry derived
|
||||
// from the RasterImage entity's insertion point, u/v vectors, and pixel size.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImageModel {
|
||||
/// Original file path (used for reload / display in properties).
|
||||
pub file_path: String,
|
||||
/// RGBA8 pixel data in row-major order. Arc-wrapped so cloning ImageModel
|
||||
/// is O(1) — the pixel bytes are shared, not copied.
|
||||
pub pixels: Arc<Vec<u8>>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Opacity: 1.0 = opaque, 0.0 = transparent.
|
||||
pub opacity: f32,
|
||||
/// World-space quad corners (CCW), same order as image_corners() helper:
|
||||
/// [0] origin (bottom-left)
|
||||
/// [1] origin + U*W (bottom-right)
|
||||
/// [2] origin + U*W + V*H (top-right)
|
||||
/// [3] origin + V*H (top-left)
|
||||
pub corners: [[f32; 3]; 4],
|
||||
/// Optional world-space XY rect [x0, y0, x1, y1] for paper-space
|
||||
/// viewport clipping. Mirrors `WireModel.vp_scissor` /
|
||||
/// `HatchModel.vp_scissor`.
|
||||
pub vp_scissor: Option<[f32; 4]>,
|
||||
/// Normalized draw-order depth in (0,1); higher draws on top. Fed to the
|
||||
/// image pipeline as a small clip-z bias so the raster orders correctly
|
||||
/// against other entity types.
|
||||
pub draw_depth: f32,
|
||||
}
|
||||
|
||||
impl ImageModel {
|
||||
/// Build an ImageModel from a DXF RasterImage entity.
|
||||
/// Returns `None` if the image file cannot be opened or decoded.
|
||||
pub fn from_raster_image(
|
||||
img: &acadrust::entities::RasterImage,
|
||||
world_offset: [f64; 3],
|
||||
) -> Option<Self> {
|
||||
let w = img.size.x;
|
||||
let h = img.size.y;
|
||||
// Model-space geometry is drawn in (WCS - world_offset) so large UTM-
|
||||
// scale coordinates stay within f32 precision; offset the image too.
|
||||
let ox = (img.insertion_point.x - world_offset[0]) as f32;
|
||||
let oy = (img.insertion_point.y - world_offset[1]) as f32;
|
||||
let oz = (img.insertion_point.z - world_offset[2]) as f32;
|
||||
let ux = (img.u_vector.x * w) as f32;
|
||||
let uy = (img.u_vector.y * w) as f32;
|
||||
let uz = (img.u_vector.z * w) as f32;
|
||||
let vx = (img.v_vector.x * h) as f32;
|
||||
let vy = (img.v_vector.y * h) as f32;
|
||||
let vz = (img.v_vector.z * h) as f32;
|
||||
let corners = [
|
||||
[ox, oy, oz],
|
||||
[ox + ux, oy + uy, oz + uz],
|
||||
[ox + ux + vx, oy + uy + vy, oz + uz + vz],
|
||||
[ox + vx, oy + vy, oz + vz],
|
||||
];
|
||||
let opacity = 1.0 - img.fade as f32 / 100.0;
|
||||
|
||||
let (pixels, width, height) = load_pixels(&img.file_path)?;
|
||||
Some(Self {
|
||||
file_path: img.file_path.clone(),
|
||||
pixels: Arc::new(pixels),
|
||||
width,
|
||||
height,
|
||||
opacity,
|
||||
corners,
|
||||
vp_scissor: None,
|
||||
draw_depth: 0.0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a raster image file into RGBA8 pixels.
|
||||
/// Returns `None` if the file does not exist or cannot be decoded.
|
||||
pub fn load_pixels(path_str: &str) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let img = image::open(Path::new(path_str)).ok()?;
|
||||
// GPUs cap 2-D texture dimensions (8192 with wgpu's default limits).
|
||||
// Downscale oversized images to fit, preserving aspect ratio, so texture
|
||||
// creation can't fail — they're displayed scaled-down anyway.
|
||||
const MAX_DIM: u32 = 8192;
|
||||
let img = if img.width() > MAX_DIM || img.height() > MAX_DIM {
|
||||
let longest = img.width().max(img.height()) as f32;
|
||||
let scale = MAX_DIM as f32 / longest;
|
||||
let nw = ((img.width() as f32 * scale) as u32).clamp(1, MAX_DIM);
|
||||
let nh = ((img.height() as f32 * scale) as u32).clamp(1, MAX_DIM);
|
||||
img.resize(nw, nh, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
Some((rgba.into_raw(), w, h))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Triangle mesh model — produced by truck Shell/Solid tessellation.
|
||||
//
|
||||
// Stored alongside WireModels in the scene; rendered by the mesh pipeline
|
||||
// (wgpu TriangleList with depth test, flat normals).
|
||||
|
||||
/// A tessellated triangle mesh ready to upload to the GPU.
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MeshModel {
|
||||
/// Unique identifier (entity handle value as decimal string).
|
||||
pub name: String,
|
||||
/// World-space vertex positions.
|
||||
pub verts: Vec<[f32; 3]>,
|
||||
/// Per-vertex normals (may be empty if not available).
|
||||
pub normals: Vec<[f32; 3]>,
|
||||
/// Triangle indices into `verts` (every 3 values = one triangle).
|
||||
pub indices: Vec<u32>,
|
||||
/// RGBA colour in [0, 1].
|
||||
pub color: [f32; 4],
|
||||
/// Whether this mesh is currently selected.
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
/// Bundle of mesh tessellations at different sampling densities, picked
|
||||
/// per frame by the render pipeline based on the projected pixel size of
|
||||
/// `world_aabb`. Phase 3.4 LOD ladder:
|
||||
///
|
||||
/// | LOD | Source | Use when projected diagonal |
|
||||
/// |-----|------------|------------------------------|
|
||||
/// | 0 | HIGH | > 200 px |
|
||||
/// | 1 | MID (½) | 50–200 px |
|
||||
/// | 2 | LOW (¼) | < 50 px |
|
||||
///
|
||||
/// `lods` holds up to one MeshModel per LOD level (high → low). Empty
|
||||
/// slots fall back to the nearest available LOD at render time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MeshLodSet {
|
||||
pub lods: Vec<MeshModel>,
|
||||
/// World XY AABB `[min_x, min_y, max_x, max_y]` of the mesh — used
|
||||
/// by the per-frame LOD selector to compute the projected pixel
|
||||
/// diagonal.
|
||||
pub world_aabb: [f32; 4],
|
||||
}
|
||||
|
||||
impl MeshLodSet {
|
||||
/// Wrap a single MeshModel as a one-LOD set. Used by interactive
|
||||
/// commands that only produce one tessellation (e.g. truck-based
|
||||
/// BOX/CYLINDER creation). The LOD selector will pick slot 0 for
|
||||
/// every zoom level.
|
||||
pub fn from_single(mesh: MeshModel) -> Self {
|
||||
let mut min_x = f32::INFINITY;
|
||||
let mut min_y = f32::INFINITY;
|
||||
let mut max_x = f32::NEG_INFINITY;
|
||||
let mut max_y = f32::NEG_INFINITY;
|
||||
for &[x, y, _] in &mesh.verts {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if x < min_x { min_x = x; }
|
||||
if y < min_y { min_y = y; }
|
||||
if x > max_x { max_x = x; }
|
||||
if y > max_y { max_y = y; }
|
||||
}
|
||||
Self {
|
||||
lods: vec![mesh],
|
||||
world_aabb: [min_x, min_y, max_x, max_y],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod wire_model;
|
||||
pub mod hatch_model;
|
||||
pub mod hatch_patterns;
|
||||
pub mod image_model;
|
||||
pub mod mesh_model;
|
||||
pub mod model_solid;
|
||||
pub mod object;
|
||||
@@ -0,0 +1,216 @@
|
||||
// truck B-rep construction for the Model tab's primitives, plus tessellation
|
||||
// into the renderer's `MeshLodSet`. Each builder follows truck's own example
|
||||
// recipes (tsweep / rsweep / cone / try_attach_plane), oriented Z-up with the
|
||||
// footprint on the z = `base_z` plane to match acadrust's `acis::primitives`.
|
||||
//
|
||||
// The resulting truck `Solid` is cached per entity handle on the Scene so the
|
||||
// Design-group boolean tools can run truck-shapeops on it.
|
||||
|
||||
use truck_modeling::{builder, Point3, Rad, Solid, Vector3, Wire};
|
||||
|
||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||
|
||||
const FULL: f64 = 7.0; // > 2π → builder closes the revolution
|
||||
|
||||
/// Axis-aligned box from its center and full extents.
|
||||
pub fn box_solid(center: [f64; 3], length: f64, width: f64, height: f64) -> Solid {
|
||||
let min = Point3::new(
|
||||
center[0] - length / 2.0,
|
||||
center[1] - width / 2.0,
|
||||
center[2] - height / 2.0,
|
||||
);
|
||||
let v = builder::vertex(min);
|
||||
let e = builder::tsweep(&v, Vector3::new(length, 0.0, 0.0));
|
||||
let f = builder::tsweep(&e, Vector3::new(0.0, width, 0.0));
|
||||
builder::tsweep(&f, Vector3::new(0.0, 0.0, height))
|
||||
}
|
||||
|
||||
/// Right triangular prism (wedge): right-triangle cross-section in XZ,
|
||||
/// extruded along Y. `origin` is the min corner, ramp rising in +X/+Z.
|
||||
pub fn wedge_solid(origin: [f64; 3], length: f64, width: f64, height: f64) -> Solid {
|
||||
let o = Point3::new(origin[0], origin[1], origin[2]);
|
||||
let a = builder::vertex(o);
|
||||
let b = builder::vertex(Point3::new(o.x + length, o.y, o.z));
|
||||
let c = builder::vertex(Point3::new(o.x, o.y, o.z + height));
|
||||
let wire: Wire = vec![
|
||||
builder::line(&a, &b),
|
||||
builder::line(&b, &c),
|
||||
builder::line(&c, &a),
|
||||
]
|
||||
.into();
|
||||
let face = builder::try_attach_plane(&[wire]).expect("wedge profile");
|
||||
builder::tsweep(&face, Vector3::new(0.0, width, 0.0))
|
||||
}
|
||||
|
||||
/// Solid cylinder: disk on the z = base plane, extruded up by `height`.
|
||||
pub fn cylinder_solid(center: [f64; 3], radius: f64, height: f64) -> Solid {
|
||||
let v = builder::vertex(Point3::new(center[0] + radius, center[1], center[2]));
|
||||
let circle = builder::rsweep(
|
||||
&v,
|
||||
Point3::new(center[0], center[1], center[2]),
|
||||
Vector3::unit_z(),
|
||||
Rad(FULL),
|
||||
);
|
||||
let disk = builder::try_attach_plane(&[circle]).expect("cylinder cap");
|
||||
builder::tsweep(&disk, Vector3::new(0.0, 0.0, height))
|
||||
}
|
||||
|
||||
/// Solid cone: profile (apex → rim → base-center) revolved about the axis.
|
||||
pub fn cone_solid(center: [f64; 3], radius: f64, height: f64) -> Solid {
|
||||
let cx = center[0];
|
||||
let cy = center[1];
|
||||
let cz = center[2];
|
||||
let apex = builder::vertex(Point3::new(cx, cy, cz + height));
|
||||
let rim = builder::vertex(Point3::new(cx + radius, cy, cz));
|
||||
let base = builder::vertex(Point3::new(cx, cy, cz));
|
||||
let wire: Wire = vec![builder::line(&apex, &rim), builder::line(&rim, &base)].into();
|
||||
let shell = builder::cone(&wire, Vector3::unit_z(), Rad(FULL));
|
||||
Solid::new(vec![shell])
|
||||
}
|
||||
|
||||
/// Solid sphere: meridian semicircle revolved about the polar (Z) axis.
|
||||
pub fn sphere_solid(center: [f64; 3], radius: f64) -> Solid {
|
||||
let c = Point3::new(center[0], center[1], center[2]);
|
||||
let top = builder::vertex(Point3::new(c.x, c.y, c.z + radius));
|
||||
// Rotate the top point about the X axis by π → meridian from +Z to −Z.
|
||||
let meridian: Wire = builder::rsweep(&top, c, Vector3::unit_x(), Rad(std::f64::consts::PI));
|
||||
let shell = builder::cone(&meridian, Vector3::unit_z(), Rad(FULL));
|
||||
Solid::new(vec![shell])
|
||||
}
|
||||
|
||||
/// Solid torus in the z = base plane (tube revolved about the Z axis).
|
||||
pub fn torus_solid(center: [f64; 3], major: f64, minor: f64) -> Solid {
|
||||
let c = Point3::new(center[0], center[1], center[2]);
|
||||
let v = builder::vertex(Point3::new(c.x + major, c.y, c.z + minor));
|
||||
let tube = builder::rsweep(
|
||||
&v,
|
||||
Point3::new(c.x + major, c.y, c.z),
|
||||
Vector3::unit_y(),
|
||||
Rad(FULL),
|
||||
);
|
||||
let shell = builder::rsweep(&tube, c, Vector3::unit_z(), Rad(FULL));
|
||||
Solid::new(vec![shell])
|
||||
}
|
||||
|
||||
// ── Edge extraction (pick geometry + wireframe overlay) ─────────────────────
|
||||
|
||||
/// Tessellate the solid's B-rep edges into acadrust `Wire`s. Stored on the
|
||||
/// `Solid3D`/result entity so it is click-pickable (the renderer's wire
|
||||
/// fallback draws these as a wireframe over the shaded mesh, and hit-testing
|
||||
/// uses their points).
|
||||
pub fn edge_wires(solid: &Solid) -> Vec<acadrust::entities::Wire> {
|
||||
use crate::scene::convert::truck_tess::{tessellate_edge, TruckTessResult};
|
||||
use acadrust::types::Vector3;
|
||||
let mut wires = Vec::new();
|
||||
for shell in solid.boundaries() {
|
||||
for edge in shell.edge_iter() {
|
||||
if let TruckTessResult::Lines(pts) = tessellate_edge(&edge, [0.0; 3]) {
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let pts3: Vec<Vector3> = pts
|
||||
.iter()
|
||||
.map(|p| Vector3::new(p[0] as f64, p[1] as f64, p[2] as f64))
|
||||
.collect();
|
||||
wires.push(acadrust::entities::Wire::from_points(pts3));
|
||||
}
|
||||
}
|
||||
}
|
||||
wires
|
||||
}
|
||||
|
||||
// ── Boolean operations (truck-shapeops) ─────────────────────────────────────
|
||||
|
||||
/// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so
|
||||
/// this scene module has no dependency on the UI module.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Bool {
|
||||
Union,
|
||||
Subtract,
|
||||
Intersect,
|
||||
}
|
||||
|
||||
const BOOL_TOL: f64 = 0.05;
|
||||
|
||||
/// Combine two solids. `Subtract` removes `b` from `a`. Returns `None` when the
|
||||
/// operation fails (e.g. the solids don't actually overlap).
|
||||
pub fn boolean(op: Bool, a: &Solid, b: &Solid) -> Option<Solid> {
|
||||
match op {
|
||||
Bool::Union => truck_shapeops::or(a, b, BOOL_TOL),
|
||||
Bool::Intersect => truck_shapeops::and(a, b, BOOL_TOL),
|
||||
Bool::Subtract => {
|
||||
let mut bn = b.clone();
|
||||
bn.not();
|
||||
truck_shapeops::and(a, &bn, BOOL_TOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tessellation ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tessellate a truck `Solid` into a single-LOD `MeshLodSet` (world-space,
|
||||
/// before world_offset is applied by the caller).
|
||||
pub fn mesh_from_solid(solid: &Solid, color: [f32; 4]) -> Option<MeshLodSet> {
|
||||
use crate::scene::convert::truck_tess::{tessellate_solid, TruckTessResult};
|
||||
match tessellate_solid(solid, [0.0; 3]) {
|
||||
TruckTessResult::Mesh {
|
||||
verts,
|
||||
normals,
|
||||
indices,
|
||||
} if !indices.is_empty() => {
|
||||
let mesh = MeshModel {
|
||||
name: String::new(),
|
||||
verts,
|
||||
normals,
|
||||
indices,
|
||||
color,
|
||||
selected: false,
|
||||
};
|
||||
Some(MeshLodSet::from_single(mesh))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tri_count(s: &Solid) -> usize {
|
||||
mesh_from_solid(s, [0.7, 0.7, 0.7, 1.0])
|
||||
.map(|m| m.lods[0].indices.len() / 3)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_primitives_triangulate() {
|
||||
let c = [0.0, 0.0, 0.0];
|
||||
assert!(tri_count(&box_solid(c, 10.0, 10.0, 10.0)) >= 12, "box");
|
||||
assert!(tri_count(&wedge_solid(c, 10.0, 10.0, 10.0)) >= 6, "wedge");
|
||||
assert!(tri_count(&cylinder_solid(c, 5.0, 12.0)) > 20, "cylinder");
|
||||
assert!(tri_count(&cone_solid(c, 5.0, 12.0)) > 10, "cone");
|
||||
assert!(tri_count(&sphere_solid(c, 5.0)) > 50, "sphere");
|
||||
assert!(tri_count(&torus_solid(c, 8.0, 2.0)) > 50, "torus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans_produce_solids() {
|
||||
let a = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0);
|
||||
let b = box_solid([5.0, 5.0, 5.0], 10.0, 10.0, 10.0);
|
||||
for (op, label) in [
|
||||
(Bool::Union, "union"),
|
||||
(Bool::Subtract, "subtract"),
|
||||
(Bool::Intersect, "intersect"),
|
||||
] {
|
||||
let r = boolean(op, &a, &b);
|
||||
let n = r.as_ref().map(tri_count).unwrap_or(0);
|
||||
eprintln!("{label}: tris={n}");
|
||||
assert!(r.is_some() && n > 0, "{label} produced nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_exposes_edges() {
|
||||
assert!(edge_wires(&box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0)).len() >= 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Shared value types used by the dispatch and grip systems.
|
||||
|
||||
use acadrust::types::{Color as AcadColor, LineWeight};
|
||||
use glam::Vec3;
|
||||
|
||||
/// The kind of value held by a property row.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PropValue {
|
||||
/// Read-only display text.
|
||||
ReadOnly(String),
|
||||
/// Editable numeric/text field.
|
||||
EditText(String),
|
||||
/// Layer name — rendered as a combo_box.
|
||||
LayerChoice(String),
|
||||
/// Generic string choice rendered as a combo_box.
|
||||
Choice {
|
||||
selected: String,
|
||||
options: Vec<String>,
|
||||
},
|
||||
/// ACI/RGB/ByLayer/ByBlock color — rendered as a color picker.
|
||||
ColorChoice(AcadColor),
|
||||
/// Color varies across the current multi-selection.
|
||||
ColorVaries,
|
||||
/// Line weight — rendered as a combo_box.
|
||||
LwChoice(LineWeight),
|
||||
/// Lineweight varies across the current multi-selection.
|
||||
LwVaries,
|
||||
/// Linetype name — rendered as a combo_box.
|
||||
LinetypeChoice(String),
|
||||
/// Boolean flag — rendered as a toggle button (e.g. Invisible).
|
||||
BoolToggle { field: &'static str, value: bool },
|
||||
/// Hatch pattern name — rendered as a combo_box from the catalog.
|
||||
HatchPatternChoice(String),
|
||||
}
|
||||
|
||||
/// A single property row in the Properties panel.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Property {
|
||||
pub label: String,
|
||||
/// Stable field identifier used in `PropGeomInput` / `PropGeomCommit` messages.
|
||||
pub field: &'static str,
|
||||
pub value: PropValue,
|
||||
}
|
||||
|
||||
/// A named section of properties (e.g. "General", "Geometry").
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PropSection {
|
||||
pub title: String,
|
||||
pub props: Vec<Property>,
|
||||
}
|
||||
|
||||
// ── Grip types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Visual marker shape for a grip point. The complete vocabulary that
|
||||
/// matches the standard CAD grip conventions:
|
||||
/// * `Square` — endpoint / vertex / centre. Drag → moves a single
|
||||
/// point or translates the entity.
|
||||
/// * `Rectangle` — direction-aware mid-segment stretch handle.
|
||||
/// Drawn as a small box rotated along `dir` (the in-plane segment
|
||||
/// direction in world XY). Used for polyline / wipeout / image /
|
||||
/// dimension segment midpoints.
|
||||
/// * `Triangle` — directional indicator (Phase 2: dynamic-block
|
||||
/// parameters, dimension reverse-arrow flips, multi-functional
|
||||
/// hover popups).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum GripShape {
|
||||
Square,
|
||||
Rectangle,
|
||||
Triangle,
|
||||
}
|
||||
|
||||
/// Describes one grip point for an entity.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GripDef {
|
||||
/// Object-local identifier (stable index, unique per object instance).
|
||||
pub id: usize,
|
||||
/// World-space position of the grip, in f64. Entity coordinates can sit at
|
||||
/// UTM magnitudes (1e7); casting to f32 before the world-offset subtraction
|
||||
/// loses ~1 drawing unit and draws the grip visibly off the wire. Producers
|
||||
/// fill this straight from the f64 entity data; the offset is subtracted in
|
||||
/// f64 and only then cast for screen-space math.
|
||||
pub world: glam::DVec3,
|
||||
/// `true` → midpoint / centre grip (drags the whole shape).
|
||||
/// `false` → endpoint / vertex grip (moves a single point).
|
||||
pub is_midpoint: bool,
|
||||
/// Visual marker shape for the grip.
|
||||
pub shape: GripShape,
|
||||
/// World-XY direction vector used to orient a `Rectangle` grip
|
||||
/// along its segment. `None` for shapes that don't need rotation
|
||||
/// (Square, Triangle in non-directional contexts).
|
||||
pub dir: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// How to apply a grip drag result.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GripApply {
|
||||
/// Move a specific vertex to this absolute world position.
|
||||
Absolute(Vec3),
|
||||
/// Translate the whole object by this delta vector.
|
||||
Translate(Vec3),
|
||||
}
|
||||
|
||||
/// One entry in the hover-popup menu that opens when the cursor dwells
|
||||
/// on a grip. The `label` is the user-visible string; `action` is the
|
||||
/// operation the entity will perform when the item is committed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GripMenuItem {
|
||||
pub label: &'static str,
|
||||
pub action: GripMenuAction,
|
||||
}
|
||||
|
||||
/// All operations a grip popup menu can dispatch. Entity-specific code
|
||||
/// in `apply_grip_menu` decodes these into edits. `Stretch` is the
|
||||
/// default no-op-equivalent — picking it just starts the regular
|
||||
/// stretch drag, identical to clicking the grip with no popup open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum GripMenuAction {
|
||||
Stretch,
|
||||
Lengthen,
|
||||
Radius,
|
||||
ArcLength,
|
||||
AddVertex,
|
||||
RemoveVertex,
|
||||
ConvertToArc,
|
||||
ConvertToLine,
|
||||
StretchVertex,
|
||||
AddLeader,
|
||||
RemoveLeader,
|
||||
ReverseArrows,
|
||||
MoveWithDimLine,
|
||||
MoveWithLeader,
|
||||
MoveIndependent,
|
||||
ResetText,
|
||||
RotateText,
|
||||
AboveDimLine,
|
||||
Center,
|
||||
OriginPoint,
|
||||
HatchAngle,
|
||||
HatchScale,
|
||||
HatchPattern,
|
||||
TangentDirection,
|
||||
AddFitPoint,
|
||||
RemoveFitPoint,
|
||||
Refit,
|
||||
RefineVertices,
|
||||
MoveWithText,
|
||||
StackText,
|
||||
UnstackText,
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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] },
|
||||
/// Circle/arc.
|
||||
Circle { center: [f32; 3], radius: f32 },
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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.
|
||||
pub points: Vec<[f32; 3]>,
|
||||
/// RGBA colour in [0, 1].
|
||||
pub color: [f32; 4],
|
||||
/// 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,
|
||||
/// 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::Vec3, 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<[f32; 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,
|
||||
/// Paper-space bounding box [x0, y0, x1, y1] for GPU scissor clipping.
|
||||
/// Set only for viewport-projected wires in paper-space layouts.
|
||||
pub vp_scissor: Option<[f32; 4]>,
|
||||
/// 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]>,
|
||||
}
|
||||
|
||||
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];
|
||||
/// Civil 3D-style object acquisition highlight (structure / catchment hover).
|
||||
pub const PICK_HIGHLIGHT: [f32; 4] = [0.95, 0.50, 0.08, 1.0];
|
||||
pub const PICK_HIGHLIGHT_DIM: [f32; 4] = [0.95, 0.50, 0.08, 0.45];
|
||||
/// Sentinel AABB that never rejects any snap query.
|
||||
pub const UNBOUNDED_AABB: [f32; 4] = [
|
||||
f32::NEG_INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::INFINITY,
|
||||
f32::INFINITY,
|
||||
];
|
||||
|
||||
/// 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 {
|
||||
name,
|
||||
points,
|
||||
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,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
for p in &mut out.points {
|
||||
p[0] += delta.x;
|
||||
p[1] += delta.y;
|
||||
p[2] += delta.z;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Return a clone with every point rotated around `center` by `angle_rad`.
|
||||
pub fn rotated(&self, center: glam::Vec3, angle_rad: f32) -> Self {
|
||||
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 dx = p[0] - center.x;
|
||||
let dy = p[1] - center.y;
|
||||
p[0] = center.x + dx * c - dy * s;
|
||||
p[1] = center.y + dx * s + dy * c;
|
||||
}
|
||||
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;
|
||||
for p in &mut out.points {
|
||||
p[0] = center.x + (p[0] - center.x) * factor;
|
||||
p[1] = center.y + (p[1] - center.y) * factor;
|
||||
p[2] = center.z + (p[2] - center.z) * factor;
|
||||
}
|
||||
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 {
|
||||
let mut out = self.clone();
|
||||
out.name = format!("preview_{}", self.name);
|
||||
out.color = Self::CYAN;
|
||||
out.selected = false;
|
||||
for p in &mut out.points {
|
||||
if p[0] >= win_min.x && p[0] <= win_max.x && p[1] >= win_min.y && p[1] <= win_max.y {
|
||||
p[0] += delta.x;
|
||||
p[1] += delta.y;
|
||||
p[2] += delta.z;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Return a clone mirrored across the line through `p1`→`p2`.
|
||||
pub fn mirrored(&self, p1: glam::Vec3, p2: glam::Vec3) -> Self {
|
||||
let ax = p2.x - p1.x;
|
||||
let ay = p2.y - p1.y;
|
||||
let len2 = ax * ax + ay * ay;
|
||||
let mut out = self.clone();
|
||||
out.name = format!("preview_{}", self.name);
|
||||
out.color = Self::CYAN;
|
||||
out.selected = false;
|
||||
if len2 < 1e-12 {
|
||||
return out;
|
||||
}
|
||||
for p in &mut out.points {
|
||||
let dx = p[0] - p1.x;
|
||||
let dy = p[1] - p1.y;
|
||||
let t = (dx * ax + dy * ay) / len2;
|
||||
p[0] = p1.x + 2.0 * t * ax - dx;
|
||||
p[1] = p1.y + 2.0 * t * ay - dy;
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WireModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
points: Vec::new(),
|
||||
color: Self::WHITE,
|
||||
selected: false,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
aci: 0,
|
||||
snap_pts: Vec::new(),
|
||||
tangent_geoms: Vec::new(),
|
||||
key_vertices: Vec::new(),
|
||||
aabb: Self::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user