Files
OneCAD-Draft/src/scene/paper.rs
T
Hakan SevenandClaude Opus 4.8 bddc050b3e refactor: split four oversized files into submodule trees
Break up the four giant files (~29k lines total) into focused submodule
directories, one concern per file. Pure mechanical moves — behavior is
unchanged.

- app/update.rs (9650) -> app/update/{mod,viewport,command,style,file,
  dynamic,dialog,util}. The 384-arm update_inner match stays in mod.rs
  with thin delegating arms; the 66 fattest arm bodies move to topic
  methods. Arm set preserved exactly (384 -> 384).
- scene/mod.rs (9017) -> scene/{mod,entity,tess,hittest,layout,paper,
  mspace,project,selection,modify,group_layer,preview}. The impl Scene
  body is split across files via inherent-impl-per-file; struct, ctor,
  fields and load-time helpers stay in the root. fn count 123 -> 123.
- app/commands.rs (6082) -> app/commands/* grouped by command family.
  The 233-arm dispatch match becomes source-ordered per-family
  dispatch_* handlers, preserving first-match precedence (233 -> 233).
- app/view.rs (4686) -> app/view/{mod,overlay,modal,viewcube,controls}.

Largest file drops from 9650 to 3343. Builds clean; app launches and
loads plugins with no panic.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-26 22:51:29 +03:00

482 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
use super::*;
impl Scene {
pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera, Handle)> {
self.active_viewports(vw, vh, acadrust::entities::ViewportRenderMode::Wireframe2D)
.into_iter()
.filter(|inst| inst.grid_on)
.map(|inst| (inst.screen_rect, inst.camera, inst.handle))
.collect()
}
/// The viewports to render this frame, one entry per scissor pass.
///
/// - **Model layout**: a single full-canvas instance driven by the
/// scene camera (tiled splits will append more later). `model_mode`
/// supplies its render mode (held on the tab, not the scene).
/// - **Paper layout**: one instance per content viewport entity
/// (`id > 1`, owned by the current layout block, switched on),
/// using each viewport's own camera and render mode.
pub fn active_viewports(
&self,
canvas_w: f32,
canvas_h: f32,
model_mode: acadrust::entities::ViewportRenderMode,
) -> Vec<ViewportInstance> {
if self.current_layout == "Model" {
let tiles = self.model_tiles.borrow();
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
return tiles
.iter()
.enumerate()
.map(|(i, tile)| {
// The active tile renders the live camera (orbit/pan act
// on it); inactive tiles use their stored snapshot.
let camera = if i == active {
self.camera.borrow().clone()
} else {
tile.camera.clone()
};
ViewportInstance {
handle: Handle::NULL,
tile_idx: Some(i),
screen_rect: iced::Rectangle {
x: tile.rect.x * canvas_w,
y: tile.rect.y * canvas_h,
width: tile.rect.width * canvas_w,
height: tile.rect.height * canvas_h,
},
camera,
// The active tile shows the live mode the picker
// drives; every other tile keeps its own stored
// style so editing one never disturbs the rest.
render_mode: if i == active { model_mode } else { tile.render_mode },
active: i == active,
grid_on: tile.grid_on,
paper_sheet: false,
}
})
.collect();
}
let layout_block = self.current_layout_block_handle();
let mut out: Vec<ViewportInstance> = Vec::new();
// The full-canvas sheet viewport renders the paper-space entities
// themselves — the layout's own view, drawn first so the floating
// content viewports overlay it. Its camera keeps the paper pan/zoom
// (target + ortho size) but is LOCKED to the top/plan orientation:
// paper is 2-D, so the sheet never orbits.
let mut sheet_cam = self.camera.borrow().clone();
sheet_cam.yaw = 0.0;
sheet_cam.pitch = std::f32::consts::FRAC_PI_2;
sheet_cam.rotation = view::camera::yaw_pitch_to_quat(0.0, std::f32::consts::FRAC_PI_2, 0.0);
sheet_cam.projection = view::camera::Projection::Orthographic;
let sheet_grid_on = match self
.document
.get_entity(self.current_layout_sheet_viewport_handle())
{
Some(EntityType::Viewport(vp)) => vp.status.grid_on,
_ => false,
};
out.push(ViewportInstance {
handle: Handle::NULL,
tile_idx: None,
screen_rect: iced::Rectangle {
x: 0.0,
y: 0.0,
width: canvas_w,
height: canvas_h,
},
camera: sheet_cam,
render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D,
active: false,
grid_on: sheet_grid_on,
paper_sheet: true,
});
for e in self.document.entities() {
let EntityType::Viewport(vp) = e else {
continue;
};
if !self.is_content_viewport_in_layout(vp, layout_block)
|| !vp.status.is_on
{
continue;
}
let h = vp.common.handle;
let (Some(screen_rect), Some(camera)) = (
self.viewport_screen_rect(h, (canvas_w, canvas_h)),
self.camera_for_viewport(h),
) else {
continue;
};
out.push(ViewportInstance {
handle: h,
tile_idx: None,
screen_rect,
camera,
render_mode: vp.render_mode,
active: self.active_viewport == Some(h),
grid_on: vp.status.grid_on,
paper_sheet: false,
});
}
out
}
/// Convert a paper-space Viewport entity's position/size into a pixel
/// `Rectangle` relative to the top-left of the canvas.
///
/// Uses the same top-down ortho transform as the GPU sheet viewport so the
/// overlay lands exactly over the drawn viewport border regardless of zoom
/// or pan level.
pub fn viewport_screen_rect(
&self,
vp_handle: Handle,
canvas_px: (f32, f32),
) -> Option<iced::Rectangle> {
let vp = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => vp,
_ => return None,
};
let (canvas_w, canvas_h) = canvas_px;
if canvas_w < 1.0 || canvas_h < 1.0 {
return None;
}
let cam = self.camera.borrow();
let aspect = canvas_w / canvas_h;
let half_h = cam.ortho_size();
let half_w = half_h * aspect;
let tx = cam.target.x as f32;
let ty = cam.target.y as f32;
drop(cam);
// Top-down ortho mapping matching the GPU sheet viewport's camera.
let to_px = |wx: f32, wy: f32| -> (f32, f32) {
let x = (wx - tx + half_w) / (2.0 * half_w) * canvas_w;
let y = (ty + half_h - wy) / (2.0 * half_h) * canvas_h;
(x, y)
};
let cx = vp.center.x as f32;
let cy = vp.center.y as f32;
let hw = (vp.width / 2.0) as f32;
let hh = (vp.height / 2.0) as f32;
let (x0, y0) = to_px(cx - hw, cy + hh); // top-left in screen
let (x1, y1) = to_px(cx + hw, cy - hh); // bottom-right in screen
let w = (x1 - x0).max(1.0);
let h = (y1 - y0).max(1.0);
Some(iced::Rectangle {
x: x0,
y: y0,
width: w,
height: h,
})
}
// ── Paper-space helpers ───────────────────────────────────────────────
/// Paper-layout hatch fills, restricted to the active layout block (used by
/// paper-space hatch hit-testing / export). The GPU-rendered
/// content viewports already draw model-block hatches inside their
/// own scissor; including those here would also draw them on the
/// paper sheet through the paper camera (huge / off-position), so
/// restrict the canvas list to entities owned by the active paper
/// layout block. Iterates the source `self.hatches` map (keyed by
/// entity handle) rather than the already-flattened arc — the
/// flattened arc carries pattern names, not handles, so filtering
/// there is unreliable.
pub fn paper_canvas_hatches(&self) -> Arc<Vec<HatchModel>> {
let layout_block = self.current_layout_block_handle();
let layer_hidden = |layer: &str| {
self.document
.layers
.get(layer)
.map(|l| l.flags.off || l.flags.frozen)
.unwrap_or(false)
};
let mut models: Vec<HatchModel> = Vec::new();
for (&handle, model) in self.hatches.iter() {
let Some(entity) = self.document.get_entity(handle) else {
continue;
};
let c = entity.common();
if c.invisible || layer_hidden(&c.layer) {
continue;
}
if !self.belongs_to_visible_block(handle, c.owner_handle, layout_block) {
continue;
}
let mut m = model.clone();
m.color = self.render_style(entity).0;
if let EntityType::Hatch(dxf) = entity {
if let model::hatch_model::HatchPattern::Pattern(_) = &m.pattern {
m.angle_offset = dxf.pattern_angle as f32;
m.scale = dxf.pattern_scale as f32;
}
}
if self.selected.contains(&handle) {
m.color = [0.15, 0.55, 1.00, m.color[3]];
}
models.push(m);
}
Arc::new(models)
}
/// Paper-layout wipeout fills (paper hit-testing / export). Same rationale as
/// `paper_canvas_hatches` — only include wipeouts owned by the
/// active paper layout block, so model wipeouts (drawn through their
/// content viewport's GPU pipeline) don't get a second mis-projected
/// copy on the paper sheet.
pub fn paper_canvas_wipeouts(&self) -> Arc<Vec<HatchModel>> {
let layout_block = self.current_layout_block_handle();
let bg_color = self.paper_bg_color;
let mut models = Vec::new();
for entity in self.document.entities() {
let EntityType::Wipeout(wo) = entity else {
continue;
};
if wo.common.invisible {
continue;
}
if self
.document
.layers
.get(&wo.common.layer)
.map(|l| l.flags.off || l.flags.frozen)
.unwrap_or(false)
{
continue;
}
if !self.belongs_to_visible_block(wo.common.handle, wo.common.owner_handle, layout_block)
{
continue;
}
// Paper-block wipeouts live in paper coords — no `world_offset`.
let (fill_origin, boundary) = Self::wipeout_boundary_2d(wo);
if boundary.len() < 3 {
continue;
}
let mut fill_color = bg_color;
if self.selected.contains(&wo.common.handle) {
fill_color = [0.15, 0.55, 1.00, 0.35];
}
models.push(HatchModel {
boundary: Arc::new(boundary),
pattern: model::hatch_model::HatchPattern::Solid,
name: "WIPEOUT_FILL".into(),
color: fill_color,
angle_offset: 0.0,
scale: 1.0,
world_origin: fill_origin,
vp_scissor: None,
draw_depth: 0.0,
});
}
Arc::new(models)
}
/// Build a Camera oriented and scaled to match a paper-space Viewport entity.
/// Used by `active_viewports` to render model-space content through each
/// content viewport's own view direction and scale.
pub(super) fn camera_for_viewport(&self, vp_handle: Handle) -> Option<view::camera::Camera> {
let vp = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => vp,
_ => return None,
};
// Floating-viewport–specific step: decide saved-view vs auto-fit, then
// hand the effective view to the shared `camera_from_view` decoder so
// twist / view_center / distance behave identically to a model VPORT.
//
// UTM / coordinate-shifted drawings often arrive with
// `view_target = (0, 0, 0)` and a stale `view_center` from before the
// file was geo-referenced; the saved view points at empty WCS while the
// actual model sits ~`world_offset` away. Decode the saved view first
// and keep it only if its target actually frames the model cluster.
//
// The overlap test runs on the *decoded* target (wire-space, so the
// cluster is `±cluster_half` about the origin), NOT a raw
// `view_target + view_center` sum: under a view twist `view_center` is a
// DCS offset, so the raw sum lands far from the real WCS centre and
// would wrongly trip the auto-fit — replacing the saved view_height with
// the whole-cluster fit and rendering the content at the wrong zoom.
let saved_h = vp.view_height.abs();
let aspect_d = (vp.width / vp.height.max(1.0)).max(1e-9);
let cluster_half = self.local_extent_max.max(1.0) as f64;
// Absolute drawing centre. Geometry now reaches the scene at absolute
// (UTM) coordinates — the old code centred the overlap test and the
// auto-fit on the origin, which was right only while world_offset
// re-centred the model there. Without it a UTM drawing sits ~5.7e6 away,
// so a stale `(0,0,0)` saved view failed the overlap test AND the
// auto-fit aimed at empty origin → blank viewports.
// Frame the overlap test / auto-fit on the robust cluster centre (median
// of entity centroids), NOT the raw extents centre: a drawing with a
// far second cluster (e.g. a small-coordinate legend beside a UTM survey)
// has an extents centre in the empty gap, which would reject a valid
// saved view and then auto-fit onto blank space. Fall back to the extents
// centre only when no cluster centre was computed.
let (cx, cy) = if self.local_center != [0.0, 0.0] {
(self.local_center[0], self.local_center[1])
} else {
self.model_space_extents()
.map(|(mn, mx)| {
(((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64)
})
.unwrap_or((0.0, 0.0))
};
if let Some(cam) = self.camera_from_view(
vp.view_direction,
vp.view_target,
acadrust::types::Vector2 {
x: vp.view_center.x,
y: vp.view_center.y,
},
saved_h,
vp.twist_angle,
) {
let half_h = saved_h * 0.5;
let half_w = half_h * aspect_d;
let (tx, ty) = (cam.target.x as f64, cam.target.y as f64);
let overlaps = tx + half_w >= cx - cluster_half
&& tx - half_w <= cx + cluster_half
&& ty + half_h >= cy - cluster_half
&& ty - half_h <= cy + cluster_half;
if overlaps {
return Some(cam);
}
}
// Auto-fit: aim at the content cluster centre, drop the stale view_center.
let fit_h = cluster_half * 2.0 * 1.05;
let tgt = acadrust::types::Vector3 {
x: cx,
y: cy,
z: vp.view_target.z,
};
self.camera_from_view(
vp.view_direction,
tgt,
acadrust::types::Vector2::ZERO,
fit_h,
vp.twist_angle,
)
}
/// Collect model-space WireModels visible through `vp_handle`, respecting
/// global layer visibility, the viewport's per-viewport layer freeze list,
/// and the per-viewport frustum + LOD cull derived from
/// `screen_height_px` (the on-paper pixel height of this viewport).
fn model_wires_for_viewport(
&self,
vp_handle: Handle,
screen_height_px: f32,
) -> Vec<WireModel> {
use rustc_hash::FxHashSet as HSet;
let (frozen, vp_anno_scale, vp_aspect) = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => {
let f: HSet<Handle> = vp.frozen_layers.iter().cloned().collect();
let vp_scale =
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
let anno = if vp_scale > 1e-9 {
(1.0 / vp_scale) as f32
} else {
1.0_f32
};
let aspect = if vp.height > 1e-9 {
(vp.width / vp.height) as f32
} else {
1.0_f32
};
(f, anno, aspect)
}
_ => (HSet::default(), 1.0_f32, 1.0_f32),
};
// Drive the per-viewport view_aabb / wpp from the *effective* camera
// `camera_for_viewport` produces — it folds in the auto-fit
// fallback for UTM-style files whose saved `view_target` sits at
// empty WCS. Without that, the GPU pass would frustum-cull every
// entity (saved-view rect doesn't overlap the offset-subtracted
// model cluster) and the viewport would render blank.
let Some(cam) = self.camera_for_viewport(vp_handle) else {
return Vec::new();
};
let vp_ortho_h = cam.ortho_size();
// Rotation-aware cull box: a twisted/rotated viewport sees a rotated
// rectangle in world XY, so derive the box from the camera basis.
// `None` (tilted view) disables the cull — render everything.
let view_aabb = view_cull_aabb(&cam, vp_aspect, 1.25);
// World units per on-screen pixel for LOD substitution + curve
// tolerance. Tracks the paper-zoom-driven pixel height the
// viewport currently occupies.
let wpp = if screen_height_px > 1.0 {
Some((2.0 * vp_ortho_h) / screen_height_px)
} else {
None
};
self.wires_for_block_culled(
self.model_space_block_handle(),
view_aabb,
wpp,
Some(&frozen),
Some(vp_anno_scale),
)
}
/// Cached per-paper-viewport tessellation. Each viewport's wpp tracks
/// the on-paper pixel height (paper-zoom dependent), so the cache key
/// includes a quantized form of that height in addition to the
/// geometry epoch — every paper zoom step that actually changes the
/// LOD bucket invalidates this viewport's entry.
pub(crate) fn model_wires_for_viewport_arc(
&self,
vp_handle: Handle,
screen_height_px: f32,
) -> Arc<Vec<WireModel>> {
// Drop sub-pixel noise so trivial paper-zoom jitter does not
// re-tessellate a 100k-entity drawing every frame; round to an
// integer pixel.
let height_key = screen_height_px.max(1.0).round() as u32;
// Hash the viewport's own view (pan + zoom + orbit) into the key.
// Editing inside the viewport (MSPACE) changes its frustum but does NOT
// bump geometry_epoch, so without this the stale frustum-culled subset
// is returned and newly-revealed lines stay invisible until the layout
// re-tessellates. Quantize to ~1 px / fine steps to ignore jitter.
let view_key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
if let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) {
let vh = vp.view_height.abs().max(1e-6);
let q = vh / (screen_height_px.max(1.0) as f64); // model units / px
(((vp.view_target.x + vp.view_center.x) / q).round() as i64).hash(&mut h);
(((vp.view_target.y + vp.view_center.y) / q).round() as i64).hash(&mut h);
((vh * 1000.0).round() as i64).hash(&mut h);
((vp.view_direction.x * 1000.0).round() as i64).hash(&mut h);
((vp.view_direction.y * 1000.0).round() as i64).hash(&mut h);
((vp.view_direction.z * 1000.0).round() as i64).hash(&mut h);
}
h.finish()
};
let key = (self.geometry_epoch, height_key, view_key);
{
let cache = self.viewport_wire_cache.borrow();
if let Some((cached_key, ref arc)) = cache.get(&vp_handle) {
if *cached_key == key {
return Arc::clone(arc);
}
}
}
let arc = Arc::new(self.model_wires_for_viewport(vp_handle, screen_height_px));
self.viewport_wire_cache
.borrow_mut()
.insert(vp_handle, (key, Arc::clone(&arc)));
arc
}
}