refactor(scene): group modules into role-based subfolders

Move the 30 flat modules under src/scene/ into six folders by role:
convert (entity→geometry), text (fonts/shaping), model (render models),
pick (hit-test/selection/grips/xclip), view (camera/transform/render),
cache (block + property caches). pipeline/ is unchanged.

Full path migration — all `scene::X` references become
`scene::<group>::X`, no compatibility re-exports. render.rs items that
were `pub(super)` (parent was scene) become `pub(in crate::scene)` to
keep their original scene-wide visibility.

No behavior change; build and tests are green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-16 11:33:46 +03:00
1 parent cd048165a9
commit 7fe7e0230d
160 files changed
+732 -715

No files matched your search

+351
View File
@@ -0,0 +1,351 @@
// 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 OpenCADStudio).
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, 0.0),
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
}
/// Project a screen point onto an arbitrary world-space plane.
///
/// The plane is defined by `plane_normal` (unit vector) and a `plane_point`
/// that lies on it. Returns the intersection of the view ray with the plane;
/// falls back to `plane_point` when the ray is nearly parallel to the plane.
pub fn pick_on_plane(
&self,
screen: Point,
bounds: Rectangle,
plane_normal: Vec3,
plane_point: Vec3,
) -> 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();
let (ray_origin, ray_dir) = 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();
(near_pt, dir)
}
Projection::Orthographic => {
let origin = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0));
let forward = (self.target - self.eye()).normalize();
(origin, forward)
}
};
let denom = ray_dir.dot(plane_normal);
if denom.abs() < 1e-6 {
return plane_point;
}
let t = (plane_point - ray_origin).dot(plane_normal) / denom;
if t < 0.0 {
return plane_point;
}
ray_origin + ray_dir * t
}
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;
}
/// Pan so the world point under the cursor tracks it: screen pixels are
/// converted to world units via the ortho world-per-pixel scale of a
/// viewport `viewport_height` pixels tall. Used by tiled panes where the
/// pane height differs from the full canvas.
pub fn pan_screen(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
let wpp = if viewport_height > 0.0 {
(2.0 * self.ortho_size()) / viewport_height
} else {
0.0
};
let cam_right = self.rotation * Vec3::X;
let cam_up = self.rotation * Vec3::Y;
self.target -= cam_right * delta_x * wpp;
self.target += cam_up * delta_y * wpp;
}
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 a canonical view direction (called by ViewCubeSnap).
/// `eye_dir` is the unit vector from the target toward the camera.
///
/// Up vector resolution:
/// 1. Take the current up.
/// 2. Pick the world axis (±X, ±Y, ±Z) whose dot product with the
/// current up is highest — skipping any axis (anti-)parallel to
/// the new gaze direction.
/// 3. Project that axis onto the plane ⊥ `new_eye` and use that as
/// the new up.
///
/// Result: small tilts collapse onto the nearest world axis (so the
/// view always lands cleanly aligned), while genuine flips of the
/// up-sense (e.g. orbited upside-down) are preserved.
pub fn snap_to_direction(&mut self, eye_dir: Vec3) {
let new_eye = eye_dir.normalize_or(Vec3::Z);
let cur_up = self.rotation * Vec3::Y;
let cardinals = [
Vec3::X,
Vec3::NEG_X,
Vec3::Y,
Vec3::NEG_Y,
Vec3::Z,
Vec3::NEG_Z,
];
let mut best_score = f32::NEG_INFINITY;
let mut best_up = Vec3::Z;
for axis in cardinals {
// Skip axes (nearly) collinear with the new gaze — they can't
// serve as up because the projection onto the plane would
// vanish.
if axis.dot(new_eye).abs() > 0.999 {
continue;
}
let score = axis.dot(cur_up);
if score > best_score {
best_score = score;
best_up = axis;
}
}
// Project the chosen axis onto the plane ⊥ new_eye and normalize.
let projected = best_up - new_eye * best_up.dot(new_eye);
let new_up = projected.normalize_or(if new_eye.z.abs() < 0.99 {
(Vec3::Z - new_eye * Vec3::Z.dot(new_eye)).normalize()
} else {
(Vec3::Y - new_eye * Vec3::Y.dot(new_eye)).normalize()
});
let new_right = new_up.cross(new_eye).normalize();
// Camera rotation columns: [cam_x | cam_y | cam_z] where
// cam_z = eye_dir (canonical "+Z is toward eye"), cam_y = up.
let mat = glam::Mat3::from_cols(new_right, new_up, new_eye);
self.rotation = Quat::from_mat3(&mat).normalize();
self.sync_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, pitch and roll.
/// Positive yaw rotates the view direction clockwise when seen from above (Z-up).
/// Roll rotates the camera around its own view axis (post-multiplied so it
/// composes after the yaw/pitch gaze direction is set).
pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32, roll: 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);
let q_roll = Quat::from_rotation_z(roll);
(q_yaw * q_pitch * q_roll).normalize()
}
+80
View File
@@ -0,0 +1,80 @@
// Dispatch entry points for entity editing.
use acadrust::types::{Color as AcadColor, LineWeight, Transparency};
use acadrust::{EntityType, Handle};
use crate::command::EntityTransform;
use crate::entities::traits::EntityTypeOps;
use crate::scene::model::object::{GripDef, PropSection};
use crate::scene::cache::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::model::object::GripApply) {
EntityTypeOps::apply_grip(entity, grip_id, apply);
}
pub fn apply_transform(entity: &mut EntityType, t: &EntityTransform) {
EntityTypeOps::apply_transform(entity, t);
}
+5
View File
@@ -0,0 +1,5 @@
pub mod camera;
pub mod transform;
pub mod viewport_pane;
pub mod render;
pub mod dispatch;
+906
View File
@@ -0,0 +1,906 @@
// GPU rendering primitives, shader::Program / shader::Primitive impls,
// and entity render-style helpers for the Scene.
use acadrust::tables::LineType;
use acadrust::types::{Color as AcadColor, LineWeight};
use acadrust::{CadDocument, EntityType, Handle};
use glam::Mat4;
use iced::mouse;
use iced::widget::shader::{self, Viewport};
use iced::{Rectangle, Size};
use std::sync::Arc;
use crate::scene::pipeline::viewcube::{hover_id, VIEWCUBE_PX};
use crate::scene::pipeline::MultiPipeline;
use crate::scene::convert::tess_util;
use crate::scene::{HatchModel, ImageModel, MeshLodSet, Scene, Uniforms, ViewportInstance, WireModel};
// ── Camera hover state (shader::Program::State) ───────────────────────────
#[derive(Clone, Default)]
pub struct CameraState {
pub hover_region: Option<usize>,
}
// ── GPU primitive ─────────────────────────────────────────────────────────
/// Everything needed to render one viewport: its geometry, camera, render
/// mode, and the screen rectangle it occupies. The unified renderer carries
/// a `Vec<ViewportData>` (one per tiled / floating viewport); each gets its
/// own inner `Pipeline` instance drawn into its own rectangle.
#[derive(Debug)]
pub struct ViewportData {
pub(in crate::scene) wires: Arc<Vec<WireModel>>,
/// Live command-preview / interim / grip-drag overlay wires. Kept out of
/// the main `wires` buffer so a drag re-uploads only this small set each
/// frame, never the resident base buffer. Drawn on top in the wire pass.
pub(in crate::scene) preview_wires: Arc<Vec<WireModel>>,
/// 3DFACE entity wires — separated so they are uploaded to the dedicated
/// face3d pipeline (fill + batched edges) instead of N individual WireGpu.
pub(in crate::scene) face3d_wires: Arc<Vec<WireModel>>,
/// Per-entity normalized draw-order depth (handle.value() → (0,1)), used
/// by the wire / face3d pipelines as a clip-z bias. WireModels carry no
/// depth field (84 construction sites); the bias is looked up by handle
/// at GPU-upload time from this map instead.
pub(in crate::scene) draw_depths: Arc<rustc_hash::FxHashMap<u64, f32>>,
pub(in crate::scene) hatches: Arc<Vec<HatchModel>>,
/// Wipeout fills — rendered in a separate pass AFTER wires.
pub(in crate::scene) wipeout_hatches: Arc<Vec<HatchModel>>,
pub(in crate::scene) images: Arc<Vec<ImageModel>>,
pub(in crate::scene) meshes: Arc<Vec<MeshLodSet>>,
pub(in crate::scene) uniforms: Uniforms,
/// Camera rotation matrix derived from the quaternion.
/// Used by the ViewCube pipeline — no gimbal lock.
pub(in crate::scene) cam_rotation: Mat4,
pub(in crate::scene) hover_region: Option<usize>,
pub(in crate::scene) show_viewcube: bool,
/// Header.fill_mode (FILLMODE): when false, hatch / wipeout / face3d-fill
/// uploads short-circuit so the renderer draws only wireframe.
pub(in crate::scene) fill_mode: bool,
/// Per-view "Wireframe vs Solid" toggle. When `true`, 3D face fills
/// are dropped on the upload path so 3D faces draw as edges only.
/// Hatch / wipeout uploads are deliberately *not* gated by this flag —
/// the user toggle should only affect 3D solids, not 2D fills.
pub(in crate::scene) view_wireframe: bool,
/// Whether the active render mode wants 3D mesh fills uploaded. Off
/// in `Wireframe2D` / `Wireframe3D`; on for every shaded variant. Set
/// at the same point `view_wireframe` is computed so the two stay in
/// lock-step for the gating logic in `prepare()`.
pub(in crate::scene) mesh_fill: bool,
/// Whether the active render mode wants 3D mesh / face edges
/// rendered on top of fills. Most shaded modes turn this off; the
/// `*WithEdges` variants and the pure wireframes leave it on.
pub(in crate::scene) show_3d_edges: bool,
/// HiddenLine routes 3D fills through a depth-only prepass so edges
/// occluded by closer geometry are culled by the LessEqual depth
/// test on the wire passes that follow.
pub(in crate::scene) hidden_line: bool,
pub(in crate::scene) geometry_epoch: u64,
/// Camera generation captured when this Primitive was assembled. Paired
/// with `geometry_epoch` so the per-frame scissor / LOD recompute runs.
pub(in crate::scene) camera_generation: u64,
/// Content id of `wires` (Phase 3.2). Stable across a pure pan that reuses
/// the Model-tile tessellation, so `prepare` skips re-uploading the
/// world-space wire buffer when only the camera moved. Non-tile and
/// preview/interim frames carry a fresh id each time → always re-upload.
pub(in crate::scene) wire_content_id: u64,
/// `selected ∪ hover` handles for the GPU xray overlay. The highlight is no
/// longer baked into the wire tessellation, so `prepare` rebuilds the
/// selected-wire batch by filtering `wires` against this set.
pub(in crate::scene) highlight_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>,
/// Bumped on selection / hover change. Paired with `wire_content_id` to
/// decide when the xray overlay batch needs rebuilding.
pub(in crate::scene) selection_generation: u64,
/// Signature of the *selected set* only (not hover). Gates the static-buffer
/// re-upload (hatch tint, issue #71) so a hover doesn't re-upload every
/// hatch / face3d buffer on hatch-heavy drawings.
pub(in crate::scene) selected_sig: u64,
/// Screen rectangle this viewport fills, **normalized** to the widget
/// bounds (each component in 0..1). A single full-widget view is
/// `(0, 0, 1, 1)`; tiled / floating viewports are sub-rectangles.
/// Normalized form lets `render()` derive the physical sub-clip from
/// the surface clip without needing the scale factor.
pub(in crate::scene) screen_rect: Rectangle,
}
#[derive(Debug)]
pub struct Primitive {
/// One entry per viewport drawn this frame (≥1).
pub(in crate::scene) viewports: Vec<ViewportData>,
/// Background color used to clear each viewport's MSAA buffer.
pub(in crate::scene) bg_color: [f32; 4],
}
/// Flags the render pipeline consumes, derived from
/// [`acadrust::entities::ViewportRenderMode`]. Each shaded variant fills
/// 3D faces and meshes; the pure wireframes drop the fill and keep only
/// edges. `*WithEdges` variants render both. HiddenLine uses a depth
/// prepass: face/mesh fills are uploaded but routed through depth-only
/// pipelines so hidden edges drop out. `FlatShaded` vs `GouraudShaded`
/// differ in shader uniform only and produce identical fill flags here.
#[derive(Clone, Copy, Debug)]
pub struct RenderModeFlags {
pub face3d_fill: bool,
pub mesh_fill: bool,
pub show_3d_edges: bool,
pub hidden_line: bool,
/// `true` for FlatShaded / FlatShadedWithEdges. The mesh shader
/// reads `Uniforms.flat_shade` and replaces the smooth per-vertex
/// normal with a per-triangle face normal so each triangle reads
/// as a single tone.
pub flat_shade: bool,
}
pub fn render_mode_flags(
mode: acadrust::entities::ViewportRenderMode,
) -> RenderModeFlags {
use acadrust::entities::ViewportRenderMode as M;
match mode {
M::Wireframe2D | M::Wireframe3D => RenderModeFlags {
face3d_fill: false,
mesh_fill: false,
show_3d_edges: true,
hidden_line: false,
flat_shade: false,
},
M::HiddenLine => RenderModeFlags {
face3d_fill: true,
mesh_fill: true,
show_3d_edges: true,
hidden_line: true,
flat_shade: false,
},
M::FlatShaded => RenderModeFlags {
face3d_fill: true,
mesh_fill: true,
show_3d_edges: false,
hidden_line: false,
flat_shade: true,
},
M::GouraudShaded => RenderModeFlags {
face3d_fill: true,
mesh_fill: true,
show_3d_edges: false,
hidden_line: false,
flat_shade: false,
},
M::FlatShadedWithEdges => RenderModeFlags {
face3d_fill: true,
mesh_fill: true,
show_3d_edges: true,
hidden_line: false,
flat_shade: true,
},
M::GouraudShadedWithEdges => RenderModeFlags {
face3d_fill: true,
mesh_fill: true,
show_3d_edges: true,
hidden_line: false,
flat_shade: false,
},
}
}
// ── shader::Primitive impl ────────────────────────────────────────────────
impl shader::Primitive for Primitive {
type Pipeline = MultiPipeline;
fn prepare(
&self,
pipeline: &mut MultiPipeline,
device: &iced::wgpu::Device,
queue: &iced::wgpu::Queue,
bounds: &Rectangle,
viewport: &Viewport,
) {
let phys = viewport.physical_size();
let full_size = Size::new(phys.width, phys.height);
let scale = viewport.scale_factor() as f32;
pipeline.ensure_len(device, queue, self.viewports.len());
for (i, vp) in self.viewports.iter().enumerate() {
let inner = &mut pipeline.inners[i];
// The MSAA / depth / resolve textures are always sized to the
// FULL viewport rectangle (not the on-canvas-visible portion)
// so the camera matrices render at consistent aspect / scale.
// The blit step picks the visible sub-rectangle out via the
// shader's UV crop uniform, which lets partially off-canvas
// viewports composite to their visible surface area without
// drift.
let clip_size = Size::new(
(vp.screen_rect.width * bounds.width * scale).ceil().max(1.0) as u32,
(vp.screen_rect.height * bounds.height * scale).ceil().max(1.0) as u32,
);
inner.ensure_depth_texture(device, clip_size);
inner.viewcube.ensure_depth_texture(device, full_size);
// Compute the UV crop for this viewport. `screen_rect` is in
// normalized canvas units (0..1) but may extend negative or
// beyond 1 when the viewport hangs off the canvas. The on-
// canvas portion in viewport-local UV is straightforward to
// derive from how much sticks out on each side.
let sr = vp.screen_rect;
let (uo_x, us_x) = uv_crop_axis(sr.x, sr.width);
let (uo_y, us_y) = uv_crop_axis(sr.y, sr.height);
inner.upload_blit_uv(queue, [uo_x, uo_y], [us_x, us_y]);
inner.upload_uniforms(queue, &vp.uniforms);
// Third component is the *selected-set* signature (not
// selection_generation, which also bumps on hover) so a rollover
// doesn't re-upload the static hatch / face3d buffers.
let cur_key = (vp.geometry_epoch, vp.camera_generation, vp.selected_sig);
let fill_mode = vp.fill_mode;
// 3D face fill requires *both* the doc-level FILLMODE *and* the
// per-view Solid toggle. Hatches / wipeouts deliberately ignore
// the view toggle so 2D fills stay on even when the user picks
// the Wireframe overlay style.
let face3d_fill_active = fill_mode && !vp.view_wireframe;
if cur_key != inner.cached_epoch {
// Hatches carry a selected-tint, so re-upload on a geometry OR
// a selection change (issue #71); images / meshes only need a
// geometry change.
let geo_changed = vp.geometry_epoch != inner.cached_epoch.0;
let sel_changed = vp.selected_sig != inner.cached_epoch.2;
if geo_changed || sel_changed {
if fill_mode {
inner.upload_hatches(device, &vp.hatches[..]);
inner.upload_wipeouts(device, &vp.wipeout_hatches[..]);
} else {
inner.upload_hatches(device, &[]);
inner.upload_wipeouts(device, &[]);
}
}
if geo_changed {
inner.upload_images(device, queue, &vp.images[..]);
inner.upload_meshes(device, &vp.meshes[..]);
}
inner.upload_face3d(
device,
&vp.face3d_wires[..],
&vp.wires[..],
!face3d_fill_active,
&vp.draw_depths,
);
inner.cached_epoch = cur_key;
}
// Wire buffers are world-space, so a camera move alone doesn't
// change them — only the view_proj uniform (uploaded every frame).
// Gate the upload on the wire content id instead of the camera tick
// (Phase 3.2): a pan that reused the Model-tile tessellation keeps
// the id, skipping the vertex re-pack + GPU write. Kept independent
// of the `cur_key` block so a preview/interim wire change still
// uploads even when the camera didn't move.
if vp.wire_content_id != inner.cached_wire_id {
inner.upload_wires(device, &vp.wires[..], &vp.draw_depths);
inner.cached_wire_id = vp.wire_content_id;
}
// Selection xray overlay — rebuilt when the selection changes or the
// underlying wires changed. A pick bumps only selection_generation,
// so this refreshes without re-tessellating or re-uploading the main
// wire buffers.
let sel_key = (vp.wire_content_id, vp.selection_generation);
if sel_key != inner.cached_selection {
inner.upload_selected_wires(
device,
&vp.wires[..],
&vp.highlight_handles,
&vp.draw_depths,
);
inner.cached_selection = sel_key;
}
// Live overlay (command preview / interim / grip drag) — small and
// refreshed every frame it's present, so a drag never re-uploads
// the resident base wire buffer.
inner.upload_preview_wires(device, &vp.preview_wires[..], &vp.draw_depths);
let vproj = vp.uniforms.view_proj;
inner.compute_wire_scissors(vproj, clip_size.width, clip_size.height);
inner.compute_wipeout_scissors(vproj, clip_size.width, clip_size.height);
inner.compute_image_scissors(vproj, clip_size.width, clip_size.height);
inner.compute_hatch_lod(queue, vproj, clip_size.width, clip_size.height);
inner.compute_wipeout_lod(vproj, clip_size.width, clip_size.height);
inner.compute_mesh_lod(vproj, clip_size.width, clip_size.height);
if vp.show_viewcube {
inner.viewcube.upload(
queue,
vp.cam_rotation,
(vp.screen_rect.width * bounds.width) as u32,
(vp.screen_rect.height * bounds.height) as u32,
vp.hover_region,
);
}
}
}
fn render(
&self,
pipeline: &MultiPipeline,
encoder: &mut iced::wgpu::CommandEncoder,
target: &iced::wgpu::TextureView,
clip: &Rectangle<u32>,
) {
let cw = clip.width as f32;
let ch = clip.height as f32;
let clip_right = clip.x + clip.width;
let clip_bottom = clip.y + clip.height;
for (i, vp) in self.viewports.iter().enumerate() {
let Some(inner) = pipeline.inners.get(i) else {
break;
};
// Where the viewport would land on the surface in absolute
// pixels (i32 because either edge may stick off the canvas).
let vp_full_x = clip.x as i32 + (vp.screen_rect.x * cw) as i32;
let vp_full_y = clip.y as i32 + (vp.screen_rect.y * ch) as i32;
let vp_full_w = (vp.screen_rect.width * cw).max(1.0) as i32;
let vp_full_h = (vp.screen_rect.height * ch).max(1.0) as i32;
// Intersect with the surface clip — that's the slice we blit.
let dest_x = vp_full_x.max(clip.x as i32);
let dest_y = vp_full_y.max(clip.y as i32);
let dest_right = (vp_full_x + vp_full_w).min(clip_right as i32);
let dest_bottom = (vp_full_y + vp_full_h).min(clip_bottom as i32);
if dest_right <= dest_x || dest_bottom <= dest_y {
continue;
}
let surface_dest = Rectangle {
x: dest_x as u32,
y: dest_y as u32,
width: (dest_right - dest_x) as u32,
height: (dest_bottom - dest_y) as u32,
};
let vp_size = Size::new(vp_full_w.max(1) as u32, vp_full_h.max(1) as u32);
// `mesh_fill` is false for Wireframe 2D / Wireframe 3D — flip
// the draw path so meshes use the wireframe pipeline + the
// pre-built triangle-edge index buffer.
let mesh_wireframe = !vp.mesh_fill;
inner.render(
encoder,
target,
vp_size,
surface_dest,
self.bg_color,
mesh_wireframe,
vp.hidden_line,
vp.show_3d_edges,
);
// The ViewCube renders directly to the surface at the full
// viewport rect. Skip it when the viewport's top-right corner
// (where the cube sits) is off-canvas — wgpu's `set_viewport`
// rejects negative origins, and a clamped cube would scale
// distortedly. The active viewport is normally fully visible.
if vp.show_viewcube
&& vp_full_x >= clip.x as i32
&& vp_full_y >= clip.y as i32
&& vp_full_x + vp_full_w <= clip_right as i32
&& vp_full_y + vp_full_h <= clip_bottom as i32
{
let vp_clip = Rectangle {
x: vp_full_x as u32,
y: vp_full_y as u32,
width: vp_full_w as u32,
height: vp_full_h as u32,
};
inner.viewcube.render(encoder, target, vp_clip);
}
}
}
}
/// On-canvas-visible UV crop on one axis. `pos` and `size` are in the
/// shader widget's normalized 0..1 coords. Returns `(uv_offset, uv_scale)`
/// applied as `actual_uv = quad_uv * uv_scale + uv_offset` in the blit
/// shader — identity `(0.0, 1.0)` for fully on-canvas viewports.
fn uv_crop_axis(pos: f32, size: f32) -> (f32, f32) {
if size <= 0.0 {
return (0.0, 1.0);
}
let left_off = (-pos).max(0.0);
let right_off = (pos + size - 1.0).max(0.0);
let visible = (size - left_off - right_off).max(0.0);
(left_off / size, visible / size)
}
/// Apply a clip-space crop to `view_proj` so the sub-rect of the original
/// view defined by UV offset `(uo, vo)` + scale `(us, vs)` is remapped to
/// NDC `[-1, 1]^2`. Identity transform when the sub-rect is the whole
/// view (`uo=vo=0`, `us=vs=1`). Used by viewports that hang off the
/// canvas — the camera frustum stays at full-vp aspect, but only the
/// visible portion lands in the MSAA target.
fn crop_view_proj(view_proj: glam::Mat4, uo: f32, vo: f32, us: f32, vs: f32) -> glam::Mat4 {
// Build the matrix that maps the visible clip-space sub-rect
// x ∈ [2uo - 1, 2(uo+us) - 1]
// y ∈ [1 - 2(vo+vs), 1 - 2vo]
// back to NDC [-1, 1]^2. (Texture v is top-down → camera y flips.)
let us = us.max(1e-6);
let vs = vs.max(1e-6);
let sx = 1.0 / us;
let sy = 1.0 / vs;
let tx = (1.0 - 2.0 * uo - us) / us;
let ty = -(1.0 - 2.0 * vo - vs) / vs;
let crop = glam::Mat4::from_cols_array(&[
sx, 0.0, 0.0, 0.0, // col 0
0.0, sy, 0.0, 0.0, // col 1
0.0, 0.0, 1.0, 0.0, // col 2
tx, ty, 0.0, 1.0, // col 3
]);
crop * view_proj
}
// ── Render-style helpers (impl Scene) ────────────────────────────────────
impl Scene {
/// Returns (entity_color, pattern_length, pattern, line_weight_px, aci).
pub(in crate::scene) fn render_style(&self, e: &EntityType) -> ([f32; 4], f32, [f32; 8], f32, u8) {
let (color, pl, pat, lw, aci) = render_style_for(&self.document, e);
let bg = if self.current_layout == "Model" {
self.bg_color
} else {
self.paper_bg_color
};
(adapt_to_bg(color, bg), pl, pat, lw, aci)
}
}
// ── Document-only render-style helpers (no &self, safe to call from parallel contexts) ──
/// Resolves the effective linetype name for an entity, falling back to the
/// layer's linetype when the entity's own linetype is "ByLayer".
pub(in crate::scene) fn linetype_name_for<'a>(document: &'a CadDocument, e: &'a EntityType) -> &'a str {
let elt = &e.common().linetype;
if elt.is_empty() || elt.eq_ignore_ascii_case("bylayer") {
document
.layers
.get(&e.common().layer)
.map(|l| l.line_type.as_str())
.unwrap_or("Continuous")
} else {
elt.as_str()
}
}
/// Returns `(entity_color, pattern_length, pattern, line_weight_px, aci)` for
/// an entity, resolving ByLayer color and linetype from the document.
pub(in crate::scene) fn render_style_for(
document: &CadDocument,
e: &EntityType,
) -> ([f32; 4], f32, [f32; 8], f32, u8) {
let layer_name = &e.common().layer;
let (entity_color, aci) = {
let ec = &e.common().color;
let resolved = if *ec == AcadColor::ByLayer {
document
.layers
.get(layer_name)
.map(|l| &l.color)
.unwrap_or(&AcadColor::WHITE)
} else {
ec
};
let aci = match resolved {
AcadColor::Index(i) => *i,
_ => 0,
};
let [r, g, b, _] = tess_util::aci_to_rgba(resolved);
let alpha = 1.0 - e.common().transparency.as_percent() as f32;
([r, g, b, alpha], aci)
};
let lt_name = linetype_name_for(document, e);
// Effective scale = global LTSCALE × per-entity scale (both default to 1.0).
let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32;
let (pattern_length, pattern) = resolve_pattern(&document.line_types, lt_name, lt_scale);
let line_weight_px = {
// LWDISPLAY is no longer evaluated here — the toggle is now applied in
// the wire shader via `Uniforms.lwdisplay_enable`, so we always bake the
// entity's resolved (layer-inherited) weight. Toggling lineweight
// visibility costs only a uniform write, not a retessellate.
let ew = &e.common().line_weight;
let resolved = match ew {
LineWeight::ByLayer | LineWeight::ByBlock | LineWeight::Default => document
.layers
.get(layer_name)
.map(|l| &l.line_weight)
.unwrap_or(&LineWeight::Default),
_ => ew,
};
const MM_TO_PX: f32 = 96.0 / 25.4;
resolved
.millimeters()
.map(|mm| (mm as f32 * MM_TO_PX).max(1.0))
.unwrap_or(1.0)
};
(entity_color, pattern_length, pattern, line_weight_px, aci)
}
/// Like `render_style_for` but resolves ByBlock properties by inheriting from
/// the INSERT entity's already-resolved style. Call this for exploded block
/// sub-entities so that ByBlock color/linetype/lineweight propagate correctly.
pub(crate) fn render_style_for_block_sub(
document: &CadDocument,
e: &EntityType,
insert_color: [f32; 4],
insert_pat_len: f32,
insert_pat: [f32; 8],
insert_lw_px: f32,
) -> ([f32; 4], f32, [f32; 8], f32, u8) {
let (color, pat_len, pat, lw_px, aci) = render_style_for(document, e);
let common = e.common();
let final_color = if common.color == AcadColor::ByBlock {
insert_color
} else {
color
};
let (final_pat_len, final_pat) = if common.linetype.eq_ignore_ascii_case("byblock") {
(insert_pat_len, insert_pat)
} else {
(pat_len, pat)
};
let final_lw = if matches!(common.line_weight, LineWeight::ByBlock) {
insert_lw_px
} else {
lw_px
};
(final_color, final_pat_len, final_pat, final_lw, aci)
}
/// Adapt white→black or black→white based on background luminance.
/// White entities on light backgrounds become black, black entities on dark
/// backgrounds become white. All other colors pass through unchanged.
pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
let lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2];
let is_white = color[0] > 0.95 && color[1] > 0.95 && color[2] > 0.95;
let is_black = color[0] < 0.05 && color[1] < 0.05 && color[2] < 0.05;
if is_white && lum > 0.5 {
[0.0, 0.0, 0.0, color[3]]
} else if is_black && lum <= 0.5 {
[1.0, 1.0, 1.0, color[3]]
} else {
color
}
}
// ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ──
impl Scene {
/// Build the unified multi-viewport `Primitive` for the current layout.
/// Model layout → one full-window viewport (more once tiled); paper
/// layout → one viewport per floating content viewport. Each entry is
/// rendered into its own screen rectangle by its own inner pipeline.
pub(in crate::scene) fn build_viewports(
&self,
bounds: Rectangle,
model_render_mode: acadrust::entities::ViewportRenderMode,
_hover_region: Option<usize>,
) -> Primitive {
// Hover comes from the scene cell driven by the app-level
// `CursorMoved` handler — the cube overlay sits above the shader
// and would otherwise mask the move event from `Program::update`.
let hover_region = self.viewcube_hover.get();
self.selection.borrow_mut().vp_size = (bounds.width, bounds.height);
if bounds.height > 0.0 {
self.set_render_aspect(bounds.width / bounds.height);
self.set_render_pixel_scale(bounds.width, bounds.height);
}
let canvas = (bounds.width.max(1.0), bounds.height.max(1.0));
let instances = self.active_viewports(canvas.0, canvas.1, model_render_mode);
// Transparent clear — outside drawn geometry the resolve texture
// stays at alpha=0, so the alpha-blended blit reveals the container
// background (model bg, or the desk colour in a paper layout).
let bg_color = [0.0, 0.0, 0.0, 0.0];
let viewports: Vec<ViewportData> = instances
.iter()
.filter_map(|inst| self.viewport_data_for(inst, canvas, hover_region))
.collect();
// Empty viewports → blit nothing; the container background (model bg
// or the paper desk colour) stays visible.
Primitive { viewports, bg_color }
}
/// Build one `ViewportData` from a `ViewportInstance`: gathers the
/// viewport's geometry (full model for the Model view / `Handle::NULL`,
/// or the layer-frozen subset for a paper viewport), its camera
/// uniforms, and the normalized screen rectangle.
fn viewport_data_for(
&self,
inst: &ViewportInstance,
canvas: (f32, f32),
hover_region: Option<usize>,
) -> Option<ViewportData> {
let flags = render_mode_flags(inst.render_mode);
let view_wireframe = !flags.face3d_fill;
// Clip the viewport rect to the canvas; size the per-viewport MSAA
// / depth / resolve textures to that visible portion. Sizing them
// to the full vp rect would blow past wgpu's per-dimension texture
// limit (8192 on common GPUs) once paper-space zoom grows the rect
// far enough off the canvas.
let full = inst.screen_rect;
if full.width <= 0.0 || full.height <= 0.0 {
return None;
}
let visible_x = full.x.max(0.0);
let visible_y = full.y.max(0.0);
let visible_x_end = (full.x + full.width).min(canvas.0);
let visible_y_end = (full.y + full.height).min(canvas.1);
let visible_w = (visible_x_end - visible_x).max(0.0);
let visible_h = (visible_y_end - visible_y).max(0.0);
if visible_w < 1.0 || visible_h < 1.0 {
return None;
}
let uo = ((visible_x - full.x) / full.width).clamp(0.0, 1.0);
let vo = ((visible_y - full.y) / full.height).clamp(0.0, 1.0);
let us = (visible_w / full.width).clamp(0.0, 1.0);
let vs = (visible_h / full.height).clamp(0.0, 1.0);
// Model tiles each get their own tessellation cache keyed off the
// tile's camera — culling/LOD depend on per-tile zoom, not the
// active tile's. Paper-space content viewports already had a
// per-viewport cache.
// `tile_wire_gen` is the Model-tile content id (Phase 3.2): present
// only on the tiled Model path, where a pan reuses the tessellation
// and its id so the GPU wire upload can be skipped. The other wire
// sources don't participate and force a re-upload below.
let (base_arc, tile_wire_gen) = if let Some(tile_idx) = inst.tile_idx {
let aspect = if full.height > 0.0 {
full.width / full.height
} else {
1.0
};
let arc = self.model_tile_wires_arc(tile_idx, &inst.camera, aspect, full.height);
(arc, Some(self.last_model_wire_gen.get()))
} else if inst.paper_sheet {
// The sheet renders the paper block's own entities + viewport
// borders — NOT the projected viewport content (the GPU content
// viewports draw that themselves).
(self.paper_sheet_wires_arc(), None)
} else if inst.handle == acadrust::Handle::NULL {
(self.entity_wires_arc(), None)
} else {
(self.model_wires_for_viewport_arc(inst.handle, full.height), None)
};
// Wire-buffer content id for the upload gate. Preview / interim wires
// are NOT part of this buffer anymore (they go in a separate per-frame
// overlay buffer below), so the base id is the stable tile content gen
// — a drag no longer re-uploads the whole base wire set every move.
// Non-tile paths have no stable id and force a re-upload.
let wire_content_id = match tile_wire_gen {
Some(g) => g,
None => {
let n = self.wire_force_nonce.get().wrapping_add(1);
self.wire_force_nonce.set(n);
n | (1u64 << 63)
}
};
// Split Face3D wires from the rest. The split is content-only (keyed
// by the tile's wire id), so on a pan-reuse it's memoized rather than
// re-walking every wire (handle lookup + clone) each frame. Non-tile
// paths have no stable id and split inline as before.
let (face3d_wires, other_arc) = match tile_wire_gen {
Some(gen) => {
let cached = {
let c = self.split_cache.borrow();
c.as_ref().filter(|(g, ..)| *g == gen).map(|(_, f, o)| (f.clone(), o.clone()))
};
cached.unwrap_or_else(|| {
let (f, o) = split_face3d_wires(&base_arc, &self.document);
let (fa, oa) = (Arc::new(f), Arc::new(o));
*self.split_cache.borrow_mut() = Some((gen, fa.clone(), oa.clone()));
(fa, oa)
})
}
None => {
let (f, o) = split_face3d_wires(&base_arc, &self.document);
(Arc::new(f), Arc::new(o))
}
};
// Base wire set — the cached `other` Arc directly, never cloned to
// append overlays. Preview / interim wires ride in their own small
// per-frame buffer so the (potentially huge) base buffer stays resident
// and unchanged while a command preview or grip drag is live.
let all_wires = other_arc;
let preview_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() {
Arc::new(Vec::new())
} else {
let mut v: Vec<WireModel> = Vec::with_capacity(self.preview_wires.len() + 1);
if let Some(iw) = &self.interim_wire {
v.push(iw.clone());
}
v.extend(self.preview_wires.iter().cloned());
Arc::new(v)
};
// Build the camera at the *full* viewport's aspect so the ortho
// frustum matches what the viewport entity stores, then post-
// multiply by a clip-space "zoom into the visible sub-rect" that
// maps the visible portion to NDC [-1, 1]. Geometry passes
// rasterize into a visible-sized MSAA, so `viewport_size` (used
// by the wire shader to extrude line thickness in screen pixels)
// must be the visible size — but `world_per_pixel` is invariant
// under cropping (full_h cancels with vs) so the value computed
// from the full bounds is the one we want.
let full_bounds = Rectangle {
x: 0.0,
y: 0.0,
width: full.width.max(1.0),
height: full.height.max(1.0),
};
let mut uniforms =
Uniforms::new(&inst.camera, full_bounds, self.document.header.lineweight_display);
uniforms.view_proj = crop_view_proj(uniforms.view_proj, uo, vo, us, vs);
uniforms.viewport_size = [visible_w, visible_h];
uniforms.flat_shade = if flags.flat_shade { 1.0 } else { 0.0 };
uniforms.transparency_enable = if self.transparency_display { 1.0 } else { 0.0 };
// `screen_rect` carries the *visible* sub-rectangle in normalized
// canvas coords — that's what `Pipeline::prepare` uses to size
// the per-viewport textures and what `Primitive::render` uses to
// pick the surface destination. The UV crop uniform reads as
// identity here, since the texture already covers exactly the
// visible portion.
let screen_rect = Rectangle {
x: visible_x / canvas.0,
y: visible_y / canvas.1,
width: visible_w / canvas.0,
height: visible_h / canvas.1,
};
// The paper sheet instance renders only the paper layout block's own
// fills (plus a synthetic white fill for the printable area) — NOT the
// model-block hatches. Those belong inside the floating content
// viewports; rendering them on the full-canvas sheet would let them
// bleed past the viewport borders whenever model coords overlap the
// paper area. Content viewports keep the full set (the model camera +
// per-viewport scissor place / clip them correctly).
let (hatches, wipeout_hatches) = if inst.paper_sheet {
let mut v: Vec<HatchModel> = Vec::new();
if let Some(sheet) = self.paper_sheet_fill() {
v.push(sheet);
}
v.extend(self.paper_canvas_hatches().iter().cloned());
(Arc::new(v), self.paper_canvas_wipeouts())
} else {
(self.hatch_models_arc(), self.wipeout_models_arc())
};
let images = if inst.paper_sheet {
self.paper_sheet_images()
} else {
self.images_arc()
};
Some(ViewportData {
wires: all_wires,
preview_wires,
face3d_wires,
draw_depths: self.draw_depth_map(),
hatches,
wipeout_hatches,
images,
meshes: self.meshes_arc(),
uniforms,
cam_rotation: inst.camera.view_rotation_mat(),
// Only the active viewport gets the hovered-region highlight.
hover_region: if inst.active { hover_region } else { None },
show_viewcube: inst.active,
fill_mode: self.document.header.fill_mode,
view_wireframe,
mesh_fill: flags.mesh_fill,
show_3d_edges: flags.show_3d_edges,
hidden_line: flags.hidden_line,
geometry_epoch: self.geometry_epoch,
camera_generation: self.camera_generation,
wire_content_id,
highlight_handles: self.highlight_handles(),
selection_generation: self.selection_generation,
selected_sig: self.selected_set_sig(),
screen_rect,
})
}
/// Update viewcube hover state from cursor position within `bounds`.
///
/// The cube draws in the top-right of the *active model tile* (which fills
/// the canvas when there is a single tile), so the hover hit-test maps the
/// cursor into that tile's local space and uses the tile's dimensions.
pub(in crate::scene) fn update_viewcube_state(
&self,
state: &mut CameraState,
bounds: Rectangle,
cursor: mouse::Cursor,
) {
let pos = cursor.position_in(bounds);
let cam_rotation = self.camera.borrow().view_rotation_mat();
if let Some(p) = pos {
let tile = self.active_model_tile_bounds(bounds.width, bounds.height);
state.hover_region = hover_id(
p.x - tile.x,
p.y - tile.y,
tile.width,
tile.height,
cam_rotation,
VIEWCUBE_PX,
);
} else {
state.hover_region = None;
}
}
pub(in crate::scene) fn viewcube_mouse_interaction(&self, state: &CameraState) -> mouse::Interaction {
if state.hover_region.is_some() {
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
}
// ── Linetype pattern helper ───────────────────────────────────────────────
pub(crate) fn resolve_pattern(
table: &acadrust::tables::Table<LineType>,
name: &str,
scale: f32,
) -> (f32, [f32; 8]) {
let solid = (0.0, [0.0f32; 8]);
if name.eq_ignore_ascii_case("continuous")
|| name.eq_ignore_ascii_case("bylayer")
|| name.eq_ignore_ascii_case("byblock")
|| name.is_empty()
{
return solid;
}
let lt = match table.get(name) {
Some(lt) => lt,
None => return solid,
};
if lt.is_continuous() || lt.elements.is_empty() {
return solid;
}
let mut pat = [0.0f32; 8];
let mut pat_len = 0.0f32;
for (i, el) in lt.elements.iter().take(8).enumerate() {
let raw = el.length as f32 * scale;
let encoded = if raw == 0.0 {
0.01 * scale.max(0.01)
} else {
raw
};
pat[i] = encoded;
pat_len += encoded.abs();
}
if pat_len < 1e-6 {
return solid;
}
(pat_len, pat)
}
/// Partition a wire list into (face3d_wires, other_wires).
///
/// Uses a document handle lookup so no changes to WireModel are needed.
/// O(N) per geometry epoch — acceptable since it runs once per epoch.
fn split_face3d_wires(
wires: &[WireModel],
document: &acadrust::CadDocument,
) -> (Vec<WireModel>, Vec<WireModel>) {
let mut face3d = Vec::new();
let mut others = Vec::new();
for w in wires {
let is_face3d = w
.name
.parse::<u64>()
.ok()
.and_then(|v| document.get_entity(Handle::new(v)))
.map(|e| matches!(e, EntityType::Face3D(_)))
.unwrap_or(false);
if is_face3d {
face3d.push(w.clone());
} else {
others.push(w.clone());
}
}
(face3d, others)
}
+202
View File
@@ -0,0 +1,202 @@
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),
}
}
/// Reflection across the world-XY line through `p1`→`p2` as an acadrust
/// `Transform`, for delegating MIRROR to acadrust's entity-aware
/// `apply_transform` paths (which handle direction flags, stored-angle
/// conventions and bulges themselves). Degenerate line → identity.
pub fn reflection_about_xy_line(p1: Vec3, p2: Vec3) -> acadrust::types::Transform {
use acadrust::types::{Matrix4, Transform};
let dx = (p2.x - p1.x) as f64;
let dy = (p2.y - p1.y) as f64;
if dx * dx + dy * dy < 1e-12 {
return Transform::identity();
}
let ang = dy.atan2(dx);
let m = Matrix4::translation(p1.x as f64, p1.y as f64, 0.0)
* Matrix4::rotation_z(ang)
* Matrix4::scaling(1.0, -1.0, 1.0)
* Matrix4::rotation_z(-ang)
* Matrix4::translation(-(p1.x as f64), -(p1.y as f64), 0.0);
Transform::from_matrix(m)
}
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);
}
/// DXF arbitrary-axis algorithm — returns the OCS X and Y basis vectors in WCS
/// for a given entity normal vector.
///
/// Returns (Ax, Ay) each as (x, y, z) tuples. When normal ≈ (0,0,1) the
/// function returns the standard basis ((1,0,0), (0,1,0)) without any
/// cross-product computation.
pub fn ocs_axes(normal: (f64, f64, f64)) -> ((f64, f64, f64), (f64, f64, f64)) {
let (nx, ny, nz) = normal;
// Fast path: normal is effectively Z-up, OCS = WCS.
if nx.abs() < 1e-10 && ny.abs() < 1e-10 && (nz - 1.0).abs() < 1e-10 {
return ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0));
}
// Arbitrary-axis algorithm (DXF spec).
// When |Nx| < 1/64 and |Ny| < 1/64 use Wy=(0,1,0)×N, else Wx=(1,0,0)×N.
let (ax_x, ax_y, ax_z) = if nx.abs() < 1.0 / 64.0 && ny.abs() < 1.0 / 64.0 {
// (0,1,0) × (nx,ny,nz) = (nz, 0, -nx)
let (x, y, z) = (nz, 0.0, -nx);
let len = (x * x + y * y + z * z).sqrt().max(1e-12);
(x / len, y / len, z / len)
} else {
// (1,0,0) × (nx,ny,nz) = (0, -nz, ny)
let (x, y, z) = (0.0, -nz, ny);
let len = (x * x + y * y + z * z).sqrt().max(1e-12);
(x / len, y / len, z / len)
};
// Ay = N × Ax
let ay_x = ny * ax_z - nz * ax_y;
let ay_y = nz * ax_x - nx * ax_z;
let ay_z = nx * ax_y - ny * ax_x;
((ax_x, ax_y, ax_z), (ay_x, ay_y, ay_z))
}
/// Transform a single OCS point to WCS using the given normal vector.
#[inline]
pub fn ocs_point_to_wcs(ocs: (f64, f64, f64), normal: (f64, f64, f64)) -> (f64, f64, f64) {
let (ax, ay) = ocs_axes(normal);
let (ox, oy, oz) = ocs;
let (nx, ny, nz) = normal;
(
ox * ax.0 + oy * ay.0 + oz * nx,
ox * ax.1 + oy * ay.1 + oz * ny,
ox * ax.2 + oy * ay.2 + oz * nz,
)
}
#[cfg(test)]
mod mirror_delegation_tests {
use super::*;
use crate::command::EntityTransform;
use crate::entities::traits::Transformable;
use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, CircularArcEdge, LineEdge};
use acadrust::entities::Hatch;
use acadrust::types::Vector2;
// MIRROR on a hatch goes through reflection_about_xy_line + acadrust's
// transform_hatch. Mirror a Line→Arc→Line path across a vertical line and
// assert the arc stays endpoint-continuous, flips its direction flag, and
// keeps its sweep magnitude (the stored-angle conventions the old
// hand-rolled closure violated).
#[test]
fn hatch_mirror_keeps_arc_continuous_and_sweep() {
let mut path = BoundaryPath::new();
path.edges.push(BoundaryEdge::Line(LineEdge {
start: Vector2::new(0.0, -1.0),
end: Vector2::new(2.0, 0.0),
}));
path.edges.push(BoundaryEdge::CircularArc(CircularArcEdge {
center: Vector2::new(1.0, 0.0),
radius: 1.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
counter_clockwise: true,
}));
path.edges.push(BoundaryEdge::Line(LineEdge {
start: Vector2::new(0.0, 0.0),
end: Vector2::new(0.0, -1.0),
}));
let mut h = Hatch::new();
h.paths.push(path);
// Mirror across the vertical line x = 5.
h.apply_transform(&EntityTransform::Mirror {
p1: Vec3::new(5.0, 0.0, 0.0),
p2: Vec3::new(5.0, 1.0, 0.0),
});
let edges = &h.paths[0].edges;
let (l1, arc) = match (&edges[0], &edges[1]) {
(BoundaryEdge::Line(a), BoundaryEdge::CircularArc(b)) => (a, b),
_ => panic!("edge kinds changed"),
};
assert!(!arc.counter_clockwise, "mirror must flip the flag");
let sweep = arc.end_angle - arc.start_angle;
assert!(
(sweep - std::f64::consts::PI).abs() < 1e-9,
"stored sweep must stay π, got {sweep}"
);
// Stored-angle convention: true point of a CW edge is at -θ.
let pt = |theta: f64| {
let a = if arc.counter_clockwise { theta } else { -theta };
(
arc.center.x + arc.radius * a.cos(),
arc.center.y + arc.radius * a.sin(),
)
};
let (sx, sy) = pt(arc.start_angle);
assert!(
(sx - l1.end.x).abs() < 1e-9 && (sy - l1.end.y).abs() < 1e-9,
"arc start {:?} must meet previous line end {:?}",
(sx, sy),
(l1.end.x, l1.end.y)
);
// Mirror of (2,0) across x=5 is (8,0).
assert!((sx - 8.0).abs() < 1e-9 && sy.abs() < 1e-9);
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Unified GPU shader widget for both model and paper layouts.
//!
//! There is one `shader::Program` for the whole canvas. `Scene::build_viewports`
//! decides what to render — model layout produces one viewport per tile
//! (`active_viewports` iterates `model_tiles`), paper layout produces one per
//! content viewport entity. Each one is drawn into its own scissor rect by a
//! dedicated inner `Pipeline` (kept in the `MultiPipeline` outer).
use super::render::{CameraState, Primitive};
use crate::scene::Scene;
use iced::widget::shader;
use iced::{mouse, Event, Rectangle};
// ── Widget struct ─────────────────────────────────────────────────────────
pub struct ViewportPane<'a> {
pub scene: &'a Scene,
pub show_viewcube: bool,
/// Render mode applied to the Model layout's tiles. Paper-space content
/// viewports use the render mode stored on their own viewport entity.
pub render_mode: acadrust::entities::ViewportRenderMode,
}
impl<'a> ViewportPane<'a> {
pub fn model(
scene: &'a Scene,
show_viewcube: bool,
render_mode: acadrust::entities::ViewportRenderMode,
) -> Self {
Self {
scene,
show_viewcube,
render_mode,
}
}
}
// ── shader::Program impl ──────────────────────────────────────────────────
impl<'a, Msg: std::fmt::Debug + Clone> shader::Program<Msg> for ViewportPane<'a> {
type State = CameraState;
type Primitive = Primitive;
fn draw(
&self,
state: &Self::State,
_cursor: mouse::Cursor,
bounds: Rectangle,
) -> Self::Primitive {
self.scene
.build_viewports(bounds, self.render_mode, state.hover_region)
}
fn update(
&self,
state: &mut Self::State,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<iced::widget::Action<Msg>> {
// ViewCube hover is also driven from the app-level CursorMoved /
// ViewportMove handlers (the cube hit-area overlay shadows this
// widget for those events). Keeping the call here gives a fallback
// path while the cursor is over the bare shader.
if self.show_viewcube {
self.scene.update_viewcube_state(state, bounds, cursor);
} else {
state.hover_region = None;
}
let _ = event;
None
}
fn mouse_interaction(
&self,
state: &Self::State,
_b: Rectangle,
_c: mouse::Cursor,
) -> mouse::Interaction {
if self.show_viewcube {
self.scene.viewcube_mouse_interaction(state)
} else {
mouse::Interaction::default()
}
}
}