first commit

This commit is contained in:
Hakan Seven 2026-03-22 14:56:27 +03:00
commit d2fdac7f1e
274 changed files with 89330 additions and 0 deletions

View file

@ -0,0 +1,30 @@
// acadrust -> truck topology conversion layer.
use acadrust::{CadDocument, EntityType};
use glam::Vec3;
use truck_modeling::{Edge, Solid, Vertex, Wire};
use crate::entities::traits::EntityTypeOps;
use crate::scene::wire_model::{SnapHint, TangentGeom};
#[allow(dead_code)]
pub enum TruckObject {
Point(Vertex),
Curve(Edge),
Contour(Wire),
Text(Vec<Vec<[f32; 2]>>),
/// Pre-computed NaN-separated 3-D point list (leader lines, arrowheads, etc.).
Lines(Vec<[f32; 3]>),
Volume(Solid),
}
pub struct TruckEntity {
pub object: TruckObject,
pub snap_pts: Vec<(Vec3, SnapHint)>,
pub tangent_geoms: Vec<TangentGeom>,
pub key_vertices: Vec<[f32; 3]>,
}
pub fn convert(entity: &EntityType, document: &CadDocument) -> Option<TruckEntity> {
entity.to_truck_entity(document)
}

252
src/scene/camera.rs Normal file
View file

@ -0,0 +1,252 @@
// Arcball orbit camera — quaternion-based rotation, no gimbal lock.
//
// The camera orbits around a `target` point using a unit quaternion (`rotation`)
// that maps the canonical "camera looks down -Z" pose to the current view.
//
// Pan: translates `target` in the view-plane (no rotation change).
// Orbit: updates `rotation` via arcball delta — converts screen drag delta
// to a rotation axis/angle, then pre-multiplies the current quaternion.
// Zoom: adjusts `distance` (exponential feel).
// Snap: directly assigns yaw+pitch encoded as a quaternion (for ViewCube).
//
// Coordinate convention: Z-up world space (same as the rest of H7CAD).
use glam::{vec3, Mat4, Quat, Vec3};
use iced::{Point, Rectangle};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Projection {
Orthographic,
Perspective,
}
#[derive(Clone)]
pub struct Camera {
/// World-space pivot point the camera orbits around.
pub target: Vec3,
/// Arcball rotation: maps canonical pose to current orientation.
pub rotation: Quat,
/// Distance from eye to target.
pub distance: f32,
/// Vertical field of view in radians (perspective only).
pub fov_y: f32,
pub projection: Projection,
// --- Legacy yaw/pitch exposed only for ViewCube hit-test compatibility ---
// Kept in sync with `rotation` whenever orbit() or snap_angles() is called.
pub yaw: f32,
pub pitch: f32,
}
impl Default for Camera {
fn default() -> Self {
// Default: look straight down at the XY drawing plane (top view, Z-up).
// yaw = 0, pitch = PI/2 → eye is directly above target.
let yaw = 0.0_f32;
let pitch = std::f32::consts::FRAC_PI_2;
Self {
target: Vec3::ZERO,
rotation: yaw_pitch_to_quat(yaw, pitch),
distance: 60.36,
fov_y: 45.0_f32.to_radians(),
projection: Projection::Orthographic,
yaw,
pitch,
}
}
}
pub const OPENGL_TO_WGPU: Mat4 = glam::mat4(
glam::vec4(1.0, 0.0, 0.0, 0.0),
glam::vec4(0.0, 1.0, 0.0, 0.0),
glam::vec4(0.0, 0.0, 0.5, 0.0),
glam::vec4(0.0, 0.0, 0.5, 1.0),
);
impl Camera {
// ── Eye position ───────────────────────────────────────────────────────
pub fn eye(&self) -> Vec3 {
// The canonical eye direction is +Z (looking at origin from above).
// The rotation maps that canonical pose to the current orientation.
let eye_dir = self.rotation * Vec3::Z;
self.target + eye_dir * self.distance
}
/// Half-height of the orthographic frustum in world units.
pub fn ortho_size(&self) -> f32 {
self.distance * (self.fov_y * 0.5).tan()
}
// ── Projection matrices ────────────────────────────────────────────────
pub fn view_proj(&self, bounds: Rectangle) -> Mat4 {
let near = self.distance * 0.001;
let far = self.distance * 1000.0;
let aspect = bounds.width / bounds.height;
// Up vector: use the rotation to find which world direction is "up"
// in the current camera frame.
let up_dir = self.rotation * Vec3::Y;
let view = Mat4::look_at_rh(self.eye(), self.target, up_dir);
let proj = match self.projection {
Projection::Perspective => Mat4::perspective_rh(self.fov_y, aspect, near, far),
Projection::Orthographic => {
let h = self.ortho_size();
let w = h * aspect;
Mat4::orthographic_rh(-w, w, -h, h, near, far)
}
};
OPENGL_TO_WGPU * proj * view
}
pub fn pick_on_target_plane(&self, screen: Point, bounds: Rectangle) -> Vec3 {
let ndc_x = (screen.x / bounds.width) * 2.0 - 1.0;
let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0;
let inv = self.view_proj(bounds).inverse();
match self.projection {
Projection::Perspective => {
let near_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0));
let far_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 1.0));
let dir = (far_pt - near_pt).normalize();
let forward = (self.target - self.eye()).normalize();
let denom = dir.dot(forward);
if denom.abs() < 1e-6 {
return self.target;
}
let t = (self.target - near_pt).dot(forward) / denom;
if t < 0.0 {
return self.target;
}
near_pt + dir * t
}
Projection::Orthographic => {
let ray_origin = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0));
let forward = (self.target - self.eye()).normalize();
let t = (self.target - ray_origin).dot(forward) / forward.dot(forward);
ray_origin + forward * t
}
}
}
pub fn position_vec4(&self) -> glam::Vec4 {
glam::Vec4::from((self.eye(), 0.0))
}
// ── ViewCube rotation matrix ───────────────────────────────────────────
/// Returns the rotation matrix for the ViewCube.
///
/// The camera quaternion maps canonical pose (+Z eye) → current view.
/// The ViewCube needs the inverse so the cube stays world-aligned.
/// Inverse of a unit quaternion = its conjugate.
pub fn view_rotation_mat(&self) -> Mat4 {
Mat4::from_quat(self.rotation.conjugate())
}
// ── Navigation ────────────────────────────────────────────────────────
/// Arcball orbit: drag delta (dx, dy) in screen pixels.
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
if delta_x.abs() < 1e-6 && delta_y.abs() < 1e-6 {
return;
}
let speed = 0.005_f32;
let angle = (delta_x * delta_x + delta_y * delta_y).sqrt() * speed;
// Screen drag → rotation axis: right drag = rotate around cam_up (Y),
// down drag = rotate around cam_right (X). Negate so drag direction
// matches intuitive "grab and spin" arcball behaviour.
let screen_axis = vec3(-delta_y, -delta_x, 0.0).normalize_or_zero();
let cam_right = self.rotation * Vec3::X;
let cam_up = self.rotation * Vec3::Y;
let world_axis = (cam_right * screen_axis.x + cam_up * screen_axis.y).normalize_or_zero();
if world_axis.length_squared() < 1e-12 {
return;
}
let delta_rot = Quat::from_axis_angle(world_axis, angle);
self.rotation = (delta_rot * self.rotation).normalize();
// Sync legacy yaw/pitch for hit-test functions.
self.sync_yaw_pitch();
}
pub fn zoom(&mut self, delta: f32) {
self.distance = (self.distance * (1.0 - delta * 0.1)).max(0.001);
}
pub fn zoom_about_point(&mut self, screen: Point, bounds: Rectangle, delta: f32) {
if bounds.width <= 0.0 || bounds.height <= 0.0 {
self.zoom(delta);
return;
}
let before = self.pick_on_target_plane(screen, bounds);
self.zoom(delta);
let after = self.pick_on_target_plane(screen, bounds);
self.target += before - after;
}
pub fn pan(&mut self, delta_x: f32, delta_y: f32) {
let speed = self.distance * 0.001;
let cam_right = self.rotation * Vec3::X;
let cam_up = self.rotation * Vec3::Y;
self.target -= cam_right * delta_x * speed;
self.target += cam_up * delta_y * speed;
}
pub fn fit_to_bounds(&mut self, min: Vec3, max: Vec3) {
self.target = (min + max) * 0.5;
let size = (max - min).length();
self.distance = size * 1.5;
}
// ── ViewCube snap ─────────────────────────────────────────────────────
/// Snap to canonical yaw+pitch (called by ViewCubeSnap message).
/// Updates the arcball quaternion to match.
pub fn snap_to_angles(&mut self, yaw: f32, pitch: f32) {
self.yaw = yaw;
self.pitch = pitch;
self.rotation = yaw_pitch_to_quat(yaw, pitch);
}
// ── Internal helpers ───────────────────────────────────────────────────
/// Derive yaw and pitch from the current quaternion for the ViewCube
/// hit-test functions (hit_test / hover_id). These use yaw/pitch to
/// compute the same rotation matrix as the shader, so they must match.
fn sync_yaw_pitch(&mut self) {
// Eye direction in world space (canonical eye dir is +Z).
let eye_dir = self.rotation * Vec3::Z;
// pitch: angle above/below the XY plane.
self.pitch = eye_dir.z.clamp(-0.999, 0.999).asin();
// yaw: atan2(x, y) matches from_rotation_z(yaw) used in view_rotation_mat.
self.yaw = eye_dir.x.atan2(eye_dir.y);
}
}
// ── Free helpers ───────────────────────────────────────────────────────────
/// Build a rotation quaternion from yaw (rotation around Z) and pitch
/// (tilt toward Z). Matches the coordinate convention of the ViewCube
/// so snap angles continue to work unchanged.
///
/// Convention (Z-up, Y-forward):
/// yaw = 0 → camera looks along +Y axis (front view)
/// pitch = PI/2 → camera looks down -Z (top view)
/// pitch = 0 → camera in the XY plane
/// Build a rotation quaternion from yaw and pitch.
/// Positive yaw rotates the view direction clockwise when seen from above (Z-up).
pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32) -> Quat {
// +yaw so ViewCube faces match camera direction (FRONT at yaw=0 = +Y world axis).
let q_yaw = Quat::from_rotation_z(yaw);
let q_pitch = Quat::from_rotation_x(std::f32::consts::FRAC_PI_2 - pitch);
(q_yaw * q_pitch).normalize()
}

273
src/scene/complex_lt.rs Normal file
View file

