fix(tess): clamp isoline counts before they reach the kernel

Bug:
$ISOLINES (header i16) and Surface.u/v_isolines (file-controlled i16)
were only ever floored at 0, never capped, and were handed to the
tessellation kernel as the per-face isolate count - which draws
0..count isolines per face with no ceiling of its own. SETVAR ISOLINES
32767 (or a DWG carrying that header, or a Surface with u_isolines =
32767) turned every rebuild and every per-cursor preview of
PRESSPULL/REVOLVE/SWEEP/LOFT into a multi-million-vertex computation:
measured 4,259,966 edge vertices for one 12-unit cylinder preview.

Reproduce:
1. SETVAR ISOLINES 32767 (or open a DWG whose $ISOLINES is large, or
   edit a Surface whose u_isolines is 32767).
2. Draw a circle and start REVOLVE (or PRESSPULL/SWEEP/LOFT), or shade
   any 3D solid.
3. Before: the app allocates without bound - abort on memory failure or
   a permanent freeze; previews recompute on every mouse move.
4. After: previews stay interactive; a hostile header renders exactly
   like the largest value the Options slider can request.

Fix:
Cap each value at the ceiling this product already agrees on, where the
type changes from i16 to the kernel count: header $ISOLINES <= 64 (the
Options slider's own "Isolines per surface" range), Surface
u/v_isolines <= 200 (the plugin editor's writable range). One helper
(clamp_header_isolines) replaces all 11 copies of .max(0) as usize
across the entity/scene/control/display feed sites,
surface_isoline_counts clamps both axes, and SETVAR ISOLINES stores
x.clamp(0, 64) so the echo, slider and re-saved files agree with what
renders; file-sourced headers keep their raw stored value and are
clamped at consumption. Clamping is density-only: geometry, bounds and
saves are untouched. Three regression tests cover hostile surface
counts, the SETVAR entry, and a file-sourced header measured through
display tessellation (hostile renders like the slider ceiling, negative
renders like zero).
This commit is contained in:
Karim Jerbi committed 2026-09-30 19:15:49 +01:00
1 parent 438a46b80f
commit e15d801011
7 files changed
+115 -13

No files matched your search

+4 -4
View File
@@ -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();
+29
View File
@@ -2223,6 +2223,14 @@ impl OpenCADStudio {
Some(v) => v
.parse::<i16>()
.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"] {
+1 -1
View File
@@ -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| {