Resolves all 21 inline comments, plus six changes that altered behaviour for
users not using the feature and seven defects found alongside them.
The reviewer's central point generalised: a pressure-advance change had moved
every RepRapFirmware user onto an unverified command form. Auditing for that
class found five more — 14 config keys leaking into every exported g-code, an
ungated Moonraker sync writing to non-IMEX printers' presets, every slice
eagerly re-rendering all plate thumbnails, a preset delta-encoding regression,
and physical_extruder_map being normalised for printers that read it the other
way.
The structural asks landed as asked: the 392-line bed-zone geometry moved to
libslic3r and is now unit-tested, the preview consumes that same layout instead
of a second copy, nine open-coded mode lookups became one, and the modes editor
moved out of Tab.cpp. Making the geometry testable exposed three further
defects in it, including an aggregated gantry that raised no collision strip.
The worst bug was not in the review: the GUI computed the firmware-managed
slice offset in the plate-list world frame while both consumers subtracted the
plate origin again, so every plate after the first failed to slice with 'part
is off the plate'.
User-facing strings now read IDEX/IQEX, honouring 461c69c83e. Config keys, C++
identifiers and 3MF metadata keys keep the imex_ spelling as on-disk format.
815/815 tests pass in Release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No IMEX code upstream, so nothing in this merge touches the feature. All 21 overlapping
files auto-resolved; verified every upstream addition is present in the merged tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Reject invalid CLI argument values instead of silently accepting them
* Add read_cli accept/reject tests
* Update Option Type for LogFile argument
* Add read_cli vector option tests
* Accept common bool spellings on the CLI, cover --logfile in tests
* Add unit tests for truthy bool parsing
Refresh detach-from-parent checkbox state
Allow the detach checkbox toggle event to propagate to the custom CheckBox control so it refreshes its bitmap after the value changes.
Closes review comment 10.
`461c69c83e` settled this in April — IMEX internally, IDEX/IQEX as the user-facing label — but the
UI strings were never converted. Every translated string naming the feature now reads IDEX/IQEX:
41 occurrences across the printer and process option labels and tooltips, the modes editor, the
plate mode indicator, the pre-slice warnings, the placement refusals and the slicing errors. The
reviewer listed eight; the rest were in the same class.
Nothing else moves. The config keys keep the `imex_` spelling — `is_imex`, `imex_mode_names`,
`imex_parallel_mode` and the rest are on-disk format in existing printer presets and 3MF projects,
so renaming them would break every profile and project already saved. C++ identifiers, filenames,
comments and test names keep IMEX as well: it stays the internal name of the subsystem, which is
what covers the topology space (one gantry with 2-4 tools, 2x1 and 2x2 grids) that neither acronym
names on its own. Where a tooltip quotes a key, the key spelling is preserved and only the feature
word around it changed.
The `is_imex` tooltip is reworded rather than substituted: it already named the hardware families
parenthetically, so a literal replacement would have said IDEX/IQEX twice in one sentence.
No translation impact — no IMEX string had reached OrcaSlicer.pot or any catalogue, so there is
nothing to migrate. One test asserted on the old error text and now matches the new one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: build_win.bat builds with whatever clang-cl is first on PATH
VsDevCmd appends the Visual Studio LLVM directory to the end of PATH, so
a standalone LLVM already on it shadows the Visual Studio one. -l -x
passed a bare clang-cl.exe for CMake to resolve, so the build ran on
whichever copy came first. For one reporter that was an LLVM 11, which
failed the compiler check before anything was compiled:
-- Check for working C compiler: C:/Program Files/LLVM/bin/clang-cl.exe - broken
lld-link: error: undefined symbol: __guard_eh_cont_table
The compiler is now resolved through vswhere and passed as a full path,
so PATH order no longer matters. CMake derives the linker from the
compiler directory, so lld-link follows. Only a configure passes it to
CMake, so -p and --no-configure resolve nothing and stay buildable on a
machine with no clang installed.
When Visual Studio has no clang toolset the script falls back to the
first clang-cl on PATH and names it. With none installed at all it now
errors with what to add, instead of failing later inside CMake. Every
clang-cl run that configures prints the compiler it resolved.
The suite gains a clang-cl fixture earlier on PATH than the Visual
Studio one, and an empty ProgramFiles(x86) to put vswhere out of reach,
which covers both fallbacks without touching the machine.
* fix: build_win.bat pointed at a solution file that is not there
The Visual Studio 2026 generator writes OrcaSlicer.slnx and the releases
before it OrcaSlicer.sln. The summary hard-coded the second, so the path
it printed after an MSVC build against 2026 was wrong.
Closes review comments 13, 14 and 15, and adds the test for a shipped-profile
regression that nothing guarded.
- 14 and 15: compute_imex_slice_offset had eight tests on the calculation and none
on the result, which is the whole firmware-managed path. test_imex_slice_offset
now covers the derivation end (which config produces a non-zero offset, and that
it is plate-local rather than moving with the plate origin -- the bug that
shifted every plate after the first) and the consumption end (emitted
coordinates and first_layer_print_min/max both move by the derived amount).
The first_layer case also cross-checks the two consumers against each other: the
declared bounds must keep the same relationship to the emitted toolpaths in both
frames, which fails if exactly one of them is shifted. It deliberately does not
pin the size of that gap -- it is 2.225 mm here, set by the wall generator, the
same with no offset at all, and pinning it would fail on an unrelated change.
- 13: nothing exercised the imex_mode / imex_mode_index / imex_mode_gcode
placeholders or the {global} flow into machine_start_gcode that their ordering
exists to guarantee. Seven cases now do, including the ordering itself -- the
mode script declares a global and machine_start_gcode reads it back, so moving
the mode processing later leaves the variable undefined and fails the export --
plus the inert cases (Primary mode, and a printer with the table filled in but
is_imex off). All matching is whole-line, because the config block the exporter
appends repeats machine_start_gcode verbatim and would make substring checks
meaningless.
- New: GCodeWriter passes this->config.is_imex.value into the heater remap, and
nothing tested that it passes the flag rather than a constant. Hardcode true
there and the whole suite stays green while fdm_bbl_3dp_002_common, which ships
physical_extruder_map [1,0], starts sending filament 0's M104/M109 to heater 1.
The new case runs a two-nozzle non-IMEX printer with that map and asserts each
filament's temperature reaches only its own tool. It uses idle_temperature via
ooze prevention rather than nozzle_temperature: keys in
filament_options_with_variant are re-indexed per filament by variant slot at
apply time, and this harness pins nozzle_diameter to one value, so every filament
resolves to the same slot and the temperatures stop telling the heads apart.
Also fixes two weaknesses in tests added earlier in this branch: an assertion that
would have been prefix-satisfied by the very routing it was meant to exclude, and
a whole-file command comparison between two slices, which this slicer's output is
not stable enough to support.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes review comment 20; the other two are non-IMEX leaks found auditing the
branch.
collect_imex_warnings re-derived the active mode, its tools, the primary and the
filament routing that Print::validate also derives, so the warning and the hard
block could drift apart -- and this function had already had one index-confusion
bug, the AFC/MMU wrong-filament names fixed in fbc58d2a1d. Both now read
imex_resolve_routing() and derive nothing themselves. The function stays
file-static: what is left in it is PresetBundle lookups, bed-type resolution and
formatting, none of which can disagree with the slicer about what the plate is
doing, and the index-confusion surface is now library code with tests covering the
AFC manifold in both directions. A latent out-of-bounds read went with it -- the
primary fallback can return -1 and the bounds checks were upper-only, so
filament_presets[-1] was reachable on a profile whose roster names a head absent
from the map.
Slicing eagerly re-rendered every plate thumbnail on the main thread after
switching to Preview, up to two blocking offscreen GL renders per plate on every
slice click. The work was already redundant: select_view_3D("Preview") invalidates
the thumbnails and marks the toolbar dirty, and the next frame force-regenerates
them anyway. It could not have served its stated purpose either, since it ran
immediately after reslice(), which only starts the background slice. Both calls
dropped; the plate badge state is recomputed per frame and is unaffected. The
export_3mf thumbnail log lines are back at info, and the slice-event traces
restored.
The Moonraker device sync wrote physical_extruder_map into the edited printer
preset ungated, so any Klipper machine running a current AFC build had its preset
marked dirty with no user action, and saving persisted the map into every 3MF
after. The map is indexed by logical extruder while the device reports one entry
per lane, and nothing in the lane payload carries the logical slot, so the two
index spaces coincide only when the counts match. Now gated on is_imex, written
only when the counts agree, and only when the value actually differs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes review comment 17.
GCodeViewer had its own copy of the flip_x/flip_y corner mapping, the active
column/row sets, the physical-to-zone index mapping and the zone pitch -- the same
derivation as the plate's, with nothing keeping the two in step. It now calls
compute_imex_zone_layout() and consumes head_zone_centers. The mirror is expressed
as a reflection about the midpoint of the two zone centres rather than about a
zone-relative strip width, which is algebraically identical for equal-sized zones
and needs no pitch, and the toolhead-box face is chosen by comparing zone centres
instead of physical columns.
The July report of a math error in the visualizer for non-primary heads was this
drift: the sec_box_offset_y else-branch hardcoded -imex_box_wy, which happened to
equal the primary's offset on the rear-* layouts and pointed the wrong way on the
front-* ones. Structurally unreachable now.
Verifying the two sides matched turned up two config defaults that disagreed, both
fixed in their own commits: imex_nozzle_clearance_x/y (the viewer's 30.0f matched
PrintConfig, the zone code's 0.0 did not, and the strip loops are gated on it) and
imex_tools_per_gantry (the library's 2 matched, both GUI paths used 1).
Consuming the shared function meant resolving it per frame, and the sequential-view
marker flag is sticky, so one drag of the slider made every subsequent frame parse
five strings and allocate a dozen containers from inputs that never change. The
resolve now sits behind a cache key mirroring PartPlate::build_imex_cache_key(),
plus the two inputs only the preview reads -- the bed extents and the tool layout.
An idle frame compares scalars and allocates nothing. The toolhead-box mesh, which
was being re-uploaded to the GPU every frame for the same reason, is rebuilt only
when the clearances change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes review comment 9.
PartPlate::reset_imex_mode() had no callers, while the PR description said
deleting a mode resets affected plates to Primary. IMEXModesCtrl now exposes an
on_mode_removed callback that TabPrinter::build_fff handles by resetting every
plate whose mode matches the deleted row, under a single undo snapshot, followed
by the same dirty/update sequence the plate's own mode button runs.
Wired to deletion only, on purpose: the name field notifies on every keystroke, so
routing renames through the same path would orphan and reset the plate on the
first character typed. Renames stay covered by the slice-time fallback and its
warning. The callback is copied to a local before notify(), because notify()
reaches load_from_config() -> clear_rows(), which tears down the row the handler
is running inside.
The rest of this file is the modes editor moving out to its own translation unit,
leaving the include and the construction site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes review comments 21, 4 and 12, and the widget half of 19.
- 21: IMEXModesCtrl was 572 lines inside Tab.cpp. It now lives in
IMEXModesCtrl.{hpp,cpp} next to IMEXFilamentPickerPopover, which was the
precedent named in the comment. The move itself is exact -- member order,
comments and every string literal unchanged -- and the class had no file-local
dependencies in Tab.cpp, only its include list, so the new source states those
explicitly.
- 4: a mode row with an empty Name was silently dropped on save, tools and G-code
with it, and matches_config() compared against that same filtered output so the
preset never went dirty and the row stayed on screen. Rows are now given a
generated unique name instead of being discarded, and add_row() pre-fills one so
the common path never produces a blank. Names are deliberately not translated:
objects store a mode name in imex_parallel_mode and GCode.cpp matches it by
string, so a localized name would break a project reopened in another language.
- 12: the editor had a third parser that read a bare token and an unknown role
suffix as Primary, while parse_imex_active_tools reads both as Copy -- so the
editor and the slicer could read one imex_mode_active_tools string two different
ways. Deleted; the editor now uses the same two helpers the slicer does.
- 19: tile state was an int shadowing ImexRole, with the role letters duplicated in
a second switch that wrote the on-disk format. The tile now holds
optional<ImexRole>, with Inactive spelled as the absence of a role rather than a
fifth integer, and the letters come from kImexRoleTable.
Four further changes, from testing rather than the review:
- Deleting a mode reported only the row's current name, so renaming a mode and then
deleting it left every plate using it stranded on a name that no longer exists.
Both the build-time and current names are now reported, minus any a surviving row
still carries.
- The instruction text and colour legend were built once in the constructor and
never rebuilt, so raising gantry count to 2 gave the tiles a Span role the legend
never explained until the preset was saved and the page reopened. Both are
rebuilt with the grid, and the per-role detail moved into legend tooltips so the
panel no longer opens with a paragraph.
- The tile holding Primary is now read-only. Primary is tool 0 and moves only via
Tool 0 Position; a click could previously demote the only Primary, leaving a mode
that parses to no primary at all, which degrades the plate to an ordinary
single-tool print with nothing in the editor showing what is wrong. A mode
arriving without a Primary keeps every tile live so it can still be repaired.
- Names and G-code were read with ToStdString() (the ANSI codepage on Windows) and
written with from_u8() (UTF-8). On a non-UTF-8 codepage a name like "Modus A"
with a diaeresis was stored as invalid UTF-8, came back blank, and was then
silently renamed by the auto-naming above. Every read is now into_u8() and every
write from_u8(); EditGCodeDialog was affected in both directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
calc_imex_zones() is now 69 lines: fetch the two edited configs, call
compute_imex_zone_layout(), store the result, and clip each returned rect to the
bed outline to build the GLModels. That last step is the only part that needs GUI
types, which is why it stayed. See the extraction commit for the behaviour-
preservation evidence.
refresh_imex_slice_offset() is deleted along with its call in
update_slice_context(); the offset is derived in the engine now, and it was
computing it in the plate-list world frame, which double-counted the plate origin
for every plate after the first.
Two smaller changes:
- The zone/ghost cache key omitted imex_tool_layout, which decides which physical
corner tool 0 occupies and therefore moves every zone rectangle, collision strip
and ghost offset while every other keyed field stays put. A layout change
produced an identical key. That this currently appears to work is incidental --
some other path happens to rebuild -- and not something to depend on. Found by
building the preview's own cache key against this one.
- The tools-per-gantry fallback for a missing key was 1 in two places where
PrintConfig registers 2 and the zone code uses 2. All four sites now agree; a
missing key otherwise grouped tools against a grid divided a different way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
None of these came from the review; they were found auditing the branch for the
same class of leak review comment 6 identified.
physical_extruder_map was normalised through effective_physical_extruder_map on
every Print::apply(), so any printer with more than one nozzle and no authored map
got the identity [0,1,...,n-1] where the single-element {0} default belongs. The
key carries two readings: the IMEX paths index it by logical extruder and need one
entry per extruder, while the inherited BBL paths read it through the clamping
get_at(), for which {0} means "everything is physical 0". Deriving unconditionally
imposed the IMEX reading on profiles that mean the other one, changing the config
block line and the {first_tools} / {first_filaments} / {curr_physical_extruder_id}
placeholders for multi-nozzle non-IMEX printers. Gated on is_imex; every consumer
needing the per-extruder form is already IMEX-gated, and profiles with an authored
map of the right length are unaffected either way.
That gating unmasked a latent out-of-bounds read: WipeTower's M104/M109 emitters
index m_physical_extruder_map by tool with no bounds check, which reads past the
end of the single-element default on a multi-nozzle machine. Upstream's bug, from
the BambuStudio wipe tower sync, previously hidden because the map was being
widened for everyone. Now bounds-checked, falling back to the tool's own index --
the form GCodeProcessor already uses for the same map.
Preset::save() and get_preset_differed_for_save() carried a branch storing the
full vector whenever a child and its parent had different lengths. It was written
against a set_with_nil that threw on mismatched sizes; upstream #13035 replaced
that with a tolerant version that keeps the child vector verbatim and nil-marks
only the overlapping range, and that fix was already in the tree when this branch
was rebased. Left in, it defeated the delta encoding for every user printer preset
whose extruder count differs from its parent's: a 7-extruder profile inheriting a
single-extruder base wrote out all of its per-variant retraction keys as literals,
including ones identical to the parent, pinning them against future vendor updates
while the UI still reported the preset as inheriting. Removed; the two save loops
are now identical to upstream. Note this only affects new saves -- presets already
written keep their frozen values until re-saved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes review comment 1.
imex_parallel_mode and imex_head_filament_map were streamed raw into XML attribute
values, while every other free-text attribute in the same writer goes through
xml_escape. Mode names are free text, so "PLA & ABS", a quote or a "<" made the
document malformed. The failure is not a bad value on reload: both load paths for
model_settings.config return false on an expat error, and m_is_bbl_3mf is set
before the second entry loop runs, so the whole project fails to open with
"Archive does not contain a valid model config".
Both attributes now use xml_escape_double_quotes_attribute_value(), which also
emits tab, CR and LF as numeric character references. That matters and plain
xml_escape would not do: XML normalises literal whitespace in attribute values on
read, so a tab in a mode name would come back as a space and silently rename the
mode. The read side needs no change -- it takes expat's already-decoded value with
no second unescape -- so this is a lossless round trip and a file written by the
new code still loads in an older build.
The round-trip test used "copy_mode", which exercised none of this; it now carries
&, <, a quote and a tab, and also pins that ' and > come back unmodified, since
both are legal raw inside a double-quoted value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes review comment 6.
The per-tool pressure advance work changed set_pressure_advance() for users who
are not using the feature. RepRapFirmware lost its D qualifier when no tool index
was supplied: upstream emits M572 D0 S<pa> unconditionally, and a bare M572
applies to whatever tool is currently selected and errors when there is none, so
PA started depending on tool-selection state for every RRF user. The D is back,
defaulting to 0, and D<tool> is reached only from the IMEX paths.
The same rewrite had also changed the comment separator from "<value>; Override"
to "<value> ; Override" on the Klipper, RRF, Marlin 2.x and Marlin Legacy
branches, so every non-IMEX print of those flavors carried a one-byte diff.
Restored. Upstream is internally inconsistent here -- BBL and Repetier do use the
spaced form -- and the point is to match it exactly rather than to tidy it.
Emitted output for all six flavors with no tool index is now byte-identical to
upstream. Verified on a real slice: a Klipper profile emits
"SET_PRESSURE_ADVANCE ADVANCE=0.02; Override pressure advance value", an exact
string match, with no EXTRUDER= qualifier. The tests were pinning the regressed
form and are inverted.
Also records at the imex key registrations why they are kept out of the g-code
config block, matching the house convention at the other banned keys.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes review comment 2, part of 18, and one non-IMEX regression the review did
not cover.
An imex_parallel_mode naming no entry in imex_mode_names still entered the
parallel branches. get_imex_active_tools() returned empty and the else was
skipped, so no head received its 1st-to-2nd layer temperature transition, and
imex_suppresses_bare_toolchange() still dropped the initial T<n> on the
expectation that a mode script would select the tool. validate() did not catch it
because its guard is declared_primary >= 0 and an unresolved mode yields -1. You
reach it by renaming a mode after a plate is set to it, or by opening a 3MF whose
printer preset names its modes differently. m_imex_parallel_mode is no longer
assigned before the lookup; a non-Primary name that matches no row now warns and
re-resolves against the Primary row, which is the fallback the PR description
already claimed. A mode that resolves to an empty tool roster takes the same path,
since the emitted G-code is wrong in the same way.
Warned rather than blocked: opening someone else's 3MF is a legitimate way to get
here and the Primary reading prints correctly, so refusing to slice would turn a
recoverable situation into a dead end. Silent was not an option either, because
the plate keeps showing the stale mode name while drawing no zones.
Separately, the 14 imex config keys all register non-nil defaults, so
append_full_config was emitting "; imex_* = <default>" into every exported
G-code, including on single-nozzle printers with nothing to do with the feature.
They are banned from the dump, matching the treatment already given to the
fast-purge, extruder-change and timelapse keys, so the config block is
byte-identical to the pre-IMEX baseline for the whole shipping fleet. Nothing
reads them back: GCodeProcessor has no imex reference, and the two per-plate keys
round-trip through the 3MF's model_settings.config on an independent path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes review comment 11, and fixes a worse bug found while doing so.
The offset was only ever pushed from PartPlate::refresh_imex_slice_offset(),
reachable from update_slice_context() and the plater -- both GUI-only, and it
dereferences wxGetApp(). A headless slice therefore kept Vec2d::Zero(), so
orca-slicer --slice on a plate with imex_firmware_managed_zones emitted
slicer-managed coordinates while the firmware applied its own offsets on top.
Print::update_imex_slice_offset() now derives it from the applied config and runs
from process() and export_gcode(), so a CLI slice gets the value a GUI slice does.
It reads m_full_print_config rather than m_config because imex_tool_layout and
imex_carriage_margin are printer-preset options with no member in the static
PrintConfig, and it takes the mode from the same place GCode.cpp resolves it, so
the shift cannot disagree with the mode that is emitted.
The GUI push and Print::set_imex_slice_offset() are deleted rather than kept as an
override, because the two did not agree. calc_imex_zones() divides
get_extents(m_shape), and set_shape translates m_shape by the plate position, so
the pushed offset carried the plate origin -- which translate_to_print_space() and
the writer offset already subtract. Plate 1 sits at the origin and agreed by
accident; every later plate had the origin subtracted twice and was shifted by a
full plate stride. Not silent, either: the displaced geometry fell outside the
printable area, so slicing plate 2 failed validation with "part is off the plate".
Confirmed fixed on hardware profiles -- the same model on plates 1 and 2 now emits
identical extents.
Deleting the push also removes the post-apply ordering constraint that forced the
duplicate call in Plater::priv::update_background_process: the value is computed
at the point of use, and process()/export_gcode() are structurally after apply().
Also routes validate()'s primary-routing check through imex_resolve_routing() so
the hard block and the plater's warning cannot describe a plate differently
(review comment 20), and through find_imex_mode() for the mode lookup (18).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes review comment 16, and fixes three defects the move exposed.
PartPlate::calc_imex_zones() was 392 lines deciding where every zone, collision
strip and safety margin sits, in the GUI layer, with no test coverage. The three
wxGetApp() calls that kept it there were all in its first 25 lines, fetching two
configs. The geometry now lives in compute_imex_zone_layout(); the wrapper fetches
the configs, calls it, and clips the returned rects to the bed outline for the
GLModels, which is the only part needing GUI types. libslic3r gained no wx
dependency: it takes DynamicPrintConfig directly, so the option lookups moved
verbatim rather than through a hand-written value struct that could drift.
The move is otherwise exact -- verified by a line-for-line diff of every
arithmetic expression against the original, and by running 15 scenarios through
the extracted code against hand-derived values. The only deletion is a lambda that
was never called.
Three fixes on top, each of which needed the code to be testable:
- An off-grid or absent Primary left pri_col/pri_row at their (0,0) initialisers
and built a layout from them, reporting the whole bed as the clear primary zone
and the whole bed as a blocked mirror zone at once; under
imex_firmware_managed_zones it shifted the slice by the bed centre. Guarding on
the resolved primary head covers both routes. Reachable only from a hand-edited
preset or a 3MF authored against another printer -- the editor pins Primary to
tool 0 -- but that is the same class the unresolved-mode fallback handles.
- imex_nozzle_clearance_x/y fell back to 0.0 where PrintConfig registers 30.0.
Both strip loops are gated on the value being positive, so the fallback emitted
no collision strips at all while the preview still drew 30 mm toolhead boxes.
- The collision-strip loop asked each mirror head for its own grid cell, but an
aggregated gantry's cell is pinned to the primary's column and expanded into a
full-width row strip. Where the representative's column differed from the
primary's, no boundary matched and the plate came back with no strips and no
margin bands -- an object flush against the shared boundary sliced without a
warning while the far carriage occupied it. Present since Span aggregation was
added in 4966d0fae8 and carried out of PartPlate verbatim. The flags now come
from the painted cells; an exhaustive sweep of the reachable grid, role and
layout space (1,630,720 configurations) shows the only behaviour change is the
missing strips appearing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes review comments 3, 5, 7, 8, 18 and 19, plus the library half of 20.
These share a file, so they share a commit; each is independent of the others.
- 3: ::isspace(char) is undefined for bytes above 0x7F because char is signed on
our targets. Three call sites now go through one strip_whitespace() using an
unsigned char cast. Line 308 parses imex_head_filament_map straight out of 3MF
metadata, so a non-ASCII byte reached it without passing through the UI.
- 5: an imex_head_filament_map override past the end of physical_extruder_map now
falls through to the printer's own routing instead of resolving to a wrong
filament. Bounded in resolve_filament_for_head, where the slot count is known,
rather than at the parse site, which has no count to check against; the parse
site also gains the absolute MAXIMUM_EXTRUDER_NUMBER cap its sibling already had.
- 7: imex_physical_heater_for's !is_imex early return is what keeps a stock BBL
profile (physical_extruder_map [1,0]) out of the heater remap, and had no test.
Six cases now cover it, pinning pass-through rather than get_at()'s clamp.
- 8: ImexRole::Span was missing from the imex_head_transform switch, so it warned
under -Wswitch. Identity is correct, not merely convenient: a Span tool prints
the primary's own zone through mid-print toolchanges and has no zone to be
translated into.
- 18: three positionally coupled string vectors were resolved by nine open-coded
lookups using three incompatible bounds idioms. None read out of bounds, but six
folded the guard into the match condition, so a ragged row did not stop the scan
and a later duplicate name could win. struct ImexMode + find_imex_mode() is now
the only resolution rule: the names array is the roster, first match wins, a
short sibling pads to empty and sets ragged, not-found is an explicit -1.
- 19: the letters P/C/M/S existed in three independent copies, one of which was the
writer of the on-disk format. kImexRoleTable is now the single source, read by
both parsers and the serializer. Adding a role was 14 edit sites with one
compiler-enforced; it is now the enum, the table entry, and four -Wswitch
switches. Verified by adding a fifth enumerator and recompiling: exactly four
warnings, nothing else.
- 20: imex_resolve_routing() extracts the mode/primary/routing chain that
Print::validate and the plater's warning collector each derived separately.
The three config keys keep their names, types and on-disk representation. This is
a read-side view only; presets and 3MF files are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep now validates each printer with the filament that printer ships, so a run
over every vendor reports what a single-vendor run does. Validator only - no change
to slicing output or shipped profiles.
Revolve failed on a sketch whose profile was closed. Decoding the reported 3mf: four
entities forming a proper closed loop (joints open by 4.44e-06 mm, well inside
tolerance) plus one stray 1.82 mm Line at (-24.2, 80.3), inside the shaded region,
touching nothing.
The viewport's region_loops discards open chains ON PURPOSE — it exists to find
EXTRUDABLE regions — so the user saw one clean closed region. entities_to_wires kept
the stray as its own one-edge loop, so it returned two wires, and Revolve goes through
entities_to_wire which demands exactly one. Extrude would have failed one step later in
wires_to_face, because a one-edge open wire bounds no face. Same class as the tolerance
split fixed in 8b568b7b: the viewport and the kernel disagreeing about the sketch — this
time about what BELONGS to the profile.
entities_to_wires/entities_to_wire/build_sketch_wire take closed_only. It is not a
blanket rule: a SurfaceExtrude builds a sheet FROM an open profile and a Sweep PATH is
normally open, so all ten call sites are classified individually — true for the face
fallback, Extrude-taper, Revolve, the Sweep PROFILE and Loft profiles; false for
SurfaceExtrude/Revolve/Loft/Fill and the Sweep path.
A component counts as open when some welded node has DEGREE 1. The first attempt used
"the traversal did not return to its starting node", which regressed the bridged C
profile: a closed loop that also carries a second edge across the same two nodes has no
free endpoint, but its Eulerian walk ends elsewhere. Degree-1 is the property that
actually distinguishes a stray segment from a closed profile; the suite caught the
difference.
Behaviour change decided by Tommaso: a stray is IGNORED, not refused. The test that
required refusal dates from when ignoring meant falling through to a default rectangle —
geometry nobody drew. That fallback is gone, so ignoring now builds the circle the user
actually drew. Its assertion is updated with the reason.
The bridge round-trip test extruded an ENTIRELY open chain and "worked" only because
OCCT will make a face from an open wire. It gets a genuinely closed profile: the test is
about serialization, and deserialize_recipe recomputes, so the document has to be one
that legitimately builds.
Failures now say WHERE. sketch_open_ends reports free endpoints under the same weld
tolerance the wire build uses, and open_loop_message is shared by both throws, because
Extrude fails through build_sketch_face and Revolve through build_sketch_wire — enriching
only one would have left the commoner path the less informative one.
Kernel 66115 assertions / 608 cases green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
Tommaso asked the question that names the real defect: if the sketch was open, why was
the same sketch shaded closed and offered for extrude? Because the two halves used
different tolerances. region_loops shades a region closed at 1e-3 mm; connected_loop
chained at 1e-3; the kernel welded at 1e-4 and OCCT matched vertices at 1e-7. The
2.28e-5 mm gap in the reported sketch did not cause that disagreement, it only made it
visible — and fixing the gap alone would have left the contradiction in place, ready to
reappear anywhere in (1e-4, 1e-3].
kSketchJoinTol now lives in SketchEngine.hpp and is the only place the number exists.
region_loops, loop_report, connected_loop and entities_to_wires all read it through
sketch_join_tol(). The viewport cannot promise a region the kernel refuses to build.
The welding is optional, because a kernel that silently closes loops should let you say
no: "Auto-close sketch loops" in Preferences, default ON, no restart. OFF means only
exactly coincident endpoints join — and since both halves read the same value, the
viewport simply stops shading the region closed, so an open loop is visible rather than
welded behind your back. No separate UI needed for that; it falls out of sharing one
number.
Details that matter. The kernel defaults to auto-close ON independently of the GUI, so
headless and MCP callers behave like the viewport instead of inheriting an unset
preference. With the tolerance at 0 the comparisons become <=, because OFF must mean
exact, not broken. OCCT never receives a zero vertex tolerance — it is clamped to
Precision::Confusion.
The preference is pushed from EVERY entry that starts a sketch session, not just
begin(): a Constrain session enters through begin_constrain / begin_constrain_entities
and uses region_loops and connected_loop, so a single push site would have left those
sessions running on whatever the previous one set. begin_imported_transform is excluded
deliberately — it works on imported regions, not chained entities.
Tests: a loop with one joint open by 9e-4 mm, given out of traversal order, builds a
closed four-edge wire; with auto-close off the same loop yields no wire; and an exactly
closed loop still builds with auto-close off, proving OFF means exact. Kernel 66104
assertions / 606 cases green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
An extrude built a solid the user never drew: three sides of the handle plus the arc
that bulges outside the outline, with the bowl's second arc and the left edge missing.
Two faults met. entities_to_wires added edges in ENTITY-CREATION order, so a partial
wire rejects the next edge even when the sketch closes perfectly; and one joint of the
reported sketch is open by 2.28e-5 mm, wider than OCCT's 1e-7 vertex tolerance and
wider than this function's own EPS of 1e-6, so that edge was refused on geometry too.
Neither showed up, because BRepLib_MakeWire::Add DROPS a disconnected edge
(BRepLib_DisconnectedWire + NotDone) while every successful Add ends with
BRepLib_WireDone + Done() — overwriting the failure. `if (!wm.IsDone()) return {}`
was therefore asking only whether the LAST edge connected. Six edges in, four out,
IsDone() true.
Endpoints now weld into shared nodes at one tolerance (kSketchWeldTol) used by BOTH
the union-find grouping and the wire build — they disagreed before, which is how a
joint gets united into a loop and then refused by the builder. Each node becomes ONE
TopoDS_Vertex, so the builder matches on identity instead of proximity, with the
vertex tolerance widened because BRepLib_MakeEdge::Init projects a vertex onto the
curve within that tolerance and a welded node sits up to the weld gap off its
neighbour's curve. Members are then walked in traversal order. Finally the result is
counted: IsDone() alone is not evidence, edge_count == members.size() is.
Arc geometry is untouched — the midpoint from (start_angle+end_angle)/2 and the
solver's angle reflow both measured correct and were never part of this.
The regression case carries the reported sketch verbatim, open joint included. It
fails 4 == 6 without the fix, which was measured, not assumed. Kernel 66092
assertions / 604 cases green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA