Skip to content

Workshop: two windows — Model Splitter and Model Fuser - #63

Merged
sswelm merged 13 commits into
masterfrom
feat/workshop-two-windows
Sep 19, 2026
Merged

sswelm merged 13 commits into
masterfrom
feat/workshop-two-windows

Conversation

@sswelm

@sswelm sswelm commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Summary

The Model Workshop is now two windows on one implementation: Model Splitter (Tools ▸ HAF ▸ Model Splitter) and Model Fuser (Tools ▸ HAF ▸ Model Fuser). ModelWorkshopWindow is abstract with one flag, Fusing; the two windows derive from it.

Shared by both: the file pickers, Probe, the filtered part list (verts, size, height, side, flat parts, Show only), the preview, "Find the mirror of …" and the ↑/↓ sweep.

Model Splitter Model Fuser
Row column popup: – / Split / Delete popup: – / ⊕A–Z / ✕ Delete
Keys Insert = split, Delete = delete, Space = toggle split, – 0 Backspace = clear A–Z = group, Delete = delete, – 0 Backspace = clear
List controls Check all splittable, Uncheck all, Hide already-whole, Save/Load marks Save groups, Load groups
Show only checked, marked for deletion, islands, whole, skipped in a group, not in a group, marked for deletion, islands, whole, skipped, every letter in use
Below the list Plane cut, Split (and delete) weld slider, Fuse (and delete)
Output suffix _split / _cut _fused

The one-step Generate (fuse AND split) went with the split. Chain the outputs instead: the ⊕ letters travel with a cut or split output (WorkshopRules.TransferLetters), and a fused output's sidecar names each shell under its group's letter (WorkshopRules.FusedOutputSidecarLines), so cut-then-fuse and fuse-then-cut both work. The Fuse button is twice as high and wraps, so nineteen groups' letters fit.

Marks persist — the Split checks and the deletion marks go to <source>.marks.txt beside the fuse sidecar (S = split, X = delete, the fuse sidecar's format and resolver), written by Save marks in the Splitter, by Save groups in the Fuser, and by every Split, Plane cut and Fuse; read at the first Probe of a file, and by Load marks / Load groups. Both windows share it, so a deletion marked in the Fuser reaches the Splitter. They are part of the pipeline, like the ⊕ letters.

Mark for deletion (both windows): a marked part loses its mesh in the output — its transform and children stay, the mesh data is left as an orphan the bake never reads — applied by Split, Plane cut and Fuse after their own work (GlbDisconnectedParts.RemoveMeshes, tested). A deleted part is neither split nor fused.

Also in this branch (Vehicle Lab, so you can test everything from one checkout): a Default reduce (%) dial — the catch-all tier that cuts every part still marked Default, exactly as Body parts are cut — and Preserve's dial moved below Detail's, the exception reading last. The rig script takes the new tier as a tagged argument (defaultreduce=@<file>|<pct>), so an older script and a newer Lab still run together, and a recipe that never touched the dial produces a byte-identical command line. Say the word if you would rather have this as its own PR.

Test plan

  • bash tools/check.sh passes (853 tests; the Roslyn editor-compile step covers the window).
  • Vehicle Lab: mark nothing, set Default reduce to 60, Verify → the projection shows the Default tier cut; Generate → the log's DEFAULT reduction lines name the parts. At 0 the command line is unchanged.
  • Unity: Tools ▸ HAF ▸ Model Splitter shows checkboxes, plane cut and Split only; Model Fuser shows ⊕ popups, weld, Save/Load and Fuse only. Probe the Romanic in the Fuser: the sidecar's letters load, Fuse writes ss_romanic_fused.glb. Open that in the Splitter: the letters are still on the rows (hidden), a split writes _split and its sidecar.

🤖 Generated with Claude Code

sswelm and others added 2 commits September 18, 2026 17:15
…Model Fuser — on one implementation

User: "the Model Workshop has too many responsibilities, which makes it
cluttered — split it up into two screens, one for cutting and one for
merging." ModelWorkshopWindow is abstract with one flag, Fusing; the
Cutter and the Fuser derive from it and share the file pickers, the
probe, the filtered part list, the preview, the mirror finder and the
keyboard sweep. The Cutter shows the Split checkboxes, "Check all
splittable", "Hide already-whole", the plane cut and Split (output
_split / _cut); the Fuser the ⊕ letters, the A–Z keys, the weld slider,
Save/Load groups and Fuse (output _fused). "Show only" lists each
window's own kinds. The one-step Generate (fuse AND split) went with the
split: chain the outputs — the ⊕ letters travel with a cut or split
output, so either order works. Menu: Tools ▸ HAF ▸ Model Cutter / Model
Fuser; docs and the quickstart follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, every hint speaks the window's own language

Found reviewing the two-window split against the running code: (1) the
Generate(alsoSplit) split branch was unreachable once the button went —
FuseMarked is the whole method now; (2) the mirror finder told the
Cutter to "press a letter" (Space checks there, and a whole part cannot
be checked at all); (3) the namesake warning, the Output GLB tooltip and
the no-preview note all said Split in the Fuser; (4) the Fuser's rows
were dimmed as "already whole" — the Cutter's concern, not the Fuser's;
(5) the Fuser's intro promised the ⊕ letters travel with a fused output,
which carries none (they travel with a cut or split output, the only
direction that needs them); (6) each window reads the sidecar at Probe,
so groups changed in the Fuser reach the Cutter only after a re-Probe —
documented; (7) a saved layout holding the old Model Workshop drops it
once, the class being abstract — noted in the changelog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sswelm

sswelm commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Self-review of 48d323b, fixed in the commit above:

  1. Dead code. Generate(bool alsoSplit) kept its split branch after the Generate button went; unreachable. FuseMarked is the whole method now.
  2. The mirror finder spoke the wrong language in the Cutter. It said "press a letter to mark it"; there Space checks the row, and a single-island part cannot be checked at all. Mode-aware now.
  3. Three more Split-isms in the Fuser: the namesake warning, the Output GLB tooltip and the no-preview note. Mode-aware.
  4. The Fuser dimmed its rows as "already whole" — the Cutter's concern. The Fuser's rows read "(N tris, N islands)" in the normal style.
  5. The Fuser's intro promised the ⊕ letters travel with a fused output. A fused output carries none; they travel with a cut or split output, the only direction that needs them. Reworded.
  6. Stale letters across windows. Each window reads the sidecar at Probe, so groups changed in the Fuser reach an already-probed Cutter only after a re-Probe, or its cut output carries the old letters. Documented in Editor-Tools.
  7. Saved layouts. A layout holding the old Model Workshop drops that window once, the class being abstract now. Noted in the changelog.

Not changed, considered: both windows open on the same source each run their own Blender probe to the same preview FBX path — sequential, and each keeps its own instantiated preview, so no conflict.

🤖 Generated with Claude Code

…s each shell under its group's letter

"The Fuser -> Cutter handoff loses group letters": Fuse wrote only the
source's sidecar, so a fused output opened in the Cutter knew no groups
and a cut shell's pieces inherited nothing. Every fuse Result now names
its shell (node index and name), and Fuse writes <output>.fuse.txt from
them: one line per fused group under the shell's own index, and the
unchanged lines of a group that produced nothing (the fuse never
renumbers nodes). WorkshopRules.FusedOutputSidecarLines, tested both
ways (written, then read back by the resolver); the parallel-fuse test
checks each result's shell against the output's node table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sswelm

sswelm commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Addressed in the commit above: Fuse now writes the OUTPUT's sidecar as well — one line per fused group under the shell's own node index and name (every fuse Result names its shell), and the unchanged lines of a group that produced nothing. A fused output opened in the Cutter knows its groups; a cut shell's pieces inherit the letter; the Fuser welds them back if asked. Rule: WorkshopRules.FusedOutputSidecarLines, tested written-then-read-back; the parallel-fuse test checks each result's shell against the output's node table.

🤖 Generated with Claude Code

sswelm and others added 2 commits September 18, 2026 20:13
…groups' letters and counts fit

User: "make the fuse button twice as high so the text of all the
groupings can fit better." 56 px and a word-wrapping button style
(built lazily from GUI.skin inside OnGUI).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…user 2026-09-18)

ModelSplitterWindow, Tools ▸ HAF ▸ Model Splitter, and every mention in
the window, the rules, the docs, the quickstart and the changelog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sswelm sswelm changed the title Workshop: two windows — Model Cutter and Model Fuser Workshop: two windows — Model Splitter and Model Fuser Sep 18, 2026
… the sidecar, so groups saved in the Fuser reach the next cut

The letters kept across a re-Probe are the user's edits — in the Fuser.
The Splitter never edits a letter: its letters are the sidecar's, yet a
re-Probe kept the stale ones in memory while the docs told the user to
re-Probe after changing groups in the Fuser. The Splitter now reloads
the sidecar on every Probe; the Fuser keeps its edits as before (Load
groups is its explicit way back).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sswelm

sswelm commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Addressed in the commit above: a re-Probe in the Splitter now re-reads the sidecar. The letters kept across a re-Probe are the user's edits, which only the Fuser makes; the Splitter never edits a letter, so its letters are the sidecar's and a re-Probe reloads them — groups saved in the Fuser meanwhile reach the next cut, as the docs say. The Fuser keeps its edits as before, with Load groups as its explicit way back. Docs and changelog now describe the two behaviours precisely.

🤖 Generated with Claude Code

sswelm and others added 2 commits September 18, 2026 21:09
…p, in both windows; Insert marks for split

User (2026-09-18): "an easy way to mark a unit for removal with the Del
key", "also possible with a combobox (split up or delete)", "Insert
should mark it for split-up", "Delete should also be active in the
Fuser". A marked part loses its mesh in the output — what a fused
source loses — and keeps its transform and children; the mesh data is
left in the file as an orphan the bake never reads. Applied after the
window's own work by Split, Plane cut and Fuse alike (none renumbers
nodes, so the marks' indices hold). The Splitter's row popup is
"– / Split / Delete" (a whole part offers no Split), the Fuser's letter
popup ends with "✕ Delete"; Delete toggles the mark in both windows and
clears the others, Insert marks for split, – / 0 / Backspace clear every
mark; "Show only" lists "Marked for deletion". The mark is the window's,
like a Split check. GlbDisconnectedParts.RemoveMeshes, tested: the
marked node loses its mesh and keeps its transform, the rest untouched,
a meshless or missing node is reported and nothing written.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…>.marks.txt, shared by both windows

User, after marking 144 chain links: "but then how is the delete state
persisted?" They were the window's only, lost on a close or a domain
reload, while the ⊕ letters had a sidecar. Now they have one too, in the
fuse sidecar's format with S for split and X for delete, so the same
resolver reads it by node index and name. Save marks / Load marks in the
Splitter; Save groups / Load groups in the Fuser carry it as well; every
Split, Plane cut and Fuse writes it beside the source; the first Probe of
a file reads it, and a re-Probe keeps what is in memory (as the Fuser
keeps its letters). Both windows share the file, so a deletion marked in
the Fuser reaches the Splitter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sswelm

sswelm commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

The Split checks and the deletion marks now persist together in <source>.marks.txt, beside the fuse sidecar and in its format (S = split, X = delete, read by the same resolver, so a re-export that moves a part is resolved by name and index exactly as the ⊕ letters are). Written by Save marks in the Splitter, by Save groups in the Fuser, and by every Split, Plane cut and Fuse; read at the first Probe of a file, and by Load marks / Load groups. Both windows share the file.

🤖 Generated with Claude Code

sswelm and others added 3 commits September 19, 2026 00:27
…now reads before Preserve

User: "could you switch 'Preserve reduce' and 'Detail reduce' and add
'Default reduce'." Preserve's dial moves below Detail's — the exception
reads last. The new dial cuts every part still marked Default (nothing
chosen for it) exactly as Body parts are cut, so a model whose thousand
small fittings are all undecided slims in one move. It joins the tier
summary, the Verify projection, the geometry-work gate, the fast-path
inert notice, the reset and the recipe DTO. The rig script takes it as a
TAGGED argument (defaultreduce=@<names file>|<percent>) because the
positional block is full at argv[75]: an older script with a newer Lab
ignores the tag, a newer script with an older Lab reads 0, and a recipe
that never touched the dial produces a byte-identical command line.
Both copies of vehicle_rig.py are in sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e must not clear the groups file

Three findings from reviewing the branch against the running code:

1. The Fuse button now fires with deletion marks alone, and the write
   path still called WriteFuseSidecar unconditionally. An empty write
   CLEARS the file (Save groups' documented "clear"), so a delete-only
   run silently dropped the ⊕ groups saved earlier. It is written only
   when a group actually fused.
2. A `BackSlash` const had crept into the Vehicle Lab: scaffolding from
   the patch script that wrote the Default tier, where every other line
   in the file spells the separator directly. Gone.
3. The Default names file was written on every Generate — thousands of
   lines for an undecided model — even with the dial at 0. Written only
   when the dial asks; the tooltip now says the tier costs real Blender
   time on a model with thousands of shards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… restart

User: "why did the Model Splitter and Model Fuser dialog disappear after
startup, it seems their position is not persistent." Unity restores a
docked editor window by binding its saved layout entry to the MonoScript
whose FILE NAME matches the class; both windows were nested in
ModelWorkshopWindow.cs, so the binding failed and each dropped out of the
layout on the next restart. ModelSplitterWindow.cs and
ModelFuserWindow.cs now hold one class each (with their .meta), the base
window keeping its own file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sswelm

sswelm commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Critical self-review of the branch, findings and fixes.

Real defects, fixed

  1. A delete-only Fuse cleared the ⊕ groups file. The Fuse button now fires with deletion marks alone, and the write path still called WriteFuseSidecar unconditionally; an empty write CLEARS the file (Save groups' documented "clear"), so a delete-only run silently dropped the groups saved earlier. It is written only when a group actually fused.
  2. The two windows vanished from the layout on restart (found by the user, not by me). Unity binds a docked window's saved layout entry to the MonoScript whose FILE NAME matches the class; both derived windows were nested in ModelWorkshopWindow.cs, so the binding failed. ModelSplitterWindow.cs and ModelFuserWindow.cs now hold one class each, with their .meta.
  3. The Default names file was written on every Generate, thousands of lines for an undecided model, even with the dial at 0. Written only when the dial asks.

Noise, removed

  1. A BackSlash const had crept into the Vehicle Lab as scaffolding from the patch script that added the Default tier, where every other line spells the separator directly.

Considered, deliberately left

  • RemoveMeshes strips one node's mesh reference, so a mesh shared by other nodes stays on those nodes. That is the right reading of "delete this part", and it matches what the fuse does to its sources.
  • Marking a part for deletion does not grey it out in the preview. "Show only: Marked for deletion" is the way to find them, and the row label says so.
  • Delete used to be one of the Fuser's clear keys and now means "mark for deletion"; the clear keys are – / 0 / Backspace. That is the requested behaviour, and the keys line states it.
  • A sidecar that marks the same node both grouped and deleted resolves to deleted, in every load path.
  • The Default tier cuts each part on its own, so a model with thousands of undecided shards costs real Blender time. The tooltip now says so rather than the code guessing a limit.

🤖 Generated with Claude Code

…ur a metre above a roof is not its skin

The Romanic's bridge deck (user: "a transparent hole on the bridge deck
— did we forget to mark something, or is this another fusion issue?"):
a fusion issue. A 39-face roof region on each side, facing up and
correct as authored, welded into a sheet with the deckhouse and found 74
"twins in front" — the deckhouse's undersides a metre and more above —
and the double-skin rule turned the roof over while the sheet's own
inside-out score read +0.55, plainly outward. Found by ray-casting the
fused output from above and tracing every hole cell back to the source
part that authored it (drill_holes_src.py): 230 cells in group D, 211 of
them roof faces that face UP in the source.

Measured on the ship, every genuine double skin has its twin within a
third of the old 1 % reach — the hull skins at 0.03-0.34, the deckhouse
skins at 0.05-0.13, the test fixture at 0.20 — while the roof's
neighbours sat at 0.72. Half the reach keeps every skin and loses every
neighbour (a deck and the ceiling below it sit at 1.4 %+). Precedence
untouched: a confident score outranking the twins was tried first and
broke the double-skin fixture, where the twins are right and the score
wrong with the same signature.

Holes from above over the whole ship: 339 cells → 126, group D 230 → 17
(the 17 left are authored inside-out in the source, 35 there); the
Teutonic's fused hull 70 → 49, its other shells and every per-group
number unchanged. Each sheet's report line now names its parts and how
far and how straight its twins lie — the two numbers that found this.
Test: a roof with a downward ceiling 0.7 % of the length above it keeps
its winding.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sswelm

sswelm commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

The bridge-deck hole (user report) — a fusion defect, fixed in 50a334f.

Found by ray-casting the fused GLB from above and tracing every hole cell back to the source part that authored it: group D had 230 hole cells, 211 of them roof faces of Object_6 / Object_1774 that face UP in the source. The sheet holding them read double skin 67% twinned (74 twin in front / 8 behind, at 0.72 of reach) and inside-out score +0.55 — the twin rule reversed a sheet whose own score said outward. The 74 "twins" were the deckhouse's undersides a metre and more above: neighbours, not a skin.

Measured across the ship, every genuine double skin has its twin within a third of the old 1 % reach (hull skins 0.03–0.34, deckhouse skins 0.05–0.13, the fixture 0.20). The reach is 0.5 % now: every skin kept, every neighbour lost. Changing the precedence (score before twins) was tried first and broke the double-skin fixture, where the twins are right and the score wrong with the same signature.

holes from above (0.4 m cells) before after
Romanic, all groups 339 126
Romanic, group D 230 17 (authored so in the source: 35 there)
Teutonic, Fused_A 70 49
Teutonic, Fused_O / Fused_E 21 / 9 21 / 9

Teutonic per-group numbers unchanged. Each sheet's report line now names its parts and how far and how straight its twins lie — the two numbers that found this. 854 tests (new: a roof with a downward ceiling 0.7 % above it keeps its winding).

🤖 Generated with Claude Code

…ene and one glTF validity gap

Reading the branch diff end to end after the reach change:

1. Two comments had been glued onto the wrong line by patch scripts
   that matched a line's prefix: the IslandLines comment sat on the
   FusedNodeIndex line, and the old FuseLabels history sat on the
   Splitter's labels. Each is back where it belongs.
2. The twin distance and straightness were still labelled DIAGNOSTIC;
   they are the report's, with the reading a skin gives (≤ 0.35 of the
   reach, ~0.9 straight) against a neighbour (0.7+) spelled out.
3. The twin-rule comment named a variable that never existed
   (`skinReach`); it is `twinReach`.
4. RemoveMeshes stripped a node's skin with its mesh but not its morph
   `weights`, equally invalid on a meshless node. Gone too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sswelm

sswelm commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Critical self-review, second pass — the whole branch diff against master, read after the reach change.

Fixed in the commit above (no behaviour change except 4)

  1. Two comments glued onto the wrong line by patch scripts that matched a line's prefix: the IslandLines comment on the FusedNodeIndex line, the FuseLabels history on the Splitter's labels. Back where they belong.
  2. The twin distance and straightness were labelled DIAGNOSTIC; they are report content, with the readings a skin gives (≤ 0.35 of the reach, ~0.9 straight) against a neighbour (0.7+) now spelled out.
  3. A comment named a variable that never existed (skinReach).
  4. RemoveMeshes stripped a node's skin with its mesh but not its morph weights — equally invalid on a meshless node. Gone too.

Checked and sound

  • The reach change halves the enclosure veto's box slack as well: the nested-cavity fixture (2 cm on 10 m = 0.4 of the new reach) still holds, and the Romanic's hull shell reads the same 81 hole cells before and after.
  • Deletion, split and group marks are mutually exclusive on every path — key, popup, and both sidecars (an X line wins over a letter in every load order).
  • A delete-only Fuse leaves the groups file alone (fixed last pass); the explicit Save groups with no letters still clears it, as documented, and its status line says so.
  • Per-group timings after the reach change: the 73,000-face hull group plans in 4.8 s.

Known and left

  • A saved S mark is dropped silently if the part reads as one island at a different merge distance than it was saved at — it cannot be split then, and the status does not say.
  • Insert on a whole part does nothing and says nothing.

🤖 Generated with Claude Code

@sswelm
sswelm merged commit cf7e91d into master Sep 19, 2026
2 of 3 checks passed
@sswelm
sswelm deleted the feat/workshop-two-windows branch September 19, 2026 01:23
sswelm added a commit that referenced this pull request Sep 19, 2026
…de regression, and the missing per-node test

Three findings from a critical review of this branch, all safe to take
on their own: none of them changes a single pixel of the preview.

- The filter block's comment was left half-replaced ("The flat-surface
  and visibility filters / the flat filter came over on 2026-09-18"):
  ungrammatical and self-contradicting, the same glued-comment class
  flagged in PR #63. Rewritten as one sentence; the stray blank line
  where the PreviewDir constant was removed is gone too.

- The accessor layout cache turned a DECLARED byteStride of 0 into
  "absent" and silently fell back to tight packing, where the old
  per-read path rejected it ("bufferView byteStride is smaller than its
  element"). glTF forbids 0, so no real file hits it, but the cache was
  supposed to be behaviour-identical. Absent is -1 now; a declared 0 is
  rejected as before.

- Nothing tested the thing that started this work: the preview must
  give every mesh-carrying node its OWN entry, because the row
  highlight keys on the node index (a file naming all 113 of its nodes
  "Material2" lit the whole ship when the match was by name).
  ExtractAll is now asserted to return one entry per node, each
  matching ExtractPart's geometry, on a fixture with two nodes sharing
  one mesh.

Left OUT deliberately: the winding swap for mirrored nodes. It is what
made the user's ship see-through, and the preview's handedness has to be
settled first. Also open from the review: the preview holds the whole
model in memory with no triangle cap, and the old *_wprobe.fbx assets
nothing references any more are still in the project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant