diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 2f00501d6..553ed01ef 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -782,7 +782,7 @@ impl OpenCADStudio { use crate::modules::insert::solid3d_cmds::PresspullCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); let mut command = PresspullCommand::new(color); - command.set_isolines(self.tabs[i].scene.document.header.isolines.max(0) as usize); + command.set_isolines(crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines)); command.set_preselection(self.presspull_preselection()); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); @@ -793,7 +793,7 @@ impl OpenCADStudio { use crate::modules::insert::solid3d_cmds::RevolveCommand; let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize; + let isolines = crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines); let mut cmd = RevolveCommand::new(color, isolines); if !selected.is_empty() { cmd.set_preselection( @@ -811,7 +811,7 @@ impl OpenCADStudio { "SWEEP" => { use crate::modules::insert::solid3d_cmds::SweepCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize; + let isolines = crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines); let mut cmd = SweepCommand::new(color, isolines); let selected = self.tabs[i].scene.selected_handles_in_order() .into_iter() @@ -829,7 +829,7 @@ impl OpenCADStudio { "LOFT" => { use crate::modules::insert::solid3d_cmds::LoftCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize; + let isolines = crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines); let selected = self.tabs[i].scene.selected_handles_in_order().into_iter() .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) .cloned().map(|entity| (handle, entity))).collect(); diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 929f6fde7..c54d29b57 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -2223,6 +2223,14 @@ impl OpenCADStudio { Some(v) => v .parse::() .map(|x| { + // Stored values stay inside the range + // the Options slider can ask for; the + // consumption sites clamp again for + // file-sourced headers. + let x = x.clamp( + 0, + crate::entities::solid3d::MAX_HEADER_ISOLINES, + ); h.isolines = x; (format!("ISOLINES = {x}"), true) }) @@ -3416,6 +3424,27 @@ mod tests { app } + /// `SETVAR ISOLINES` stored any parseable `i16` verbatim. The header + /// value is the per-face isolate count handed to the tessellation + /// kernel (which loops `0..count`), so `ISOLINES 32767` made every + /// rebuild and per-cursor preview allocate without bound. The stored + /// value must stay inside the range the app's own UI can ask for + /// (the Options slider is 0..=64). + #[test] + fn setvar_isolines_stays_within_the_renderable_range() { + let mut app = fresh_app(); + let i = app.active_tab; + + let _ = app.run_command_line("SETVAR ISOLINES 32767"); + assert_eq!(app.tabs[i].scene.document.header.isolines, 64); + + let _ = app.run_command_line("SETVAR ISOLINES 8"); + assert_eq!(app.tabs[i].scene.document.header.isolines, 8); + + let _ = app.run_command_line("SETVAR ISOLINES -3"); + assert_eq!(app.tabs[i].scene.document.header.isolines, 0); + } + #[test] fn selection_commands_gather_instead_of_using_the_whole_drawing() { for command in ["SETBYLAYER", "OVERKILL"] { diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 9fb65a887..e2e64735a 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1240,7 +1240,7 @@ impl OpenCADStudio { [1.; 4], h.facet_resolution, crate::entities::solid3d::display_deflection(h, h.facet_resolution), - h.isolines.max(0) as usize, + crate::entities::solid3d::clamp_header_isolines(h.isolines), ) }); let curve = crate::entities::curve::entity_curve(entity).map(|planar| { diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index d24b9a703..b8721f0fe 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -110,10 +110,25 @@ fn write_surface_property_state(surface: &mut Surface, state: SurfacePropertySta surface.common.extended_data.upsert_record(record); } +/// Upper bound for the header `$ISOLINES` value handed to tessellation — +/// the Options slider's own ceiling ("Isolines per surface", 0..=64). +pub(crate) const MAX_HEADER_ISOLINES: i16 = 64; + +/// Upper bound for per-surface `u/v_isolines` — the plugin editor's +/// writable range for those fields (0..=200). +pub(crate) const MAX_SURFACE_ISOLINES: i16 = 200; + +/// Header `$ISOLINES` (file- or command-controlled `i16`) → the bounded +/// per-face isolate count handed to the kernel, which draws `0..count` +/// isolines per face with no ceiling of its own. +pub(crate) fn clamp_header_isolines(raw: i16) -> usize { + raw.clamp(0, MAX_HEADER_ISOLINES) as usize +} + pub(crate) fn surface_isoline_counts(surface: &Surface) -> [usize; 2] { [ - surface.u_isolines.max(0) as usize, - surface.v_isolines.max(0) as usize, + surface.u_isolines.clamp(0, MAX_SURFACE_ISOLINES) as usize, + surface.v_isolines.clamp(0, MAX_SURFACE_ISOLINES) as usize, ] } @@ -939,4 +954,27 @@ mod tests { assert_eq!(min, [0.0, 0.0, 0.0]); assert_eq!(max, [10.0, 10.0, 0.0]); } + + /// `Surface.u/v_isolines` are raw `i16` — a file can carry 32767 and the + /// native property editor only floors at 0 — and the counts go straight + /// into the kernel, which draws `0..count` isolines per face. The + /// bounded answer the rest of the product agrees on is the plugin + /// editor's writable range, 0..=200. + #[test] + fn surface_isolate_counts_stay_within_the_plugin_editor_range() { + use codec::entities::{Surface, SurfaceKind}; + + let mut surface = Surface::new(SurfaceKind::Extruded); + surface.u_isolines = i16::MAX; + surface.v_isolines = -1; + assert_eq!( + surface_isoline_counts(&surface), + [200, 0], + "a hostile surface must not hand the kernel 32767 isolines" + ); + + surface.u_isolines = 8; + surface.v_isolines = 6; + assert_eq!(surface_isoline_counts(&surface), [8, 6]); + } } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index d16269d3a..067b4cbf4 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -319,7 +319,7 @@ impl Scene { let facet_res = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_res); - let isolines = self.document.header.isolines.max(0) as usize; + let isolines = crate::entities::solid3d::clamp_header_isolines(self.document.header.isolines); let mesh_seed = if matches!( &entity, EntityType::Solid3D(_) @@ -603,7 +603,7 @@ impl Scene { let facet_res = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_res); - let isolines = self.document.header.isolines.max(0) as usize; + let isolines = crate::entities::solid3d::clamp_header_isolines(self.document.header.isolines); let mesh_seed = if matches!( &entity, EntityType::Solid3D(_) @@ -832,7 +832,7 @@ impl Scene { let facet_res = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_res); - let isolines = self.document.header.isolines.max(0) as usize; + let isolines = crate::entities::solid3d::clamp_header_isolines(self.document.header.isolines); use crate::par::prelude::*; let built: Vec<(Handle, MeshLodSet, bool)> = entries .into_par_iter() @@ -2578,7 +2578,7 @@ impl Scene { let facet_res = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_res); - let isolines = self.document.header.isolines.max(0) as usize; + let isolines = crate::entities::solid3d::clamp_header_isolines(self.document.header.isolines); // Top-level solids: offset into the render frame, drawn flat. // Block-definition solids: keep block-local coords for per-INSERT // instancing (no offset applied here). diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 84987dcda..af1598ac3 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1294,7 +1294,7 @@ fn build_derived_caches_impl( // definition solids keep block-local coords for per-INSERT instancing. (#123) let facet_res = doc.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&doc.header, facet_res); - let isolines = doc.header.isolines.max(0) as usize; + let isolines = crate::entities::solid3d::clamp_header_isolines(doc.header.isolines); // Real layout blocks come from the Layout objects' block_record handles — // `BlockRecord::is_layout()` is unreliable here (it flags ordinary blocks). let layout_blocks: std::collections::HashSet = doc @@ -3818,7 +3818,7 @@ impl Scene { crate::entities::solid3d::surface_isoline_counts(surface), crate::entities::solid3d::surface_property_state(surface).isolines, ), - _ => ([self.document.header.isolines.max(0) as usize; 2], false), + _ => ([crate::entities::solid3d::clamp_header_isolines(self.document.header.isolines); 2], false), }; let (mut set, wires, center) = crate::scene::model::solid_model::display_from_solid( solid, diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index a236241e2..031c15c04 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -640,6 +640,41 @@ mod tests { assert_eq!(display([2, 2], false), boundaries); } + /// A file-sourced header `$ISOLINES` bypasses `SETVAR` entirely, so the + /// consumption side must clamp too: `prepare_solid_model_display` hands + /// the header count to the kernel for every curved face, and 32767 + /// isolines on a cylinder is a ~100k-vertex preview rebuilt per cursor + /// event. + #[test] + fn header_isolines_are_clamped_before_display_tessellation() { + let measure = |value: i16| { + let mut scene = crate::scene::Scene::new(); + scene.document.header.isolines = value; + let body = cylinder_solid([0.0; 3], 5.0, 12.0).unwrap(); + scene + .prepare_solid_model_display(codec::Handle::new(0xDEAD), &body) + .expect("cylinder display") + .0 + .edge_verts + .len() + }; + + // A hostile file-sourced header must render exactly like the + // largest value the Options slider can request — and a negative + // one exactly like zero. + let hostile = measure(i16::MAX); + assert_eq!( + hostile, + measure(crate::entities::solid3d::MAX_HEADER_ISOLINES), + "header.isolines must be clamped before tessellation" + ); + assert_eq!(measure(-5), measure(0)); + assert!( + hostile < 20_000, + "even the ceiling must stay a bounded preview: {hostile} vertices" + ); + } + #[test] fn box_face_centers_are_six() { let body = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap();