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:
Hakan SevenandClaude Opus 4.8 committed 2026-06-16 11:33:46 +03:00
1 parent cd048165a9
commit 7fe7e0230d
160 files changed
+732 -715

No files matched your search

+319
View File
@@ -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
}
}
+88
View File
@@ -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(),
}
}
+98
View File
@@ -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))
}
+69
View File
@@ -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],
}
}
}
+7
View File
@@ -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;
+216
View File
@@ -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);
}
}
+151
View File
@@ -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,
}
+235
View File
@@ -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(),
}
}
}