A BOM row with a file name and a matching drawing but a blank thickness
was reported "No DXF" and locked, so its thickness could not be entered
and the part was dropped: BomAnalyzer skips such items before looking
for a drawing, and the form only knew drawings the analyzer matched.
A row with a blank material read "Matched" but was silently left out
of every group.
Rows now resolve their drawing through a DrawingFileIndex shared with
BomAnalyzer (whose behavior and tests are unchanged), and a row's status
is computed from its values:
Ready | Needs material | Needs thickness | No drawing found | No file name
Rows with a drawing stay editable, and an edit updates the status cell.
Groups take only Ready rows (a zero, negative or non-finite thickness is
not Ready). The summary line counts ready rows and each problem.
Build_RowWithoutThickness_StillFindsItsDrawing failed at its DxfPath
assertion against the previous commit. Mutation reds: removing the
material check, accepting zero or non-finite thickness, skipping the
drawing for rows without thickness, and grouping without the status
each fail the Bom tests.
Project Memory: b8e3a978-2cfe-4fe7-b2a5-eb01c6c54679
A BOM row whose File Name carried its extension ("PT01.dxf") showed
"No DXF", was locked and was never imported, although BomAnalyzer had
found the file: the matched paths were stored under the raw BOM name
and looked up by the name without the extension. Both sides now use
the extension-less name.
The new BomImportRowsTests row failed at its IsEditable assertion
against the previous commit.
Project Memory: f3be7a4b-e679-470c-bb52-7057899228e4
Move the BOM import dialog's row building, grouping and per-group nest
construction out of BomImportForm into OpenNest.IO/Bom so they can be
tested on Linux:
- BomPartRow (now public) and BomImportRows.Build, a verbatim move of
the form's BuildPartRows (still via BomAnalyzer).
- BomImportGroups.Build: one grouping used by both the Groups tab and
Create Nests, which each carried their own copy. Create Nests now
creates nests in the Groups tab's order (material, then thickness).
- BomNestBuilder.Build: the nest for one group (saved defaults first,
then the group's plate size, spacing, material and thickness; one
drawing per row with the row's quantity, 1 when blank).
The form keeps the file dialogs, grids, EditNestForm windows and the
completion message. Behavior is otherwise unchanged; the two row-status
defects found while planning are fixed in the following commits.
Nest Info edits only the material name, but SaveNestInfo replaced the
nest's material with new Material(name), so pressing OK without touching
the material silently dropped its grade and density.
SaveNestInfo now goes through NestMaterialSelection.Apply in OpenNest.Data:
a name with the same SharedListNames.Key as the current material (trimmed,
whitespace collapsed, invariant upper case) keeps its grade and density
and takes the typed name; any other name gets a name-only material, as
before. It always returns a new Material. SharedListNames.Key is the
name normalizer the shared customer/material lists will use.
Behavioral reds (each restored byte-identically): restoring the old
name-only path fails both keep-grade tests; exact-string matching,
dropping the upper-casing or the whitespace collapse fails the
normalized-name test; returning the current instance fails the copy test.
EditNestInfoFormTests cover Load -> Save through the real form; they are
cross-compiled here and execute in the windows-desktop CI job.
The Details area of the Database-mode Open dialog now has a plate
preview beside the Plates/Drawings tabs, with a draggable divider. It
draws one plate of the highlighted nest; the arrow buttons below it step
through the plates ("Plate 2 of 5"), and choosing a row on the Plates tab
shows that plate, so the table and the preview stay on the same plate.
The preview is read-only (no selection or drop) and refits when resized.
NestDetails keeps the downloaded nest's plates (PlateLayouts) beside its
rows, so the preview draws the copy already read for the tables with no
second download.
Tests: NestDetails keeps every plate in nest order (fails with the
assignment removed). The Windows form test steps forward with the
button, checks the row follows, and selects a row back.
The server stores only nest-level metadata, so a browser that shows a
saved nest's plates and drawings has to read them from its archive.
NestDetails.FromNest turns a nest into one row per plate (duplicates,
size, parts and distinct drawings without cutoffs, utilization; 0 for a
zero-size plate rather than NaN) and one row per non-cutoff drawing
(required, nested across every plate's duplicates, remaining, area),
plus the nest's units.
NestDetailsSession loads the details of the highlighted nest. A new
load or Clear supersedes the previous one before cancelling it, so a
load that completes synchronously on cancellation and any late result
or failure of a superseded load are discarded; IsLoading follows only
the latest load.
The second delta review found four more false-equal classes in the
general reflective fingerprint, all reachable only through custom
settings subclasses: cycle markers that dropped the target ancestor,
display-formatted DateTime/DateTimeOffset, ignored dictionary and set
comparers, and arrays flattened without their dimensions. Safe arrays of
OpenNest elements were also newly refused. Every repair of the generic
traversal opened another such case.
Settings capture now supports exactly the types regeneration already
accepts (OwnedCuttingParameters): CuttingParameters, SequenceParameters,
AssignmentParameters and the built-in lead-in, lead-out and tab types.
Each member is written explicitly, doubles by bit pattern with invariant
numerals and text length-prefixed. Every object's runtime type is checked
before any member is read, so no other type's code runs. A plate-scoped
request whose part or plate settings contain any other type, subclasses
included, is UnsupportedGeometry at capture instead of a Ready plan that
can never apply. A nested settings object replaced by such a type after
capture makes Apply Stale. Detached part-list requests are unaffected.
Coverage tests fail when a supported type gains a property or field the
fingerprint does not write, or when a new built-in lead or tab type is
added without fingerprint support.
Delta review found three Important defects and a Minor one in the
settings fingerprint that closed the first freshness gap:
- Accepted settings state was silently omitted: dictionary entries render
as KeyValuePair structs whose Key/Value are properties, property-backed
custom structs contribute no public fields, and graphs past the depth
limit wrote a constant marker, so all three edits fingerprinted equal
and a changed plate still applied.
- Reading public properties executed arbitrary getters, so a capture
documented as read-only could mutate live settings (Bump => ++Kerf).
- An enumerable settings member was enumerated at Apply, where its
enumerator could throw out of the public commit call.
- Fingerprint text used ambient-culture interpolation, so an invariant
capture compared unequal under a digit-substituting culture.
Traversal is now a closed boundary. OpenNest types render their public
readable properties and fields. Foreign types render only declared
instance fields, which include auto-property backing fields, because a
field read executes no code. Only arrays and List/Dictionary/HashSet/
KeyValuePair are enumerated, with insertion-ordered containers sorted;
other enumerables, delegates and unrepresentable shapes refuse to an
Invalid marker that never compares equal, so refused state is Stale
rather than silently equal. Doubles fingerprint by bit pattern rendered
with invariant formatting, and depth or budget overflow refuses instead
of truncating. A reference already on the path renders as a cycle
marker; built-in lead and tab objects reference settings back.
Capture stores refusals as-is, so a plate with uncaptureable settings
stays plannable and every commit against it reports Stale without
re-reading live state, and a fingerprint that fails on re-read is
likewise Stale, never an exception.
A CuttingPlanRequest constructor overload taking a Plate made the existing
detached call new CuttingPlanRequest(null) ambiguous (CS0121). Plate scope
is now requested with CuttingPlanRequest.ForPlate, and the result summary
describes dependencies and Apply as they now behave.
The containment prefilter used the inner part's whole clean-program
bounds, which include rapid endpoints and scribe marks. A remote rapid or
mark could push those bounds outside the host and drop a genuine
inner-before-host prerequisite. Candidate pairs now use the material
extent only (cut and display motions); containment is still proven on
native material.
Review of the atomic Apply found two commit-boundary gaps:
- The plan installer checked only root program references, so a public
caller could install a payload whose subprograms alias another live
(even locked) part's program, or share settings between parts. The
installer and its payload types are now internal; CuttingPlanService.Apply,
which installs owned copies of independently replayed proposals, is the
only public path.
- A part placed on two plates in one scope was planned once per plate; one
plate's install replaced the program the other plate verified as fixed.
A part repeated anywhere in the scope is now invalid input.
Review of the atomic Apply found freshness gaps:
- A drawing's cutoff classification decides lead, material, obstacle and
dependency treatment but was not captured; changing it after planning
still applied the old proposal.
- Part and plate cutting settings were compared by reference only, so an
in-place edit after capture applied (and a regenerated part overwrote
it); plate settings were not compared at all. Settings are now captured
as an exact public-state fingerprint.
- A live program whose instruction list was set to null made Apply throw
instead of returning Stale, and a respelled key in a case-insensitive
binding dictionary compared equal.
Caller-confirmed planning settings remain planning input: editing them
after capture does not stale the plan and does not leak into the result.
The cutting planner now accepts cutoffs on plate-scoped requests and
plans whole-part prerequisites captured from owned values:
- A cutoff precedes every part its nominal span crosses, using the same
rule and drawing-reference matching as automatic sequencing; a cutoff
without a definition precedes every part. Cutoffs stay fixed programs,
need no lead-in and never become rapid obstacles; rapids into and out
of them are still checked.
- A part proven, on native clean material, to lie inside a cutout of
another part precedes that host. Touching or crossing boundaries are
ambiguous and refuse; a part in a concave pocket has no dependency.
- Both searches only expand ready parts, a preserved order that breaks a
prerequisite is a constraint conflict, and final replay rechecks the
captured prerequisites instead of trusting the search.
Plate-scoped cutting plan requests now record the plate's exact state at
capture, and CuttingPlanService.Apply installs Ready, replayed proposals
for a whole scope at once:
- Any change after capture (order, pose bits, program reference or
in-place content, drawing program, lock/lead-in flags, settings,
quantity, size, quadrant or cutoff definitions) returns Stale with
nothing changed.
- Order changes without PartAdded/PartRemoved, so drawing quantities and
sentinel plates are untouched; ObservableList.Reorder exposes the same
operation and Plate.PartsReordered is raised once per changed plate.
- Regenerated parts receive owned copies of the replayed program and of
the settings captured with the request; fixed programs stay in place.
- An install failure restores every plate exactly; an observer failure
after publication is reported as a refresh error, not a rollback.
A tab trims the perimeter short of its entry, but the lead-out was still
generated from the nominal entry point. An arc lead-out therefore started
off its own radius (ExecutionMotionReader rejected it as inconsistent), and
a line lead-out ran diagonally back toward the entry.
Every lead-out style on a tabbed perimeter now leaves from the trimmed
cut's actual end, on that entity's normal, so arcs are tangent and the tab
gap stays uncut. Untabbed contours and the corner run-out rules are
unchanged. Malformed legacy output is still refused, never refit.
Red before the fix: the three tabbed arc cases threw "Arc has zero or
inconsistent radius" and the line case ended at y=5 instead of 4.8. Keeping
the entry's normal at the actual end fails the curved-perimeter case.
FillPattern gathered per-angle results in a ConcurrentBag and kept the first best it
enumerated, so an exact tie between angles went to whichever worker finished first:
identical inputs could return different, equally scored layouts. Results now sit in
slots indexed by angle, V before H within an angle (the order the single-angle bag
already produced and the existing tie tests pin), and ties keep the earliest slot.
Single-angle behaviour is unchanged.
Regression: a never-prefer comparer over one 0-degree and fifteen 180-degree angles
must keep the 0-degree layout in 50 of 50 calls; it failed on the old code.
The new windows-desktop CI job surfaced several tests that had never
actually run on Windows:
- SeparatorPenLifetimeTests expected Control.Disposed to fire only
once; current WinForms re-fires it on every Dispose() call.
- EditNestFocusTraversalTests assumed the Plates tab was selected by
default, which stopped being true once Drawings became the landing
tab; select it explicitly before asserting focus.
- NestReportExportTests asserted an exact plate count that ignored
PlateManager's trailing sentinel plate, and didn't expect the nest
name in the busy-export message.
- AtomicReportFileTests only expected IOException when replacing a
directory target; Windows raises UnauthorizedAccessException instead,
which callers already treat the same way.
Also register a ThreadException handler so an exception inside a
WndProc callback fails the test normally instead of popping a modal
dialog that blocks the desktop.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New only fell back to the legacy DefaultUnit setting when the whole
defaults file was missing or invalid, so a readable file without a
usable unit produced inches. BOM import never assigned units at all,
and the Nest Defaults dialog showed inches for a missing file.
All three now load through MainForm.LoadSavedNestDefaults, which passes
DefaultUnit as the per-field unit fallback. BOM-created nests start from
NestDefaults.ApplyTo, like New, before applying each group's plate size
and spacing.
The ApplyTo test now asserts units; removing the unit assignment from
ApplyTo fails it. WinForms behaviour is compile-verified only on Linux.
Enum.TryParse accepted numeric strings ("7" loaded an undefined unit,
"1" loaded millimeters) and comma-joined names, and a JSON number for
units failed the whole file. Units now load only from a defined unit
name; anything else falls back for that field alone, while the other
valid fields still load.
Add Load(path, fallbackUnits, out status) so callers can keep their
existing unit preference when the file is missing, unusable, or lacks a
usable unit (previously a readable file without units silently became
inches). An undefined fallback is ignored in favour of the built-in
units.
The numeric/comma/JSON-number rows failed against the previous commit;
dropping the fallback assignment or its IsDefined guard fails the new
fallback tests.
NestDefaults.Load caught only JsonException and IOException, so a
defaults.json that File.Exists can see but the process cannot read
threw UnauthorizedAccessException out of New. Report such files as
Invalid and return the fallback values, like a corrupt file.
The regression mode-000s the file on Unix (skipped on Windows and when
running as root); it failed with UnauthorizedAccessException before
this change.
A request is now marked superseded before the previous one is
cancelled, so a query that completes synchronously on cancellation
returns quietly instead of clearing the page and surfacing a stale
cancellation error. NestBrowseSession.IsLoading follows only the latest
request, and the dialog's Previous/Next and Loading text use it rather
than a count that included superseded requests. Each request disposes
its own cancellation source when it ends.
SQLite LIKE stops matching at an embedded NUL, so search=Alpha%00x
behaved like 'Alpha' followed by a wildcard and returned rows that do
not contain the whole search text. The shared query validation now
rejects NUL, so the server answers 400 and the client throws before
sending.
The Database-mode Open dialog no longer downloads every record. It
browses through NestBrowseSession, which sends one bounded query per
change: the filter box (300 ms debounce) searches on the server and
returns to the first page, column headers sort every match on the
server (a second click reverses), and Previous/Next page by 100 with a
range and total in the status line. A response superseded by a newer
request is cancelled and discarded, refresh steps back when the current
page was emptied, and failures clear the rows and show the error
without a modal box per keystroke.
The query accepts sort=<column>&order=asc|desc over a fixed allowlist
(saved, name, customer, status, material, dates, thickness, plate and
part counts, made by, comments, file size). Text columns sort with
NOCASE, ties break by id in the same direction so pages partition the
matches, and unknown or numeric sort values and other orders return 400.
The client sends the sort as its camelCase name.
GET /api/nests/query filters name, customer, material, made by, comments
and status (stored and display names) with an escaped LIKE parameter,
orders newest saved first with an id tie-break, and returns one page of
at most 500 records plus the total match count, read in one database
hold. Unknown, repeated or out-of-range parameters return 400.
RemoteNestRepository.QueryAsync validates the same bounds before
sending, rejects oversized pages, and reports a 404 from an older server
as a server that needs updating. GET /api/nests stays the full
enumeration used by backup, restore checks and the container smoke.
MaximalRectangles.InRegion finds the largest axis-aligned rectangles that fit
wholly inside a Clipper region, such as a cutout shrunk by the part spacing.
It grids the region on its vertex coordinates plus even divisions, keeps a cell
only when no edge enters it and an even-odd row scan puts it inside, then runs
the shared histogram search.
Exact for regions with only horizontal and vertical edges; slanted and curved
edges are followed as a staircase that never crosses the boundary, short of the
true maximum by up to about one cell per side. Tests cover a rectangle, an
L shape, a frame with a hole, a round hole (inscribed square), a diamond and a
star; disabling the edge-crossing check fails the three slanted-edge tests.
Add DrawingAligner: bounded multi-start rigid ICP that maps a revised
drawing's geometry into the old drawing-local frame as an operator-review
seed. Material-filtered programs flatten through the shared chord-error
machinery, resample via the shared ContourSampler ring scheduler, and fit
closest bounded target segments with trimmed weighted least squares
(reflection never solved, never applied). Seeds come from outer-centroid
translation and MBR angle deltas with the 90-degree family plus identity.
The stable outer boundary is fitted first; retained hole evidence only
reorders candidates inside the outer tie band, so a moved hole cannot
drag the perimeter. Structured AlignmentResult reports convergence,
residual quantiles, bidirectional coverage, symmetry ambiguity,
reflection uncertainty, and a bounded diagnostic IoU. Refusals
(invalid input, insufficient support, unsupported topology, sample
budget, cancellation) are reported as reasons, not thrown.
Tests verify recovered poses against exact fixture geometry (true
segments/arcs/circles, not sample clouds) for known transforms,
symmetric rectangle/circle ambiguity, mirror non-selection, moved-hole
perimeter preservation, and bounded region arithmetic (identical=1,
disjoint=0).
Extract the pure world-space position/tangent math from the WinForms
cut-direction arrow renderer into Core (ContourSampler), keeping screen
conversion, arrowheads, and the per-move display policy in the view.
Add a contour-wide arclength scheduler (RingMoves) for measurement use:
distance carries across segment boundaries, short segments are never
omitted, and the closing vertex is not duplicated. Characterization
tests pin the arrow policy (counts, short-move skipping, CW/CCW and
full-circle sweeps, subprogram offsets, suppressed/rapid moves,
incremental mode) and the scheduler's start-vertex invariance.
DxfImport_ArcBoundingBoxes_Diagnostic read a drawing from one user's
desktop, returned early elsewhere and asserted nothing, so the near-zero
sweep bounding-box fix (4053f1f) had no regression test that could fail.
Replace it with ArcBoundingBoxTests: a forward and a reversed arc with a
sweep below Tolerance.Epsilon must be bounded by its endpoints, and an
ordinary arc crossing 90 degrees must still reach the top of its circle.
With the 4053f1f guard reverted, both near-zero cases fail (the box grows
to the full circle); with it in place all three pass.
GeometrySimplifierTests hardcoded a customer DXF on one user's desktop and
returned early when it was missing, so the test passed without running
anywhere else. Read the path from the optional "SimplifierGapDxfPath"
entry in test-config.json and report a skip when it is not configured,
matching the other external-fixture tests.
Replace the customer part name in a RemnantFinderTests comment with a
neutral description. Customer drawings stay outside the repository.
EllipseConverter, SplineConverter and GeometrySimplifier each carried an
identical private SumSignedAngles. Move the unchanged body to
ArcFit.SumSignedAngles and call it from all three, keeping the ordered
accumulation, strict half-turn comparisons and empty/single-point result.
ArcFitTests compares the shared method bit-for-bit with a separate
test-local accumulator across half turns, the atan2 seam, multiple
turns, translated centres and NaN inputs, and checks that inputs are not
mutated. The converter winding characterization from 8664656 still
passes unchanged.