Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions Tests/GlbFuseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,143 @@ public void A_mirrored_instance_keeps_the_facing_it_renders_with()
Assert.Equal(-1f, Enumerable.Range(0, p.Length / 3).Min(i => p[i * 3]));
}

// ---- the mirrored-part check (2026-09-19, the Confederate frigate; option "Check mirrored parts") ----
// A 1 x 3 strip of quads in the XY plane at z, spanning x0..x1 and y 0..3: its x = x0 column is three edges long, so
// a strip abutting it shares three seam edges (the check wants at least three before it judges a part). `inward`
// winds it so the geometric normal points -Z.
static Part Strip(string name, float x0, float x1, float z, bool inward)
{
var pos = new List<float>(); var idx = new List<int>();
for (int j = 0; j <= 3; j++) { pos.AddRange(new[] { x0, (float)j, z }); pos.AddRange(new[] { x1, (float)j, z }); }
for (int k = 0; k < 3; k++)
{
int v00 = 2 * k, v10 = 2 * k + 1, v11 = 2 * k + 3, v01 = 2 * k + 2;
idx.AddRange(inward ? new[] { v00, v11, v10, v00, v01, v11 } : new[] { v00, v10, v11, v00, v11, v01 });
}
return new Part { Name = name, Positions = pos.ToArray(), Indices = idx.ToArray() };
}

// `pairs` plain strips (x 0..1, facing +Z) each abutted at x = 0 by a mirrored strip (scale -1 on X, so world x -1..0).
// preFlipped[i] true = the file stores that mirrored strip ALREADY facing +Z after the mirror (the frigate's case: the
// glTF reversal then turns it to -Z); false = stored the standard way (the reversal is what brings it to +Z).
static Part[] MirrorPairs(params bool[] preFlipped)
{
var parts = new List<Part>();
for (int i = 0; i < preFlipped.Length; i++)
{
parts.Add(Strip("P" + i, 0, 1, 10 * i, inward: false));
var m = Strip("M" + i, 0, 1, 10 * i, inward: preFlipped[i]); m.Scale = new double[] { -1, 1, 1 };
parts.Add(m);
}
return parts.ToArray();
}

static int[] AllNodes(int n) => Enumerable.Range(0, n).ToArray();

static List<double[]> LoneFaces(byte[] fused)
{
var g = Read(fused); var prim = (JObject)g.Primitives(g.Node("P0_Fused"))[0];
float[] p = g.Floats(((JObject)prim["attributes"]).Value<int>("POSITION"), 3);
uint[] ix = g.Indices(prim.Value<int>("indices"));
var all = FaceNormals(g, prim); var r = new List<double[]>();
for (int t = 0; t < ix.Length; t += 3) if (p[ix[t] * 3 + 2] > 39f) r.Add(all[t / 3]);
Assert.Equal(6, r.Count); // the lone strip's six triangles
return r;
}

[Fact]
public void A_file_that_stores_mirrored_parts_pre_flipped_is_fixed_only_with_the_option()
{
var parts = MirrorPairs(true, true, true);
byte[] glb = BuildGlb(parts);
var off = GlbDisconnectedParts.FuseNodes(glb, AllNodes(parts.Length), 0.0, null, false);
Assert.Contains(off.Warnings, w => w.Contains("Check mirrored parts") && w.Contains("3 of 3")); // off: it says so, changes nothing
Assert.DoesNotContain(off.Details, d => d.StartsWith("mirrored parts", StringComparison.Ordinal));
// (These simple pairs are cleanly orientable, so the consistency stage repairs them even with the option off.
// The frigate broke because its hull island also held a structure that cannot be oriented, and the fuse then
// keeps the whole island as it stands. The case the fuse cannot repair alone is reproduced in the lone-strip
// test below; the real hull is covered by the drill: 94.8 % back-facing off, 0.0 % on.)

var on = GlbDisconnectedParts.FuseNodes(glb, AllNodes(parts.Length), 0.0, null, true);
Assert.Contains(on.Details, d => d.StartsWith("mirrored parts", StringComparison.Ordinal) && d.Contains("3 of 3 judged") && d.Contains("undone for 3"));
var g = Read(on.Bytes);
var normals = FaceNormals(g, (JObject)g.Primitives(g.Node("P0_Fused"))[0]);
Assert.Equal(36, normals.Count); // 6 strips x 6 triangles
Assert.All(normals, n => Assert.True(n[2] > 0, "every face +Z, the mirrored halves included"));
}

