feat(mview): add viewport creation options

Support polygonal and object viewports through native clip-boundary handles, including clipped rendering and selection.

Closes #525
This commit is contained in:
Hakan Seven 2026-07-27 19:28:36 +03:00
commit b8889ccd20
10 changed files with 1039 additions and 136 deletions

View file

@ -1386,8 +1386,36 @@ pub fn tessellate(
}
// ── Fallback for Viewport / Insert / Hatch / Ole2Frame ────────────────
let (points_f64, snap_pts, tangent_geoms, key_vertices) =
let (mut points_f64, snap_pts, tangent_geoms, mut key_vertices) =
fallback_geometry(entity);
let clipped_viewport_polygon = match entity {
EntityType::Viewport(viewport) if !viewport.clip_boundary_handle.is_null() => {
let polygon = crate::scene::project::clip_boundary_polygon_for_document(
document,
viewport.clip_boundary_handle,
viewport.center.z as f32,
);
if polygon.len() >= 3 {
let polygon: Vec<[f64; 3]> = polygon
.into_iter()
.map(|point| {
[
point[0] as f64,
point[1] as f64,
point[2] as f64,
]
})
.collect();
points_f64 = polygon.clone();
points_f64.push(polygon[0]);
key_vertices = polygon.clone();
Some(polygon)
} else {
None
}
}
_ => None,
};
// `points_f64` are absolute world coords; split into the double-single
// high/low pair so the outline reconstructs to f64 precision at UTM scale
// (a NaN separator stays NaN in both buffers).
@ -1427,14 +1455,18 @@ pub fn tessellate(
};
let (pick_tris, pick_tris_low) = match entity {
EntityType::Viewport(vp) if !is_sheet_vp(vp) => {
let (cx, cy, cz) = (vp.center.x, vp.center.y, vp.center.z);
let (hw, hh) = (vp.width / 2.0, vp.height / 2.0);
points_to_ds(crate::entities::common::quad_pick_tris(&[
[cx - hw, cy - hh, cz],
[cx + hw, cy - hh, cz],
[cx + hw, cy + hh, cz],
[cx - hw, cy + hh, cz],
]))
if let Some(polygon) = clipped_viewport_polygon.as_ref() {
points_to_ds(crate::entities::mesh::triangulate_planar(polygon))
} else {
let (cx, cy, cz) = (vp.center.x, vp.center.y, vp.center.z);
let (hw, hh) = (vp.width / 2.0, vp.height / 2.0);
points_to_ds(crate::entities::common::quad_pick_tris(&[
[cx - hw, cy - hh, cz],
[cx + hw, cy - hh, cz],
[cx + hw, cy + hh, cz],
[cx - hw, cy + hh, cz],
]))
}
}
_ => (Vec::new(), Vec::new()),
};

View file

@ -2710,6 +2710,21 @@ impl Scene {
self.current_layout_block_handle()
}
/// True when an entity belongs to the active layout, including imported
/// DXF entities whose owner handle is NULL but whose BlockRecord still
/// lists the entity. Keep command validation aligned with the same
/// ownership fallback used by rendering and hit-testing.
pub(crate) fn entity_belongs_to_current_layout(&self, handle: Handle) -> bool {
let Some(entity) = self.document.get_entity(handle) else {
return false;
};
self.belongs_to_visible_block(
handle,
entity.common().owner_handle,
self.current_layout_block_handle(),
)
}
/// Returns the block-record handle for `current_layout`.
///
/// Primary path: the Layout object's `block_record` field (set correctly
@ -3066,6 +3081,46 @@ impl Scene {
.or(Some(((0.0, 0.0), (297.0, 210.0))))
}
/// Printable rectangle of the current paper layout, in paper-space units.
/// Falls back to the whole sheet when the layout has no usable margins.
pub fn printable_area_limits(&self) -> Option<((f64, f64), (f64, f64))> {
let ((x0, y0), (x1, y1)) = self.paper_limits()?;
let margins = self.document.objects.values().find_map(|object| {
if let ObjectType::Layout(layout) = object {
if layout.name == self.current_layout {
return Some((
layout.plot_margin_left,
layout.plot_margin_bottom,
layout.plot_margin_right,
layout.plot_margin_top,
layout.plot_rotation,
));
}
}
None
});
let Some((left, bottom, right, top, rotation)) = margins else {
return Some(((x0, y0), (x1, y1)));
};
let (left, bottom, right, top) = match rotation {
1 | 3 => (bottom, left, top, right),
2 => (right, top, left, bottom),
_ => (left, bottom, right, top),
};
let factor = self.paper_space_unit_factor();
let printable = (
(x0 + left * factor, y0 + bottom * factor),
(x1 - right * factor, y1 - top * factor),
);
if printable.1.0 - printable.0.0 < 1e-6
|| printable.1.1 - printable.0.1 < 1e-6
{
Some(((x0, y0), (x1, y1)))
} else {
Some(printable)
}
}
/// Scale of the first user viewport (id > 1) in the current paper layout,
/// used for the status-bar display. Returns `None` in Model space or if
/// no user viewport exists.

View file

@ -418,65 +418,180 @@ impl Scene {
}
/// Tessellated outline of a non-rectangular viewport / XCLIP clip-boundary
/// entity, in paper coordinates, by reusing the entity's own `to_truck`
/// tessellation (Lines). NaN segment breaks are dropped so the result is a
/// single ordered ring suitable for a stencil triangle-fan.
pub(super) fn clip_boundary_polygon(&self, handle: Handle, z: f32) -> Vec<[f32; 3]> {
use std::f64::consts::TAU;
let Some(entity) = self
.document
.entities()
.find(|e| e.common().handle == handle)
else {
return vec![];
};
// Circles and ellipses tessellate directly — their `to_truck` returns a
// parametric TruckObject (not `Lines`), so extracting a polygon there
// would come back empty. Everything else (splines, polylines, …) reuses
// the entity's own `Lines` tessellation.
const N: usize = 64;
match entity {
EntityType::Circle(c) => (0..N)
/// entity, in paper coordinates. Closed polylines are sampled directly
/// because their normal tessellation is a `Contour`, not `Lines`.
pub(crate) fn clip_boundary_polygon(&self, handle: Handle, z: f32) -> Vec<[f32; 3]> {
clip_boundary_polygon_for_document(&self.document, handle, z)
}
}
pub(crate) fn clip_boundary_polygon_for_document(
document: &CadDocument,
handle: Handle,
z: f32,
) -> Vec<[f32; 3]> {
use std::f64::consts::TAU;
let Some(entity) = document
.entities()
.find(|e| e.common().handle == handle)
else {
return vec![];
};
// Circles and ellipses tessellate directly — their `to_truck` returns a
// parametric TruckObject (not `Lines`), so extracting a polygon there
// would come back empty. Everything else (splines, polylines, …) reuses
// the entity's own `Lines` tessellation.
const N: usize = 64;
match entity {
EntityType::Circle(c) => (0..N)
.map(|i| {
let a = i as f64 * TAU / N as f64;
[
(c.center.x + a.cos() * c.radius) as f32,
(c.center.y + a.sin() * c.radius) as f32,
z,
]
})
.collect(),
EntityType::Ellipse(el) => {
// major_axis = center → major endpoint; minor = perp(major) × ratio.
let (mx, my) = (el.major_axis.x, el.major_axis.y);
let r = el.minor_axis_ratio;
(0..N)
.map(|i| {
let a = i as f64 * TAU / N as f64;
[
(c.center.x + a.cos() * c.radius) as f32,
(c.center.y + a.sin() * c.radius) as f32,
z,
]
let t = i as f64 * TAU / N as f64;
let px = mx * t.cos() - my * r * t.sin();
let py = my * t.cos() + mx * r * t.sin();
[(el.center.x + px) as f32, (el.center.y + py) as f32, z]
})
.collect(),
EntityType::Ellipse(el) => {
// major_axis = center → major endpoint; minor = perp(major) × ratio.
let (mx, my) = (el.major_axis.x, el.major_axis.y);
let r = el.minor_axis_ratio;
(0..N)
.map(|i| {
let t = i as f64 * TAU / N as f64;
let px = mx * t.cos() - my * r * t.sin();
let py = my * t.cos() + mx * r * t.sin();
[(el.center.x + px) as f32, (el.center.y + py) as f32, z]
})
.collect()
}
EntityType::LwPolyline(polyline) if polyline.is_closed => {
let vertices: Vec<([f64; 2], f64)> = polyline
.vertices
.iter()
.map(|vertex| {
(
[vertex.location.x, vertex.location.y],
vertex.bulge,
)
})
.collect();
sample_polyline_clip_boundary(
&vertices,
polyline.elevation,
(
polyline.normal.x,
polyline.normal.y,
polyline.normal.z,
),
z,
)
}
EntityType::Polyline2D(polyline) if polyline.is_closed() => {
let vertices: Vec<([f64; 2], f64)> = polyline
.vertices
.iter()
.map(|vertex| {
(
[vertex.location.x, vertex.location.y],
vertex.bulge,
)
})
.collect();
sample_polyline_clip_boundary(
&vertices,
polyline.elevation,
(
polyline.normal.x,
polyline.normal.y,
polyline.normal.z,
),
z,
)
}
EntityType::Polyline(polyline) if polyline.is_closed() => polyline
.vertices
.iter()
.map(|vertex| {
[
vertex.location.x as f32,
vertex.location.y as f32,
z,
]
})
.collect(),
EntityType::Polyline3D(polyline) if polyline.flags.closed => polyline
.vertices
.iter()
.map(|vertex| {
[
vertex.position.x as f32,
vertex.position.y as f32,
z,
]
})
.collect(),
_ => {
use crate::entities::traits::EntityTypeOps;
let Some(te) = entity.to_truck_entity(document) else {
return vec![];
};
if let crate::scene::convert::acad_to_truck::TruckObject::Lines(pts) = te.object {
pts.into_iter()
.filter(|p| p[0].is_finite() && p[1].is_finite())
.map(|p| [p[0] as f32, p[1] as f32, z])
.collect()
}
_ => {
use crate::entities::traits::EntityTypeOps;
let Some(te) = entity.to_truck_entity(&self.document) else {
return vec![];
};
if let crate::scene::convert::acad_to_truck::TruckObject::Lines(pts) = te.object {
pts.into_iter()
.filter(|p| p[0].is_finite() && p[1].is_finite())
.map(|p| [p[0] as f32, p[1] as f32, z])
.collect()
} else {
vec![]
}
} else {
vec![]
}
}
}
}
fn sample_polyline_clip_boundary(
vertices: &[([f64; 2], f64)],
elevation: f64,
normal: (f64, f64, f64),
z: f32,
) -> Vec<[f32; 3]> {
if vertices.len() < 3 {
return Vec::new();
}
let to_wcs = |point: [f64; 2]| {
crate::scene::view::transform::ocs_point_to_wcs(
(point[0], point[1], elevation),
normal,
)
};
let mut output = Vec::new();
let first = to_wcs(vertices[0].0);
output.push([first.0 as f32, first.1 as f32, z]);
for index in 0..vertices.len() {
let (start, bulge) = vertices[index];
let end_index = (index + 1) % vertices.len();
let end = vertices[end_index].0;
if let Some(arc) =
crate::entities::common::BulgeArc::from_bulge(start, end, bulge)
{
let steps = ((arc.sweep.abs() / std::f64::consts::TAU * 64.0).ceil()
as usize)
.clamp(4, 64);
for step in 1..=steps {
if end_index == 0 && step == steps {
break;
}
let point = to_wcs(arc.sample(step as f64 / steps as f64));
output.push([point.0 as f32, point.1 as f32, z]);
}
} else if end_index != 0 {
let point = to_wcs(end);
output.push([point.0 as f32, point.1 as f32, z]);
}
}
output
}
// ── Paper boundary wire ────────────────────────────────────────────────────
// ── Cohen-Sutherland line clipping ───────────────────────────────────────