@ -0,0 +1,273 @@
//! CPU-side complex linetype tessellation.
//!
//! Walks the entity path and applies LT elements (dashes, gaps, dots, shapes),
//! returning one `WireModel` per continuous stroke.
//!
//! Coordinate convention: 2D entities live in the **XZ** plane (Y = elevation).
//! Shape X → along the linetype direction; Shape Y → perpendicular in XZ.
use crate::linetypes::{ComplexLt, LtSegment};
use crate::scene::cxf;
use crate::scene::wire_model::WireModel;
// ── Public entry point ────────────────────────────────────────────────────
/// Apply `lt` along `path_pts` (the entity's tessellated world-space strip).
///
/// Returns one `WireModel` per continuous stroke. Pass the entity's `name`,
/// `color`, `selected` flag, and `line_weight_px` so the WireModels inherit
/// the entity's visual properties.
pub fn apply_along(
name: &str,
path_pts: &[[f32; 3]],
lt: &ComplexLt,
scale: f32,
color: [f32; 4],
selected: bool,
line_weight_px: f32,
) -> Vec<WireModel> {
if path_pts.len() < 2 || lt.segments.is_empty() {
return vec![];
}
let scaled: Vec<LtSeg> = scale_segments(&lt.segments, scale);
if scaled.is_empty() {
return vec![];
}
let pattern_len: f32 = scaled
.iter()
.map(|s| match s {
LtSeg::Dash(l) | LtSeg::Space(l) => *l,
LtSeg::Dot | LtSeg::Shape { .. } => 0.0,
})
.sum();
if pattern_len < 1e-10 {
return vec![];
}
let mut strokes: Vec<Vec<[f32; 3]>> = Vec::new();
let mut cur_stroke: Vec<[f32; 3]> = Vec::new();
let mut elem_idx: usize = 0;
let mut elem_consumed: f32 = 0.0;
for i in 0..path_pts.len() - 1 {
let ps = path_pts[i];
let pe = path_pts[i + 1];
let dx = pe[0] - ps[0];
let dy = pe[1] - ps[1];
let dz = pe[2] - ps[2];
let seg_len = (dx * dx + dy * dy + dz * dz).sqrt();
if seg_len < 1e-10 {
continue;
}
let fwd = [dx / seg_len, dy / seg_len, dz / seg_len];
let perp = [-fwd[1], fwd[0], fwd[2]];
let mut pos = 0.0f32;
while pos < seg_len - 1e-6 {
let idx = elem_idx % scaled.len();
match &scaled[idx] {
LtSeg::Dash(dash_len) => {
let remaining_dash = dash_len - elem_consumed;
let remaining_seg = seg_len - pos;
let advance = remaining_dash.min(remaining_seg);
let p_start = lerp(ps, pe, pos / seg_len);
let p_end = lerp(ps, pe, (pos + advance) / seg_len);
if cur_stroke.is_empty() {
cur_stroke.push(p_start);
}
cur_stroke.push(p_end);
pos += advance;
elem_consumed += advance;
if (elem_consumed - dash_len).abs() < 1e-6 {
elem_idx += 1;
elem_consumed = 0.0;
}
}
LtSeg::Space(space_len) => {
let remaining = space_len - elem_consumed;
let advance = remaining.min(seg_len - pos);
flush(&mut cur_stroke, &mut strokes);
pos += advance;
elem_consumed += advance;
if (elem_consumed - space_len).abs() < 1e-6 {
elem_idx += 1;
elem_consumed = 0.0;
}
}
LtSeg::Dot => {
flush(&mut cur_stroke, &mut strokes);
let p = lerp(ps, pe, pos / seg_len);
strokes.push(vec![p, p]);
elem_idx += 1;
elem_consumed = 0.0;
if pos < 1e-6 {
break;
}
}
LtSeg::Shape {
name: sh_name,
x,
y,
scale: sh_scale,
rot_deg,
} => {
flush(&mut cur_stroke, &mut strokes);
let insert = lerp(ps, pe, pos / seg_len);
let insert = offset_pt(insert, fwd, perp, *x, *y);
let shape_strokes = emit_shape(sh_name, insert, fwd, perp, *sh_scale, *rot_deg);
strokes.extend(shape_strokes);
elem_idx += 1;
elem_consumed = 0.0;
if pos < 1e-6 {
break;
}
}
}
}
}
flush(&mut cur_stroke, &mut strokes);
strokes
.into_iter()
.filter(|s| s.len() >= 2)
.map(|pts| WireModel {
name: name.to_string(),
points: pts,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
})
.collect()
}
// ── Helpers ───────────────────────────────────────────────────────────────
#[derive(Clone)]
enum LtSeg {
Dash(f32),
Space(f32),
Dot,
Shape {
name: String,
x: f32,
y: f32,
scale: f32,
rot_deg: f32,
},
}
fn scale_segments(segs: &[LtSegment], scale: f32) -> Vec<LtSeg> {
segs.iter()
.map(|s| match s {
LtSegment::Dash(l) => LtSeg::Dash(l * scale),
LtSegment::Space(l) => LtSeg::Space(l * scale),
LtSegment::Dot => LtSeg::Dot,
LtSegment::Shape {
name,
x,
y,
scale: sh_scale,
rot_deg,
} => LtSeg::Shape {
name: name.clone(),
x: x * scale,
y: y * scale,
scale: *sh_scale * scale,
rot_deg: *rot_deg,
},
})
.collect()
}
fn flush(cur: &mut Vec<[f32; 3]>, strokes: &mut Vec<Vec<[f32; 3]>>) {
if !cur.is_empty() {
strokes.push(std::mem::take(cur));
}
}
fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
]
}
fn offset_pt(pt: [f32; 3], fwd: [f32; 3], perp: [f32; 3], dx: f32, dy: f32) -> [f32; 3] {
[
pt[0] + fwd[0] * dx + perp[0] * dy,
pt[1] + fwd[1] * dx + perp[1] * dy,
pt[2] + fwd[2] * dx + perp[2] * dy,
]
}
/// Transform a CXF shape into world-space strokes.
fn emit_shape(
name: &str,
insert: [f32; 3],
fwd: [f32; 3],
perp: [f32; 3],
scale: f32,
rot_deg: f32,
) -> Vec<Vec<[f32; 3]>> {
// cxf::get() now returns Option<&CxfGlyph> — same .strokes field.
let shape = match cxf::get(name) {
Some(s) => s,
None => return vec![],
};
let rot_r = rot_deg.to_radians();
let (cos_r, sin_r) = (rot_r.cos(), rot_r.sin());
let rx = cos_r;
let ry = sin_r;
let sx = -sin_r;
let sy = cos_r;
shape
.strokes
.iter()
.map(|stroke| {
stroke
.iter()
.map(|&[lx, ly]| {
let scaled_x = lx * scale;
let scaled_y = ly * scale;
let along_fwd = rx * scaled_x + sx * scaled_y;
let along_perp = ry * scaled_x + sy * scaled_y;
[
insert[0] + fwd[0] * along_fwd + perp[0] * along_perp,
insert[1] + fwd[1] * along_fwd + perp[1] * along_perp,
insert[2] + fwd[2] * along_fwd + perp[2] * along_perp,
]
})
.collect()
})
.collect()
}

585
src/scene/cxf.rs Normal file
View file

@ -0,0 +1,585 @@
//! CXF vector font and shape parser.
//!
//! Handles both linetype shape files (ltypeshp.cxf) and full font files
//! (Standard.cxf, NormalLatin2.cxf, etc.) with a single parser.
//!
//! Supported CXF geometry commands:
//! L x0,y0,x1,y1 — single line segment
//! PL x,y,b,... — open polyline (x, y, bulge triplets)
//! PLC x,y,b,... — closed polyline
//! A cx,cy,r,start,end — arc CCW, angles in degrees
//! AR cx,cy,r,start,end — arc CW, angles in degrees
//!
//! Entry type decision rule:
//! [XXXX] LABEL
//! - If LABEL is exactly one character AND equals char::from_u32(hex)
//! -> stored as a Unicode glyph (font use-case, e.g. [0041] A)
//! - Otherwise -> stored as a named shape (ltypeshp, e.g. [0042] BAT)
//!
//! Glyph coordinate space:
//! Origin = left baseline corner.
//! Cap height = 9 glyph units.
//! Scale factor = text_height / 9.0.
use std::collections::HashMap;
use std::f32::consts::TAU;
use std::sync::OnceLock;
// ── Embedded assets ───────────────────────────────────────────────────────
const SRC_LTYPESHP: &str = include_str!("../../assets/fonts/ltypeshp.cxf");
const SRC_STANDARD: &str = include_str!("../../assets/fonts/Standard.cxf");
const SRC_NORMAL: &str = include_str!("../../assets/fonts/NormalLatin2.cxf");
const SRC_COURIER: &str = include_str!("../../assets/fonts/CourierCad.cxf");
const SRC_SANS: &str = include_str!("../../assets/fonts/SansNS.cxf");
const SRC_ITALIC: &str = include_str!("../../assets/fonts/ItalicT.cxf");
const SRC_GOTHIC: &str = include_str!("../../assets/fonts/GothITT.cxf");
const SRC_CURSIVE: &str = include_str!("../../assets/fonts/Cursive.cxf");
const SRC_SCRIPTC: &str = include_str!("../../assets/fonts/ScriptS.cxf");
// ── Public types ──────────────────────────────────────────────────────────
/// One glyph or shape: a list of open 2-D polyline strokes in local units.
#[derive(Clone, Default)]
pub struct CxfGlyph {
/// Each stroke is a list of [x, y] points forming a connected polyline.
pub strokes: Vec<Vec<[f32; 2]>>,
/// Advance width in glyph units (rightmost X of all strokes).
pub advance: f32,
}
/// A parsed CXF file.
#[derive(Clone)]
pub struct CxfFile {
/// Font/file name from the `# Name:` header.
pub name: String,
/// Extra space between characters in glyph units (`# LetterSpacing:`).
pub letter_spacing: f32,
/// Space character width in glyph units (`# WordSpacing:`).
pub word_spacing: f32,
/// Line height multiplier relative to text height (`# LineSpacingFactor:`).
pub line_spacing: f32,
/// Named shapes keyed by uppercase name — used for ltypeshp.
shapes: HashMap<String, CxfGlyph>,
/// Unicode glyphs keyed by character — used for font files.
glyphs: HashMap<char, CxfGlyph>,
}
impl CxfFile {
/// Look up a linetype shape by name (case-insensitive).
pub fn shape(&self, name: &str) -> Option<&CxfGlyph> {
self.shapes.get(&name.to_ascii_uppercase())
}
/// Look up a font glyph by Unicode character.
pub fn glyph(&self, c: char) -> Option<&CxfGlyph> {
self.glyphs.get(&c)
}
}
// ── Static registries ─────────────────────────────────────────────────────
static SHAPES: OnceLock<CxfFile> = OnceLock::new();
static FONTS: OnceLock<HashMap<String, CxfFile>> = OnceLock::new();
fn shapes_file() -> &'static CxfFile {
SHAPES.get_or_init(|| parse(SRC_LTYPESHP))
}
fn fonts_map() -> &'static HashMap<String, CxfFile> {
FONTS.get_or_init(|| {
let mut map = HashMap::new();
let mut insert = |src: &str, aliases: &[&str]| {
let f = parse(src);
map.insert(f.name.to_ascii_uppercase(), f.clone());
for alias in aliases {
map.insert(alias.to_ascii_uppercase(), f.clone());
}
};
insert(SRC_STANDARD, &["STANDARD", "TXT", "TXT.SHX", "SIMPLEX"]);
insert(
SRC_NORMAL,
&["NORMAL", "NORMALLATIN2", "ROMANS", "ROMANS.SHX"],
);
insert(SRC_COURIER, &["COURIER", "COURIERCAD", "COURIERCAD"]);
insert(SRC_SANS, &["SANS", "SANSNS"]);
insert(SRC_ITALIC, &["ITALIC", "ITALICT"]);
insert(SRC_GOTHIC, &["GOTHIC", "GOTHITT"]);
insert(SRC_CURSIVE, &["CURSIVE"]);
insert(SRC_SCRIPTC, &["SCRIPTS", "SCRIPTC", "SCRIPT"]);
map
})
}
// ── Public API — linetype shapes ──────────────────────────────────────────
/// Look up a linetype shape by name (case-insensitive). Used by `complex_lt.rs`.
pub fn get(name: &str) -> Option<&'static CxfGlyph> {
shapes_file().shape(name)
}
// ── Public API — text tessellation ────────────────────────────────────────
/// Return a font by name (case-insensitive).
/// Falls back to Standard → Normal → any available font.
pub fn get_font(name: &str) -> &'static CxfFile {
let map = fonts_map();
let key = name.to_ascii_uppercase();
map.get(&key)
.or_else(|| map.get("STANDARD"))
.or_else(|| map.get("NORMAL"))
.or_else(|| map.values().next())
.expect("at least one CXF font must be embedded")
}
/// Tessellate a text string into world-space 2-D strokes.
///
/// - `origin` — insertion point [world_x, world_y]
/// - `height` — text height in world units (cap height = 9 glyph units)
/// - `rotation` — rotation angle in radians (CCW positive)
/// - `font_name` — e.g. "Standard", "Romans", "Courier" (case-insensitive)
/// - `text` — the string to render
///
/// Returns one Vec<[f32;2]> per stroke. Strokes from different glyphs are
/// separate entries.
#[allow(dead_code)]
pub fn tessellate_text(
origin: [f32; 2],
height: f32,
rotation: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
if text.is_empty() || height <= 0.0 {
return vec![];
}
let font = get_font(font_name);
let scale = height / 9.0; // CXF cap height = 9 glyph units
let (cos_r, sin_r) = (rotation.cos(), rotation.sin());
let xform = |gx: f32, gy: f32, cursor_x: f32| -> [f32; 2] {
let lx = (cursor_x + gx) * scale;
let ly = gy * scale;
[
origin[0] + lx * cos_r - ly * sin_r,
origin[1] + lx * sin_r + ly * cos_r,
]
};
let mut out: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cursor_x: f32 = 0.0;
for ch in text.chars() {
if ch == ' ' {
cursor_x += font.word_spacing;
continue;
}
match font.glyph(ch) {
Some(glyph) => {
for stroke in &glyph.strokes {
if stroke.len() < 2 {
continue;
}
out.push(
stroke
.iter()
.map(|&[gx, gy]| xform(gx, gy, cursor_x))
.collect(),
);
}
cursor_x += glyph.advance + font.letter_spacing;
}
None => {
cursor_x += 6.0 + font.letter_spacing;
}
}
}
out
}
pub fn tessellate_text_ex(
origin: [f32; 2],
height: f32,
rotation: f32,
width_factor: f32,
oblique_angle: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
if text.is_empty() || height <= 0.0 {
return vec![];
}
let font = get_font(font_name);
let scale = height / 9.0;
let wf = width_factor.clamp(0.01, 100.0);
let ob = oblique_angle.tan(); // shear multiplier
let (cos_r, sin_r) = (rotation.cos(), rotation.sin());
let xform = |gx: f32, gy: f32, cursor_x: f32| -> [f32; 2] {
let sx = (cursor_x + gx) * scale * wf + gy * scale * ob;
let sy = gy * scale;
[
origin[0] + sx * cos_r - sy * sin_r,
origin[1] + sx * sin_r + sy * cos_r,
]
};
let mut out: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cursor_x: f32 = 0.0;
for ch in text.chars() {
if ch == ' ' {
cursor_x += font.word_spacing;
continue;
}
match font.glyph(ch) {
Some(glyph) => {
for stroke in &glyph.strokes {
if stroke.len() < 2 {
continue;
}
out.push(
stroke
.iter()
.map(|&[gx, gy]| xform(gx, gy, cursor_x))
.collect(),
);
}
cursor_x += (glyph.advance + font.letter_spacing) * wf;
}
None => {
cursor_x += (6.0 + font.letter_spacing) * wf;
}
}
}
out
}
// ── Parser ────────────────────────────────────────────────────────────────
fn parse(src: &str) -> CxfFile {
let mut file = CxfFile {
name: String::from("Unknown"),
letter_spacing: 3.0,
word_spacing: 6.75,
line_spacing: 1.0,
shapes: HashMap::new(),
glyphs: HashMap::new(),
};
let mut cur_char: Option<char> = None;
let mut cur_name: Option<String> = None;
let mut cur_strokes: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cur_max_x: f32 = 0.0;
for raw in src.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
// ── Header ────────────────────────────────────────────────────────
if line.starts_with('#') {
if let Some(rest) = line.strip_prefix("# Name:") {
file.name = rest.trim().to_string();
} else if let Some(rest) = line.strip_prefix("# LetterSpacing:") {
if let Ok(v) = rest.trim().parse::<f32>() {
file.letter_spacing = v;
}
} else if let Some(rest) = line.strip_prefix("# WordSpacing:") {
if let Ok(v) = rest.trim().parse::<f32>() {
file.word_spacing = v;
}
} else if let Some(rest) = line.strip_prefix("# LineSpacingFactor:") {
if let Ok(v) = rest.trim().parse::<f32>() {
file.line_spacing = v;
}
}
continue;
}
// ── Entry header: `[XXXX] LABEL` ─────────────────────────────────
if line.starts_with('[') {
flush_entry(
&mut file,
&mut cur_char,
&mut cur_name,
&mut cur_strokes,
&mut cur_max_x,
);
let hex_end = match line.find(']') {
Some(i) => i,
None => continue,
};
let hex_str = line[1..hex_end].trim();
let label = line[hex_end + 1..].trim();
if let Ok(cp) = u32::from_str_radix(hex_str, 16) {
let cp_char = char::from_u32(cp);
// Glyph: label is exactly the one character that the codepoint maps to.
// Shape: label is a word (different from the codepoint char) or empty
// with a non-printable / non-matching codepoint.
let is_glyph = match cp_char {
Some(ch) => label.chars().count() == 1 && label.chars().next() == Some(ch),
None => false,
};
if is_glyph {
cur_char = cp_char;
cur_name = None;
} else if !label.is_empty() {
cur_name = Some(label.to_string());
cur_char = None;
} else if let Some(ch) = cp_char {
// No label but valid codepoint — treat as glyph.
cur_char = Some(ch);
cur_name = None;
}
}
continue;
}
// ── Geometry (only inside a valid entry) ──────────────────────────
if cur_char.is_none() && cur_name.is_none() {
continue;
}
let up = line.to_ascii_uppercase();
if up.starts_with("PLC ") {
if let Some(s) = parse_triplet_poly(&line[4..], true) {
update_max_x(&s, &mut cur_max_x);
if !s.is_empty() {
cur_strokes.push(s);
}
}
} else if up.starts_with("PL ") {
if let Some(s) = parse_triplet_poly(&line[3..], false) {
update_max_x(&s, &mut cur_max_x);
if !s.is_empty() {
cur_strokes.push(s);
}
}
} else if up.starts_with("AR ") {
if let Some(s) = parse_arc(&line[3..], true) {
update_max_x(&s, &mut cur_max_x);
cur_strokes.push(s);
}
} else if up.starts_with("A ") {
if let Some(s) = parse_arc(&line[2..], false) {
update_max_x(&s, &mut cur_max_x);
cur_strokes.push(s);
}
} else if up.starts_with("L ") {
let vals: Vec<f32> = line[2..]
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if vals.len() >= 4 {
let s = vec![[vals[0], vals[1]], [vals[2], vals[3]]];
update_max_x(&s, &mut cur_max_x);
cur_strokes.push(s);
}
}
}
flush_entry(
&mut file,
&mut cur_char,
&mut cur_name,
&mut cur_strokes,
&mut cur_max_x,
);
file
}
fn flush_entry(
file: &mut CxfFile,
cur_char: &mut Option<char>,
cur_name: &mut Option<String>,
cur_strokes: &mut Vec<Vec<[f32; 2]>>,
cur_max_x: &mut f32,
) {
let strokes = std::mem::take(cur_strokes);
let advance = *cur_max_x;
*cur_max_x = 0.0;
let glyph = CxfGlyph { strokes, advance };
if let Some(ch) = cur_char.take() {
file.glyphs.insert(ch, glyph);
} else if let Some(name) = cur_name.take() {
file.shapes.insert(name.to_ascii_uppercase(), glyph);
}
}
fn update_max_x(stroke: &[[f32; 2]], max_x: &mut f32) {
for &[x, _] in stroke {
if x > *max_x {
*max_x = x;
}
}
}
// ── Arc tessellation ──────────────────────────────────────────────────────
/// Parse `cx,cy,radius,start_deg,end_deg` and tessellate into a polyline.
/// `reversed = true` → CW sweep (AR), `false` → CCW (A).
fn parse_arc(data: &str, reversed: bool) -> Option<Vec<[f32; 2]>> {
let vals: Vec<f32> = data
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if vals.len() < 5 {
return None;
}
let (cx, cy, r) = (vals[0], vals[1], vals[2]);
let sa = vals[3].to_radians();
let ea = vals[4].to_radians();
if r <= 0.0 {
return None;
}
let sweep = if reversed {
let mut s = ea - sa;
if s > 0.0 {
s -= TAU;
}
if s.abs() < 1e-6 {
s = -TAU;
}
s
} else {
let mut s = ea - sa;
if s < 0.0 {
s += TAU;
}
if s.abs() < 1e-6 {
s = TAU;
}
s
};
let segs = ((sweep.abs() / TAU) * 64.0).ceil().clamp(4.0, 64.0) as u32;
let mut pts = Vec::with_capacity(segs as usize + 1);
for i in 0..=segs {
let t = sa + sweep * (i as f32 / segs as f32);
pts.push([cx + r * t.cos(), cy + r * t.sin()]);
}
Some(pts)
}
// ── Polyline (bulge) tessellation ─────────────────────────────────────────
/// Parse `(x, y, bulge)` triplets from a PL / PLC command.
fn parse_triplet_poly(data: &str, closed: bool) -> Option<Vec<[f32; 2]>> {
let vals: Vec<f32> = data
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
let n = vals.len();
if n < 6 || n % 3 != 0 {
return None;
}
let triplets: Vec<([f32; 2], f32)> = (0..n)
.step_by(3)
.map(|i| ([vals[i], vals[i + 1]], vals[i + 2]))
.collect();
let tc = triplets.len();
let mut pts: Vec<[f32; 2]> = Vec::with_capacity(tc * 6);
for (idx, &(start, bulge)) in triplets.iter().enumerate() {
let end = if idx + 1 < tc {
triplets[idx + 1].0
} else if closed && tc > 0 {
triplets[0].0
} else {
if pts.last() != Some(&start) {
pts.push(start);
}
break;
};
if pts.last() != Some(&start) {
pts.push(start);
}
if bulge.abs() < 1e-7 {
pts.push(end);
} else {
bulge_arc(&mut pts, start, end, bulge, 16);
}
}
if closed {
if let Some(&first) = pts.first() {
if pts.last() != Some(&first) {
pts.push(first);
}
}
}
Some(pts)
}
/// Approximate a bulge arc from `p0` to `p1`.
/// `bulge = tan(included_angle / 4)`: positive = CCW, negative = CW.
fn bulge_arc(out: &mut Vec<[f32; 2]>, p0: [f32; 2], p1: [f32; 2], bulge: f32, n: usize) {
let (x1, y1) = (p0[0], p0[1]);
let (x2, y2) = (p1[0], p1[1]);
let theta = 4.0 * bulge.atan();
let dx = x2 - x1;
let dy = y2 - y1;
let d = (dx * dx + dy * dy).sqrt();
if d < 1e-10 {
out.push(p1);
return;
}
let half_theta = theta * 0.5;
let r = (d * 0.5) / half_theta.sin().abs();
let px = -dy / d;
let py = dx / d;
let d_mc = r * half_theta.cos();
let sign = if bulge > 0.0 { 1.0_f32 } else { -1.0_f32 };
// Center lies to the LEFT of chord for CCW (bulge>0) and to the RIGHT for CW (bulge<0).
// px = -dy/d is the left-perpendicular unit vector of the chord.
// CCW (sign=+1): center = mid + perp * d_mc (left)
// CW (sign=-1): center = mid - perp * d_mc (right)
let cx = (x1 + x2) * 0.5 + sign * px * d_mc;
let cy = (y1 + y2) * 0.5 + sign * py * d_mc;
let a1 = (y1 - cy).atan2(x1 - cx);
let a2 = (y2 - cy).atan2(x2 - cx);
// With the corrected center, raw sweep (a2-a1) is already in the right
// range for most arcs. A single clamp is sufficient — no loop needed.
let mut sweep = a2 - a1;
if bulge > 0.0 {
// CCW: sweep must be in (0, TAU].
if sweep <= 0.0 {
sweep += TAU;
}
} else {
// CW: sweep must be in [-TAU, 0).
if sweep >= 0.0 {
sweep -= TAU;
}
}
if sweep.abs() < 1e-7 {
sweep = if bulge > 0.0 { TAU } else { -TAU };
}
for i in 1..=n {
let t = i as f32 / n as f32;
let a = a1 + sweep * t;
out.push([cx + r * a.cos(), cy + r * a.sin()]);
}
}