[Fact]
public void A_file_stored_the_standard_way_is_left_exactly_as_before()
{
// The Teutonic's case: the glTF reversal is RIGHT. The check must confirm it and change nothing, byte for byte.
var parts = MirrorPairs(false, false, false);
byte[] glb = BuildGlb(parts);
var off = GlbDisconnectedParts.FuseNodes(glb, AllNodes(parts.Length), 0.0, null, false);
var on = GlbDisconnectedParts.FuseNodes(glb, AllNodes(parts.Length), 0.0, null, true);
Assert.Equal(off.Bytes, on.Bytes);
Assert.Contains(on.Details, d => d.StartsWith("mirrored parts", StringComparison.Ordinal) && d.Contains("confirm the glTF reversal"));
Assert.DoesNotContain(off.Warnings, w => w.Contains("Check mirrored parts"));
}

[Fact]
public void Mixed_evidence_changes_nothing()
{
// The Romanic's group H judged 1 pre-flipped against 2 standard, and acting on the one made the group worse
// (2,667 -> 2,955 back-facing cells). One pre-flipped of three is no convention: output identical, no warning.
var parts = MirrorPairs(true, false, false);
byte[] glb = BuildGlb(parts);
var off = GlbDisconnectedParts.FuseNodes(glb, AllNodes(parts.Length), 0.0, null, false);
var on = GlbDisconnectedParts.FuseNodes(glb, AllNodes(parts.Length), 0.0, null, true);
Assert.Equal(off.Bytes, on.Bytes);
Assert.Contains(on.Details, d => d.Contains("not enough agreement to act on"));
Assert.DoesNotContain(off.Warnings, w => w.Contains("Check mirrored parts"));
}

[Fact]
public void Mirrored_lap_strips_lying_on_their_plates_are_not_mistaken_for_inside_out()
{
// Review of PR #67 (76de8e3): a lap strip lies ON its plate, stitched along one edge and facing the same way. Both
// faces then walk the shared edge the SAME way, legitimately — the consistency pass's lap rule knows this (the
// Teutonic's Object_8, 671 strips). The first mirrored-part check counted it as a conflict, gave the group a
// unanimous "already facing outward" verdict, and flipped all 18 lap faces inward.
var parts = new List<Part>();
for (int i = 0; i < 3; i++)
{
parts.Add(Strip("P" + i, 0, 1, 10 * i, inward: false)); // the plate, x 0..1, +Z
var lap = Strip("L" + i, -0.5f, 0, 10 * i, inward: false); // local x -0.5..0 -> world 0..0.5: ON the plate
lap.Scale = new double[] { -1, 1, 1 }; // mirrored, stored the standard way: +Z after the glTF reversal
parts.Add(lap);
}
byte[] glb = BuildGlb(parts.ToArray());
var off = GlbDisconnectedParts.FuseNodes(glb, AllNodes(parts.Count), 0.0, null, false);
var goff = Read(off.Bytes);
Assert.All(FaceNormals(goff, (JObject)goff.Primitives(goff.Node("P0_Fused"))[0]), n => Assert.True(n[2] > 0, "off: every face +Z (premise)"));
var on = GlbDisconnectedParts.FuseNodes(glb, AllNodes(parts.Count), 0.0, null, true);
Assert.Equal(off.Bytes, on.Bytes); // nothing to change, nothing changed
Assert.DoesNotContain(on.Details, d => d.Contains("already stores them facing outward"));
Assert.DoesNotContain(off.Warnings, w => w.Contains("Check mirrored parts"));
}

