feat(render): phase 0 — render-mode pick_list + 3-flag pipeline plumbing

Replaces the binary Wireframe/Solid toggle with a 7-mode dropdown
that maps onto acadrust's `ViewportRenderMode` enum: Wireframe 2D /
Wireframe 3D / Hidden Line / Flat Shaded / Gouraud Shaded / Flat
Shaded + Edges / Gouraud Shaded + Edges. Per-tab state lives on
`DocumentTab::render_mode`; the old `SetWireframe` message forwards
to `SetRenderMode` so the ribbon + WIREFRAME / SOLID command line
keep working.

`scene::render::render_mode_flags()` translates the mode into three
pipeline-facing booleans (`face3d_fill`, `mesh_fill`,
`show_3d_edges`); `mesh_fill` / `show_3d_edges` are stubs that the
upcoming phases will consume. `Primitive` carries the derived
`view_wireframe` plus the two new fields through to `prepare()`.

`Pipeline::upload_face3d` takes a `wireframe_only` flag that drops
3DFACE quad fills (built from `key_vertices`) while keeping the
`all_wires.fill_tris` channel — that channel carries text-LOD greek
rectangles and PolyfaceMesh face triangles which must stay visible
in wireframe mode. 3DFACE edges stay on regardless of mode.

Solid3D mesh wireframe rendering, Hidden Line z-prepass, and Flat /
Gouraud shader uniforms come in phases 1–4.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-27 00:08:37 +03:00
1 parent 5ea996d5fe
commit 2145ab8a66
8 files changed
+273 -90

No files matched your search

+3 -1
View File
@@ -27,6 +27,7 @@ pub(super) struct DocumentTab {
pub(super) selected_grips: Vec<GripDef>,
pub(super) selected_handle: Option<Handle>,
pub(super) wireframe: bool,
pub(super) render_mode: acadrust::entities::ViewportRenderMode,
pub(super) visual_style: String,
pub(super) last_cursor_world: glam::Vec3,
pub(super) last_cursor_screen: iced::Point,
@@ -80,7 +81,8 @@ impl DocumentTab {
selected_grips: vec![],
selected_handle: None,
wireframe: false,
visual_style: "Shaded".into(),
render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D,
visual_style: "Wireframe 2D".into(),
last_cursor_world: glam::Vec3::ZERO,
last_cursor_screen: iced::Point::ORIGIN,
history: HistoryState::default(),
+4
View File
@@ -308,6 +308,10 @@ pub enum Message {
SaveDialogCancel,
ClearScene,
SetWireframe(bool),
/// Set the active tab's render mode (one of acadrust's seven visual
/// styles). Replaces the binary `SetWireframe` over time; the older
/// message stays for ribbon/CLI back-compat and forwards.
SetRenderMode(acadrust::entities::ViewportRenderMode),
/// Switch camera projection: true = Orthographic, false = Perspective.
SetProjection(bool),
/// Select a ribbon module tab by index.
+33 -11
View File
@@ -654,19 +654,41 @@ impl OpenCADStudio {
}
Message::SetWireframe(w) => {
let i = self.active_tab;
self.tabs[i].wireframe = w;
self.ribbon.set_wireframe(w);
self.tabs[i].visual_style = if w {
"Wireframe".into()
// Back-compat shim: forward to the new render-mode path so
// the ribbon button + WIREFRAME / SOLID command line still
// work without duplicating the rendering plumbing.
let mode = if w {
acadrust::entities::ViewportRenderMode::Wireframe2D
} else {
"Shaded".into()
acadrust::entities::ViewportRenderMode::FlatShaded
};
self.command_line.push_output(if w {
"Visual style: Wireframe"
} else {
"Visual style: Shaded"
});
Task::done(Message::SetRenderMode(mode))
}
Message::SetRenderMode(mode) => {
use acadrust::entities::ViewportRenderMode as M;
let i = self.active_tab;
self.tabs[i].render_mode = mode;
// Keep the legacy `wireframe` bool synced — both wireframe
// modes set it, everything else clears it.
let wf = matches!(mode, M::Wireframe2D | M::Wireframe3D);
self.tabs[i].wireframe = wf;
self.ribbon.set_wireframe(wf);
let label = match mode {
M::Wireframe2D => "Wireframe 2D",
M::Wireframe3D => "Wireframe 3D",
M::HiddenLine => "Hidden Line",
M::FlatShaded => "Flat Shaded",
M::GouraudShaded => "Gouraud Shaded",
M::FlatShadedWithEdges => "Flat Shaded + Edges",
M::GouraudShadedWithEdges => "Gouraud Shaded + Edges",
};
self.tabs[i].visual_style = label.into();
// Re-upload face3d fills on the next frame — the render
// pipeline keys its upload cache off `geometry_epoch`.
self.tabs[i].scene.bump_geometry();
self.command_line
.push_output(&format!("Visual style: {label}"));
Task::none()
}
+91 -13
View File
@@ -16,6 +16,27 @@ use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Tas
const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX;
/// `pick_list` requires its items to implement `Display`; acadrust's
/// `ViewportRenderMode` enum carries the raw DXF integers, not a label,
/// so wrap it locally with a friendly name renderer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct RenderModeChoice(pub acadrust::entities::ViewportRenderMode);
impl std::fmt::Display for RenderModeChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use acadrust::entities::ViewportRenderMode as M;
f.write_str(match self.0 {
M::Wireframe2D => "Wireframe 2D",
M::Wireframe3D => "Wireframe 3D",
M::HiddenLine => "Hidden Line",
M::FlatShaded => "Flat Shaded",
M::GouraudShaded => "Gouraud Shaded",
M::FlatShadedWithEdges => "Flat Shaded + Edges",
M::GouraudShadedWithEdges => "Gouraud Shaded + Edges",
})
}
}
impl OpenCADStudio {
pub fn view(&self, window_id: window::Id) -> Element<'_, Message> {
// ── Floating panel windows ─────────────────────────────────────────
@@ -236,7 +257,11 @@ impl OpenCADStudio {
} else if is_paper {
paper_canvas_view(tab)
} else {
shader(ViewportPane::model(&tab.scene, self.show_viewcube))
shader(ViewportPane::model(
&tab.scene,
self.show_viewcube,
tab.render_mode,
))
.width(Fill)
.height(Fill)
.into()
@@ -357,15 +382,53 @@ impl OpenCADStudio {
)
};
let info = container(overlay::info_bar(
if is_paper {
&tab.scene.current_layout
} else {
"Custom View"
// Render-mode picker, top-left of the viewport. Drives whether
// 3D face / mesh fills and edges are rendered for the active
// tab. Hatch fills are deliberately *not* gated by this — the
// document's FILLMODE still owns 2D fill state.
let render_modes: Vec<RenderModeChoice> = vec![
RenderModeChoice(acadrust::entities::ViewportRenderMode::Wireframe2D),
RenderModeChoice(acadrust::entities::ViewportRenderMode::Wireframe3D),
RenderModeChoice(acadrust::entities::ViewportRenderMode::HiddenLine),
RenderModeChoice(acadrust::entities::ViewportRenderMode::FlatShaded),
RenderModeChoice(acadrust::entities::ViewportRenderMode::GouraudShaded),
RenderModeChoice(acadrust::entities::ViewportRenderMode::FlatShadedWithEdges),
RenderModeChoice(acadrust::entities::ViewportRenderMode::GouraudShadedWithEdges),
];
let info = container(
iced::widget::pick_list(
render_modes,
Some(RenderModeChoice(tab.render_mode)),
|c| Message::SetRenderMode(c.0),
)
.text_size(11),
)
.style(|_: &Theme| iced::widget::container::Style {
background: Some(iced::Background::Color(Color {
r: 0.10,
g: 0.10,
b: 0.10,
a: 0.75,
})),
border: iced::Border {
color: Color {
r: 0.35,
g: 0.35,
b: 0.35,
a: 1.0,
},
width: 1.0,
radius: 4.0.into(),
},
&tab.visual_style,
))
.padding([4, 6]);
text_color: Some(Color {
r: 0.85,
g: 0.85,
b: 0.85,
a: 1.0,
}),
..Default::default()
})
.padding([4, 8]);
let viewport_mouse = mouse_area(container(
iced::widget::Space::new().width(Fill).height(Fill),
@@ -443,20 +506,35 @@ impl OpenCADStudio {
})
.width(Fill)
.height(Fill),
container(info).width(Fill).height(Fill),
selection_overlay,
viewport_mouse,
// Toggle sits ABOVE the viewport mouse_area so clicks
// inside the toggle's bounds reach it instead of the
// shader behind it; `opaque` blocks the same clicks from
// bubbling further down. Outside the toggle's footprint
// the outer Fill container is transparent — viewport
// drawing / selection stays unaffected.
container(iced::widget::opaque(info))
.width(Fill)
.height(Fill)
.align_x(iced::alignment::Horizontal::Left)
.align_y(iced::alignment::Vertical::Top),
]
.width(Fill)
.height(Fill)
};
if self.show_navbar && !tab.is_start {
// Anchor the nav toolbar to the vertical centre of the
// viewport area, flush against the right edge with a small
// gutter, so it stays aligned no matter how tall the
// window is.
let nav = container(overlay::nav_toolbar())
.align_right(Fill)
.align_top(Fill)
.align_y(iced::alignment::Vertical::Center)
.height(Fill)
.padding(iced::Padding {
top: 148.0,
top: 0.0,
right: 8.0,
bottom: 0.0,
left: 0.0,
@@ -805,7 +883,7 @@ fn paper_canvas_view<'a>(tab: &'a super::document::DocumentTab) -> Element<'a, M
let w = rect.width.clamp(1.0, canvas_w - x);
let h = rect.height.clamp(1.0, canvas_h - y);
let vp_widget = shader(PaperViewportPane::new(scene, vp_handle))
let vp_widget = shader(PaperViewportPane::new(scene, vp_handle, tab.render_mode))
.width(iced::Length::Fixed(w))
.height(iced::Length::Fixed(h));
+15 -6
View File
@@ -784,16 +784,25 @@ impl Pipeline {
device: &wgpu::Device,
face3d_wires: &[WireModel],
all_wires: &[WireModel],
wireframe_only: bool,
) {
let has_fills =
!face3d_wires.is_empty() || all_wires.iter().any(|w| !w.fill_tris.is_empty());
// Edge buffer is always built from `face3d_wires`, so 3DFACE
// outlines stay on the screen regardless of mode.
self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires);
// Fill buffer: in wireframe mode we drop the 3DFACE quads
// (those come from `face3d_wires.key_vertices`) but keep
// `all_wires.fill_tris` — that channel carries 2D content like
// text-LOD greek rectangles and PolyfaceMesh face triangles
// which shouldn't disappear with the toggle.
let face3d_for_fill: &[WireModel] = if wireframe_only { &[] } else { face3d_wires };
let has_fills = !face3d_for_fill.is_empty()
|| all_wires.iter().any(|w| !w.fill_tris.is_empty());
if !has_fills {
self.gpu_face3d_fill = None;
self.gpu_face3d_edges = vec![];
return;
} else {
self.gpu_face3d_fill =
Some(Face3DGpu::from_wires(device, face3d_for_fill, all_wires));
}
self.gpu_face3d_fill = Some(Face3DGpu::from_wires(device, face3d_wires, all_wires));
self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires);
}
pub fn upload_meshes(&mut self, device: &wgpu::Device, meshes: &[MeshLodSet]) {
+89 -8
View File
@@ -98,6 +98,22 @@ pub struct Primitive {
/// Header.fill_mode (FILLMODE): when false, hatch / wipeout / face3d-fill
/// uploads short-circuit so the renderer draws only wireframe.
pub(super) 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(super) 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()`.
#[allow(dead_code)]
pub(super) 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.
#[allow(dead_code)]
pub(super) show_3d_edges: bool,
pub(super) geometry_epoch: u64,
/// Camera generation captured when this Primitive was assembled. Paired
/// with `geometry_epoch` so the wire buffers re-upload when the view
@@ -105,6 +121,48 @@ pub struct Primitive {
pub(super) camera_generation: u64,
}
/// 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` is currently
/// rendered as plain solid (until Phase 2 wires up the depth-only
/// prepass); `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 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,
},
M::HiddenLine => RenderModeFlags {
face3d_fill: true,
mesh_fill: true,
show_3d_edges: true,
},
M::FlatShaded | M::GouraudShaded => RenderModeFlags {
face3d_fill: true,
mesh_fill: true,
show_3d_edges: false,
},
M::FlatShadedWithEdges | M::GouraudShadedWithEdges => RenderModeFlags {
face3d_fill: true,
mesh_fill: true,
show_3d_edges: true,
},
}
}
// ── shader::Primitive impl ────────────────────────────────────────────────
impl shader::Primitive for Primitive {
@@ -132,6 +190,11 @@ impl shader::Primitive for Primitive {
pipeline.upload_uniforms(queue, &self.uniforms);
let cur_key = (self.geometry_epoch, self.camera_generation);
let fill_mode = self.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 && !self.view_wireframe;
if cur_key != pipeline.cached_epoch {
// Static buffers (hatches/images/meshes) only need refresh on a
// real geometry change, not on every camera tick.
@@ -149,12 +212,15 @@ impl shader::Primitive for Primitive {
// Wires re-upload on every camera change because the visible
// subset shifts under frustum culling.
pipeline.upload_wires(device, &self.wires[..]);
if fill_mode {
pipeline.upload_face3d(device, &self.face3d_wires[..], &self.wires[..]);
} else {
// Edges still need to draw, but no fill_tris are forwarded.
pipeline.upload_face3d(device, &self.face3d_wires[..], &[]);
}
// `wireframe_only=true` keeps the face3d edge buffer but
// drops the fill — that's the on-screen result of toggling
// the render mode to Wireframe2D / Wireframe3D.
pipeline.upload_face3d(
device,
&self.face3d_wires[..],
&self.wires[..],
!face3d_fill_active,
);
pipeline.cached_epoch = cur_key;
}
pipeline.compute_wire_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
@@ -337,7 +403,10 @@ impl Scene {
hover_region: Option<usize>,
bounds: Rectangle,
show_viewcube: bool,
render_mode: acadrust::entities::ViewportRenderMode,
) -> Primitive {
let flags = render_mode_flags(render_mode);
let view_wireframe = !flags.face3d_fill;
let cam = self.camera.borrow();
self.selection.borrow_mut().vp_size = (bounds.width, bounds.height);
// Record the active widget's aspect so view_world_aabb() can compute
@@ -379,6 +448,9 @@ impl Scene {
bg_color,
show_viewcube,
fill_mode: self.document.header.fill_mode,
view_wireframe,
mesh_fill: flags.mesh_fill,
show_3d_edges: flags.show_3d_edges,
geometry_epoch: self.geometry_epoch,
camera_generation: self.camera_generation,
}
@@ -391,10 +463,13 @@ impl Scene {
vp_handle: Handle,
hover_region: Option<usize>,
bounds: Rectangle,
render_mode: acadrust::entities::ViewportRenderMode,
) -> Primitive {
let flags = render_mode_flags(render_mode);
let view_wireframe = !flags.face3d_fill;
let cam = match self.camera_for_viewport(vp_handle) {
Some(c) => c,
None => return self.build_primitive(hover_region, bounds, false),
None => return self.build_primitive(hover_region, bounds, false, render_mode),
};
let base_arc = self.model_wires_for_viewport_arc(vp_handle);
@@ -423,6 +498,9 @@ impl Scene {
bg_color: self.bg_color,
show_viewcube: false,
fill_mode: self.document.header.fill_mode,
view_wireframe,
mesh_fill: flags.mesh_fill,
show_3d_edges: flags.show_3d_edges,
geometry_epoch: self.geometry_epoch,
camera_generation: self.camera_generation,
}
@@ -435,8 +513,11 @@ impl Scene {
vp_handle: Handle,
hover_region: Option<usize>,
bounds: Rectangle,
render_mode: acadrust::entities::ViewportRenderMode,
) -> PaperViewportPrimitive {
PaperViewportPrimitive(self.build_viewport_primitive(vp_handle, hover_region, bounds))
PaperViewportPrimitive(
self.build_viewport_primitive(vp_handle, hover_region, bounds, render_mode),
)
}
/// Update viewcube hover state from cursor position within `bounds`.
+37 -12
View File
@@ -20,14 +20,20 @@ pub struct ViewportPane<'a> {
pub scene: &'a Scene,
pub mode: ViewportPaneMode,
pub show_viewcube: bool,
pub render_mode: acadrust::entities::ViewportRenderMode,
}
impl<'a> ViewportPane<'a> {
pub fn model(scene: &'a Scene, show_viewcube: bool) -> Self {
pub fn model(
scene: &'a Scene,
show_viewcube: bool,
render_mode: acadrust::entities::ViewportRenderMode,
) -> Self {
Self {
scene,
mode: ViewportPaneMode::Model,
show_viewcube,
render_mode,
}
}
@@ -39,6 +45,7 @@ impl<'a> ViewportPane<'a> {
scene,
mode: ViewportPaneMode::Paper { handle },
show_viewcube: false,
render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D,
}
}
}
@@ -52,11 +59,20 @@ impl<'a> ViewportPane<'a> {
pub struct PaperViewportPane<'a> {
pub scene: &'a Scene,
pub handle: Handle,
pub render_mode: acadrust::entities::ViewportRenderMode,
}
impl<'a> PaperViewportPane<'a> {
pub fn new(scene: &'a Scene, handle: Handle) -> Self {
Self { scene, handle }
pub fn new(
scene: &'a Scene,
handle: Handle,
render_mode: acadrust::entities::ViewportRenderMode,
) -> Self {
Self {
scene,
handle,
render_mode,
}
}
}
@@ -71,7 +87,12 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program<Msg> for PaperViewportPan
bounds: Rectangle,
) -> Self::Primitive {
self.scene
.build_active_viewport_primitive(self.handle, state.hover_region, bounds)
.build_active_viewport_primitive(
self.handle,
state.hover_region,
bounds,
self.render_mode,
)
}
fn update(
@@ -109,14 +130,18 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program<Msg> for ViewportPane<'a>
bounds: Rectangle,
) -> Self::Primitive {
match &self.mode {
ViewportPaneMode::Model => {
self.scene
.build_primitive(state.hover_region, bounds, self.show_viewcube)
}
ViewportPaneMode::Paper { handle } => {
self.scene
.build_viewport_primitive(*handle, state.hover_region, bounds)
}
ViewportPaneMode::Model => self.scene.build_primitive(
state.hover_region,
bounds,
self.show_viewcube,
self.render_mode,
),
ViewportPaneMode::Paper { handle } => self.scene.build_viewport_primitive(
*handle,
state.hover_region,
bounds,
self.render_mode,
),
}
}
+1 -39
View File
@@ -1,10 +1,9 @@
//! Viewport overlay widgets:
//! • info_bar() — top-left bracket buttons showing view name / style
//! • nav_toolbar() — vertical orbit/pan/zoom buttons on the right
use glam::{Mat4, Vec3};
use iced::mouse;
use iced::widget::{button, canvas, column, container, row, text};
use iced::widget::{button, canvas, column, container, text};
use iced::{Background, Border, Color, Element, Length, Point, Size, Theme};
use crate::app::Message;
@@ -30,43 +29,6 @@ pub struct GripMarker {
pub is_hot: bool,
}
// ── Info bar ─────────────────────────────────────────────────────────────
pub fn info_bar<'a>(view_name: &'a str, visual_style: &'a str) -> Element<'a, Message> {
let bracket = |s: &'a str| -> Element<'a, Message> {
container(text(s).size(10).color(Color {
r: 0.78,
g: 0.78,
b: 0.78,
a: 1.0,
}))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color {
r: 0.10,
g: 0.10,
b: 0.10,
a: 0.75,
})),
border: Border {
color: Color {
r: 0.35,
g: 0.35,
b: 0.35,
a: 1.0,
},
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
})
.padding([2, 6])
.into()
};
row![bracket("[-]"), bracket(view_name), bracket(visual_style)]
.spacing(0)
.into()
}
// ── Nav toolbar ───────────────────────────────────────────────────────────
pub fn nav_toolbar<'a>() -> Element<'a, Message> {