first commit
This commit is contained in:
parent
d52c6440ff
commit
d2fdac7f1e
274 changed files with 89330 additions and 0 deletions
30
src/scene/acad_to_truck.rs
Normal file
30
src/scene/acad_to_truck.rs
Normal 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
252
src/scene/camera.rs
Normal 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
273
src/scene/complex_lt.rs
Normal 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(<.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
585
src/scene/cxf.rs
Normal 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
80
src/scene/dispatch.rs
Normal 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
79
src/scene/grip.rs
Normal 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
57
src/scene/hatch_model.rs
Normal 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,
|
||||
}
|
||||
88
src/scene/hatch_patterns.rs
Normal file
88
src/scene/hatch_patterns.rs
Normal 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
339
src/scene/hit_test.rs
Normal 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
28
src/scene/mesh_model.rs
Normal 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
1323
src/scene/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
83
src/scene/object.rs
Normal file
83
src/scene/object.rs
Normal 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),
|
||||
}
|
||||
351
src/scene/pipeline/hatch_gpu.rs
Normal file
351
src/scene/pipeline/hatch_gpu.rs
Normal 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
|
||||
}
|
||||
97
src/scene/pipeline/mesh_gpu.rs
Normal file
97
src/scene/pipeline/mesh_gpu.rs
Normal 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
458
src/scene/pipeline/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
22
src/scene/pipeline/uniforms.rs
Normal file
22
src/scene/pipeline/uniforms.rs
Normal 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],
|
||||
}
|
||||
}
|
||||
}
|
||||
1271
src/scene/pipeline/viewcube.rs
Normal file
1271
src/scene/pipeline/viewcube.rs
Normal file
File diff suppressed because it is too large
Load diff
212
src/scene/pipeline/wire_gpu.rs
Normal file
212
src/scene/pipeline/wire_gpu.rs
Normal 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
89
src/scene/properties.rs
Normal 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
784
src/scene/tessellate.rs
Normal 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
65
src/scene/transform.rs
Normal 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
185
src/scene/truck_tess.rs
Normal 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
158
src/scene/wire_model.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue