diff --git a/CHANGELOG.md b/CHANGELOG.md index 2761a73..31a2175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -321,6 +321,12 @@ breaking changes to the API or the command-line interface; patch releases ### Changed +- Deck reader: under `--fasthenry-compat`, a `contact decay_rect` or + seven-value `contact rect` cell at or above the rectangle's width on an + axis is clamped to 0.99 × that width, as FastHenry does, with one + line-numbered warning per clamped axis (issue #145). Both axes clamp at + `cell >= width`. Natively such a cell is still an error, and a cell ≤ 0 + is an error in both modes. - Deck reader: a corner-point `G` statement's `contact rect` or `contact decay_rect` wholly outside the plane's footprint is no longer a plane assembly error on line 0 ("contact region … lies outside the plane diff --git a/docs/fasthenry-compat.md b/docs/fasthenry-compat.md index c14d6de..c894919 100644 --- a/docs/fasthenry-compat.md +++ b/docs/fasthenry-compat.md @@ -116,9 +116,9 @@ records how each documented field maps. | `file=` (a named hierarchy file) | Not supported | Issue #122. Rejected by name on the statement's own line: a stored nonuniform-discretization hierarchy is an *input* this reader does not read (not, as the message used to say, an output it does not write), and silently ignoring it would mesh the plane at a resolution the deck never asked for. The error names the file, says only `file=NONE` is accepted, and points at the in-statement alternatives — `seg1`/`seg2` or `contact initial_grid (n1, n2)` plus the `contact` refinement clauses, which is what this reader discretizes from. | | In-plane node `N (x, y, z)` | Supported | An ordinary deck node belonging to the plane; a reference to it lands on the nearest live cell-centre node of that plane. | | `hole rect (x1, y1, z1, x2, y2, z2)` | Supported | Two opposite corners, as documented, onto the plane model's rectangular hole. The redundant `z` coordinates are checked against the plane's slab, so a rectangle meant for another plane cannot land here silently. A rectangle (like a `hole point`/`circle`, `contact rect` or `contact decay_rect`) wholly outside the plane's footprint is accepted with a line-numbered warning — see the decision below (issue #105). | -| `contact rect (x, y, z, xwidth, ywidth, xcell, ycell)` | Supported | Issue #95. The **documented** spelling — the rectangle's centre, its full widths about that centre, and the largest cell wanted inside it, the same shape of argument list every other `contact` shape uses. It is exactly `contact decay_rect` without the outward limits, and is read as one with both `maxcell`s at the negative "no limit" sentinel, so the row below describes it in full: `ceil(width/cell)` fine cells per axis, the documented decay law `1/(1 − cell/width)` outside, `cell` smaller than `width`. | +| `contact rect (x, y, z, xwidth, ywidth, xcell, ycell)` | Supported | Issue #95. The **documented** spelling — the rectangle's centre, its full widths about that centre, and the largest cell wanted inside it, the same shape of argument list every other `contact` shape uses. It is exactly `contact decay_rect` without the outward limits, and is read as one with both `maxcell`s at the negative "no limit" sentinel, so the row below describes it in full: `ceil(width/cell)` fine cells per axis, the documented decay law `1/(1 − cell/width)` outside, `cell` smaller than `width` (natively; under `--fasthenry-compat` a `cell` at or above `width` is clamped to 0.99 × `width` with a warning, issue #145 — see the row below). | | `contact rect (x1, y1, z1, x2, y2, z2)` | Supported, extended | Issue #95. Two opposite corners, as `hole rect` spells them — **not** documented FastHenry syntax but this reader's own extension, kept because decks written against earlier releases use it. Refined to 2 × 2 fine cells at ratio 2, which is the seven-value form at `xcell = xwidth/2`, `ycell = ywidth/2`: `contact rect (4, 2, 0, 6, 4, 0)` and `contact rect (5, 3, 0, 2, 2, 1, 1)` are the same region. The two spellings are told apart by **value count alone**, and any other count is a line-numbered error naming both — see the decision below. | -| `contact decay_rect (x, y, z, xwidth, ywidth, xcell, ycell, xmaxcell, ymaxcell)` | Supported, differs | Issue #80. Maps onto `fasterhenry::plane::ContactRegion`: centre and full widths give the rectangle, `ceil(width/cell)` per axis gives the fine cells, and the documented decay law `1/(1 − cell/width)` gives that axis's growth ratio. All three `x…`/`y…` pairs are in the plane's own coordinate system (`x…` along `p1 → p2`), per the decision below; the centre is a global coordinate. `cell` must be smaller than `width` (the documentation's own `r0 < 1`). The differences are both in the outward limit: this engine's grading levels off at the plane's **background cell** rather than at `maxcell`, so a positive `maxcell` **finer** than the background cell is rejected by name (raise `seg1`/`seg2` instead of shipping a quietly coarser mesh), while one at or above it never binds; a negative `maxcell` — the sentinel the documented `contact connection` shorthand expands to — asks for no limit. The resulting mesh is this engine's own graded cell-centre mesh, not FastHenry's cell subdivision, so equal cell sizes mean equal resolution, not an identical node set. | +| `contact decay_rect (x, y, z, xwidth, ywidth, xcell, ycell, xmaxcell, ymaxcell)` | Supported, differs | Issue #80. Maps onto `fasterhenry::plane::ContactRegion`: centre and full widths give the rectangle, `ceil(width/cell)` per axis gives the fine cells, and the documented decay law `1/(1 − cell/width)` gives that axis's growth ratio. All three `x…`/`y…` pairs are in the plane's own coordinate system (`x…` along `p1 → p2`), per the decision below; the centre is a global coordinate. `cell` must be smaller than `width` (the documentation's own `r0 < 1`); natively a `cell` at or above `width` is a line-numbered error. Issue #145: under `--fasthenry-compat`, such a cell is clamped to 0.99 × `width` on that axis — FastHenry's own behaviour, a plain refinement with effectively no decay (ratio 100) — with one line-numbered warning per clamped axis. Both axes clamp at `cell >= width`; FastHenry clamps y only at `ycell > ywidth` (an equal y cell gives it r0 = 1), an asymmetry this reader does not reproduce. A `cell` ≤ 0 is an error in both modes. The differences are both in the outward limit: this engine's grading levels off at the plane's **background cell** rather than at `maxcell`, so a positive `maxcell` **finer** than the background cell is rejected by name (raise `seg1`/`seg2` instead of shipping a quietly coarser mesh), while one at or above it never binds; a negative `maxcell` — the sentinel the documented `contact connection` shorthand expands to — asks for no limit. The resulting mesh is this engine's own graded cell-centre mesh, not FastHenry's cell subdivision, so equal cell sizes mean equal resolution, not an identical node set. | | `hole point (x, y, z)`, `hole circle (x, y, z, r)` | Supported | Issue #98. Map onto `fasterhenry::plane::Hole::Point` and `Hole::Circle`, widening the plane's hole model beyond the axis-aligned rectangle. A point removes exactly the one cell whose own extent (edges included) contains it; a point landing exactly on a shared cell edge or corner is the documented tie and removes every cell touching it, rather than guessing a single winner. A circle removes every cell whose centre lies at or inside its radius `r` — a centre exactly on the circle counts (a closed boundary, unlike `hole rect`'s open one, since there is no prior rectangle behaviour to match). Both check their own `z` against the plane's slab like every other hole/contact clause. | | `contact equiv_rect N (x, y, z, xwidth, ywidth)` | Supported, differs | Issue #101. The named **contact area**: the rectangle (centre and full widths, as `decay_rect` spells it — and, like `decay_rect`'s, the widths are in the plane's own coordinate system, per the decision below) is tied to the one node the clause names, so a deck can `.equiv` an external node onto a landing *pad* rather than a point. It maps onto the new `fasterhenry::plane::Equipotential`: every live cell centre inside the rectangle — boundary included — shares one node, the bars between those cells are not built, and the bars crossing the patch's boundary end on that node, which sits at the mean of the cell centres it ties. That last part is the difference worth stating: the model is exact for a patch covering one cell, and for a larger patch each entering bar reaches the tie through metal the mesh still treats as ordinary plane rather than as the perfect conductor the patch is — an over-, never under-, estimate of the contact's own resistance, and one that (unlike landing on a single cell centre) does not grow as the mesh is refined. Two rectangles that tie a cell in common merge into one equipotential. The node name follows the in-plane-node rule (`N`-prefixed); a rectangle catching no live cell centre is a line-numbered error naming the plane rather than a silent landing on a neighbouring cell. | | `contact connection N (x, y, z, xwidth, ywidth, ratio)` | Supported | Issue #101. The documented shorthand: exactly a `contact equiv_rect` over the rectangle plus a `contact decay_rect` over the same rectangle with cells `xwidth/ratio`, `ywidth/ratio` and no decay limit (the negative-`maxcell` sentinel in the row above). Writing the two clauses out by hand produces an identical deck, which is what `connection_is_an_equiv_rect_plus_a_decay_rect` in `inp.rs` asserts. `ratio` must be > 1 — it divides the widths into the contact's own cells — and a ratio of 1 or less is a line-numbered error rather than a rectangle that grades nothing. The widths are in the plane's own coordinate system, as in both halves it expands to (decision below). | diff --git a/fasterhenry-cli/src/inp.rs b/fasterhenry-cli/src/inp.rs index c8613b4..f50a599 100644 --- a/fasterhenry-cli/src/inp.rs +++ b/fasterhenry-cli/src/inp.rs @@ -175,7 +175,11 @@ //! cell, the documented decay law, until they reach the plane's //! background cell. `cell` must therefore be smaller than `width` //! (the ratio is otherwise not a ratio at all), and a `cell` equal -//! to half the width is the familiar ratio 2; +//! to half the width is the familiar ratio 2. Under +//! [`ParseOptions::fasthenry_compat`] a `cell` at or above `width` +//! is instead clamped to 0.99 × `width` on that axis, as FastHenry +//! does, with one line-numbered warning per clamped axis (issue +//! #145); a `cell` ≤ 0 is an error in both modes; //! * a positive `maxcell` **finer** than the plane's own background //! cell is rejected by name: this engine's grading levels off *at* //! the background cell, so honouring a tighter limit would need a @@ -1252,15 +1256,23 @@ struct DecayRect { /// `values` may be longer — [`decay_rect_values`] reads its own two /// trailing limits over the top of this — so the caller checks its own /// arity first. +/// +/// Under [`ParseOptions::fasthenry_compat`] (`compat`), a `cell` at or +/// above its axis's `width` is clamped to [`COMPAT_CELL_CLAMP`] × `width` +/// instead of rejected (issue #145): the second value returned holds, per +/// axis, the cell as written wherever it was clamped, for the caller's +/// warning. A `cell` ≤ 0 is an error in both modes. fn contact_rect_values( values: &[String], what: &str, unit: f64, + compat: bool, line: usize, -) -> Result { +) -> Result<(DecayRect, [Option; 2]), ParseError> { let centre = triple(&values[..3], what, unit, line)?; let mut widths = [0.0f64; 2]; let mut cell = [0.0f64; 2]; + let mut clamped = [None; 2]; for axis in 0..2 { let name = ['x', 'y'][axis]; widths[axis] = parse_number(&values[3 + axis], line)? * unit; @@ -1276,6 +1288,11 @@ fn contact_rect_values( )); } cell[axis] = parse_number(&values[5 + axis], line)? * unit; + // Both axes clamp at `>=`, so the ratio below never meets r0 = 1. + if compat && cell[axis] > 0.0 && cell[axis] >= widths[axis] { + clamped[axis] = Some(cell[axis]); + cell[axis] = COMPAT_CELL_CLAMP * widths[axis]; + } if !(cell[axis] > 0.0 && cell[axis] < widths[axis]) { return Err(err( line, @@ -1286,14 +1303,24 @@ fn contact_rect_values( )); } } - Ok(DecayRect { - centre, - widths, - cell, - limit: [None, None], - }) + Ok(( + DecayRect { + centre, + widths, + cell, + limit: [None, None], + }, + clamped, + )) } +/// The fraction of its rectangle's width a `contact rect` / +/// `contact decay_rect` cell at or above that width is clamped to under +/// [`ParseOptions::fasthenry_compat`] (issue #145) — FastHenry's own +/// observed clamp, which keeps the decay ratio `1/(1 − cell/width)` finite +/// (here 100) and so grades effectively nothing outside the rectangle. +const COMPAT_CELL_CLAMP: f64 = 0.99; + /// The nine values of a `contact decay_rect` clause: /// `(x, y, z, xwidth, ywidth, xcell, ycell, xmaxcell, ymaxcell)` — the /// seven of the documented `contact rect` above, plus the largest cell the @@ -1303,8 +1330,9 @@ fn decay_rect_values( values: &[String], what: &str, unit: f64, + compat: bool, line: usize, -) -> Result { +) -> Result<(DecayRect, [Option; 2]), ParseError> { if values.len() != 9 { return Err(err( line, @@ -1314,7 +1342,7 @@ fn decay_rect_values( ), )); } - let mut rect = contact_rect_values(values, what, unit, line)?; + let (mut rect, clamped) = contact_rect_values(values, what, unit, compat, line)?; for axis in 0..2 { let name = ['x', 'y'][axis]; let raw = parse_number(&values[7 + axis], line)?; @@ -1331,7 +1359,7 @@ fn decay_rect_values( )); }; } - Ok(rect) + Ok((rect, clamped)) } /// A `contact point` or `contact line` clause, kept raw until the plane's @@ -1955,7 +1983,9 @@ impl<'a> PlaneStatement<'a> { // default 2 × 2 cells at ratio 2. ("contact", "rect") => match values.len() { 7 => { - let mut decay = contact_rect_values(values, &what, unit, line)?; + let (mut decay, clamped) = + contact_rect_values(values, &what, unit, self.compat, line)?; + self.warn_clamped_cells("'contact rect'", &decay, clamped); decay.centre = self.shift(decay.centre); self.contact_decays .push(("'contact rect'", decay, clause_line)); @@ -1974,7 +2004,9 @@ impl<'a> PlaneStatement<'a> { } }, ("contact", "decay_rect") => { - let mut decay = decay_rect_values(values, &what, unit, line)?; + let (mut decay, clamped) = + decay_rect_values(values, &what, unit, self.compat, line)?; + self.warn_clamped_cells("'contact decay_rect'", &decay, clamped); decay.centre = self.shift(decay.centre); self.contact_decays .push(("'contact decay_rect'", decay, clause_line)); @@ -2003,6 +2035,29 @@ impl<'a> PlaneStatement<'a> { Ok(()) } + /// One warning per axis of a `contact rect` / `contact decay_rect` + /// whose cell [`contact_rect_values`] clamped under + /// [`ParseOptions::fasthenry_compat`] (issue #145), on the clause's own + /// line and in its source order. + fn warn_clamped_cells(&mut self, what: &str, decay: &DecayRect, clamped: [Option; 2]) { + for axis in 0..2 { + let Some(written) = clamped[axis] else { + continue; + }; + let name = ['x', 'y'][axis]; + self.warnings.push(( + self.clause_seq, + ParseWarning { + line: self.clause_line, + message: format!( + "ground plane '{}': {what}: {name}cell={written} metres is not smaller than {name}width={} metres; clamped to {}×{name}width = {} metres, as FastHenry does (--fasthenry-compat), so the rectangle grades effectively nothing outside itself", + self.head, decay.widths[axis], COMPAT_CELL_CLAMP, decay.cell[axis] + ), + }, + )); + } + } + /// `hole circle (x, y, z, r)`. fn apply_hole_circle(&mut self, what: &str, values: &[String]) -> Result<(), ParseError> { let (unit, line) = (self.unit, self.line); @@ -5327,6 +5382,133 @@ Gp x1=0 y1=0 z1=0 x2=10 y2=0 z2=0 x3=10 y3=6 z3=0 } } + /// Issue #145: under `--fasthenry-compat`, a `contact decay_rect` / + /// seven-value `contact rect` cell at or above its axis's width is + /// clamped to 0.99 × that width — the deck then reads exactly as the + /// one writing the clamped cell out — with one warning per clamped + /// axis on the clause's own line. Both axes clamp at `>=`, so a cell + /// *equal* to the width (r0 = 1) clamps on y as on x. + #[test] + fn compat_clamps_a_cell_at_or_above_the_width() { + let statement = |clause: &str| { + plane_deck( + &format!( + "\ +Gp x1=0 y1=0 z1=0 x2=10 y2=0 z2=0 x3=10 y3=6 z3=0 ++ thick=0.04 seg1=5 seg2=3 ++ {clause}" + ), + "", + ) + }; + let compat = |clause: &str| { + // Compat reads line 1 as the title, so the clause moves to line 6. + parse_with_options_reporting(&format!("title\n{}", statement(clause)), COMPAT) + .unwrap_or_else(|error| panic!("{clause}: {error}")) + }; + let clamp_warnings = |warnings: &[ParseWarning]| -> Vec { + warnings + .iter() + .filter(|warning| warning.message.contains("clamped")) + .cloned() + .collect() + }; + for (clause, explicit, axes) in [ + // Both axes above the width, as the issue's black-box deck. + ( + "contact decay_rect (5, 3, 0, 2, 2, 4, 4, 2, 2)", + "contact decay_rect (5, 3, 0, 2, 2, 1.98, 1.98, 2, 2)", + &['x', 'y'][..], + ), + // A negative limit clamps the same way. + ( + "contact decay_rect (5, 3, 0, 2, 2, 4, 4, -1, -1)", + "contact decay_rect (5, 3, 0, 2, 2, 1.98, 1.98, -1, -1)", + &['x', 'y'][..], + ), + // Equal on both axes: `>=` on y too, not FastHenry's `>`. + ( + "contact decay_rect (5, 3, 0, 2, 2, 2, 2, -1, -1)", + "contact decay_rect (5, 3, 0, 2, 2, 1.98, 1.98, -1, -1)", + &['x', 'y'][..], + ), + // One axis only: the other keeps its own cell and decay. + ( + "contact decay_rect (5, 3, 0, 2, 2, 1, 2, -1, -1)", + "contact decay_rect (5, 3, 0, 2, 2, 1, 1.98, -1, -1)", + &['y'][..], + ), + ( + "contact decay_rect (5, 3, 0, 2, 1, 3, 0.5, -1, -1)", + "contact decay_rect (5, 3, 0, 2, 1, 1.98, 0.5, -1, -1)", + &['x'][..], + ), + // The seven-value `contact rect` is the same clause. + ( + "contact rect (5, 3, 0, 2, 2, 2, 5)", + "contact rect (5, 3, 0, 2, 2, 1.98, 1.98)", + &['x', 'y'][..], + ), + ] { + let (clamped, warnings) = compat(clause); + let (expected, explicit_warnings) = compat(explicit); + assert_eq!(clamped.geometry, expected.geometry, "{clause}"); + assert!(clamp_warnings(&explicit_warnings).is_empty(), "{explicit}"); + let warnings = clamp_warnings(&warnings); + assert_eq!(warnings.len(), axes.len(), "{clause}: {warnings:?}"); + for (warning, axis) in warnings.iter().zip(axes) { + // The clause's own continuation line, not the `G` line. + assert_eq!(warning.line, 6, "{clause}"); + assert!( + warning.message.contains(&format!("{axis}cell=")) + && warning.message.contains("0.99×") + && warning.message.contains("ground plane 'Gp'"), + "{clause}: {}", + warning.message + ); + } + // Native mode keeps the error. + let error = parse(&statement(clause)).unwrap_err(); + assert!( + error.message.contains("must be > 0 and smaller than"), + "{clause}: {error}" + ); + } + // A cell ≤ 0 is still an error under compat. + for clause in [ + "contact decay_rect (5, 3, 0, 2, 2, 0, 4, -1, -1)", + "contact rect (5, 3, 0, 2, 2, 4, -1)", + ] { + let error = + parse_with_options(&format!("title\n{}", statement(clause)), COMPAT).unwrap_err(); + assert!( + error.message.contains("must be > 0 and smaller than"), + "{clause}: {error}" + ); + } + } + + /// The clamp warnings take their clause's place among the statement's + /// other warnings, in source order (issue #145). + #[test] + fn compat_clamp_warnings_keep_source_order() { + let deck = plane_deck( + "\ +Gp x1=0 y1=0 z1=0 x2=10 y2=0 z2=0 x3=10 y3=6 z3=0 ++ thick=0.04 seg1=5 seg2=3 ++ hole point (50, 50, 0) contact decay_rect (5, 3, 0, 2, 2, 4, 1, -1, -1) ++ hole point (60, 60, 0)", + "", + ); + let (_, warnings) = + parse_with_options_reporting(&format!("title\n{deck}"), COMPAT).unwrap(); + let shape: Vec<(usize, bool)> = warnings + .iter() + .map(|warning| (warning.line, warning.message.contains("clamped"))) + .collect(); + assert_eq!(shape, [(6, false), (6, true), (7, false)], "{warnings:?}"); + } + /// The unsupported-contact-shape message is user-facing documentation of /// the grammars this reader accepts, so the `contact rect` spelling it /// advertises has to be one the reader actually reads. Asserted by