Skip to content

Latest commit

 

History

History
941 lines (792 loc) · 47.4 KB

File metadata and controls

941 lines (792 loc) · 47.4 KB

SWFileFixer — Architecture

This is the "documentation IS the logic" document for SWFileFixer. Read this before touching any code, and update it whenever a design decision changes.

Goal

Given a folder tree of SolidWorks assemblies and parts, tell the user — without opening SolidWorks — every Toolbox integrity problem the tree contains: duplicate IDs, mismatched linked IDs, missing configurations, Toolbox-flag/path mismatches, and name-drift.

The output is a machine-readable list of findings. Repairs happen in SolidWorks (with the human in the loop). This tool never writes to a SolidWorks file — the write side belongs in SWFormat, not here.

Non-goals

  • Editing / auto-fixing files. Every finding is human-reviewed.
  • Live COM. There is a sibling toolchain (combridge, sw_bridge) for that.
  • Toolbox admin surgery (rebuilding SWBrowser.sldedb, regenerating parts, etc.). Also live-COM territory.

Data flow

                                             +----------------------+
    root_dir  ------> walker  ------>  every  |  .SLDASM / .SLDPRT   |
                                             +----------+-----------+
                                                        |
                                          +-------------+-------------+
                                          |                           |
                            +-------------v-------+         +---------v----------+
                            | reader.read_asm_    |         | reader.read_part_  |
                            |   components(asm)   |         |   identity(part)   |
                            |                     |         |                    |
                            | Component list, +   |         | creation_time, +   |
                            | per-swFile          |         | config list        |
                            | expected creation   |         +---------+----------+
                            | stamps              |                   |
                            +-------------+-------+                   |
                                          |                           |
                                          v                           v
                                   +----------------- registry -------------------+
                                   |  ScanRegistry                                |
                                   |    parts:    {abspath  -> PartRecord}        |
                                   |    by_name:  {basename -> [PartRecord ...]}  |
                                   |    assemblies: [AssemblyRecord ...]          |
                                   |    (each AssemblyRecord holds the resolved   |
                                   |    Component list PLUS the parent-side       |
                                   |    "expected creation_time" per swFile)      |
                                   +----------------------+-----------------------+
                                                          |
                                     +--------------------+--------------------+
                                     |         |          |         |         |
                                +----v---+ +---v----+ +---v----+ +--v-----+ +-v------+
                                | dup_id | | mism_  | | miss_  | | tb_pth | | name_  |
                                |        | | linked | | config | |        | | drift  |
                                +--------+ +--------+ +--------+ +--------+ +--------+
                                     |          |         |         |         |
                                     +----------+---------+---------+---------+
                                                          |
                                                          v
                                                    Finding list
                                                          |
                                          +---------------+---------------+
                                          |                               |
                                     json_writer                     text_writer
                                     (--out FILE.json)          (default stdout / --text)

Modules

swfilefixer._swformat_bootstrap

Prepends D:/Dev/SWFormat/src (or $SWFILEFIXER_SWFORMAT_SRC) to sys.path at import time so import swformat works without a pip-install. Imported at the top of swfilefixer/__init__.py. If the bootstrap path doesn't exist, the import raises with a clear message telling the user how to override it.

swfilefixer.io.walker

walk(root, patterns=("*.SLDASM", "*.SLDPRT")) -> Iterable[Path]

Recursive glob walk. Skips SolidWorks backup / bak / autorecover files (~$*, *.bak, *_backup*, AutoRecover_*) and skips hidden directories. Yields resolved absolute paths.

swfilefixer.io.reader

Wraps SWFormat plus an extended COMPINSTANCETREE parser.

Public functions:

  • read_part_identity(path) -> PartRecord | None — reads swXmlContents/Features via swformat.api.components.read_part_config_tree. Returns a PartRecord with creation_time, configs, most_recent_config, plus the resolved absolute path. Returns None when the file has no Features stream (drawing, corrupt, non-part).
  • read_assembly_components(path) -> AssemblyRecord | None — the extended reader. Uses swformat.io.reader.read_document to grab the raw COMPINSTANCETREE bytes, then parses them once — extracting both:
    1. The Component list SWFormat already gives us (name, path, config, exclude_from_bom, flexible, hidden, suppressed, virtual, transform, bounding_box, model_ref).
    2. A parallel expected_creation_time[model_ref] map extracted from the <swFile> elements — this is the identity fingerprint the parent assembly stored the last time it saved a resolved link to that file. read_component_tree() in SWFormat drops this attribute; we don't, because we need it for the mismatched-linked-ID check. Returns None for non-assembly / no-COMPINSTANCETREE files.

swfilefixer.io.registry

ScanRegistry accumulates results across the whole walk:

  • parts: dict[str, PartRecord] keyed by lowercased absolute path.
  • by_basename: dict[str, list[PartRecord]] keyed by lowercased basename (e.g. hex bolt.sldprt). This is where duplicate-ID detection lives.
  • assemblies: list[AssemblyRecord] in scan order.

Also stores per-file parse errors so the report can note "N files were unreadable" without hiding the fact.

swfilefixer.checks

Each check is a module exposing run(registry) -> Iterable[Finding]. The registry is passed in already fully populated — checks don't do I/O.

checks/__init__.py holds REGISTRY: dict[str, Check] mapping the short name (duplicate_ids, mismatched_linked_id, missing_configuration, toolbox_flag_path_mismatch, read_only_toolbox_part, broken_reference, name_drift, parse_error) to its run function.

Purity exception: two checks perform minimal read-only I/O over paths already in the registry — broken_reference (existence stat per unique unresolved path) and read_only_toolbox_part (attribute stat per toolbox-resident part). Both are documented deliberate exceptions; the "checks do no I/O" rule otherwise stands.

See docs/CHECKS.md for one page per check.

swfilefixer.report

  • json_writer.write(findings, scan_meta, path) — one JSON object with {scan: {...}, findings: [...]}. Every finding serializes its evidence verbatim (no summarization).
  • text_writer.write(findings, scan_meta, stream) — grouped by severity, then by check, then by file. Human-readable, not intended for parsing.

swfilefixer.cli

argparse CLI. Subcommands:

  • scan ROOT [--toolbox-root PATH] [--check LIST] [--out FILE.json] [--text FILE.txt] [--include-drawings] — the main command.
  • checks — print the registry with descriptions and severities.

Identity fingerprint — why swCreationTime

SolidWorks stores an internal ID on every model document. This ID is NOT byte-readable from the file (see C:/personal_rag/solidworks/lesson_20260521_sw_file_id_not_byte_readable.md — confirmed empirically that no regex, no offset math, no CArchive walk recovers it as a stable stamp). What IS in the plain-XML mirror is swCreationTime, and it appears in TWO places:

  1. On the <swFile> element inside the parent assembly's COMPINSTANCETREE — this is the parent's expected stamp for the referenced child file at the moment the parent was last saved.
  2. On the <swFile> element inside the child part's own swXmlContents/Features stream — this is the child's current stamp on disk.

If those two disagree for a given (parent, child) pair, the parent now points at a different file than it was pointing at last save. That is the exact scenario SolidWorks flags at open time as "swComponentInternalIdMismatch=5" (see C:/personal_rag/solidworks/lesson_20260521_swcomponentinternalidmismatch_marker.md).

So swCreationTime gives us:

  • duplicate_ids — a basename appearing twice under the scan root with two different creation_time values means two distinct files share the same filename. That is a genuine duplication that will bite SW's Toolbox resolver at some point.
  • mismatched_linked_id — parent-expected != child-actual for a specific reference means SW will fire the InternalIdMismatch warning the next time this assembly is opened.

Caveat: swCreationTime is a creation stamp, not an update stamp. Two files can have the same creation stamp and different content (rebuilt, resaved). But two files with different creation stamps are provably distinct. This asymmetry is fine for the check we run — we only trigger on different, not same.

Configuration matching — what counts as "missing"

For missing_configuration we compare, case-insensitively, the component's config string against the target part's configs list (from read_part_config_tree). A missing config is a case where the component's string is neither in the list nor equal to "" (empty string means "use the part's default" and SW handles that fine).

Special case: if the target part cannot be resolved (path stale, missing file), missing_configuration skips it — that's a "broken reference" finding of a different class (not yet in v0.1; tracked in the roadmap).

Toolbox flag detection

Implemented (2026-07-16). SolidWorks persists the IsToolboxPart flag in each part's docProps/ISolidWorksInformation.xml — a plain-XML OPC side-channel. SWFormat exposes it via swformat.api.toolbox (shipped same-day off our feature request in D:/Dev/FeatureRequests/SWFormat_FeatureRequests/is_toolbox_part_bit/).

Integration points:

  • swfilefixer.io.reader.read_part_identity reads the document's stream map once and feeds three parsers: the Features config tree, is_toolbox_part_from_streams (the flag decision), and parse_solidworks_information (the raw swToolBoxPartType_e for evidence). One read_document per part, unchanged scan cost.
  • PartRecord.is_toolbox_part is three-state: True (Toolbox), False (info stream present, part not Toolbox), None (stream absent — undeterminable; never treated as False by checks).
  • PartRecord.toolbox_part_type carries the raw 0/1/2 value (not-toolbox / standard / copied).
  • toolbox_flag_path_mismatch fires on three directions: flag-on outside root (A), flag-off inside root (B), and the original basename-split heuristic (C). See docs/CHECKS.md.
  • The GUI auto-detects the Toolbox root from HKCU\SOFTWARE\SolidWorks\SOLIDWORKS 20NN\General\Toolbox Data Location (swfilefixer.io.sw_registry.detect_toolbox_root, newest installed version first, folder-exists validated).

Repair plan automation (GUI)

The Repair Plan tab is populated three ways, all editable afterwards:

  1. Auto-build from findings (swfilefixer_gui/engine/plan_builder.py, button on the Repair tab / F7). Walks the last scan's findings and generates rules, resolving replacement paths via four strategies in order:

    • browser-subpath remap — a dead path containing \browser\<subpath> is retried as <current_toolbox_root>\browser\<subpath>. Toolbox's folder layout under browser is standard-defined and stable across machines, so a hit is near-certainly the same catalogue part.
    • Toolbox basename match — the missing file's basename exists under the current Toolbox root (lazy cache-backed index): remap there automatically, even when internal-ID matching would be ambiguous (user directive 2026-07-25 — Toolbox seed stamps are shared by hundreds of files, so stamp ambiguity is the norm for a part that merely moved roots; prompting on each defeats the auto-build). Same-named files in several folders disambiguate by requested-config coverage → expected internal ID → deepest folder-tail agreement with the dead path; only a full tie falls to the candidates dropdown.
    • internal-ID (creation-stamp) match — reached only when the basename is NOT in the Toolbox (renamed/copied part): unique stamp+requested-config hit auto-resolves; ambiguity → ranked candidates dropdown (never silently relink to a renamed file).
    • scanned-parts basename match — unique same-basename file already read during the scan. Unresolvable rules are still generated (empty replacement, NEEDS INPUT note, amber row) — the plan is editable, not filtered. Pressing the button repeatedly is safe: rules are deduped by repair-intent key against the existing plan.

    Selection propagation (user feature 2026-07-25): when the user resolves one NEEDS-INPUT rule by picking a candidate from its dropdown, the tab offers to apply the analogous choice to every other still-incomplete rule of the same kind (plan_builder.propose_similar_selections, pure and unit-tested; the GUI only confirms and applies). A proposal is made per rule only when it is unambiguous for THAT rule: the single candidate in the same directory as the chosen path first, else the candidate sharing the strictly longest leading-path prefix (minimum drive + one folder); config-name rules propagate the chosen config verbatim to rules listing it among their candidates. The offer fires only on an actual candidate pick (typing a path never matches a full candidate string mid-keystroke), a confirmation dialog previews the affected rows (capped at 10 lines), and propagated rules get an explanatory note. First real-corpus run (reference corpus, 250 broken refs): 238 rules in 0.21 s, 178 auto-resolved (34 remap + 144 scanned-basename), 60 needing input.

  2. Send to Repair Plan — per-finding right-click on the Findings tab (pre-fills one rule).

  3. By hand — Add-rule dialogs, in-place cell editing (Match / Replacement / Note columns), or Edit as text (the whole plan in a line format that round-trips losslessly; see the plan_builder module docstring for the grammar). ? marks a still-unresolved replacement.

Apply preflight: incomplete rules are counted, surfaced, and skipped — they never reach an executor.

Finalize pass — accepting new internal IDs

ReplaceReferencedDocument rewrites the reference PATH in a closed parent but the parent still stores the OLD file's internal ID. SW surfaces that at next open (swFileLoadWarning_IdMismatch = 1) and only re-stamps the stored ID when the parent is saved. So a consolidation is only HALF done until every affected assembly has been opened + saved once. The dispatcher automates that: after a non-dry-run apply, it collects every assembly touched by replace_reference / consolidate_duplicates rules and runs resave_assemblies.csx (open silent → rebuild → save → close, per-assembly error isolation, id_mismatch_seen recorded in the audit log). Toggle: the "Re-save affected assemblies (accept new IDs)" checkbox on the Repair tab (default ON). After the finalize pass a re-scan shows no mismatched_linked_id findings for the repaired assemblies — the end-to-end answer to "two identical Toolbox parts with different IDs across assemblies".

Toolbox-flag surgery (set_toolbox_flag)

flag_off_inside_root findings (a file in the Toolbox tree without the IsToolboxPart flag — typically kit parts copied into browser folders) generate a SetToolboxFlagRule instead of being skipped. The combridge executor applies it via IModelDocExtension.ToolboxPartType {get; set;} (swToolBoxPartType_e: 0 = not Toolbox, 1 = standard, 2 = copied) — open silently, set, Save3, QuitDoc, with a read-back check. This replaces the manual sldsetdocprop.exe workflow. The reverse direction (clearing the flag on a part that deliberately lives in a project folder) is addable via the "Add — Set Toolbox flag" dialog or the SETFLAG <path> -> no plan-text line. Preview is pure registry reasoning: affected iff the scanned flag differs from the desired state. The SwDM engine will use the settable ISwDMDocument.ToolboxPart when the licence key lands.

Toolbox size generation (generate_toolbox_size)

Generates missing Toolbox size configurations inside a Toolbox master part (e.g. every Style-2 M8 hex-nut size the standard defines but the part file doesn't yet carry). Field origin: this replaces the manual "open Toolbox settings, tick sizes, wait for per-config rebuilds" workflow. Engine: swfilefixer_gui/engine/toolbox_generator.py (read its module docstring — it is the authoritative mechanism doc); GUI entry points: the auto-plan builder, the "Add — Generate Toolbox sizes…" dialog, and Tools → "Build Toolbox sizes…" (which just adds an all-missing-sizes rule to the plan — the run still goes through the normal Preview/Apply flow, so nothing writes silently).

Offline-solve / live-apply split. The expensive, error-prone part of size generation — resolving a size into a config name plus the exact dimension values and feature-suppression states — needs NO SolidWorks at all. tools/toolbox_solver.ps1 (Windows PowerShell 5.1; sldtoolboxdata.dll is a .NET Framework assembly and will not load in pwsh 7) drives the same .NET engine Toolbox Settings uses, so the recipes are what Toolbox itself would produce, including data-driven per-size defaults like washer-face variants. Only the last step — actually adding configurations to the part — runs live SolidWorks, via combridge and combridge_scripts/generate_toolbox_configs_batch.csx. Preview (F8) therefore shows the full, exact list of configs that would be created ("CREATE <config> in <part>", plus "UNSOLVABLE …" lines) with zero writes and zero SW.

Why the batch apply is fast. The csx opens the part ONCE, adds every config with AddConfiguration3 and applies each recipe via SetSystemValue3 / SetSuppression2 scoped to the named config — configurations are never activated one by one. One rebuild, one save. Geometry for each new config regenerates lazily on its first activation, which is normal SolidWorks behavior (identical to design-table configs) and is exactly what the native Toolbox flow pays for eagerly (activate + rebuild per config). Measured: 13 configs in ~24 s.

Unit-conversion ownership. The solver emits dimension values in the standard's units (part_units = MM or INCH); the csx script takes strictly SI meters (value_si). The conversion happens in ONE place — toolbox_generator.solve_part (×0.001 / ×0.0254, applied only when the solver marks the element convert_unit: true). Neither the executor nor the csx ever converts; a recipe that reaches the payload is already SI.

Length-axis enumeration (bolts/screws). Enumerate-all mode yields one solve per size, but bolt/screw families have a second axis — length — that lives only in the Toolbox SQLite DB (<root>/lang/english/swbrowser.sldedb, opened mode=ro&immutable=1). enumerate_length_jobs walks: <STD>_TYPE_* row (matched by Filename basename) → its ConfigurationTable grid → the grid row named Length → its AltDataSource +DATA_*_LENGTHS table → distinct (SIZE, LENGTH) pairs (thread-length variants produce duplicate rows; deduped — the solver picks the canonical thread length). Prefixed-table gotcha (real bug, regression-tested): the intra-DB links are stored WITHOUT the standard prefix (+CFG_BS_HHBOLT) while the physical tables carry it (AM_CFG_BS_HHBOLT); both spellings are tried. Parts with no length axis (nuts, washers) return None and enumerate-all covers them fully.

Two modes. config_names == [] means "build all missing sizes"; a non-empty list solves exactly those names (SolveForGivenConfig). Guard kept from live testing: SolveForGivenConfig silently solves a bogus name to a nearest/ parseable config instead of erroring, so a produced recipe only counts for a requested name when the names match (whitespace-collapsed, case-insensitive) — everything else lands in failed_jobs and, in explicit mode, fails the rule BEFORE any live work (a subset of an explicit request would be a silent partial repair).

Plan-builder strategy. A missing_configuration finding whose referenced part lies under the Toolbox root and whose requested config is genuinely absent (not case drift) generates a GenerateToolboxSizeRule instead of a NEEDS-INPUT set_component_config: creating the config in the master makes the assembly's stored reference valid with no assembly edit at all. Findings against the SAME part merge into one rule carrying all the requested names (one open/save cycle). Cosmetic case/whitespace drift still auto-fixes via set_component_config — generating a config differing only by case would collide, since SW matches config names case-insensitively. Parts outside the Toolbox root keep the previous behavior. Plan-text line: GENSIZE <part> -> all or GENSIZE <part> -> <cfg1> ;; <cfg2> (;; because Toolbox config names can contain commas and | is the note separator).

No finalize, live-only. The rule touches ONLY the part file — no assembly reference paths or internal IDs change — so it never enters the dispatcher's finalize (resave-assemblies) pass; the finalize collector stays a whitelist of replace_reference / consolidate_duplicates for exactly this reason. The SwDM engine reports the rule unsupported (the Document Manager API cannot create configurations with driven dimensions), so the dispatcher always routes it to combridge.

Weldment profile repathing

A weldment structural member pulls its cross-section from an external profile library file (.SLDLFP) and stores that file's path. When the library moves, the members go unresolved — or worse, SolidWorks silently re-resolves to a same-named profile from a different standard (Tube Square.SLDLFP exists under ANSI Configurations, ISO Configurations, DIN Configurations, … in the same library) and the cut list quietly lies.

Read side — off disk, via SWFormat (2026-07-31)

This was previously believed impossible. Both the ScripTree WeldmentProfileRepath README and C:/personal_rag/solidworks/lesson_20260731_weldment_profile_repath_preserves_locateprofile.md stated there was "no headless path" for profile references. That is true of the Document Manager API and of SWFormat's read_referenced_models (which reads only Contents/Definition, empty of paths on a part) — but false of the bytes on disk. The path is an ordinary MFC CStringW living in Contents/Config-N-ResolvedFeatures.

Rather than hand-roll that read here (which the project's hard rules forbid), we contributed swformat.api.weldments to SWFormat. It reuses SWFormat's existing carchive.cstring + api.references suffix-anchor scanner — no new binary schema, no object-map decode. PartRecord gains weldment_profile_refs (path + which config streams carried it) and structural_member_names, both filled by read_weldment_info_from_streams inside the existing single read_document call, so the scan cost is unchanged.

.SLDLFP means "library feature part", which covers ordinary Design Library features too. So the check reports reference_kindweldment_profile when a referencing part has Type="Structural Member" features in its KeyWords index, library_feature when not. The two agreed exactly across 78 production weldment parts, but the gate is offered rather than the equivalence assumed.

Write side — live SolidWorks only, and why

Config-N-ResolvedFeatures is a large undecoded CArchive object map, and the SW API itself couples the profile path to ConfigurationName. So there is no validated off-disk write path and SWFormat ships this read-only. RepathWeldmentProfileRule executes through combridge (combridge_scripts/repath_weldment_profile.csx), porting the field-proven recipe from the ScripTree tool:

  1. AccessSelections (mandatory) on the member's IStructuralMemberFeatureData; obtain it with a direct castas silently returns null on the RCW.
  2. Capture ConfigurationName + RotationAngle (and, defensively, each group's MirrorProfile / MirrorProfileAxis / Angle / AlignAxis).
  3. Set WeldmentProfilePath.
  4. Re-assert the captured values in the same ModifyDefinition call.

Step 4 is the whole trick. Setting the path alone silently resets ConfigurationName to the profile's default config, and that reset is what drags the Locate Profile pierce point off — not the path change. With the config held constant, SolidWorks preserves the pierce-point corner identity automatically for same-topology replacements (the "same file, new location" case). The csx refuses to repath onto a non-existent file — trading one broken reference for another is not a repair.

Optional size change. A .SLDLFP holds one configuration per size (Tube Square.SLDLFP carries 98; Pipe Standard S40.SLDLFP 19). RepathWeldmentProfileRule.new_config is empty by default, meaning "keep whatever size the member already has" — the normal case, where only the path moves. Setting it switches size deliberately, which is needed when the replacement library names its sizes differently: re-asserting a name the new file lacks makes SolidWorks fall back to the profile's default config and silently resize the member.

Three guards make that safe:

  1. profile_index.read_profile_configs() reads a .SLDLFP's configuration list off disk (a profile file is part-format, so SWFormat's ordinary read_configurations works unchanged). The GUI populates a size dropdown from it with zero SolidWorks.
  2. The executor validates the requested size exists in the replacement profile before any live work — a bad name fails the rule instead of silently defaulting.
  3. The csx reads ConfigurationName back after ModifyDefinition and fails the member if SW did not apply the requested size, so a fallback can never be reported as success.

Changing size is safe for the pierce point: the corner identity is preserved and coordinates simply re-resolve against the new size's sketch — empirically, a config change moved a pierce from (0.0254, 0.0381) to (0.0254, 0.127), i.e. the same corner of a larger tube.

Which size? — engine/profile_size.py

"Which configuration of the replacement most closely matches what this member uses?" is answerable off disk, in three tiers, and the tiers are the safety mechanism. Only tier 1 may proceed without asking (SizeSuggestion.is_safe_to_apply), because a wrong size is a silent geometry change — new cross-section, new cut length, wrong BOM.

An exact match means "keep the current size", NOT "write this size". Getting this backwards corrupts data, so it is encoded as SizeSuggestion.implies_keep_current_size and regression-tested.

A repath rule targets every part referencing the dead profile, and different parts legitimately use different sizes of the same profile. Measured on the ROPS corpus: 27 parts reference Tube Square.SLDLFP across 8 distinct sizes (TS5x5x0.375, TS4x4x0.5, TS2x2x0.25, TS6x6x0.5, …). The exact match is corroborated from ONE sampled part, so writing it into the rule would force that size on all 27 and silently resize about twenty of them.

What an exact match actually proves is narrower and is precisely what is needed: the size naming carries over to the replacement, so re-asserting each member's own ConfigurationName resolves instead of falling back to the profile default. That is new_config = "". An explicit size belongs to the close case, where the naming does not carry over and a confirmed size change is the only way to avoid a silent fallback.

  1. EXACT — trustworthy. Intersect the strings in the part's Contents/Config-N-ResolvedFeatures (swformat.api.weldments.iter_resolved_feature_strings) with the replacement's configuration names. The member's stored ConfigurationName is one of those strings, so a unique hit means "the size this member uses exists verbatim in the replacement".

    This needs no structural decode: it is membership testing against a known candidate set, not parsing an object map, so it cannot silently mis-parse. Measured: a ~300-string part against a 98-entry profile intersects to exactly one name, independently confirmed by that part's cut list.

  2. CLOSE — a suggestion, never auto-applied. No exact hit means the replacement library names sizes differently. Recover the member's current size from, in priority order: the cut-list Description; the structural member's name (SW builds them as <profile> <size>(n)); or the missing profile's own basename when the library is the one-file-per-size kind. Then rank the replacement's configurations with rank_sizes.

    Plain string similarity is not enough — P0.5 and PIPE 0.5 SCH 40 share few characters yet denote the same pipe. Size names are numbers plus a naming convention, so the score blends numeric-token agreement (0.55), leading-number agreement (0.30, the primary dimension and strongest single discriminator) and character similarity (0.15). Real case: P0.5PIPE 0.5 SCH 40 at 0.91 with every runner-up at 0.06.

    A pick also requires a margin over the runner-up, not just an absolute score: within a family (TS5x5x0.375 / TS5x5x0.5 / TS5x5x0.1875) a confident-looking best match would really be a coin toss between three wall thicknesses.

  3. UNKNOWN — declines to guess. Nothing recoverable (member renamed to Structural Member1, no cut list, non-size filename), or the part references several profiles and sizes for more than one of them are present — we cannot tell which belongs to this profile without decoding the object map, so we refuse and offer the list.

Verified on the real corpus: S BEAM.SLDPRT → exact TR16x4x0.5; hyro tube 10 INCH.SLDPRT → close, P0.5PIPE 0.5 SCH 40 (recovered from the missing profile's filename, because the member had been renamed); TEMP2.SLDPRT → unknown, correctly declining because it uses two profiles.

How the dialog presents it

RepathWeldmentProfileDialog takes a referencing_parts_provider(match_path) -> [(part_path, member_names)] callback rather than a static list — in the Add flow the match path does not exist when the dialog is constructed, and it stays editable afterwards, so a list would be stale on arrival. RepairTab supplies it from CombridgeExecutor.preview, the same source as the Affects column, guaranteeing the sampled part is one the rule actually covers.

The suggestion refreshes on the settle triggers only (Browse, library picker, editingFinished) — never per keystroke — and reads one representative part to build part_strings. Any failure degrades to the filename/member-name tiers rather than raising.

Presentation follows the tiers: green for exact, an amber bordered panel for close, neutral grey for unknown. Two behaviours matter more than the styling:

  • An untouched close preselection is gated behind a confirmation ("Use size" vs "Keep current size", conservative default) rather than trusting an amber label to be read. This enacts is_safe_to_apply is False instead of merely displaying it.
  • A hand-picked size rewrites the hint to name what was corroborated versus what will be recorded, so stale advice never sits above a changed selection.

Combo order stays alphabetical: the suggestion is already preselected, so promoting ranked entries would buy nothing for the one entry that matters while destroying positional predictability in a 98-entry list. Finding a different size is the substring completer's job.

This rule touches only PART files, so it is deliberately excluded from the dispatcher's finalize (resave-assemblies) pass. It is the subtlest exclusion of the three: it does rewrite a stored external path, which looks like a reference edit — but the path lives in the part's own feature data and the csx already saves that part, so no parent assembly's stored ID is invalidated. The SwDM engine reports it unsupported (the Document Manager cannot see feature data at all), so the dispatcher always routes it to combridge.

Resolution cascade (plan builder)

weldment_profile_root (a new build_plan parameter) enables two strategies, in order:

  1. Profile-root remap — generalises the Toolbox \browser\ remap. A profile library has no universal marker folder, so we split the dead path on the root's own leaf folder name, matched case-insensitively (the same corpus spells it both SolidWorks Weldment Profiles and Solidworks Weldment Profiles), and rejoin the remainder under the current root.
  2. Basename + folder tail — the basename under the root. Usually ambiguous (3–5 hits across standards folders), so _best_tail_overlap picks the candidate whose parent folders agree with the dead path. Ties fall to the candidates dropdown; nothing is ever guessed, because guessing swaps a member's standard silently.

Unresolvable refs still generate an editable NEEDS-INPUT rule. WeldmentProfileIndex (engine/profile_index.py) is the lazy, cache-backed .sldlfp index behind strategy 2; an unreadable root indexes to empty rather than raising.

First real-corpus run (D:/ROPS TEST STATION BACKUP, 587 files, profile root W:\...\Solidworks Weldment Profiles with 123 profiles): 78 weldment parts → 9 distinct missing profiles across 14 parts → 7 auto-resolved by root-remap, 2 correctly NEEDS INPUT (a per-size ANSI Inch\Tube (square)\TS5x5x0.375.sldlfp from a different library layout, and a contractor's C:\Users\sam\Desktop\... path — neither has a counterpart under the current root, and inventing one would be wrong).

Auto-detect + selector

swfilefixer.io.sw_registry.detect_weldment_profile_roots() reads Weldment Profile Folders from HKCU\SOFTWARE\SolidWorks\SOLIDWORKS 20NN\ExtReferences, newest version first. Structural difference from the Toolbox root: that value is semicolon-separated — SolidWorks File Locations is inherently a list of folders searched in order — so the detector returns list[str], entry 0 being the folder SW would consult first. The GUI's Detect button fills directly on a single hit and offers a chooser on multiple, which is why this side has a selector where Toolbox has a one-shot fill.

Feature-tree restoration (live-verified 2026-07-31)

A profile change is not a neutral edit: SolidWorks renames the member's profile sketch and inserts an orphan sketch above the member. Observed exactly:

+ Sketch13  ProfileFeature  id=117     <- new orphan, above the member
  Structural Member1
-      sub: Sketch11  id=31            <- original profile sketch
+      sub: Sketch14  id=119           <- replaced

That matters because SolidWorks equations and drawing dimensions reference sketches by name (D1@Sketch11), so an un-restored rename silently breaks every such reference — the user's stated risk ("I sometimes reference dimensions from these").

The csx therefore snapshots, per part, every top-level feature ID before touching anything, and per member the names of its ProfileFeature sub-features. After a successful ModifyDefinition it:

  1. Restores the profile sketch's original name.
  2. Restores its visibility. SolidWorks builds a brand-new profile sketch, so it arrives at the default (shown) state — a hidden profile sketch becomes visible and clutters every view. Reproduced exactly: the path sketch stayed HIDDEN while the member's profile sketch went HIDDEN → shown. IFeature.Visible is read-only (swVisibilityState_e: 1 = Hide, 2 = Shown), so the restore goes through select + BlankSketch/UnblankSketch (and BlankRefGeom/UnBlankRefGeom for the member's RefPlane). It runs after the rename so the selection resolves the original name.
  3. Deletes the orphan sketch, identified exactly as "a top-level ProfileFeature whose ID did not exist before, and which no feature consumes". IDs are the right key here — names get reused, IDs do not.

The internal feature ID cannot be restored. IFeature.GetID() has no setter; the ID is SolidWorks-assigned. This is a real limit, but not the one that bites: equations and dimensions bind by name, not ID. Verified end-to-end — after a repath the feature tree diffs identical to the original.

Reported per part as sketches_renamed_back, visibility_restored and orphan_sketches_deleted. Controlled by CombridgeExecutor.restore_weldment_tree (default on).

Saving and leaving files open

save_parts (default on) governs whether a part-modifying rule writes to disk at all, and leave_open leaves modified documents open in SolidWorks for review. These exist because of a reported surprise: unchecking "re-save affected assemblies" stopped assembly writes but part files were still saved. A user who says "don't re-save" means "don't write", so the same toggle now governs both. set_toolbox_flag refuses outright when saving is disabled — that flag lives in the file and has no meaningful in-memory form.

Running a repair — pause, cancel, and the save policy

How the repair log gets its detail

CombridgeExecutor.apply(rule, registry, out_notes=None) populates the caller's out_notes dict with the .csx script's own response. The dispatcher passes one in, merges it into RepairResult.notes, and hands each completed result to result_cb(res) the moment it finishes — so the UI logs per-rule progress live instead of waiting for the whole run to return.

That is the whole mechanism, and it is deliberately boring. The earlier version monkey-patched dispatcher.audit.write and CombridgeExecutor._run_script with pass-through wrappers for the duration of a run. It worked and unwound cleanly in a finally, but it reached into another module's privates to read values the engine simply was not exposing. The fix was to expose them.

Consequences worth knowing:

  • apply() still returns the affected-file list. An out-parameter was chosen over a richer return type because the file list is what every caller and the audit log want; changing the return would churn the executor protocol and every test to serve one consumer.
  • Both executors must accept out_notes, and so must any test double. The dispatcher's broad except turns a signature mismatch into a generic "failed" outcome rather than a visible TypeError, so a stale double fails confusingly — test_apply_out_notes_reaches_ repair_result_notes pins the contract.
  • A raising result_cb is swallowed: a bad observer is not a repair failure.
  • Intra-rule granularity ("which document is open right now") is deliberately NOT reported — a .csx only answers when it returns, so it would cost a combridge round-trip per update.

Interruption happens BETWEEN rules, never inside one. A rule is a single combridge call that opens, edits, saves and closes SolidWorks documents. Killing it mid-flight would leave half-edited or ownerless documents open, and the off-disk scanner would then disagree with what is on screen. Pause and Cancel are therefore evaluated at the top of each rule iteration (through the dispatcher's cancel_cb), so a requested pause takes effect only once the rule now running finishes. Both the UI and the log say so, because a cancel that looks like nothing happened invites the user to reach for Task Manager — the one action that genuinely can corrupt a half-repaired document.

Controls: Apply becomes Pause while running and Resume while paused; Cancel is hidden when idle and shown while running or paused; auto-pause every N rules (0 = never) stops the run periodically so the operator can eyeball SolidWorks. N is persisted in QSettings rather than the workspace — it describes how this operator likes to work, not the corpus being repaired, so it should follow them across workspaces.

One save switch, not two

"Save modified files (and re-save affected assemblies to accept new IDs)" drives both CombridgeExecutor.save_parts and the dispatcher's finalize pass. Two independent toggles would merely relocate the reported surprise: the state "save parts ON, re-save OFF" is reachable and means writing to disk after the user said not to.

"Off" genuinely means off, and where it cannot, the rule refuses rather than writing:

Rule kind Behaviour when saving is OFF
repath_weldment_profile honoured — edits left in memory
set_component_config honoured — edits left in memory
generate_toolbox_size honoured — configs left in memory
set_toolbox_flag refuses — the flag exists only in the file
replace_reference / consolidate_duplicates refusesReplaceReferencedDocument rewrites a CLOSED parent, so the write is the operation

leave_open additionally keeps modified documents open in SolidWorks for review instead of closing them; it pairs naturally with saving off.

Session pinning (safety)

CombridgeExecutor.session pins combridge's --session. combridge defaults to MRU — the SolidWorks window the user last focused — and shops routinely run several instances. Since our scripts open, save and close documents, an unpinned run can land in a window holding unsaved work. This is also what makes it safe to exercise a write path against a scratch instance while a real assembly is open elsewhere.

Scan targets — never scan nothing silently

ScanTarget carries a kind ("dir" / "file") alongside its path, and the two can disagree: typing DIR <some file> in the Edit-as-text dialog, or editing the path cell of a row originally added as a folder, both produce a file path labelled as a directory. The original _collect_paths branched on kind and skipped the mismatch, so such a target contributed zero files, silently — and the scan then reported success with zero findings.

Reported from the shop on 2026-07-31: "When I added an assembly and a part file to the targets list, the scans didn't pick up anything. When I added a folder, it seemed to pick up the problems from the other two." The checks were fine; nothing was ever handed to them.

Two rules now hold:

  1. The filesystem wins over the stored kind. is_dir() / is_file() answer the question definitively, so they decide and kind is treated as a hint. Be liberal in what we accept.
  2. A target that contributes nothing is reported, never silent. _collect_paths returns (paths, warnings); the warnings reach scan_meta["target_warnings"] and the GUI raises a dialog. When zero files were scanned the dialog is a warning that says plainly the empty result does not mean the files are clean.

This matters more than it looks: for an integrity scanner, "0 findings because everything is fine" and "0 findings because I looked at nothing" are indistinguishable to the user, and the second one is actively dangerous. scan_meta also carries files_scanned so any consumer can tell them apart.

Legacy OLE2 files — "unreadable" must never look like "clean"

SWFormat's chunk walker reads the modern (2015+) SolidWorks container. A pre-2015 file is an OLE2 compound document, and read_document(...).streams() returns {} for it — no exception.

Before 2026-07-31 that propagated silently: the component parser reported zero components, every check found nothing to complain about, and the scan declared success. Reported from the shop — an assembly known to be broken (W:/Engineering/Products/<project>/<assembly>.SLDASM) scanned clean.

Measured on that production tree: 843 of 1,200 files (70%) are legacy OLE2. The scanner had been silently blind to most of it.

reader._require_streams() now raises UnreadableFormatError when the stream map is empty, which the fail-open wrappers turn into a parse_error finding naming the detected container and what to do about it ("open in SolidWorks and re-save"). An empty stream map is never legitimate for a real model document — even an empty part carries dozens of streams.

This is the same principle as the target-collection fix above, one layer down: for an integrity scanner, "I found nothing" and "I could not read it" must never be indistinguishable.

Drawings

Drawings are scannable targets, read for exactly one thing: the models they depict (DrawingRecord.referenced_models, via SWFormat's shipped read_referenced_models). A drawing whose part or assembly has moved shows empty or errored views — the same defect class as a broken component reference — so it feeds broken_reference rather than a parallel check. A model referenced by both an assembly and its drawing is ONE finding, with referencing_assemblies and referencing_drawings kept separate because the two are repaired differently.

We deliberately do not parse drawing sheets, geometry or annotations. SWFormat can, but no check needs them and it would multiply scan cost on drawing-heavy shops for no finding.

First real-corpus run (ROPS, drawings enabled): 10 missing models were referenced only by a drawing — invisible to the scanner until now.

Drawings feed the reference-resolution pass. Phase B originally collected referenced paths from assemblies only, so a drawing's models were listed (for broken_reference) but never opened — meaning every content check had nothing to work on when a drawing was the target. It is now a bounded fixed point over assemblies AND drawings, dispatching by extension, so a drawing → assembly → parts chain resolves. Capped (_MAX_RESOLVE_ROUNDS) because a reference cycle between assemblies would otherwise spin; each file is still opened at most once. The CLI mirrors this and now also accepts a single FILE as its root, not just a folder.

Failure semantics

  • A parse error on file X emits a parse_error finding but doesn't abort the scan.
  • A referenced part that isn't in the registry (out-of-tree, missing) is silently skipped by missing_configuration and mismatched_linked_id. It IS logged in the scan metadata as an unresolved reference count.
  • Empty output (no findings) is a legitimate scan result — the scanner prints a "clean" banner.

Roadmap

  • broken_reference check (referenced part not present anywhere in the tree).
  • is_toolbox_bit extraction (needs an SWFormat contribution).
  • create_parts_vs_create_configurations_family — detect Toolbox families where some sizes are per-file and some are per-config; requires clustering by naming template.
  • --fix mode is explicitly deferred; repairs must go through SolidWorks with human review.