diff --git a/CHANGELOG.md b/CHANGELOG.md index 2761a737..a4c1bca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ breaking changes to the API or the command-line interface; patch releases ### Added +- Deck reader: under `--fasthenry-compat`, a diagonal `contact trace` is + accepted (issue #157) and refined as its bounding box padded by `3w/2`, + with cells `(w/2)·scale_factor^min(|tan θ|, |cot θ|)`, and a + line-numbered warning that this approximates FastHenry's staircase + refinement. Native mode still rejects it. - Deck reader: `relx=` / `rely=` / `relz=` on a corner-point `G` statement (issue #146), the documented offset (User's Guide §1.3.9), in every mode. It is added to every node-reference, hole and contact coordinate on the diff --git a/docs/fasthenry-compat.md b/docs/fasthenry-compat.md index c14d6de7..1b478cac 100644 --- a/docs/fasthenry-compat.md +++ b/docs/fasthenry-compat.md @@ -125,7 +125,7 @@ records how each documented field maps. | `hole user1 (…)` … `hole user7 (…)` | Not supported, permanent | Issue #99. Rejected by name on the statement's own line, and the rejection is final rather than deferred — see the decision below. The error names the alternatives: the declarative shapes above, and `fasterhenry::plane::GroundPlane::mesh` + `fasterhenry::plane::Hole::Point` for a shape none of them describe. | | `contact point (x, y, z, xcell, ycell)`, `contact line (x0, y0, z0, x1, y1, z1, xcell, ycell)` | Supported, differs | Issue #100. Every cell holding the point, or crossed by the line, is no larger than `xcell` × `ycell` — the pair measured in the plane's own coordinate system, `xcell` along `p1 → p2` (the decision below; this is the clause the public memo states that convention for outright), while the point's or line's own coordinates are global. Both map onto one `fasterhenry::plane::ContactRegion`: the locus's bounding box padded by half a requested cell on every side, cut into the fewest cells no larger than that cell — so a point is exactly one `xcell × ycell` cell centred on it, and a zero-length line is that point. Outside it the mesh grades back at ratio 2, the reader's `contact rect` default (neither shape documents a decay). A requested cell at or above the plane's background cell is already met and is clamped there rather than coarsening the mesh; a point or line met on both axes adds nothing. Both ends are checked against the plane's slab and footprint. **The difference**: a diagonal line refines its whole padded bounding box, because this engine's mesh is a tensor product — a refined band on one axis spans the plane on the other, so any refinement covering the line (a chain of rectangles included) has the same bands. That costs about `(Lx/xcell)·(Ly/ycell)` fine cells where a mesh following the diagonal would need about `Lx/xcell + Ly/ycell`; an axis-aligned line costs nothing extra. | | `contact trace (x0, y0, z0, x1, y1, z1, trace_width, scale_factor)` along x or y | Supported | Issue #110. Refines the plane finely across the trace's projection, not along it. Source: the public memo *Nonuniformly Discretized Reference Planes in FastHenry 3.0* (M. Kamon, 10 October 1996, section "Grouped contact utilities"), whose worked example expands an x-directed trace of width `w` and length `L` into five `contact line`s — at the trace and at `±w/2` with cells `(L, w/2)`, and at `±3w/2` with cells `(L, w)` — and which states that `scale_factor` has no effect on a trace parallel to x or y. This reader performs exactly that expansion (axes swapped for a trace along y), each line mapping as in the `contact line` row above; writing the five lines out by hand gives the same mesh, which `contact_trace_along_x_is_the_documented_five_lines` in `inp.rs` asserts. `scale_factor` is checked to be positive and otherwise unused. The trace's own ends are checked against the plane's slab and footprint on the statement's line; the side lines are clipped to the plane (and dropped where they miss it), so a trace along the plane's edge is accepted. A zero-length trace has no direction and is a line-numbered error naming `contact point`. The clause carries no `x…`/`y…` pair of its own — one `trace_width`, and a direction read from its own global ends — and its expansion is symmetric in the two axes, so the plane-coordinate decision below does not touch it: rotating the plane rotates the trace with it (issue #118). | -| `contact trace (…)` not parallel to x or y | Not supported, permanent | Issue #110. Rejected by name on the statement's own line, as a decision rather than deferred work — see "Decision: a diagonal `contact trace` is rejected" below. The error names `contact line (x0, y0, z0, x1, y1, z1, xcell, ycell)`, which states the cell size outright. | +| `contact trace (…)` not parallel to x or y | Native: not supported, permanent. `--fasthenry-compat`: supported, approximated | Issue #110 (native), issue #157 (compat). Under compat the trace is refined as its bounding box padded by `3w/2` on every side — a `contact rect` over it with cells `(w/2)·scale_factor^min(|tan θ|, \|cot θ\|)` on both axes, θ measured in the plane's own frame so 30° and 60° mirror each other — with a line-numbered warning that this approximates FastHenry's staircase refinement (see the decision below). Natively: rejected by name on the statement's own line, as a decision rather than deferred work — see "Decision: a diagonal `contact trace` is rejected" below. The error names `contact line (x0, y0, z0, x1, y1, z1, xcell, ycell)`, which states the cell size outright. | | `contact circle (…)` | Not supported, permanent | Issue #109. Rejected by name on the statement's own line — and, like the diagonal `contact trace` row above, this is not deferred work: the public description of the `contact` family names the simple refinement utilities `point`, `line`, `rect` and `decay_rect`, the contact-*area* utility `equiv_rect`, the grouped `connection` and `trace` built on them, and the `initial_grid`/`initial_mesh_grid` pair — no disc among them. `circle` is a **hole** shape (`hole circle (x, y, z, r)`), so there is no argument list to pin and nothing in a deck to read — see the decision below. The geometry is not what is missing: on this tensor-product mesh the whole of what a disc could mean is `contact decay_rect` over its bounding square, and the error names that. | | `contact initial_grid (n1, n2)` | Supported | Issue #113, which pinned the convention #101 left open: `n1` cells along `p1 → p2` and `n2` along `p2 → p3` — the same pair `seg1`/`seg2` set, as the clause's own public description states ("`seg1=10 seg2=12` could be replaced with `file=NONE contact initial_grid (10,12)`"). See the decision below for the evidence, including why the description's word *rows* does not overturn it. The counts are cells, not grid lines. Because the two clauses are one statement, a plane giving both `contact initial_grid` and `seg1`/`seg2` (in either order), or two initial grids, is a line-numbered error rather than a race between them. `file=NONE`, which the public example pairs with the clause, is accepted as the no-op it is (issue #122 — see its own row above), so the documented line reads as written; the token says there is no discretization hierarchy file, which is the only case this reader has. | | `contact initial_mesh_grid (n1, n2)` | Supported, differs | Issue #113. That same initial grid, plus the documented checkerboard: "every cell that has an even value for both of its indices where the numbering is from the top left" — so, with the cells numbered from 1 at the plane's own origin (its `p1` corner) along each axis, every cell whose two indices are **both even**. An axis of one cell has no even index and so no hole. The difference is what each hole *is*: a `Hole::Rect` over the holed cell's own rectangle rather than a `Hole::Point` at its centre. On the initial grid alone the two are the same cut; the grid is *initial*, though, and a later `contact` clause may refine that region — the documented hole is the square ("no conductor will be defined in that square region"), not whichever smaller cell a refinement leaves under the centre. Numbering from `p1` is what fixes the checkerboard's phase: for an odd count either end selects the same cells, but for an even count the two differ by one cell, and `p1` is the origin of the plane coordinate system the same description defines (and the corner its own uniform-plane figure draws at the top left). | @@ -330,7 +330,23 @@ the disc misses. two-line parser arm over the mapping above, with the padding rule `contact point` already uses. Nothing about the plane model would change. -### Decision: a diagonal `contact trace` is rejected (issue #110) +### Decision: a diagonal `contact trace` is rejected natively, approximated under compat (issue #110, #157) + +*Native mode* keeps the rejection described below: the public description +does not determine the cell size, so the native reader will not guess. +*`--fasthenry-compat`* (issue #157) instead accepts the clause and refines +the trace's bounding box, padded by `3w/2` on every side, to cells +`(w/2)·s^min(|tan θ|, |cot θ|)` on both axes (`s` = `scale_factor`, θ from +`p1 → p2` in the plane's frame), as one `contact rect`. Black-box runs +against self-authored decks show this lands within a few percent of +FastHenry's own staircase refinement (about 0.1-0.4% in L and 0.3-2% in R at +`s = 1`), against about 5% without any refinement. The `min()` deliberately +keeps 30° and 60° mirror-symmetric rather than copying FastHenry's behaviour +past 45°, and the cell is capped at half the box's narrower width. A +line-numbered warning says the refinement approximates FastHenry's staircase. +The word "permanent" in this section's native rationale applies to native +mode only. + A `contact trace` parallel to x or y is supported (its row above): the public memo *Nonuniformly Discretized Reference Planes in FastHenry 3.0* diff --git a/fasterhenry-cli/src/inp.rs b/fasterhenry-cli/src/inp.rs index c8613b41..342a07ce 100644 --- a/fasterhenry-cli/src/inp.rs +++ b/fasterhenry-cli/src/inp.rs @@ -314,9 +314,14 @@ //! instead. A zero-length trace has no direction to refine across and is //! an error naming `contact point` instead. //! -//! A trace **not parallel to x or y** is rejected by name, on the -//! statement's own line, and the rejection is a decision rather than -//! deferred work (see `docs/fasthenry-compat.md`, "Decision: a diagonal +//! A trace **not parallel to x or y** is, under `--fasthenry-compat` +//! only, approximated (issue #157): its bounding box, padded by `3w/2` on +//! every side, is refined like a `contact rect` with cells +//! `(w/2)·scale_factor^min(|tan θ|, |cot θ|)` (θ in the plane's frame, so +//! 30° and 60° mirror each other), with a line-numbered warning that this +//! approximates FastHenry's staircase refinement. Natively it is rejected +//! by name, on the statement's own line, and the rejection is a decision +//! rather than deferred work (see `docs/fasthenry-compat.md`, "Decision: a diagonal //! `contact trace` is rejected"): the same memo says the cells under a //! diagonal trace are magnified by `scale_factor^|tan θ|` (θ from the x //! axis) and, a paragraph earlier, that the magnification runs from 1 to @@ -386,7 +391,8 @@ //! quietly approximated by one of those. Unlike the user-defined holes, //! these have a public meaning, and each is rejected for its own stated //! reason: -//! * `contact trace` **not parallel to x or y** — see its own entry +//! * `contact trace` **not parallel to x or y** (natively; compat +//! approximates it, issue #157) — see its own entry //! above (issue #110: the public description of the diagonal case //! does not determine the cell size); //! * **`contact circle` is rejected by name as well — and it is not a @@ -1407,8 +1413,15 @@ struct RefineTrace { ends: [[f64; 3]; 2], /// `trace_width`, metres. width: f64, - /// The axis the trace runs along: 0 for x, 1 for y. + /// The axis the trace runs along: 0 for x, 1 for y. Meaningless for a + /// [`diagonal`](Self::diagonal) trace. along: usize, + /// A trace not parallel to x or y, read only under + /// `--fasthenry-compat` (issue #157): refined as its padded bounding + /// box by [`PlaneFrame::diagonal_trace_contact`]. + diagonal: bool, + /// `scale_factor` (dimensionless); used only by a diagonal trace. + scale: f64, } impl RefineTrace { @@ -1462,6 +1475,7 @@ fn refine_trace_values( what: &str, unit: f64, line: usize, + compat: bool, ) -> Result { if values.len() != 8 { return Err(err( @@ -1503,20 +1517,30 @@ fn refine_trace_values( ), )); } + let mut diagonal = false; let along = if dy <= 1e-9 * dx { 0 } else if dx <= 1e-9 * dy { 1 + } else if compat { + diagonal = true; + 0 } else { return Err(err( line, format!( - "{what} from ({}, {}) to ({}, {}) metres is not parallel to x or y, and a diagonal 'contact trace' is not supported: the public description says the cells under it are magnified by scale_factor^|tan θ| yet also that the magnification runs from 1 to scale_factor as θ goes from 90° to 45°, which disagree past 45°, and it does not say where a diagonal trace's refining lines go or what cell they ask for along the trace, so this reader will not guess the cell size. Refine under it explicitly with 'contact line (x0, y0, z0, x1, y1, z1, xcell, ycell)' — a diagonal line refines its bounding box to the cell you choose (see docs/fasthenry-compat.md)", + "{what} from ({}, {}) to ({}, {}) metres is not parallel to x or y, and a diagonal 'contact trace' is not supported (natively; --fasthenry-compat approximates it by its bounding box): the public description says the cells under it are magnified by scale_factor^|tan θ| yet also that the magnification runs from 1 to scale_factor as θ goes from 90° to 45°, which disagree past 45°, and it does not say where a diagonal trace's refining lines go or what cell they ask for along the trace, so the native reader will not guess the cell size. Refine under it explicitly with 'contact line (x0, y0, z0, x1, y1, z1, xcell, ycell)' — a diagonal line refines its bounding box to the cell you choose (see docs/fasthenry-compat.md)", ends[0][0], ends[0][1], ends[1][0], ends[1][1] ), )); }; - Ok(RefineTrace { ends, width, along }) + Ok(RefineTrace { + ends, + width, + along, + diagonal, + scale, + }) } /// The fewest uniform cells across `span` whose extent is no larger than @@ -1990,8 +2014,20 @@ impl<'a> PlaneStatement<'a> { self.contact_lines.push(("'contact line'", refine)); } ("contact", "trace") => { - let mut trace = refine_trace_values(values, &what, unit, line)?; + let mut trace = refine_trace_values(values, &what, unit, line, self.compat)?; trace.ends = trace.ends.map(|end| self.shift(end)); + if trace.diagonal { + self.warnings.push(( + self.clause_seq, + ParseWarning { + line: self.clause_line, + message: format!( + "ground plane '{}': diagonal 'contact trace' approximated by refining its padded bounding box (a staircase-free tensor-product refinement), not FastHenry's staircase refinement; cells (trace_width/2)·scale_factor^min(|tan θ|, |cot θ|)", + self.head + ), + }, + )); + } self.contact_traces.push(trace); } ("contact", "equiv_rect") => self.apply_contact_equiv_rect(what, name, values)?, @@ -2872,6 +2908,9 @@ impl PlaneFrame<'_> { for end in trace.ends { self.on_plane("'contact trace'", end)?; } + if trace.diagonal { + return Ok(vec![self.diagonal_trace_contact(trace)?]); + } Ok(trace .lines(self.axis1) .iter() @@ -2879,6 +2918,33 @@ impl PlaneFrame<'_> { .collect()) } + /// The decay rectangle a diagonal `contact trace` stands for under + /// `--fasthenry-compat` (issue #157): the trace's bounding box padded by + /// `3w/2` on every side, with cells `(w/2)·s^min(|tan θ|, |cot θ|)` on + /// both axes — `θ` being the trace's angle in the plane's own frame. + /// `min(|tan θ|, |cot θ|)` is `min(|dx|,|dy|)/max(|dx|,|dy|)`, which is + /// the same whichever global axis the plane's x runs along and is + /// mirror-symmetric (30° and 60° agree). The cell is capped at half the + /// rectangle's narrower width so a large `scale_factor` cannot make the + /// decay law degenerate. No outward limit, like `contact rect`. + fn diagonal_trace_contact(&self, trace: &RefineTrace) -> Result { + let (a, b) = (trace.ends[0], trace.ends[1]); + let span = [(b[0] - a[0]).abs(), (b[1] - a[1]).abs()]; + let ratio = span[0].min(span[1]) / span[0].max(span[1]); + let pad = 3.0 * trace.width / 2.0; + let global_widths = [span[0] + 2.0 * pad, span[1] + 2.0 * pad]; + let cell = (trace.width / 2.0 * trace.scale.powf(ratio)) + .min(global_widths[0].min(global_widths[1]) / 2.0); + let decay = DecayRect { + centre: [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, a[2]], + // Plane coordinates (an involution of the global pair). + widths: self.plane_pair(global_widths), + cell: [cell, cell], + limit: [None, None], + }; + self.decay_contact("'contact trace'", &decay) + } + /// The region a refinement line asks for: the segment's bounding box, /// padded by half a requested cell each side, cut into cells no coarser /// than that cell and graded outward at the reader's default ratio 2 — @@ -5390,6 +5456,11 @@ Gp x1=0 y1=0 z1=0 x2=10 y2=0 z2=0 x3=10 y3=6 z3=0 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"; + /// `refine_deck` behind the title line compat mode treats as prose. + fn compat_refine_deck(clause: &str) -> String { + format!("title\n{}", refine_deck(clause)) + } + /// A deck whose plane is `REFINE_PLANE` carrying one extra clause. fn refine_deck(clause: &str) -> String { plane_deck(&format!("{REFINE_PLANE}\n+ {clause}"), "") @@ -5978,6 +6049,60 @@ Gp x1=0 y1=0 z1=0 x2=10 y2=0 z2=0 x3=10 y3=6 z3=0 } } + /// Compat mode (issue #157): a diagonal `contact trace` is the + /// bounding-box `contact rect` padded by `3w/2`, with cells + /// `(w/2)·s^min(|tan θ|, |cot θ|)`. The 30° and 60° mirror images use + /// the same cell, and 45° uses the full `s`. + #[test] + fn compat_diagonal_contact_trace_is_its_padded_bounding_box() { + let (w, s) = (0.4f64, 4.0f64); + let a = 4.0 / 3.0f64.sqrt(); // 4·tan 30° + for (span, k) in [([4.0, a], a / 4.0), ([a, 4.0], a / 4.0), ([3.0, 3.0], 1.0)] { + let (x0, y0) = (2.0f64, 1.0f64); + let trace = parse_compat(&compat_refine_deck(&format!( + "contact trace ({x0}, {y0}, 0, {}, {}, 0, {w}, {s})", + x0 + span[0], + y0 + span[1] + ))) + .unwrap(); + let cell = w / 2.0 * s.powf(k); + let rect = parse_compat(&compat_refine_deck(&format!( + "contact rect ({}, {}, 0, {}, {}, {cell}, {cell})", + x0 + span[0] / 2.0, + y0 + span[1] / 2.0, + span[0] + 3.0 * w, + span[1] + 3.0 * w + ))) + .unwrap(); + assert_same_mesh(&trace, &rect); + let plain = parse_ok(&plane_deck(REFINE_PLANE, "")); + assert_ne!(trace.geometry, plain.geometry); + } + } + + /// The compat diagonal trace warns on its own line, naming the + /// approximation; natively it stays rejected. + #[test] + fn compat_diagonal_contact_trace_warns_and_native_still_rejects() { + let text = refine_deck("contact trace (1, 1, 0, 5, 3, 0, 0.2, 2)"); + let (_, warnings) = parse_compat_warned(&format!("title\n{text}")); + let warning = warnings + .iter() + .find(|w| w.message.contains("diagonal 'contact trace' approximated")) + .expect("a diagonal trace warns"); + assert_eq!(warning.line, 6, "the clause's own physical line"); + assert!(warning.message.contains("bounding box")); + let error = parse(&text).unwrap_err(); + assert!(error + .message + .contains("a diagonal 'contact trace' is not supported")); + // An axis-aligned trace does not warn in compat mode. + let (_, warnings) = parse_compat_warned(&compat_refine_deck( + "contact trace (2, 3, 0, 8, 3, 0, 0.4, 1)", + )); + assert!(warnings.iter().all(|w| !w.message.contains("diagonal"))); + } + /// Every `contact trace` value the reader cannot honour is rejected by /// name on the statement's own line. #[test]