80
src/scene/dispatch.rs Normal file
View file

@ -0,0 +1,80 @@
// Dispatch entry points for entity editing.
use acadrust::types::{Color as AcadColor, LineWeight, Transparency};
use acadrust::{EntityType, Handle};
use crate::command::EntityTransform;
use crate::entities::traits::EntityTypeOps;
use crate::scene::object::{GripDef, PropSection};
use crate::scene::properties;
pub fn grips(entity: &EntityType) -> Vec<GripDef> {
EntityTypeOps::grips(entity)
}
pub fn properties_sectioned(
handle: Handle,
entity: &EntityType,
text_style_names: &[String],
) -> Vec<PropSection> {
let general = properties::general_section(entity);
let geometry = entity
.geometry_properties(text_style_names)
.unwrap_or_else(|| properties::fallback_properties(handle, entity));
vec![general, geometry]
}
pub fn apply_common_prop(entity: &mut EntityType, field: &str, value: &str) {
let e = entity.as_entity_mut();
match field {
"layer" => e.set_layer(value.to_string()),
"linetype" => {
entity.common_mut().linetype = if value == "ByLayer" {
String::new()
} else {
value.to_string()
};
}
"linetype_scale" => {
if let Ok(v) = value.trim().parse::<f64>() {
if v > 0.0 {
entity.common_mut().linetype_scale = v;
}
}
}
"transparency" => {
if let Ok(pct) = value.trim().parse::<f64>() {
let alpha = (pct.clamp(0.0, 100.0) / 100.0 * 255.0).round() as u8;
entity
.as_entity_mut()
.set_transparency(Transparency::new(alpha));
}
}
_ => {}
}
}
pub fn toggle_invisible(entity: &mut EntityType) {
let cur = entity.as_entity_mut().is_invisible();
entity.as_entity_mut().set_invisible(!cur);
}
pub fn apply_color(entity: &mut EntityType, color: AcadColor) {
entity.as_entity_mut().set_color(color);
}
pub fn apply_line_weight(entity: &mut EntityType, lw: LineWeight) {
entity.as_entity_mut().set_line_weight(lw);
}
pub fn apply_geom_prop(entity: &mut EntityType, field: &str, value: &str) {
EntityTypeOps::apply_geom_prop(entity, field, value);
}
pub fn apply_grip(entity: &mut EntityType, grip_id: usize, apply: crate::scene::object::GripApply) {
EntityTypeOps::apply_grip(entity, grip_id, apply);
}
pub fn apply_transform(entity: &mut EntityType, t: &EntityTransform) {
EntityTypeOps::apply_transform(entity, t);
}

79
src/scene/grip.rs Normal file
View file

@ -0,0 +1,79 @@
//! H7CAD-style grip editing.
use acadrust::Handle;
use glam::{Mat4, Vec3};
use iced::{Point, Rectangle};
use super::object::{GripDef, GripShape};
/// Pixel radius for grip hit-detection.
pub const GRIP_THRESHOLD_PX: f32 = 8.0;
/// Half-size of the rendered grip square / diamond in pixels.
pub const GRIP_HALF_PX: f32 = 5.0;
// ── Active drag state ─────────────────────────────────────────────────────
/// Stored on `H7CAD` while a grip is being dragged.
#[derive(Clone, Debug)]
pub struct GripEdit {
/// Handle of the entity being edited.
pub handle: Handle,
/// Index into the entity's grip list.
pub grip_id: usize,
/// `true` → midpoint / translate grip; `false` → endpoint / absolute grip.
pub is_translate: bool,
/// World-space position of the grip when the drag started (ortho/polar base).
pub origin_world: Vec3,
/// Last world-space cursor position (needed for incremental delta on translate drags).
pub last_world: Vec3,
}
// ── Screen-space helpers ───────────────────────────────────────────────────
/// Project a slice of `GripDef`s to screen space.
/// Returns `(grip_id, screen_pos, is_midpoint, shape)` for each grip.
pub fn grips_to_screen(
grips: &[GripDef],
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<(usize, Point, bool, GripShape)> {
grips
.iter()
.map(|g| {
let ndc = view_proj.project_point3(g.world);
let screen = Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
);
(g.id, screen, g.is_midpoint, g.shape)
})
.collect()
}
/// Find the closest grip within `GRIP_THRESHOLD_PX` pixels of `cursor`.
/// Returns `(grip_id, is_translate, world_pos)` if found, else `None`.
pub fn find_hit_grip(
cursor: Point,
grips: &[GripDef],
view_proj: Mat4,
bounds: Rectangle,
) -> Option<(usize, bool, Vec3)> {
let mut best_dist = GRIP_THRESHOLD_PX;
let mut best: Option<(usize, bool, Vec3)> = None;
for g in grips {
let ndc = view_proj.project_point3(g.world);
let screen = Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
);
let dx = screen.x - cursor.x;
let dy = screen.y - cursor.y;
let d = (dx * dx + dy * dy).sqrt();
if d < best_dist {
best_dist = d;
best = Some((g.id, g.is_midpoint, g.world));
}
}
best
}

57
src/scene/hatch_model.rs Normal file
View file

@ -0,0 +1,57 @@
// HatchModel — CPU-side hatch fill data; rendered entirely on the GPU.
//
// The boundary is a closed polygon in world XY coordinates (max 64 vertices).
// The GPU fragment shader performs point-in-polygon and hatch-line tests so
// no line geometry is ever tessellated on the CPU.
/// 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 coordinates of the boundary polygon vertices (max 64).
pub boundary: 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,
}

View file

@ -0,0 +1,88 @@
// H7CAD hatch pattern catalog — built from `assets/patterns/H7CAD.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::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(),
}
}

339
src/scene/hit_test.rs Normal file
View file

@ -0,0 +1,339 @@
//! CPU-side hit-testing for wire geometry.
//!
//! All tests are performed in **screen space** — wire vertices are projected
//! to 2-D pixel coordinates, then compared against the cursor or selection box.
//! This matches the visual result the user sees.
use std::collections::HashMap;
use acadrust::Handle;
use glam::{Mat4, Vec3};
use iced::{Point, Rectangle};
use super::hatch_model::HatchModel;
use super::wire_model::WireModel;
/// Pixel radius used for single-click wire detection.
const CLICK_THRESHOLD_PX: f32 = 8.0;
// ── Single-click hit test ─────────────────────────────────────────────────
/// Return the `name` of the closest wire whose screen-space segments pass
/// within `CLICK_THRESHOLD_PX` pixels of `cursor`.
///
/// Returns `None` when no wire is close enough.
pub fn click_hit<'a>(
cursor: Point,
wires: &'a [WireModel],
view_proj: Mat4,
bounds: Rectangle,
) -> Option<&'a str> {
let mut best_dist = CLICK_THRESHOLD_PX;
let mut best: Option<&str> = None;
for wire in wires {
let screen: Vec<Point> = wire
.points
.iter()
.map(|&p| world_to_screen(Vec3::from(p), view_proj, bounds))
.collect();
for seg in screen.windows(2) {
let d = dist_point_to_segment(cursor, seg[0], seg[1]);
if d < best_dist {
best_dist = d;
best = Some(&wire.name);
}
}
}
best
}
// ── Box / window selection ────────────────────────────────────────────────
/// Return the names of wires selected by a completed rectangular selection box.
///
/// - **Window mode** (`crossing = false`, left→right drag):
/// ALL projected points must lie inside the box.
/// - **Crossing mode** (`crossing = true`, right→left drag):
/// ANY projected point inside the box is sufficient.
pub fn box_hit<'a>(
corner_a: Point,
corner_b: Point,
crossing: bool,
wires: &'a [WireModel],
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<&'a str> {
let min_x = corner_a.x.min(corner_b.x);
let max_x = corner_a.x.max(corner_b.x);
let min_y = corner_a.y.min(corner_b.y);
let max_y = corner_a.y.max(corner_b.y);
// Ignore zero-area boxes.
if (max_x - min_x) < 1.0 || (max_y - min_y) < 1.0 {
return vec![];
}
let inside = |sp: Point| sp.x >= min_x && sp.x <= max_x && sp.y >= min_y && sp.y <= max_y;
wires
.iter()
.filter_map(|wire| {
if wire.points.is_empty() {
return None;
}
let screen: Vec<Point> = wire
.points
.iter()
.map(|&p| world_to_screen(Vec3::from(p), view_proj, bounds))
.collect();
let hit = if crossing {
screen.iter().any(|&sp| inside(sp))
} else {
screen.iter().all(|&sp| inside(sp))
};
if hit {
Some(wire.name.as_str())
} else {
None
}
})
.collect()
}
// ── Polygon / lasso selection ─────────────────────────────────────────────
/// Return the names of wires selected by a freehand polygon lasso.
///
/// - **Window mode** (`crossing = false`): ALL projected points inside polygon.
/// - **Crossing mode** (`crossing = true`): ANY point inside OR any wire
/// segment crosses a polygon edge.
pub fn poly_hit<'a>(
poly: &[Point],
crossing: bool,
wires: &'a [WireModel],
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<&'a str> {
if poly.len() < 3 {
return vec![];
}
wires
.iter()
.filter_map(|wire| {
if wire.points.is_empty() {
return None;
}
let screen: Vec<Point> = wire
.points
.iter()
.map(|&p| world_to_screen(Vec3::from(p), view_proj, bounds))
.collect();
let hit = if crossing {
// Any vertex inside OR any wire segment crosses any polygon edge.
screen.iter().any(|&sp| point_in_polygon(sp, poly))
|| screen
.windows(2)
.any(|seg| segment_crosses_polygon(seg[0], seg[1], poly))
} else {
screen.iter().all(|&sp| point_in_polygon(sp, poly))
};
if hit {
Some(wire.name.as_str())
} else {
None
}
})
.collect()
}
// ── Helpers ───────────────────────────────────────────────────────────────
fn world_to_screen(world: Vec3, view_proj: Mat4, bounds: Rectangle) -> Point {
let ndc = view_proj.project_point3(world);
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
)
}
/// Even-odd ray-casting test: is `p` inside the polygon?
fn point_in_polygon(p: Point, poly: &[Point]) -> bool {
let n = poly.len();
let mut inside = false;
let (mut xi, mut yi) = (poly[n - 1].x, poly[n - 1].y);
for &pt in poly {
let (xj, yj) = (pt.x, pt.y);
if ((yi > p.y) != (yj > p.y)) && (p.x < (xj - xi) * (p.y - yi) / (yj - yi) + xi) {
inside = !inside;
}
(xi, yi) = (xj, yj);
}
inside
}
/// Does segment `[a, b]` cross any edge of the polygon?
fn segment_crosses_polygon(a: Point, b: Point, poly: &[Point]) -> bool {
let n = poly.len();
for i in 0..n {
let c = poly[i];
let d = poly[(i + 1) % n];
if segments_intersect(a, b, c, d) {
return true;
}
}
false
}
/// Do segments `[a,b]` and `[c,d]` intersect?
fn segments_intersect(a: Point, b: Point, c: Point, d: Point) -> bool {
let cross = |o: Point, p: Point, q: Point| -> f32 {
(p.x - o.x) * (q.y - o.y) - (p.y - o.y) * (q.x - o.x)
};
let d1 = cross(c, d, a);
let d2 = cross(c, d, b);
let d3 = cross(a, b, c);
let d4 = cross(a, b, d);
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0))
&& ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0))
{
return true;
}
false
}
// ── Hatch hit-testing ─────────────────────────────────────────────────────
/// Return the Handle of the first hatch whose screen-space boundary polygon
/// contains `cursor`.
pub fn click_hit_hatch(
cursor: Point,
hatches: &HashMap<Handle, HatchModel>,
view_proj: Mat4,
bounds: Rectangle,
) -> Option<Handle> {
for (&handle, hatch) in hatches {
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x, y, 0.0), view_proj, bounds))
.collect();
if screen.len() >= 3 && point_in_polygon(cursor, &screen) {
return Some(handle);
}
}
None
}
/// Return Handles of hatches selected by a completed rectangular selection box.
pub fn box_hit_hatch(
corner_a: Point,
corner_b: Point,
crossing: bool,
hatches: &HashMap<Handle, HatchModel>,
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<Handle> {
let min_x = corner_a.x.min(corner_b.x);
let max_x = corner_a.x.max(corner_b.x);
let min_y = corner_a.y.min(corner_b.y);
let max_y = corner_a.y.max(corner_b.y);
if (max_x - min_x) < 1.0 || (max_y - min_y) < 1.0 {
return vec![];
}
let inside = |sp: Point| sp.x >= min_x && sp.x <= max_x && sp.y >= min_y && sp.y <= max_y;
hatches
.iter()
.filter_map(|(&handle, hatch)| {
if hatch.boundary.is_empty() {
return None;
}
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x, y, 0.0), view_proj, bounds))
.collect();
let hit = if crossing {
screen.iter().any(|&sp| inside(sp))
} else {
screen.iter().all(|&sp| inside(sp))
};
if hit {
Some(handle)
} else {
None
}
})
.collect()
}
/// Return Handles of hatches selected by a freehand polygon lasso.
pub fn poly_hit_hatch(
poly: &[Point],
crossing: bool,
hatches: &HashMap<Handle, HatchModel>,
view_proj: Mat4,
bounds: Rectangle,
) -> Vec<Handle> {
if poly.len() < 3 {
return vec![];
}
hatches
.iter()
.filter_map(|(&handle, hatch)| {
if hatch.boundary.is_empty() {
return None;
}
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x, y, 0.0), view_proj, bounds))
.collect();
let hit = if crossing {
screen.iter().any(|&sp| point_in_polygon(sp, poly))
|| screen
.windows(2)
.any(|seg| segment_crosses_polygon(seg[0], seg[1], poly))
} else {
screen.iter().all(|&sp| point_in_polygon(sp, poly))
};
if hit {
Some(handle)
} else {
None
}
})
.collect()
}
/// Minimum distance from point `p` to line segment `[a, b]` in 2-D.
fn dist_point_to_segment(p: Point, a: Point, b: Point) -> f32 {
let abx = b.x - a.x;
let aby = b.y - a.y;
let len2 = abx * abx + aby * aby;
let t = if len2 < 1e-6 {
0.0
} else {
let apx = p.x - a.x;
let apy = p.y - a.y;
((apx * abx + apy * aby) / len2).clamp(0.0, 1.0)
};
let cx = a.x + t * abx;
let cy = a.y + t * aby;
let dx = p.x - cx;
let dy = p.y - cy;
(dx * dx + dy * dy).sqrt()
}

28
src/scene/mesh_model.rs Normal file
View file

@ -0,0 +1,28 @@
// 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,
}
impl MeshModel {
#[allow(dead_code)]
pub const WHITE: [f32; 4] = [1.00, 1.00, 1.00, 1.0];
pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0];
}

1323
src/scene/mod.rs Normal file

File diff suppressed because it is too large Load diff

83
src/scene/object.rs Normal file
View file

@ -0,0 +1,83 @@
// 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 ─────────────────────────────────────────────────────────────
/// Describes one grip point for an entity.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GripShape {
Square,
Diamond,
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.
pub world: Vec3,
/// `true` → midpoint grip (diamond, translates whole object).
/// `false` → endpoint grip (square, moves a single vertex).
pub is_midpoint: bool,
/// Visual marker shape for the grip.
pub shape: GripShape,
}
/// 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),
}

View file

@ -0,0 +1,351 @@
// Hatch GPU buffers — renders hatch fills as shader-driven bounding-box quads.
//
// Group 1 bindings per hatch:
// binding 0 — HatchUniformData (64 bytes) mode, color, angle_offset, scale, gradient params
// binding 1 — BoundaryData (1024 bytes) up to 64 boundary polygon vertices
// binding 2 — FamilyBatchData (1296 bytes) up to 16 line families + 128 dash values
use crate::scene::hatch_model::{HatchModel, HatchPattern, PatFamily};
use iced::wgpu;
use iced::wgpu::util::DeviceExt;
// ── Constants ──────────────────────────────────────────────────────────────
pub const MAX_FAMILIES: usize = 16;
pub const MAX_DASHES: usize = 128;
// ── Vertex ────────────────────────────────────────────────────────────────
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct HatchVertex {
pub pos: [f32; 3],
pub _pad: f32,
}
impl HatchVertex {
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<HatchVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
}],
}
}
}
// ── Uniform structs ───────────────────────────────────────────────────────
/// Per-hatch parameters (binding 0). Must be 64 bytes.
///
/// `mode` encoding:
/// 0 → Pattern (families in FamilyBatchData)
/// 1 → Solid
/// 2 → Gradient (grad_cos/sin/min/range used)
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct HatchUniformData {
pub color: [f32; 4], // 0: primary RGBA / gradient start
pub color2: [f32; 4], // 16: gradient end color
pub mode: u32, // 32: 0=pattern, 1=solid, 2=gradient
pub vertex_count: u32, // 36: boundary vertex count
pub angle_offset: f32, // 40: pattern rotation (radians)
pub scale: f32, // 44: pattern scale multiplier
pub grad_cos: f32, // 48: gradient direction cos
pub grad_sin: f32, // 52: gradient direction sin
pub grad_min: f32, // 56: gradient proj_min
pub grad_range: f32, // 60: gradient proj_range
} // total 64 bytes
/// Boundary polygon (binding 1). Matches WGSL `Boundary`.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct BoundaryData {
pub verts: [[f32; 4]; 64], // world XY in .xy, .zw unused
}
/// One line family packed for the shader (48 bytes).
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct LineFamilyGpu {
pub cos_a: f32, // cos(angle_rad)
pub sin_a: f32, // sin(angle_rad)
pub x0: f32, // family origin x
pub y0: f32, // family origin y
pub dx: f32, // step vector x
pub dy: f32, // step vector y
pub perp_step: f32, // -dx*sin_a + dy*cos_a (perpendicular spacing)
pub along_step: f32, // dx*cos_a + dy*sin_a (along-line phase shift per step)
pub line_width: f32, // |perp_step| * 0.08
pub period: f32, // sum of |dash values| (0 = solid)
pub n_dashes: u32, // number of dash entries (0 = solid)
pub dash_off: u32, // index into FamilyBatchData::dash_values
}
/// All line families + dash values for one hatch (binding 2).
/// Total size: 16×48 + 32×16 + 4×4 = 768 + 512 + 16 = 1296 bytes.
/// dash_values is [f32; 4]×32 so each element is vec4<f32> (16-byte stride, valid in uniform).
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct FamilyBatchData {
pub families: [LineFamilyGpu; MAX_FAMILIES], // 768 bytes
pub dash_values: [[f32; 4]; 32], // 512 bytes (128 f32s as 32×vec4)
pub n_families: u32, // 4 bytes
pub _pad: [u32; 3], // 12 bytes
} // 1296 bytes total
// ── Per-hatch GPU handle ───────────────────────────────────────────────────
pub struct HatchGpu {
pub vertex_buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
_uniform_buf: wgpu::Buffer,
_boundary_buf: wgpu::Buffer,
_family_buf: wgpu::Buffer,
}
impl HatchGpu {
pub fn new(device: &wgpu::Device, model: &HatchModel, bgl1: &wgpu::BindGroupLayout) -> Self {
// ── Decode pattern mode ──────────────────────────────────────────
let (mode, color2, grad_cos, grad_sin) = match &model.pattern {
HatchPattern::Solid => (1u32, [0.0f32; 4], 0.0f32, 0.0f32),
HatchPattern::Pattern(_) => (0u32, [0.0f32; 4], 0.0f32, 0.0f32),
HatchPattern::Gradient { angle_deg, color2 } => {
let r = angle_deg.to_radians();
(2u32, *color2, r.cos(), r.sin())
}
};
// ── Bounding box ─────────────────────────────────────────────────
let min_x = model
.boundary
.iter()
.map(|v| v[0])
.fold(f32::INFINITY, f32::min);
let max_x = model
.boundary
.iter()
.map(|v| v[0])
.fold(f32::NEG_INFINITY, f32::max);
let min_y = model
.boundary
.iter()
.map(|v| v[1])
.fold(f32::INFINITY, f32::min);
let max_y = model
.boundary
.iter()
.map(|v| v[1])
.fold(f32::NEG_INFINITY, f32::max);
let max_spacing = match &model.pattern {
HatchPattern::Pattern(families) => families
.iter()
.map(|f| perp_spacing(f).abs())
.fold(0.0f32, f32::max),
_ => 5.0,
};
let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
let pad = (diag * 0.8 + max_spacing * 2.0 * model.scale).max(1.0);
let (x0, x1, y0, y1) = (min_x - pad, max_x + pad, min_y - pad, max_y + pad);
let quad = [
HatchVertex {
pos: [x0, y0, 0.0],
_pad: 0.0,
},
HatchVertex {
pos: [x1, y0, 0.0],
_pad: 0.0,
},
HatchVertex {
pos: [x1, y1, 0.0],
_pad: 0.0,
},
HatchVertex {
pos: [x0, y0, 0.0],
_pad: 0.0,
},
HatchVertex {
pos: [x1, y1, 0.0],
_pad: 0.0,
},
HatchVertex {
pos: [x0, y1, 0.0],
_pad: 0.0,
},
];
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.vbuf"),
contents: bytemuck::cast_slice(&quad),
usage: wgpu::BufferUsages::VERTEX,
});
// ── Gradient: projection range ───────────────────────────────────
let (grad_min, grad_range) = if mode == 2 {
let projs: Vec<f32> = model
.boundary
.iter()
.map(|&[x, y]| x * grad_cos + y * grad_sin)
.collect();
let proj_min = projs.iter().cloned().fold(f32::INFINITY, f32::min);
let proj_max = projs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
(proj_min, (proj_max - proj_min).max(1e-4))
} else {
(0.0, 1.0)
};
// ── HatchUniformData ─────────────────────────────────────────────
let n = model.boundary.len().min(64);
let uniform_data = HatchUniformData {
color: model.color,
color2,
mode,
vertex_count: n as u32,
angle_offset: model.angle_offset,
scale: model.scale,
grad_cos,
grad_sin,
grad_min,
grad_range,
};
// ── BoundaryData ─────────────────────────────────────────────────
let mut boundary_data = BoundaryData {
verts: [[0.0; 4]; 64],
};
for (i, &[x, y]) in model.boundary.iter().take(64).enumerate() {
boundary_data.verts[i] = [x, y, 0.0, 0.0];
}
// ── FamilyBatchData ───────────────────────────────────────────────
let family_batch = build_family_batch(&model.pattern);
// ── GPU buffers ───────────────────────────────────────────────────
let _uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.uniforms"),
contents: bytemuck::bytes_of(&uniform_data),
usage: wgpu::BufferUsages::UNIFORM,
});
let _boundary_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.boundary"),
contents: bytemuck::bytes_of(&boundary_data),
usage: wgpu::BufferUsages::UNIFORM,
});
let _family_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.families"),
contents: bytemuck::bytes_of(&family_batch),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("hatch.bind_group1"),
layout: bgl1,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: _uniform_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: _boundary_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: _family_buf.as_entire_binding(),
},
],
});
Self {
vertex_buffer,
bind_group,
_uniform_buf,
_boundary_buf,
_family_buf,
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// Perpendicular spacing between adjacent parallel lines.
/// QCAD PAT format stores (dx, dy) in line-local coordinates: dy = perpendicular, dx = along-line.
fn perp_spacing(f: &PatFamily) -> f32 {
f.dy
}
/// Pack pattern families into the GPU batch struct.
fn build_family_batch(pattern: &HatchPattern) -> FamilyBatchData {
let mut batch = FamilyBatchData {
families: [LineFamilyGpu {
cos_a: 0.0,
sin_a: 0.0,
x0: 0.0,
y0: 0.0,
dx: 0.0,
dy: 0.0,
perp_step: 0.0,
along_step: 0.0,
line_width: 0.0,
period: 0.0,
n_dashes: 0,
dash_off: 0,
}; MAX_FAMILIES],
dash_values: [[0.0; 4]; 32],
n_families: 0,
_pad: [0; 3],
};
let HatchPattern::Pattern(families) = pattern else {
return batch;
};
let mut dash_cursor: usize = 0;
for (fi, family) in families.iter().take(MAX_FAMILIES).enumerate() {
let angle_r = family.angle_deg.to_radians();
let cos_a = angle_r.cos();
let sin_a = angle_r.sin();
// QCAD PAT local-frame convention: dy = perpendicular spacing, dx = along-line phase shift.
let perp_step = family.dy;
let along_step = family.dx;
let line_width = 0.0_f32; // unused: shader uses screen-space derivative for 1px lines
// Dash pattern: collect up to available space.
let n_avail = MAX_DASHES.saturating_sub(dash_cursor);
let n_dashes = family.dashes.len().min(n_avail);
let period: f32 = family.dashes[..n_dashes].iter().map(|d| d.abs()).sum();
let dash_off = dash_cursor as u32;
for &d in &family.dashes[..n_dashes] {
batch.dash_values[dash_cursor / 4][dash_cursor % 4] = d;
dash_cursor += 1;
}
batch.families[fi] = LineFamilyGpu {
cos_a,
sin_a,
x0: family.x0,
y0: family.y0,
dx: family.dx,
dy: family.dy,
perp_step,
along_step,
line_width,
period: if n_dashes > 0 { period } else { 0.0 },
n_dashes: n_dashes as u32,
dash_off,
};
batch.n_families += 1;
if dash_cursor >= MAX_DASHES {
break;
}
}
batch
}

View file

@ -0,0 +1,97 @@
// Mesh GPU buffers — TriangleList rendering for solid objects.
//
// Vertex layout (40 bytes):
// position [f32; 3] offset 0 12 B
// normal [f32; 3] offset 12 12 B
// color [f32; 4] offset 24 16 B
// ------
// 40 B / vertex
use crate::scene::mesh_model::MeshModel;
use iced::wgpu;
use iced::wgpu::util::DeviceExt;
// ── Vertex layout ─────────────────────────────────────────────────────────
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MeshVertex {
pub position: [f32; 3],
pub normal: [f32; 3],
pub color: [f32; 4],
}
impl MeshVertex {
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<MeshVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
},
wgpu::VertexAttribute {
offset: 12,
shader_location: 1,
format: wgpu::VertexFormat::Float32x3,
},
wgpu::VertexAttribute {
offset: 24,
shader_location: 2,
format: wgpu::VertexFormat::Float32x4,
},
],
}
}
}
// ── GPU handle ────────────────────────────────────────────────────────────
pub struct MeshGpu {
pub vertex_buffer: wgpu::Buffer,
pub index_buffer: wgpu::Buffer,
pub index_count: u32,
}
impl MeshGpu {
pub fn new(device: &wgpu::Device, mesh: &MeshModel) -> Self {
let has_normals = mesh.normals.len() == mesh.verts.len();
let vertices: Vec<MeshVertex> = mesh
.verts
.iter()
.enumerate()
.map(|(i, &pos)| {
let normal = if has_normals {
mesh.normals[i]
} else {
[0.0, 1.0, 0.0]
};
MeshVertex {
position: pos,
normal,
color: mesh.color,
}
})
.collect();
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("mesh.vbuf.{}", mesh.name)),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("mesh.ibuf.{}", mesh.name)),
contents: bytemuck::cast_slice(&mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
Self {
vertex_buffer,
index_buffer,
index_count: mesh.indices.len() as u32,
}
}
}

458
src/scene/pipeline/mod.rs Normal file
View file

@ -0,0 +1,458 @@
pub mod hatch_gpu;
pub mod mesh_gpu;
pub mod uniforms;
pub mod viewcube;
pub mod wire_gpu;
use iced::wgpu;
use iced::{Rectangle, Size};
pub use hatch_gpu::HatchGpu;
pub use mesh_gpu::MeshGpu;
pub use uniforms::Uniforms;
pub use viewcube::ViewCubePipeline;
pub use wire_gpu::WireGpu;
use crate::scene::hatch_model::HatchModel;
use crate::scene::mesh_model::MeshModel;
use crate::scene::wire_model::WireModel;
pub struct Pipeline {
wire_pipeline: wgpu::RenderPipeline,
hatch_pipeline: wgpu::RenderPipeline,
mesh_pipeline: wgpu::RenderPipeline,
uniform_buffer: wgpu::Buffer,
uniform_bind_group: wgpu::BindGroup,
hatch_bgl1: wgpu::BindGroupLayout,
depth_texture_size: Size<u32>,
depth_view: wgpu::TextureView,
gpu_wires: Vec<WireGpu>,
gpu_hatches: Vec<HatchGpu>,
gpu_meshes: Vec<MeshGpu>,
pub viewcube: ViewCubePipeline,
}
impl Pipeline {
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
// ── Shared frame uniform buffer (view_proj etc.) ───────────────────
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("viewer.uniform_buffer"),
size: std::mem::size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Bind group layout 0 — shared by wire and hatch pipelines.
let frame_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("viewer.frame_bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("viewer.bind_group"),
layout: &frame_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
}],
});
// ── Wire pipeline ──────────────────────────────────────────────────
let wire_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("wire.pipeline_layout"),
bind_group_layouts: &[&frame_bgl],
push_constant_ranges: &[],
});
let depth_tex = create_depth_texture(device, Size::new(1, 1));
let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
let wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("wire.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/wire.wgsl"
))),
});
let wire_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("wire.pipeline"),
layout: Some(&wire_layout),
vertex: wgpu::VertexState {
module: &wire_shader,
entry_point: Some("vs_main"),
buffers: &[wire_gpu::WireVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &wire_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// ── Hatch pipeline ─────────────────────────────────────────────────
let hatch_entry = |binding: u32| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let hatch_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("hatch.bgl1"),
entries: &[hatch_entry(0), hatch_entry(1), hatch_entry(2)],
});
let hatch_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("hatch.pipeline_layout"),
bind_group_layouts: &[&frame_bgl, &hatch_bgl1],
push_constant_ranges: &[],
});
let hatch_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("hatch.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/hatch.wgsl"
))),
});
let hatch_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("hatch.pipeline"),
layout: Some(&hatch_layout),
vertex: wgpu::VertexState {
module: &hatch_shader,
entry_point: Some("vs_main"),
buffers: &[hatch_gpu::HatchVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &hatch_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// ── Mesh pipeline ──────────────────────────────────────────────────
let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mesh.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/mesh.wgsl"
))),
});
let mesh_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("mesh.pipeline_layout"),
bind_group_layouts: &[&frame_bgl],
push_constant_ranges: &[],
});
let mesh_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mesh.pipeline"),
layout: Some(&mesh_layout),
vertex: wgpu::VertexState {
module: &mesh_shader,
entry_point: Some("vs_main"),
buffers: &[mesh_gpu::MeshVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &mesh_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
let viewcube = ViewCubePipeline::new(device, queue, format);
Self {
wire_pipeline,
hatch_pipeline,
mesh_pipeline,
uniform_buffer,
uniform_bind_group,
hatch_bgl1,
depth_texture_size: Size::new(1, 1),
depth_view,
gpu_wires: vec![],
gpu_hatches: vec![],
gpu_meshes: vec![],
viewcube,
}
}
pub fn upload_wires(&mut self, device: &wgpu::Device, wires: &[WireModel]) {
self.gpu_wires = wires.iter().map(|w| WireGpu::new(device, w)).collect();
}
pub fn upload_meshes(&mut self, device: &wgpu::Device, meshes: &[MeshModel]) {
self.gpu_meshes = meshes
.iter()
.filter(|m| !m.indices.is_empty())
.map(|m| MeshGpu::new(device, m))
.collect();
}
pub fn upload_hatches(&mut self, device: &wgpu::Device, hatches: &[HatchModel]) {
self.gpu_hatches = hatches
.iter()
.filter(|h| h.boundary.len() >= 3)
.map(|h| HatchGpu::new(device, h, &self.hatch_bgl1))
.collect();
}
pub fn upload_uniforms(&self, queue: &wgpu::Queue, uniforms: &Uniforms) {
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms));
}
pub fn render(
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
clip_bounds: Rectangle<u32>,
) {
let vp = clip_bounds;
// ── Pass 1: hatch fills ────────────────────────────────────────────
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("hatch.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(
vp.x as f32,
vp.y as f32,
vp.width as f32,
vp.height as f32,
0.0,
1.0,
);
if !self.gpu_hatches.is_empty() {
pass.set_pipeline(&self.hatch_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for hatch in &self.gpu_hatches {
pass.set_bind_group(1, &hatch.bind_group, &[]);
pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..));
pass.draw(0..6, 0..1);
}
}
}
// ── Pass 2: solid meshes ───────────────────────────────────────────
if !self.gpu_meshes.is_empty() {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mesh.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(
vp.x as f32,
vp.y as f32,
vp.width as f32,
vp.height as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.mesh_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for mesh in &self.gpu_meshes {
if mesh.index_count > 0 {
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..mesh.index_count, 0, 0..1);
}
}
}
// ── Pass 3: wires ──────────────────────────────────────────────────
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("wire.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(
vp.x as f32,
vp.y as f32,
vp.width as f32,
vp.height as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.wire_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for wire in &self.gpu_wires {
if wire.vertex_count >= 6 {
pass.set_vertex_buffer(0, wire.vertex_buffer.slice(..));
pass.draw(0..wire.vertex_count, 0..1);
}
}
}
}
pub fn ensure_depth_texture(&mut self, device: &wgpu::Device, size: Size<u32>) {
if self.depth_texture_size != size {
let tex = create_depth_texture(device, size);
self.depth_view = tex.create_view(&wgpu::TextureViewDescriptor::default());
self.depth_texture_size = size;
}
}
}
fn create_depth_texture(device: &wgpu::Device, size: Size<u32>) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer.depth_texture"),
size: wgpu::Extent3d {
width: size.width.max(1),
height: size.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
}
impl iced::widget::shader::Pipeline for Pipeline {
fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
Self::new(device, queue, format)
}
}