[Fact]
public void A_mirrored_part_with_no_plain_neighbour_follows_the_files_convention()
{
// The frigate's Object_961: no plain neighbour to judge it by, and it kept the reversal, carrying 2,443 of the
// 2,462 back-facing cells left on the hull. Three judged parts agreeing make the convention; it follows.
var parts = MirrorPairs(true, true, true).ToList();
var lone = Strip("Lone", 0, 1, 40, inward: true); lone.Scale = new double[] { -1, 1, 1 }; // pre-flipped, touches nothing
parts.Add(lone);
byte[] glb = BuildGlb(parts.ToArray());
// off: a flat strip on its own gives the direction stage nothing to judge, so it keeps the glTF reversal: -Z,
// see-through from above. This is the bug, reproduced where the fuse cannot repair it by itself.
var off = GlbDisconnectedParts.FuseNodes(glb, AllNodes(parts.Count), 0.0, null, false);
Assert.All(LoneFaces(off.Bytes), n => Assert.True(n[2] < 0, "off: the lone strip is inside-out"));
var on = GlbDisconnectedParts.FuseNodes(glb, AllNodes(parts.Count), 0.0, null, true);
Assert.Contains(on.Details, d => d.Contains("undone for 4") && d.Contains("1 of them had no plain neighbour"));
Assert.All(LoneFaces(on.Bytes), n => Assert.True(n[2] > 0, "on: the lone strip faces +Z"));
var g = Read(on.Bytes);
Assert.All(FaceNormals(g, (JObject)g.Primitives(g.Node("P0_Fused"))[0]), n => Assert.True(n[2] > 0, "and every other face too"));
}

[Fact]
public void Vertex_colours_and_a_second_UV_set_survive_the_fuse_and_keep_their_own_vertices()
{
Expand Down
3 changes: 3 additions & 0 deletions docs/Editor-Tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,9 @@ searchable catalog pick list. *Writes:* `haf_sounds.json` (via `SoundOverrideReg
**Fuse — weld seams closer than** at **0** (coincident positions, within float rounding — the Teutonic's plates already touch,
and that alone found its hole; raise it only for plates that leave gaps, knowing that every triangle smaller
than the distance collapses: at 0.5‰ the Teutonic lost 1,564 rivet-sized faces and its hull island broke apart),
tick **Check mirrored parts** if one side of a symmetric hull comes out see-through (0.5.7: some files store their
mirrored parts already facing outward, and the glTF reversal then turns them inward; the fuse checks them against
the plain parts they touch and acts only on a clear verdict, and warns you when a group would need it),
press **Fuse … into one shell each**: every group becomes ONE mesh in the output GLB, their seam vertices
welded (a UV seam or a hard edge keeps its own vertex; connectivity is by position regardless), the winding
made **consistent by majority**
Expand Down
19 changes: 19 additions & 0 deletions editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ lives in the repository's root `CHANGELOG.md`.) Versions are also git tags: `edi

## 0.5.7 — unreleased

- **Model Fuser: "Check mirrored parts", for files that store their mirrored parts already facing outward.** glTF
says a mirrored part (a negative-scale node, one side of a symmetric hull) renders with its winding reversed, and
the fuse applies that, which is what the Teutonic needed. The Confederate frigate's file stores its mirrored hull
half already facing outward, so the reversal turned that whole side inward: 94.8 % of the fused hull rendered
back-facing from that side, 0.1 % for the same parts unfused. Welded to the correct half, the two cancelled every
direction signal and the fuse left the island as it stood. Ticked, the fuse compares each mirrored part with the
plain parts it is welded to (a plain part's winding is never in doubt) and undoes the reversal only when the group
gives a clear verdict: at least three mirrored parts judged and nine in ten agreeing. Parts with no plain neighbour
follow that verdict; a part whose own seams disagree keeps the reversal; mixed evidence changes nothing. **Off by
default** (user: "make it an option"), and the fuse warns when a group would need it. Measured with the real fuse
on the real files: the frigate's hull 94.8 % -> 0.0 %; the Romanic's hull 108 -> 8 back-facing cells from the
beam and its deck 161 -> 29 from above, which turn out to be the same bug; the Teutonic hull and eight other
Romanic groups byte-identical; the Romanic's group H, whose judged parts disagreed 1 to 2 and got worse when the
one was acted on, now left untouched. Four tests: a pre-flipped file fixed only with the option, a standard file
byte-identical, mixed evidence unchanged, and a lone mirrored part following the file's convention (the failure
reproduced with the option off). A lap strip lying on its plate walks their shared edge the same way on purpose; the
check applies the consistency pass's own lap rule, so mirrored lap strips are never mistaken for inside-out (review
of PR #67: three of them had drawn a unanimous verdict and 18 correct faces were turned inward).

- **Fuse: one stray edge, stray geometry, and double-skinned solids** (the SS Romanic). Its 41,799-face hull reached
1 unsatisfied edge of 92 same-way in 59,000 and the "0 or nothing" orientability rule refused it: the parity pass
must now resolve 95 % of the same-way edges (a propeller blade at 138 left of 32 is still refused) — with that the
Expand Down
Loading
Loading