View file

@ -0,0 +1,22 @@
use crate::scene::camera::Camera;
use iced::Rectangle;
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct Uniforms {
pub view_proj: glam::Mat4,
pub camera_pos: glam::Vec4,
pub viewport_size: [f32; 2],
pub _pad: [f32; 2],
}
impl Uniforms {
pub fn new(camera: &Camera, bounds: Rectangle) -> Self {
Self {
view_proj: camera.view_proj(bounds),
camera_pos: camera.position_vec4(),
viewport_size: [bounds.width, bounds.height],
_pad: [0.0; 2],
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,212 @@
// Wire GPU buffers — quad (TriangleList) rendering for thick lines.
//
// Each segment [A→B] emits 6 vertices (2 triangles). Every vertex carries
// both endpoints so the vertex shader can compute the screen-space
// perpendicular direction and expand the quad to the correct pixel width.
//
// NaN sentinel: text glyphs pack multiple disconnected strokes into one
// WireModel, separated by [NaN, NaN, NaN] points. Segments where either
// endpoint contains NaN are silently skipped during upload.
//
// Vertex layout (96 bytes, stride = 96):
// pos_a [f32; 3] offset 0 12 B — segment start (world)
// pos_b [f32; 3] offset 12 12 B — segment end (world)
// which_end f32 offset 24 4 B — 0.0 = A end, 1.0 = B end
// side f32 offset 28 4 B — ±1.0 perpendicular side
// color [f32; 4] offset 32 16 B — RGBA [0,1]
// distance f32 offset 48 4 B — arc-length from wire start
// half_width f32 offset 52 4 B — half line width in pixels
// pattern_length f32 offset 56 4 B — dash pattern total length
// _pad f32 offset 60 4 B
// pat0 [f32; 4] offset 64 16 B — pattern elements 0-3
// pat1 [f32; 4] offset 80 16 B — pattern elements 4-7
// ------
// 96 B / vertex
use crate::scene::wire_model::WireModel;
use iced::wgpu;
use iced::wgpu::util::DeviceExt;
// ── Vertex layout ─────────────────────────────────────────────────────────
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct WireVertex {
pub pos_a: [f32; 3],
pub pos_b: [f32; 3],
pub which_end: f32,
pub side: f32,
pub color: [f32; 4],
pub distance: f32,
pub half_width: f32,
pub pattern_length: f32,
pub _pad: f32,
pub pat0: [f32; 4],
pub pat1: [f32; 4],
}
impl WireVertex {
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<WireVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
}, // pos_a
wgpu::VertexAttribute {
offset: 12,
shader_location: 1,
format: wgpu::VertexFormat::Float32x3,
}, // pos_b
wgpu::VertexAttribute {
offset: 24,
shader_location: 2,
format: wgpu::VertexFormat::Float32,
}, // which_end
wgpu::VertexAttribute {
offset: 28,
shader_location: 3,
format: wgpu::VertexFormat::Float32,
}, // side
wgpu::VertexAttribute {
offset: 32,
shader_location: 4,
format: wgpu::VertexFormat::Float32x4,
}, // color
wgpu::VertexAttribute {
offset: 48,
shader_location: 5,
format: wgpu::VertexFormat::Float32,
}, // distance
wgpu::VertexAttribute {
offset: 52,
shader_location: 6,
format: wgpu::VertexFormat::Float32,
}, // half_width
wgpu::VertexAttribute {
offset: 56,
shader_location: 7,
format: wgpu::VertexFormat::Float32,
}, // pattern_length
wgpu::VertexAttribute {
offset: 64,
shader_location: 8,
format: wgpu::VertexFormat::Float32x4,
}, // pat0
wgpu::VertexAttribute {
offset: 80,
shader_location: 9,
format: wgpu::VertexFormat::Float32x4,
}, // pat1
],
}
}
}
// ── GPU handle ────────────────────────────────────────────────────────────
pub struct WireGpu {
pub vertex_buffer: wgpu::Buffer,
pub vertex_count: u32,
}
impl WireGpu {
pub fn new(device: &wgpu::Device, wire: &WireModel) -> Self {
let pat0 = [
wire.pattern[0],
wire.pattern[1],
wire.pattern[2],
wire.pattern[3],
];
let pat1 = [
wire.pattern[4],
wire.pattern[5],
wire.pattern[6],
wire.pattern[7],
];
let half_width = wire.line_weight_px * 0.5;
let n = wire.points.len();
let seg_count = if n >= 2 { n - 1 } else { 0 };
let mut vertices: Vec<WireVertex> = Vec::with_capacity(seg_count * 6);
// Precompute cumulative arc-length at each point.
// NaN points do not contribute to arc length — they are separator sentinels.
let mut dists = vec![0.0_f32; n];
for i in 1..n {
let p = wire.points[i - 1];
let q = wire.points[i];
// If either point is a NaN sentinel, keep the same distance
// (the segment will be skipped anyway).
if p[0].is_nan() || q[0].is_nan() {
dists[i] = dists[i - 1];
} else {
let dx = q[0] - p[0];
let dy = q[1] - p[1];
let dz = q[2] - p[2];
dists[i] = dists[i - 1] + (dx * dx + dy * dy + dz * dz).sqrt();
}
}
for i in 0..seg_count {
let a = wire.points[i];
let b = wire.points[i + 1];
// Skip any segment that involves a NaN sentinel point.
// NaN sentinels are inserted between disconnected glyph strokes
// by tessellate.rs so that multiple strokes can share one WireModel.
if a[0].is_nan()
|| a[1].is_nan()
|| a[2].is_nan()
|| b[0].is_nan()
|| b[1].is_nan()
|| b[2].is_nan()
{
continue;
}
let dist_a = dists[i];
let dist_b = dists[i + 1];
let make = |which_end: f32, side: f32| -> WireVertex {
let dist = if which_end < 0.5 { dist_a } else { dist_b };
WireVertex {
pos_a: a,
pos_b: b,
which_end,
side,
color: wire.color,
distance: dist,
half_width,
pattern_length: wire.pattern_length,
_pad: 0.0,
pat0,
pat1,
}
};
// Triangle 1: A(-1), B(-1), B(+1)
vertices.push(make(0.0, -1.0));
vertices.push(make(1.0, -1.0));
vertices.push(make(1.0, 1.0));
// Triangle 2: A(-1), B(+1), A(+1)
vertices.push(make(0.0, -1.0));
vertices.push(make(1.0, 1.0));
vertices.push(make(0.0, 1.0));
}
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("wire.vbuf.{}", wire.name)),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
Self {
vertex_buffer,
vertex_count: vertices.len() as u32,
}
}
}

89
src/scene/properties.rs Normal file
View file

@ -0,0 +1,89 @@
use acadrust::{EntityType, Handle};
use crate::scene::object::{PropSection, PropValue, Property};
pub fn general_section(entity: &EntityType) -> PropSection {
let common = entity.common();
let linetype_display = if common.linetype.is_empty() {
"ByLayer".to_string()
} else {
common.linetype.clone()
};
let transp_pct = (common.transparency.alpha() as f64 / 255.0 * 100.0).round() as u32;
PropSection {
title: "General".into(),
props: vec![
Property {
label: "Layer".into(),
field: "layer",
value: PropValue::LayerChoice(common.layer.clone()),
},
Property {
label: "Color".into(),
field: "color",
value: PropValue::ColorChoice(common.color),
},
Property {
label: "Linetype".into(),
field: "linetype",
value: PropValue::LinetypeChoice(linetype_display),
},
Property {
label: "LT Scale".into(),
field: "linetype_scale",
value: PropValue::EditText(format!("{:.4}", common.linetype_scale)),
},
Property {
label: "Lineweight".into(),
field: "lineweight",
value: PropValue::LwChoice(common.line_weight),
},
Property {
label: "Transparency".into(),
field: "transparency",
value: PropValue::EditText(format!("{transp_pct}")),
},
Property {
label: "Invisible".into(),
field: "invisible",
value: PropValue::BoolToggle {
field: "invisible",
value: common.invisible,
},
},
],
}
}
pub fn fallback_properties(_handle: Handle, entity: &EntityType) -> PropSection {
PropSection {
title: "Geometry".into(),
props: vec![Property {
label: "Type".into(),
field: "type",
value: PropValue::ReadOnly(entity_type_name(entity).into()),
}],
}
}
fn entity_type_name(e: &EntityType) -> &'static str {
match e {
EntityType::Line(_) => "Line",
EntityType::Circle(_) => "Circle",
EntityType::Arc(_) => "Arc",
EntityType::Ellipse(_) => "Ellipse",
EntityType::Spline(_) => "Spline",
EntityType::LwPolyline(_) => "LwPolyline",
EntityType::Text(_) => "Text",
EntityType::MText(_) => "MText",
EntityType::Dimension(_) => "Dimension",
EntityType::Insert(_) => "Insert",
EntityType::Solid3D(_) => "Solid3D",
EntityType::Point(_) => "Point",
EntityType::Hatch(_) => "Hatch",
EntityType::Leader(_) => "Leader",
EntityType::MultiLeader(_) => "MultiLeader",
_ => "Entity",
}
}

784
src/scene/tessellate.rs Normal file
View file

@ -0,0 +1,784 @@
// Tessellation — convert acadrust EntityType to GPU-ready WireModel or MeshModel.
//
// Flow:
// EntityType
// ↓ acad_to_truck::convert()
// TruckEntity { object: TruckObject, snap_pts, tangent_geoms, key_vertices }
// ↓ truck_tess::tessellate_*()
// TruckTessResult::Lines → WireModel
// TruckTessResult::Point → WireModel (small cross)
// TruckTessResult::Mesh → MeshModel
// TruckObject::Text → one WireModel per glyph stroke (elevation from entity Z)
//
// Entities not handled by acad_to_truck (Viewport, Hatch, …) are tessellated
// by the legacy geometry() path so nothing regresses.
use acadrust::entities::{Dimension, Leader, MultiLeader, MultiLeaderPathType, Text};
use acadrust::types::{Color as AcadColor, Vector3};
use acadrust::{CadDocument, EntityType, Handle};
use glam::Vec3;
use crate::scene::acad_to_truck::{convert, TruckObject};
use crate::scene::mesh_model::MeshModel;
use crate::scene::truck_tess::{
self, tessellate_edge, tessellate_solid, tessellate_vertex, tessellate_wire, TruckTessResult,
};
use crate::scene::wire_model::{SnapHint, TangentGeom, WireModel};
// ── Colour helper ──────────────────────────────────────────────────────────
/// Convert an acadrust Color (ACI index or true-color) to a GPU RGBA value.
pub fn aci_to_rgba(color: &AcadColor) -> [f32; 4] {
if let Some((r, g, b)) = color.rgb() {
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
} else {
WireModel::WHITE
}
}
// ── Public entry points ────────────────────────────────────────────────────
/// Tessellate one entity into a WireModel.
/// For Text/MText entities this produces one WireModel with all glyph strokes
/// encoded as NaN-separated segments (wire_gpu skips NaN pairs).
/// For Solid3D entities this returns an empty wire; use `tessellate_mesh` instead.
pub fn tessellate(
document: &CadDocument,
handle: Handle,
entity: &EntityType,
selected: bool,
entity_color: [f32; 4],
pattern_length: f32,
pattern: [f32; 8],
line_weight_px: f32,
) -> WireModel {
let color = if selected {
WireModel::SELECTED
} else {
entity_color
};
let name = handle.value().to_string();
// ── Try the truck path first ───────────────────────────────────────────
if let Some(te) = convert(entity, document) {
match te.object {
// ── Text / MText: pre-tessellated glyph strokes ───────────────
TruckObject::Text(strokes_2d) => {
// Elevation comes from the entity's Z coordinate.
let elev = entity_z(entity);
// Pack all strokes into one flat point list, separated by
// NaN sentinels so wire_gpu.rs skips disconnected segments.
let mut points: Vec<[f32; 3]> = Vec::new();
for (i, stroke) in strokes_2d.iter().enumerate() {
if stroke.len() < 2 {
continue;
}
if i > 0 && !points.is_empty() {
// NaN sentinel — wire_gpu skips any segment where
// either endpoint contains NaN.
points.push([f32::NAN, f32::NAN, f32::NAN]);
}
for &[x, y] in stroke {
points.push([x, y, elev]);
}
}
return WireModel {
name,
points,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: te.snap_pts,
tangent_geoms: te.tangent_geoms,
key_vertices: te.key_vertices,
};
}
// ── Standard topology objects ─────────────────────────────────
TruckObject::Point(v) => {
let result = tessellate_vertex(&v);
match result {
TruckTessResult::Point([x, y, z]) => {
let s = 0.1_f32;
return WireModel {
name,
points: vec![
[x - s, y, z],
[x + s, y, z],
[x, y - s, z],
[x, y + s, z],
],
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: te.snap_pts,
tangent_geoms: te.tangent_geoms,
key_vertices: te.key_vertices,
};
}
_ => {}
}
}
TruckObject::Curve(e) => {
if let TruckTessResult::Lines(points) = tessellate_edge(&e) {
return WireModel {
name,
points,
color,
selected,
pattern_length,
pattern,
line_weight_px,
snap_pts: te.snap_pts,
tangent_geoms: te.tangent_geoms,
key_vertices: te.key_vertices,
};
}
}
TruckObject::Contour(w) => {
if let TruckTessResult::Lines(points) = tessellate_wire(&w) {
return WireModel {
name,
points,
color,
selected,
pattern_length,
pattern,
line_weight_px,
snap_pts: te.snap_pts,
tangent_geoms: te.tangent_geoms,
key_vertices: te.key_vertices,
};
}
}
TruckObject::Lines(points) => {
return WireModel {
name,
points,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: te.snap_pts,
tangent_geoms: te.tangent_geoms,
key_vertices: te.key_vertices,
};
}
TruckObject::Volume(_) => {
// Solid3D → handled by tessellate_mesh(), not WireModel.
return WireModel::solid(name, vec![], color, selected);
}
}
}
// ── Legacy fallback for Viewport and other unhandled types ────────────
let (points, snap_pts, tangent_geoms, key_vertices) = legacy_geometry(entity);
WireModel {
name,
points,
color,
selected,
pattern_length,
pattern,
line_weight_px,
snap_pts,
tangent_geoms,
key_vertices,
}
}
pub fn tessellate_dimension(
document: &CadDocument,
handle: Handle,
dim: &Dimension,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
) -> Vec<WireModel> {
let color = if selected {
WireModel::SELECTED
} else {
entity_color
};
let name = handle.value().to_string();
let points = dimension_geometry(dim);
let key_vertices = points
.iter()
.copied()
.filter(|p| !(p[0].is_nan() || p[1].is_nan() || p[2].is_nan()))
.collect();
let mut wires = vec![WireModel {
name: name.clone(),
points,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices,
}];
if let Some(text) = dimension_text_entity(dim) {
let mut wire = tessellate(
document,
handle,
&EntityType::Text(text),
selected,
entity_color,
0.0,
[0.0; 8],
line_weight_px,
);
wire.name = name;
wires.push(wire);
}
wires
}
/// Kept for backwards compatibility — geometry now lives in entities/leader.rs.
#[allow(dead_code)]
fn tessellate_leader(
handle: Handle,
leader: &Leader,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
) -> Vec<WireModel> {
let color = if selected { WireModel::SELECTED } else { entity_color };
let name = handle.value().to_string();
let verts = &leader.vertices;
if verts.len() < 2 {
return vec![WireModel {
name,
points: vec![],
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
}];
}
let to_f32 = |v: &Vector3| -> [f32; 3] { [v.x as f32, v.y as f32, v.z as f32] };
let nan = [f32::NAN; 3];
// Main path
let mut points: Vec<[f32; 3]> = verts.iter().map(to_f32).collect();
// Arrowhead at vertex[0] — only when arrow_enabled
if leader.arrow_enabled {
let tip = verts[0];
let next = verts[1];
let dx = (next.x - tip.x) as f32;
let dy = (next.y - tip.y) as f32;
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
let (dx, dy) = (dx / len, dy / len);
let arrow_size = (leader.text_height as f32).max(1.0) * 0.8;
let angle = std::f32::consts::PI / 6.0;
let (s, c) = angle.sin_cos();
let wing1 = [
tip.x as f32 + (dx * c - dy * s) * arrow_size,
tip.y as f32 + (dx * s + dy * c) * arrow_size,
tip.z as f32,
];
let wing2 = [
tip.x as f32 + (dx * c + dy * s) * arrow_size,
tip.y as f32 + (-dx * s + dy * c) * arrow_size,
tip.z as f32,
];
points.push(nan);
points.push(wing1);
points.push(to_f32(&tip));
points.push(wing2);
}
// Landing line at last vertex
if leader.hookline_enabled {
let last = *verts.last().unwrap();
let prev = verts[verts.len() - 2];
let last_dir_x = (last.x - prev.x) as f32;
let sign = if last_dir_x >= 0.0 { 1.0_f32 } else { -1.0_f32 };
let landing_len = leader.text_height as f32 * 1.5;
let landing_pt = [
last.x as f32 + sign * landing_len,
last.y as f32,
last.z as f32,
];
points.push(nan);
points.push(to_f32(&last));
points.push(landing_pt);
}
let key_vertices: Vec<[f32; 3]> = verts.iter().map(to_f32).collect();
vec![WireModel {
name,
points,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices,
}]
}
/// Kept for backwards compatibility — geometry now lives in entities/multileader.rs.
#[allow(dead_code)]
fn tessellate_multileader(
document: &CadDocument,
handle: Handle,
ml: &MultiLeader,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
) -> Vec<WireModel> {
let color = if selected { WireModel::SELECTED } else { entity_color };
let name = handle.value().to_string();
let nan = [f32::NAN; 3];
let to_f32 = |v: &acadrust::types::Vector3| -> [f32; 3] {
[v.x as f32, v.y as f32, v.z as f32]
};
let arrow_size = ml.arrowhead_size as f32;
let draw_arrow = arrow_size > 0.0;
let invisible = ml.path_type == MultiLeaderPathType::Invisible;
let mut points: Vec<[f32; 3]> = Vec::new();
let mut key_verts: Vec<[f32; 3]> = Vec::new();
let mut first_segment = true;
for root in &ml.context.leader_roots {
let cp = &root.connection_point;
let cp_f = to_f32(cp);
for line in &root.lines {
if line.points.is_empty() { continue; }
// Leader line segments (hidden when path_type = Invisible)
if !invisible {
if !first_segment { points.push(nan); }
first_segment = false;
for p in &line.points {
points.push(to_f32(p));
key_verts.push(to_f32(p));
}
// Closing segment: last bend point → connection_point
let last = line.points.last().unwrap();
let last_f = to_f32(last);
let dist = ((last_f[0]-cp_f[0]).powi(2) + (last_f[1]-cp_f[1]).powi(2)).sqrt();
if dist > 1e-9 {
points.push(cp_f);
key_verts.push(cp_f);
}
}
// Arrowhead — only when arrowhead_size > 0
if draw_arrow {
let tip = line.points[0];
let tip_f = to_f32(&tip);
let next_dir = if line.points.len() >= 2 { line.points[1] } else { *cp };
let dx = (next_dir.x - tip.x) as f32;
let dy = (next_dir.y - tip.y) as f32;
let dlen = (dx * dx + dy * dy).sqrt().max(1e-9);
let (dx, dy) = (dx / dlen, dy / dlen);
let angle = std::f32::consts::PI / 6.0;
let (s, c) = angle.sin_cos();
let w1 = [tip_f[0] + (dx*c - dy*s)*arrow_size,
tip_f[1] + (dx*s + dy*c)*arrow_size, tip_f[2]];
let w2 = [tip_f[0] + (dx*c + dy*s)*arrow_size,
tip_f[1] + (-dx*s + dy*c)*arrow_size, tip_f[2]];
points.push(nan);
points.push(w1);
points.push(tip_f);
points.push(w2);
}
}
// Short landing shelf at connection_point — respects enable_landing and enable_dogleg
if ml.enable_landing && ml.enable_dogleg && ml.dogleg_length > 0.0 {
let dir = &root.direction;
let dlen = (dir.x * dir.x + dir.y * dir.y).sqrt().max(1e-9);
let dl = ml.dogleg_length;
let end = [
(cp.x + dir.x / dlen * dl) as f32,
(cp.y + dir.y / dlen * dl) as f32,
cp.z as f32,
];
points.push(nan);
points.push(cp_f);
points.push(end);
}
}
let mut wires = vec![WireModel {
name: name.clone(),
points,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: key_verts,
}];
// Render text content as MText wire
if ml.content_type == acadrust::entities::LeaderContentType::MText
&& !ml.context.text_string.is_empty()
{
let mut mtext = acadrust::entities::MText::new();
mtext.value = ml.context.text_string.clone();
mtext.insertion_point = ml.context.text_location;
mtext.height = if ml.context.text_height > 0.0 {
ml.context.text_height
} else {
ml.text_height
};
mtext.common.layer = ml.common.layer.clone();
let mut w = tessellate(
document, handle, &EntityType::MText(mtext),
selected, entity_color, 0.0, [0.0; 8], line_weight_px,
);
w.name = name;
wires.push(w);
}
wires
}
/// Tessellate a Solid3D entity into a MeshModel (truck Shell/Solid path).
#[allow(dead_code)]
pub fn tessellate_mesh(
document: &CadDocument,
handle: Handle,
entity: &EntityType,
selected: bool,
color: [f32; 4],
) -> Option<MeshModel> {
let te = convert(entity, document)?;
let result = match te.object {
TruckObject::Volume(solid) => tessellate_solid(&solid),
_ => return None,
};
truck_tess::tess_to_mesh_model(
result,
handle.value().to_string(),
if selected { MeshModel::SELECTED } else { color },
selected,
)
}
// ── Entity Z helper ───────────────────────────────────────────────────────
/// Extract the Z elevation from a text/mtext entity.
fn entity_z(entity: &EntityType) -> f32 {
match entity {
EntityType::Text(t) => t.insertion_point.z as f32,
EntityType::MText(t) => t.insertion_point.z as f32,
_ => 0.0,
}
}
// ── Legacy geometry (Viewport, Hatch outline, unrecognised) ───────────────
type Geometry = (
Vec<[f32; 3]>,
Vec<(Vec3, SnapHint)>,
Vec<TangentGeom>,
Vec<[f32; 3]>,
);
fn legacy_geometry(entity: &EntityType) -> Geometry {
match entity {
EntityType::Viewport(vp) => {
let cx = vp.center.x as f32;
let cy = vp.center.y as f32;
let cz = vp.center.z as f32;
let hw = (vp.width / 2.0) as f32;
let hh = (vp.height / 2.0) as f32;
let pts = vec![
[cx - hw, cy - hh, cz],
[cx + hw, cy - hh, cz],
[cx + hw, cy + hh, cz],
[cx - hw, cy + hh, cz],
[cx - hw, cy - hh, cz],
];
(pts, vec![], vec![], vec![])
}
EntityType::Hatch(h) => {
let mut pts: Vec<[f32; 3]> = Vec::new();
'outer: for path in &h.paths {
for edge in &path.edges {
if let acadrust::entities::BoundaryEdge::Polyline(poly) = edge {
for v in &poly.vertices {
pts.push([v.x as f32, v.y as f32, 0.0]);
}
if let Some(first) = pts.first().cloned() {
pts.push(first);
}
break 'outer;
}
}
}
if pts.is_empty() {
pts = vec![[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]];
}
(pts, vec![], vec![], vec![])
}
_ => {
let s = 0.5_f32;
(vec![[-s, 0.0, 0.0], [s, 0.0, 0.0]], vec![], vec![], vec![])
}
}
}
fn dimension_geometry(dim: &Dimension) -> Vec<[f32; 3]> {
let mut points = Vec::new();
match dim {
Dimension::Aligned(d) => {
let first = vec3(d.first_point);
let second = vec3(d.second_point);
let def = vec3(d.definition_point);
let axis = normalized_or(second - first, Vec3::X);
append_linear_dimension(&mut points, first, second, def, axis);
}
Dimension::Linear(d) => {
let first = vec3(d.first_point);
let second = vec3(d.second_point);
let def = vec3(d.definition_point);
let axis = Vec3::new(d.rotation.cos() as f32, d.rotation.sin() as f32, 0.0);
append_linear_dimension(&mut points, first, second, def, normalized_or(axis, Vec3::X));
}
Dimension::Radius(d) => {
let center = vec3(d.angle_vertex);
let point = vec3(d.definition_point);
let text = dimension_text_position(dim);
add_segment(&mut points, center, point);
add_segment(&mut points, point, text);
append_arrow(&mut points, point, normalized_or(center - point, Vec3::X), 0.12);
}
Dimension::Diameter(d) => {
let p1 = vec3(d.angle_vertex);
let p2 = vec3(d.definition_point);
add_segment(&mut points, p1, p2);
append_arrow(&mut points, p1, normalized_or(p2 - p1, Vec3::X), 0.12);
append_arrow(&mut points, p2, normalized_or(p1 - p2, Vec3::X), 0.12);
}
Dimension::Angular2Ln(d) => {
append_angular_dimension(
&mut points,
vec3(d.angle_vertex),
vec3(d.first_point),
vec3(d.second_point),
vec3(d.dimension_arc),
);
}
Dimension::Angular3Pt(d) => {
append_angular_dimension(
&mut points,
vec3(d.angle_vertex),
vec3(d.first_point),
vec3(d.second_point),
vec3(d.definition_point),
);
}
Dimension::Ordinate(d) => {
add_segment(&mut points, vec3(d.feature_location), vec3(d.definition_point));
add_segment(&mut points, vec3(d.definition_point), vec3(d.leader_endpoint));
}
}
points
}
fn append_linear_dimension(
points: &mut Vec<[f32; 3]>,
first: Vec3,
second: Vec3,
def: Vec3,
axis: Vec3,
) {
let perp = Vec3::new(-axis.y, axis.x, 0.0);
let offset = (def - first).dot(perp);
let d1 = first + perp * offset;
let d2 = second + perp * offset;
add_segment(points, first, d1);
add_segment(points, second, d2);
add_segment(points, d1, d2);
append_arrow(points, d1, normalized_or(d2 - d1, axis), 0.12);
append_arrow(points, d2, normalized_or(d1 - d2, -axis), 0.12);
}
fn append_angular_dimension(
points: &mut Vec<[f32; 3]>,
vertex: Vec3,
first: Vec3,
second: Vec3,
arc_point: Vec3,
) {
add_segment(points, vertex, first);
add_segment(points, vertex, second);
let radius = vertex.distance(arc_point);
if radius <= 1e-6 {
return;
}
let start = (first.y - vertex.y).atan2(first.x - vertex.x);
let mut end = (second.y - vertex.y).atan2(second.x - vertex.x);
let mut delta = end - start;
while delta <= 0.0 {
delta += std::f32::consts::TAU;
}
if delta > std::f32::consts::PI {
end -= std::f32::consts::TAU;
delta = end - start;
}
let steps = 32;
let mut arc_pts = Vec::with_capacity((steps + 1) as usize);
for i in 0..=steps {
let t = i as f32 / steps as f32;
let a = start + delta * t;
arc_pts.push(vertex + Vec3::new(a.cos() * radius, a.sin() * radius, 0.0));
}
add_polyline(points, &arc_pts);
if arc_pts.len() >= 2 {
append_arrow(
points,
arc_pts[0],
normalized_or(arc_pts[1] - arc_pts[0], Vec3::X),
0.1,
);
let n = arc_pts.len();
append_arrow(
points,
arc_pts[n - 1],
normalized_or(arc_pts[n - 2] - arc_pts[n - 1], Vec3::X),
0.1,
);
}
}
fn append_arrow(points: &mut Vec<[f32; 3]>, tip: Vec3, dir: Vec3, size: f32) {
let dir = normalized_or(dir, Vec3::X) * size;
let left = rotate(dir, 2.6);
let right = rotate(dir, -2.6);
add_segment(points, tip, tip + left);
add_segment(points, tip, tip + right);
}
fn add_segment(points: &mut Vec<[f32; 3]>, a: Vec3, b: Vec3) {
if !points.is_empty() {
points.push([f32::NAN, f32::NAN, f32::NAN]);
}
points.push([a.x, a.y, a.z]);
points.push([b.x, b.y, b.z]);
}
fn add_polyline(points: &mut Vec<[f32; 3]>, polyline: &[Vec3]) {
if polyline.len() < 2 {
return;
}
if !points.is_empty() {
points.push([f32::NAN, f32::NAN, f32::NAN]);
}
points.extend(polyline.iter().map(|p| [p.x, p.y, p.z]));
}
fn dimension_text_entity(dim: &Dimension) -> Option<Text> {
let value = dimension_text_value(dim)?;
let pos = dimension_text_position(dim);
let base = dim.base();
let mut text = Text::with_value(value, Vector3::new(pos.x as f64, pos.y as f64, pos.z as f64))
.with_height(dimension_text_height(dim))
.with_rotation(base.text_rotation);
text.style = base.style_name.clone();
text.common = base.common.clone();
Some(text)
}
fn dimension_text_value(dim: &Dimension) -> Option<String> {
let base = dim.base();
if let Some(user_text) = &base.user_text {
if !user_text.trim().is_empty() {
return Some(user_text.clone());
}
}
if !base.text.trim().is_empty() {
return Some(base.text.clone());
}
Some(match dim {
Dimension::Radius(_) => format!("R{:.4}", dim.measurement()),
Dimension::Diameter(_) => format!("Ø{:.4}", dim.measurement()),
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => {
format!("{:.2}°", dim.measurement())
}
_ => format!("{:.4}", dim.measurement()),
})
}
fn dimension_text_position(dim: &Dimension) -> Vec3 {
let base = dim.base();
let pos = vec3(base.text_middle_point);
if pos.length_squared() > 1e-8 {
return pos;
}
match dim {
Dimension::Aligned(d) => (vec3(d.first_point) + vec3(d.second_point)) * 0.5,
Dimension::Linear(d) => (vec3(d.first_point) + vec3(d.second_point)) * 0.5,
Dimension::Radius(d) => (vec3(d.angle_vertex) + vec3(d.definition_point)) * 0.5,
Dimension::Diameter(d) => (vec3(d.angle_vertex) + vec3(d.definition_point)) * 0.5,
Dimension::Angular2Ln(d) => vec3(d.dimension_arc),
Dimension::Angular3Pt(d) => vec3(d.definition_point),
Dimension::Ordinate(d) => vec3(d.leader_endpoint),
}
}
fn dimension_text_height(dim: &Dimension) -> f64 {
let scale = (dim.measurement().abs() * 0.12).clamp(0.25, 2.0);
if scale.is_finite() { scale } else { 0.25 }
}
fn vec3(v: Vector3) -> Vec3 {
Vec3::new(v.x as f32, v.y as f32, v.z as f32)
}
fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 {
if v.length_squared() <= 1e-12 {
fallback
} else {
v.normalize()
}
}
fn rotate(v: Vec3, angle: f32) -> Vec3 {
let (s, c) = angle.sin_cos();
Vec3::new(v.x * c - v.y * s, v.x * s + v.y * c, v.z)
}

65
src/scene/transform.rs Normal file
View file

@ -0,0 +1,65 @@
use acadrust::types::{Transform, Vector3};
use glam::Vec3;
use crate::command::EntityTransform;
#[inline]
fn to_v3(v: Vec3) -> Vector3 {
Vector3::new(v.x as f64, v.y as f64, v.z as f64)
}
pub fn apply_standard_transform<T>(entity: &mut T, center: Vec3, angle_rad: f32)
where
T: acadrust::Entity,
{
let z_axis = Vector3::new(0.0, 0.0, 1.0);
let t = Transform::from_translation(to_v3(-center))
.then(&Transform::from_rotation(z_axis, angle_rad as f64))
.then(&Transform::from_translation(to_v3(center)));
entity.apply_transform(&t);
}
pub fn apply_standard_scale<T>(entity: &mut T, center: Vec3, factor: f32)
where
T: acadrust::Entity,
{
let s = factor as f64;
let t = Transform::from_scaling_with_origin(Vector3::new(s, s, s), to_v3(center));
entity.apply_transform(&t);
}
pub fn apply_standard_entity_transform<T, F>(entity: &mut T, t: &EntityTransform, mirror: F)
where
T: acadrust::Entity,
F: FnOnce(&mut T, Vec3, Vec3),
{
match t {
EntityTransform::Translate(d) => entity.translate(to_v3(*d)),
EntityTransform::Rotate { center, angle_rad } => {
apply_standard_transform(entity, *center, *angle_rad)
}
EntityTransform::Scale { center, factor } => apply_standard_scale(entity, *center, *factor),
EntityTransform::Mirror { p1, p2 } => mirror(entity, *p1, *p2),
}
}
pub fn reflect_xy_point(x: &mut f64, y: &mut f64, p1: Vec3, p2: Vec3) {
let ax = (p2.x - p1.x) as f64;
let ay = (p2.y - p1.y) as f64;
let len2 = ax * ax + ay * ay;
if len2 < 1e-12 {
return;
}
let rx = *x - p1.x as f64;
let ry = *y - p1.y as f64;
let dot = rx * ax + ry * ay;
let mx = 2.0 * dot * ax / len2 - rx;
let my = 2.0 * dot * ay / len2 - ry;
*x = p1.x as f64 + mx;
*y = p1.y as f64 + my;
}
pub fn mirror_xy_line(line: &mut acadrust::entities::Line, p1: Vec3, p2: Vec3) {
reflect_xy_point(&mut line.start.x, &mut line.start.y, p1, p2);
reflect_xy_point(&mut line.end.x, &mut line.end.y, p1, p2);
}

185
src/scene/truck_tess.rs Normal file
View file

@ -0,0 +1,185 @@
// Unified tessellation for all truck topology levels.
//
// Vertex → TruckTessResult::Point
// Edge → TruckTessResult::Lines (curve sampled with tolerance)
// Wire → TruckTessResult::Lines (edges joined, no duplicate junctions)
// Face / Shell / Solid → TruckTessResult::Mesh (triangle mesh via truck-meshalgo)
//
// The caller (acad_to_truck / tessellate) decides which function to call based on
// the truck topology level of the object.
use truck_meshalgo::tessellation::{MeshableShape, MeshedShape};
use truck_modeling::base::{BoundedCurve, ParameterDivision1D};
use truck_modeling::{Edge, Shell, Solid, Vertex, Wire};
use truck_polymesh::PolygonMesh;
use crate::scene::mesh_model::MeshModel;
// Chord-height tolerance used for adaptive curve sampling (world units).
const CURVE_TOL: f64 = 0.005;
// Triangle mesh tolerance (world units).
pub const MESH_TOL: f64 = 0.01;
// ── Public result type ────────────────────────────────────────────────────
/// Output of any truck topology tessellation.
#[allow(dead_code)]
pub enum TruckTessResult {
/// A single world-space point (from Vertex).
Point([f32; 3]),
/// An ordered sequence of points forming a polyline (from Edge or Wire).
Lines(Vec<[f32; 3]>),
/// A triangle mesh (from Face, Shell, or Solid).
Mesh {
verts: Vec<[f32; 3]>,
normals: Vec<[f32; 3]>,
indices: Vec<u32>,
},
}
// ── Vertex ────────────────────────────────────────────────────────────────
pub fn tessellate_vertex(v: &Vertex) -> TruckTessResult {
let p = v.point();
TruckTessResult::Point([p.x as f32, p.y as f32, p.z as f32])
}
// ── Edge ──────────────────────────────────────────────────────────────────
pub fn tessellate_edge(e: &Edge) -> TruckTessResult {
// oriented_curve() respects the edge direction (inverts if needed).
let curve = e.oriented_curve();
let (t0, t1) = curve.range_tuple();
// parameter_division samples adaptively: fewer points on straight segments,
// more on tight curves, all within CURVE_TOL chord-height error.
let (_, pts) = curve.parameter_division((t0, t1), CURVE_TOL);
let lines = pts
.iter()
.map(|p| [p.x as f32, p.y as f32, p.z as f32])
.collect();
TruckTessResult::Lines(lines)
}
// ── Wire ──────────────────────────────────────────────────────────────────
pub fn tessellate_wire(w: &Wire) -> TruckTessResult {
let mut pts: Vec<[f32; 3]> = Vec::new();
for edge in w.edge_iter() {
if let TruckTessResult::Lines(ep) = tessellate_edge(edge) {
if pts.is_empty() {
pts = ep;
} else {
// Skip the first point of each continuation edge to avoid
// duplicating the shared junction vertex.
pts.extend_from_slice(&ep[1..]);
}
}
}
TruckTessResult::Lines(pts)
}
// ── Shell ─────────────────────────────────────────────────────────────────
#[allow(dead_code)]
pub fn tessellate_shell(s: &Shell) -> TruckTessResult {
let meshed = s.triangulation(MESH_TOL);
polygon_to_result(meshed.to_polygon())
}
// ── Solid ─────────────────────────────────────────────────────────────────
#[allow(dead_code)]
pub fn tessellate_solid(s: &Solid) -> TruckTessResult {
let meshed = s.triangulation(MESH_TOL);
polygon_to_result(meshed.to_polygon())
}
// ── MeshModel helper ──────────────────────────────────────────────────────
/// Convert a TruckTessResult::Mesh into a MeshModel (fills name/color later).
pub fn tess_to_mesh_model(
result: TruckTessResult,
name: String,
color: [f32; 4],
selected: bool,
) -> Option<MeshModel> {
match result {
TruckTessResult::Mesh {
verts,
normals,
indices,
} => Some(MeshModel {
name,
verts,
normals,
indices,
color,
selected,
}),
_ => None,
}
}
// ── Internal ─────────────────────────────────────────────────────────────
fn polygon_to_result(mesh: PolygonMesh) -> TruckTessResult {
let verts: Vec<[f32; 3]> = mesh
.positions()
.iter()
.map(|p| [p.x as f32, p.y as f32, p.z as f32])
.collect();
// Per-vertex normals: if the mesh has normals, map each triangle vertex's
// normal index back to the normals array. Fall back to empty if absent.
let mesh_normals = mesh.normals();
let indices: Vec<u32> = mesh
.tri_faces()
.iter()
.flat_map(|tri| [tri[0].pos as u32, tri[1].pos as u32, tri[2].pos as u32])
.collect();
// Build a per-position normal by averaging normals of all faces that share it.
// If mesh has no normals, leave the Vec empty.
let normals: Vec<[f32; 3]> = if !mesh_normals.is_empty() {
let n = verts.len();
let mut acc = vec![[0.0_f32; 3]; n];
let mut cnt = vec![0u32; n];
for tri in mesh.tri_faces() {
for sv in tri {
let pos_idx = sv.pos;
if let Some(nor_idx) = sv.nor {
if let Some(nv) = mesh_normals.get(nor_idx) {
acc[pos_idx][0] += nv.x as f32;
acc[pos_idx][1] += nv.y as f32;
acc[pos_idx][2] += nv.z as f32;
cnt[pos_idx] += 1;
}
}
}
}
acc.iter()
.zip(cnt.iter())
.map(|(s, &c)| {
if c == 0 {
[0.0, 1.0, 0.0]
} else {
let inv = 1.0 / c as f32;
let nx = s[0] * inv;
let ny = s[1] * inv;
let nz = s[2] * inv;
let len = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-9);
[nx / len, ny / len, nz / len]
}
})
.collect()
} else {
vec![]
};
TruckTessResult::Mesh {
verts,
normals,
indices,
}
}

158
src/scene/wire_model.rs Normal file
View file

@ -0,0 +1,158 @@
/// 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,
}
/// 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,
/// 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]>,
}
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];
/// 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,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: 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 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()
}
}