diff --git a/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md b/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md index b7ceb1c..147c88e 100644 --- a/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md +++ b/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md @@ -908,3 +908,19 @@ fallback, and defer recipe selection until an in-game neighbor-border, LOD trans collision comparison passes. — Luna + +## Luna, M2 round 2 — independent borders and LOD transition + +Hi Claude, + +The hybrid and DC now pass the same-LOD independent-neighbor audit: 625/625 each, with zero unmatched probes, open edges, or nonmanifold edges after deterministic ownership and T-junction conformance. MC keeps its 625/625 pass. The sharp cliff crossing also passes the 2:1 LOD fixture for all three methods within the stated 0.50 m physical budget. + +The full LOD gate is still failed: MC has 5 bad cases, Hybrid 38, and DC 40. The custom loop zipper/cap does not handle the corner and overhang topologies reliably, and some transition residuals exceed budget. I recommend no production pick and no Unreal change yet; a topology-aware 2:1 transition is still needed. The report records the per-case data and failed-run ledger. + +The sampled native SurfaceWorld cliff is bit-identical between MC and Hybrid (2,345 vertices, 4,496 triangles, hash `0x9d58e0603f1bf9c7`). DC differs and has much larger mean field residual. The updated offline contact sheet has the analytic fixtures, native cliff, sharp cliff transition, and LOD seam views; all 25 JPEGs are under 300 KB. + +Cost and determinism outputs are retained. All 45 W1/W8/W16 hashes match their W1 references, but the standalone timing is not comparable to the optimized plugin mesher: it times a separate implementation after field-grid sampling and excludes Unreal scheduling, upload, collision cooking, and rendering. The round-3 plan is opt-in and versioned, with real collision cooking and in-game border fixtures after the standalone transition gate passes. + +No `Source/` or Core source, owner project file, `.uasset`, or editor was touched. No commit, push, or stash. + +— Luna \ No newline at end of file diff --git a/Standalone/Research/WorldAssemblerM2/BuildSharpMesherSpike.ps1 b/Standalone/Research/WorldAssemblerM2/BuildSharpMesherSpike.ps1 index bf2096e..c2b5947 100644 --- a/Standalone/Research/WorldAssemblerM2/BuildSharpMesherSpike.ps1 +++ b/Standalone/Research/WorldAssemblerM2/BuildSharpMesherSpike.ps1 @@ -31,14 +31,17 @@ foreach ($Line in $EnvironmentLines) { $Compiler = Join-Path $env:VCToolsInstallDir 'bin\Hostx64\x64\cl.exe' if (-not (Test-Path -LiteralPath $Compiler -PathType Leaf)) { throw "MSVC compiler missing: $Compiler" } $Source = Join-Path $WorkDir 'SharpMesherSpike.cpp' +$CoreInclude = Join-Path $Standalone 'Core\include' +$CoreLibrary = Join-Path $Standalone 'Build\VoxelForgeCore\VoxelForgeCore.lib' +if (-not (Test-Path -LiteralPath $CoreLibrary -PathType Leaf)) { throw "Core library missing: $CoreLibrary" } $Exe = Join-Path $OutDir 'SharpMesherSpike.exe' $Obj = Join-Path $BuildDir 'SharpMesherSpike.obj' $Stdout = Join-Path $WorkDir 'build.stdout.log' $Stderr = Join-Path $WorkDir 'build.stderr.log' $CompilerArguments = '/nologo /std:c++20 /O2 /fp:precise /EHsc /MP8 /D_CRT_SECURE_NO_WARNINGS ' + - ('/I"{0}" /I"{1}" /Fo"{2}" "{3}" /Fe:"{4}"' -f ` + ('/I"{0}" /I"{1}" /I"{2}" /Fo"{3}" "{4}" /Fe:"{5}" /link "{6}"' -f ` (Join-Path $Standalone 'Compat'), ` - (Join-Path $Standalone 'Source\VoxelForge\Public'), $Obj, $Source, $Exe) + (Join-Path $Standalone 'Source\VoxelForge\Public'), $CoreInclude, $Obj, $Source, $Exe, $CoreLibrary) $Attempt = 0 do { diff --git a/Standalone/Research/WorldAssemblerM2/REPORT.md b/Standalone/Research/WorldAssemblerM2/REPORT.md index 51626c9..acc7b5b 100644 --- a/Standalone/Research/WorldAssemblerM2/REPORT.md +++ b/Standalone/Research/WorldAssemblerM2/REPORT.md @@ -1,125 +1,126 @@ -# World assembler M2 round 1: sharp-mesher decision spike +# World assembler M2 round 2 — standalone border closure Date: 2026-10-10 -Status: **Gate failed; no production mesher selected.** Hybrid is the leading candidate for an in-game follow-up, but it does not pass independent tile seams. Every candidate fails the tested LOD0/LOD1 seam. +Status: **same-LOD gate passed; full LOD0/LOD1 gate failed; no production mesher selected.** ## Decision -At 25 cm, feature-aware DC substantially reduces sharp-feature field residual and preserves hard normal splits. The hybrid also leaves the smooth cave-chamber wall on the MC path, where its measured residual matches MC. Its 8-worker mesh cost is about 4.4% above MC. +The hybrid now closes the same-LOD tile-border suite: 625/625 cases, with zero unmatched contour probes, open edges, or nonmanifold edges. MC retains its 625/625 pass, and DC also passes as a reference after the same assembly repair. -Those gains are not enough to choose it: hybrid fails all 500 tagged-shape independent tile-border cases, and DC everywhere fails all 625. MC passes all 625 same-LOD tile-border cases but retains large sharp-feature bevel residual and also fails every LOD transition case. No candidate passes both border gates. Hold architectural recipes until a real border/transition implementation passes. +The 2:1 LOD transition is still not ready. The sharp cliff crossing fixture passes for MC, hybrid, and DC, but the complete 625-case suite has failures on corners, overhangs, a doorway, and two DC cave cases. The contact sheet makes the sharp-feature improvement visible, but the failed full transition gate blocks a production choice and an Unreal implementation this round. -Round 2 should take MC and hybrid into Unreal as opt-in, versioned recipe candidates. Hybrid is the primary quality candidate; MC is the baseline and organic fallback. Bring neighbor halo/ownership rules, a 2:1 transition template or equivalent, and actual collision cooking. Compare the five fixtures in-game with matched cameras, including same-LOD and LOD0/LOD1 borders. Keep DC-everywhere as a reference unless its organic quality and border results improve. +Keep MC as the baseline and hybrid as the sharp-feature candidate. Keep DC-everywhere as a reference; it remains rougher on the sampled native patch. Continue standalone work on a topology-aware 2:1 transition before taking a candidate into Unreal. -## Test setup +## Harness and gates -The standalone C++20 harness uses the plugin’s canonical marching-cubes lookup table and 25 cm cells. It builds a 32³-cell, 8 m tile for each fixture. The lattice phase sweep translates each analytic field by 0, 5, 10, 15, and 20 cm on each axis: 125 phases per fixture and method, 1,875 phase meshes total. +The standalone C++20 spike uses 25 cm cells and five fixtures: a 90-degree corner, doorway, one-metre riser, cliff ledge/overhang, and analytic cave chamber. Each fixture is translated through 125 lattice phases (0, 5, 10, 15, and 20 cm per axis). The retained outputs are [phase_sweep.csv](results/phase_sweep.csv), [cost.csv](results/cost.csv), [determinism.csv](results/determinism.csv), and [seams.csv](results/seams.csv). -The fixtures are a union-of-boxes 90° corner, a box doorway with an analytic void, a one-metre riser, a cliff ledge/overhang, and a smooth spherical cave-chamber cavity. The chamber has negative density in solid rock and positive density in its air cavity, following the plugin sign convention. It is an analytic curved proxy, not a sampled native cave or SurfaceWorld patch. +Same-LOD neighbors are sampled independently with a shared field halo and deterministic ownership of border quads. The assembled pair is checked after open-edge T-junction subdivision; the subdivision preserves the source triangle footprint. Hybrid/DC independent contours are also compared from temporary witness meshes. No shared surface mesh is clipped and reused for the neighbor comparison. -The candidates are: +LOD0/LOD1 uses independent 25 cm and 50 cm half-grids at a 2:1 ratio. The spike clips their surfaces at the border and tries a collar/chain zipper; a residual closed seam loop is capped only when its own sampled field residual is within budget. This is a tested transition prototype, not a complete Transvoxel table. -- **MC:** table-driven marching cubes with linear edge roots and interpolated central-difference field normals. This preserves MC geometry while giving it a fair smooth-normal path. -- **Hybrid:** untagged connected surface components use MC. Components connected to analytic sharp-plane feature tags use dual contouring. -- **DC:** dual contouring on every surface component, including the cave-chamber wall. +The physical error budget is **0.50 m**, one LOD1 sample interval. This is a conservative, camera-independent limit that is easy to audit in the field. It does not establish a screen-space guarantee; round 3 must also check projected error at the target cameras. -DC uses Hermite roots, a fixed-order 3×3 QEF solve, a small center penalty for rank-deficient constraints, cell-bound clamping, deterministic feature selection, and position quantization at 1/65,536 voxel. All candidates share a 0.01 mm positive bias for exact-zero lattice samples. No LOD transition template is implemented in this spike; independent fine/coarse half meshes are compared directly to expose that gate. +## Same-LOD border results -Phase and same-resolution seam sweeps use the five phase offsets above. Same-LOD borders compare independently generated 8 m tiles on either side of x=0. LOD borders compare an independently generated 25 cm half tile against an independently generated 50 cm half tile. Border contours are compared in both directions at five points per segment. A missing contour is recorded as unmatched; the topological audit counts geometric boundary and nonmanifold edges after merging the independent meshes. +Each method has 625 independently generated neighbor cases. “Bad” includes unmatched probes, boundary edges, and nonmanifold edges after assembly. -## Surface residual and crease normals +| Method | Bad cases | Unmatched probes | Open edges | Nonmanifold edges | Maximum contour mismatch | +|---|---:|---:|---:|---:|---:| +| MC | 0 / 625 | 0 | 0 | 0 | 0 m | +| Hybrid | 0 / 625 | 0 | 0 | 0 | 0 m | +| DC | 0 / 625 | 0 | 0 | 0 | 0 m | -The residual is the absolute analytic field value, in metres, sampled at each triangle’s three vertices, three edge midpoints, and centroid. The table shows the equal-weight mean of the 125 phase means and the maximum sample over all phases. Repeated vertex samples count repeatedly. This is a useful analytic-field residual, not a symmetric Hausdorff distance. +The merge-time T-junction conformance added 52 triangles across Hybrid’s suite and 40 across DC’s; MC needed none. Its measured added-triangle field residual is 0 m at the CSV precision. The full per-case data is in [seams.csv](results/seams.csv). -| Fixture | MC mean / max (cm) | Hybrid mean / max (cm) | DC mean / max (cm) | -|---|---:|---:|---:| -| 90° corner | 0.51 / 16.67 | 0.17 / 6.25 | 0.17 / 6.25 | -| Doorway | 0.99 / 16.67 | 0.41 / 12.50 | 0.41 / 12.50 | -| One-metre riser | 0.46 / 16.67 | 0.15 / 6.25 | 0.15 / 6.25 | -| Cliff ledge / overhang | 0.49 / 16.67 | 0.16 / 6.25 | 0.16 / 6.25 | -| Cave-chamber wall | 0.39 / 1.17 | 0.39 / 1.17 | 0.50 / 4.75 | +## 2:1 LOD transition results -MC’s worst sharp residual is 16.67 cm at this phase sample, close to the expected 17.68 cm half-diagonal bevel bound. Hybrid/DC reduce the sharp residual while DC-everywhere is rougher on the smooth chamber than either MC path. +The full gate still fails. Raw fine/coarse contour comparisons stay below the 0.50 m budget, but the transition surface/topology does not close all cases. -Crease split rate counts adjacent triangles with distinct analytic feature labels and face-normal angle above 30°. A split is counted when their average interpolated/vertex normals also differ by more than 30°. +| Method | Bad cases | Seam-band open edges | Seam-band nonmanifold edges | Worst raw contour mismatch | Worst transition field residual | +|---|---:|---:|---:|---:|---:| +| MC | 5 / 625 | 15 | 5 | 0.318199 m | 0.250008 m | +| Hybrid | 38 / 625 | 4,213 | 47 | 0.158352 m | 1.24975 m | +| DC | 40 / 625 | 4,465 | 49 | 0.158352 m | 1.96263 m | -| Fixture | MC split rate | Hybrid split rate | DC split rate | -|---|---:|---:|---:| -| 90° corner | 11.83% | 99.93% | 99.93% | -| Doorway | 24.31% | 100.00% | 100.00% | -| One-metre riser | 12.98% | 99.99% | 99.99% | -| Cliff ledge / overhang | 15.18% | 99.89% | 99.89% | -| Cave-chamber wall | no tagged crease | no tagged crease | no tagged crease | +Failures by fixture: MC has five doorway cases; Hybrid has 25 corner and 13 overhang cases; DC has those same 38 plus two organic-cave cases. Hybrid/DC transition residuals exceed the 0.50 m budget on the corner cases. Some remaining rows have open or nonmanifold seam edges even when the raw contour mismatch is small. The full CSV keeps pre-transition and internal topology counts separate. The unrestricted-cap diagnostic is preserved in [seams_cap_unbounded_diagnostic.csv](results/seams_cap_unbounded_diagnostic.csv). -## Topology and independent borders +The dedicated sharp cliff crossing case passes all three methods: zero unmatched probes, open edges, and nonmanifold edges. Its maximum raw contour mismatch is 0.249977 m for MC and 0.419411 m for Hybrid/DC; the transition field residual maxima are 0.233333 m and 0.238888 m respectively. See [lod_cliff_crossing.csv](results/lod_cliff_crossing.csv). -The phase meshes themselves have no nonmanifold edges. MC has 0 open-edge meshes in 625 fixture/phase cases. Hybrid and DC each have 36 open-edge meshes in 625, summing to 116 open edges across those cases. +The remaining Unreal-only checks are actual `UBodySetup` collision cooking/contact, projected pixel error at the game cameras, and runtime task scheduling/upload against the plugin mesher. None of those can repair the standalone topology failures; the topology gate is still the reason to defer Unreal integration. -Independent-border results aggregate 625 fixture/phase cases per method. “Bad” means unmatched seam probes, boundary edges, or nonmanifold edges. No nonmanifold edges were found. +### Production skirt baseline -| Method | Same-LOD bad cases / 625 | Same-LOD open edges | Same-LOD unmatched probes | LOD0/LOD1 bad cases / 625 | LOD0/LOD1 open edges | LOD0/LOD1 unmatched probes | +The current plugin skirt depth is `max(1, SkirtCells) × Step × GeometryVoxelSize`; skirts are normal-extruded, double-sided visual strips. With Core’s 25 cm geometry voxel size and the default `SkirtCells = 4`, nominal depth is 1 m at LOD0 and 2 m at LOD1. Project settings can override these defaults. Skirts hide many visual gaps but do not make the neighboring surface topologically watertight. The new transition has not yet replaced that production behavior. + +## Surface residual and crease splits + +Residual is the absolute field value sampled at triangle vertices, edge midpoints, and centroids; it is not a symmetric Hausdorff distance. Values below average the 625 phase rows per method; maxima are the worst sampled value. + +| Method | Mean residual | Worst residual | Tagged crease pairs | Normal split pairs | Split rate on tagged creases | Mean triangles / vertices | |---|---:|---:|---:|---:|---:|---:| -| MC | 0 | 0 | 0 | 625 | 75,494 | 0 | -| Hybrid | 500 | 85,632 | 3,484 | 625 | 75,794 | 5,819 | -| DC | 625 | 101,440 | 3,609 | 625 | 75,790 | 6,128 | +| MC | 0.5664 cm | 16.6670 cm | 10,926 | 1,847 | 16.90% | 3,363.1 / 1,728.8 | +| Hybrid | 0.2552 cm | 12.5027 cm | 140,876 | 140,806 | 99.95% | 3,365.3 / 3,469.2 | +| DC | 0.2786 cm | 12.5027 cm | 140,876 | 140,806 | 99.95% | 3,365.8 / 1,985.6 | -MC’s same-LOD contour match is exact in all cases: zero measured contour distance and zero merged open edges. Hybrid passes only the 125 smooth chamber cases; its 500 tagged-shape cases have open edges. DC also fails the chamber border. +The hybrid keeps untagged/organic components on MC and uses DC for tagged sharp features. It preserves the sharp corner/riser/ledge better than MC in the analytic fixtures, while its feature splits roughly double the vertex count. -Every LOD0/LOD1 case has open boundary edges. For MC, the per-case maximum sampled contour distance has an 18.57 cm median, 26.41 cm 95th percentile, and 31.82 cm worst case; 39 of 625 cases exceed 25 cm. Hybrid has complete contour comparisons in only 130 cases; five of those exceed 25 cm, with a 560.47 cm worst finite comparison. DC has complete contour comparisons in only five cases, with a 560.47 cm worst finite comparison. The large finite outliers and unmatched probes are direct failures, not a pass hidden by averages. The spike has no transition templates, so these results are a transition gate, not a claim that any candidate’s final transition design has been tuned. +## Sampled native SurfaceWorld cliff -## Cost, determinism, and collision proxy +The native comparison samples the 8 m patch at 25 cm from the M1 SurfaceWorld manifest (feature type 4, feature `0x9812c26310b90c9`, manifest hash `0x8933693d9560ba9f`). MC and Hybrid emit bit-identical vertex and triangle arrays, with identical mesh hash `0x9d58e0603f1bf9c7`: 2,345 vertices and 4,496 triangles each. This confirms that the hybrid leaves this untagged native patch unchanged. -Each timing uses an already sampled 32³-cell field grid, three mesh builds, and the median build time. Field sampling, Unreal scheduling, upload, collision cooking, and rendering are excluded. All values below average the five fixtures. +The sampled scalar-field residual is 0.212167 m mean and 16.0068 m max for both MC and Hybrid. This patch is cropped at the 8 m volume boundary and has 192 boundary edges; these values are not a seam result, and the scalar field is not a signed-distance guarantee. DC-everywhere differs (2,247 vertices, 3,961 triangles, 4.57272 m mean / 16.5898 m max, 504 boundary edges and 74 nonmanifold edges). The native CSV is [native_surface.csv](results/native_surface.csv). -| Worker count | MC mean ms/tile | Hybrid mean ms/tile | DC mean ms/tile | +## Cost and determinism + +The timer starts after the 32³ field grid is sampled, measures meshing only, and excludes Unreal scheduling, upload, collision cooking, and rendering. Values below are the median of five fixture medians; W1/W8 use three builds, while W16 uses one timed build because repeated W16 thread creation had caused a host access violation. Hash determinism is still checked at all three worker counts. + +| Workers | MC ms/tile | Hybrid ms/tile | DC ms/tile | |---:|---:|---:|---:| -| 1 | 16.308 | 17.375 | 16.269 | -| 8 | 14.275 | 14.907 | 13.996 | -| 16 | 14.555 | 15.291 | 14.129 | +| 1 | 16.316 | 17.534 | 16.417 | +| 8 | 53.914 | 54.840 | 52.606 | +| 16 | 86.961 | 90.490 | 89.476 | -At W8, hybrid is 4.4% slower and DC is 2.0% faster than MC. Mean triangle counts are 3,454.4 / 3,456.8 / 3,457.6 for MC / Hybrid / DC. Mean vertex counts are 1,728.8 / 3,469.2 / 1,985.6. Hybrid’s feature-split path therefore roughly doubles output vertices for negligible triangle change. +All 45 W1/W8/W16 rows match the W1 hash for that method and fixture. The standalone collision proxy copies the render mesh: all hashes match and measured geometry delta is 0 m. This does not test Unreal collision cooking. -All 45 determinism rows (five fixtures × three methods × three worker counts) match the W1 reference for that fixture and method: zero cross-worker hash mismatches. W16 is a 16-worker software pool running with process affinity limited to eight logical CPUs. +These timings are **not comparable to the optimized production MC mesher**. The harness is a separate STL/map-based implementation with its own worker dispatch and already-sampled grid; the strong slowdown at W8/W16 is further evidence not to use these numbers as a production estimate. They are useful only for comparisons within this harness. -The standalone collision proxy is the same emitted vertex/triangle mesh. All 45 proxy hashes match the visual mesh hash and report 0 m geometry delta. This verifies the candidate mesh handoff only; there is no independent collision simplification, Unreal physics cook, or contact test in round 1. +## Contact sheet -## Captures +The [updated offline contact sheet](results/captures/contact_sheet.jpg) is 864 × 1,968 pixels. It contains the five analytic fixtures, the sampled SurfaceWorld cliff, and both LOD seam views, with MC/Hybrid/DC columns. The 24 individual JPEGs are 288 × 246 pixels; all 25 files including the sheet are below 300 KB. No viewer or GPU path was used. -The [contact sheet](results/captures/contact_sheet.jpg) is an offline orthographic rasterization of matched MC, Hybrid, and DC meshes for all five fixtures, plus one LOD seam view per method. It is 864 × 1,476 pixels. The 18 individual captures are 288 × 246 pixels. There are 19 JPEGs including the sheet; all are under 300 KB, and the largest is the 128,260-byte contact sheet. No viewer was launched. +## Recommendation and round-3 Unreal plan -Capture files: +Do not select or enable the hybrid in the owner project yet. The same-LOD gate is closed, but the full LOD gate is not. The current Core world hash/default path is untouched. -- [90° corner — MC](results/captures/corner90_MC.jpg), [Hybrid](results/captures/corner90_Hybrid.jpg), [DC](results/captures/corner90_DC.jpg) -- [Doorway — MC](results/captures/doorway_MC.jpg), [Hybrid](results/captures/doorway_Hybrid.jpg), [DC](results/captures/doorway_DC.jpg) -- [One-metre riser — MC](results/captures/riser1m_MC.jpg), [Hybrid](results/captures/riser1m_Hybrid.jpg), [DC](results/captures/riser1m_DC.jpg) -- [Cliff ledge — MC](results/captures/cliff_overhang_MC.jpg), [Hybrid](results/captures/cliff_overhang_Hybrid.jpg), [DC](results/captures/cliff_overhang_DC.jpg) -- [Cave chamber — MC](results/captures/organic_cave_chamber_MC.jpg), [Hybrid](results/captures/organic_cave_chamber_Hybrid.jpg), [DC](results/captures/organic_cave_chamber_DC.jpg) -- [LOD seam — MC](results/captures/lodseam_MC.jpg), [Hybrid](results/captures/lodseam_Hybrid.jpg), [DC](results/captures/lodseam_DC.jpg) +When the standalone LOD gate passes, round 3 should: -## Run ledger and constraints +1. Add an **opt-in, versioned** recipe such as `WorldAssemblerHybridV1`, default off, with its mesher, feature ownership, transition, and collision settings recorded as part of the version. Keep the existing Core recipe and default hashes unchanged. +2. Add an Unreal test map with the five analytic fixtures, the sampled SurfaceWorld cliff, and a native Core cave tile. Include independent same-LOD neighbors and a sharp cliff crossing from LOD0 to LOD1. Compare skirts on/off and use matched cameras plus wireframe views. +3. Cook collision through the real Unreal path. Wait for cooking completion, then run capsule sweeps/walk tests over the doorway, riser, cliff underside, cave wall, and both borders. Check collision/render agreement and recooking after a local edit. +4. Record mesh and collision cook latency, CPU/memory, triangle/vertex counts, LOD error in pixels at the fixture cameras, and player contact results. Keep MC as the direct baseline and preserve a one-switch rollback to the existing recipe. + +## Run ledger and scope | Run/check | Result | |---|---| -| Final MSVC C++20 build | Passed in 4.42 s, BelowNormal, affinity 0xFF00. | -| Final phase/cost/determinism/seam run | Passed in 114.208 s; 1,875 phase meshes and 1,875 independent seam cases, BelowNormal, affinity 0xFF00. | -| Final capture renderer | Passed in 2.61 s, BelowNormal, affinity 0xFF00; 19 files under the 300 KB cap. | -| Earlier phase run 1 | Exited normally in 31.14 s; exact-zero lattice degeneracies exposed topology defects. | -| Earlier phase run 2 | Exited normally in 31.11 s after adding the deterministic exact-zero bias; DC still had boundary defects. | -| Earlier phase run 3 | Exited normally in 35.07 s after splitting disconnected cell patches; remaining boundary defects kept it from acceptance. | -| Earlier phase run 4 | Exited normally in 36.55 s after making MC endpoint-gradient interpolation explicit; used for normal-split comparison. | -| Earlier seam run | Exited normally in 74.21 s, but clipped a shared mesh and did not test independently generated borders. It was superseded by the final independent-neighbor seam run. | -| Tool validation | One early PowerShell parser check used an invalid reference argument. Two later analysis-only PowerShell aggregation commands had syntax errors. All were corrected; none launched a mesher process. One early MSVC environment setup printed a nonfatal vswhere helper warning; compilation succeeded, and the final build emitted no warning. | +| Final MSVC C++20 build | Passed in 5.44 s, BelowNormal, affinity `0xFF00`. | +| Full seam run with unrestricted residual-loop caps | Completed in 602.78 s; 83 LOD cases failed. Preserved as `results/seams_cap_unbounded_diagnostic.csv`. | +| Final full seam run with 0.50 m cap-residual guard | Completed in 601.31 s; same-LOD passed 625/625 for all methods, LOD failures were MC 5, Hybrid 38, DC 40. No crash or guard trip. | +| Cost/phase/native analysis | Completed in 120.95 s; 1,875 phase rows, 45 determinism rows, native patch comparison. | +| Final offline renderer | Passed in 3.14 s; 25 JPEGs under the 300 KB cap. | +| Earlier full seam variants | Completed in 559.32 s and 566.44 s. Both failed earlier same-LOD Hybrid/DC cases (36/625 each); superseded by the final T-junction conformance pass. | +| Focused transition prototypes | Completed within seconds. Earlier MC doorway variants left 64, then 3–4 open edges; whole-mesh endpoint snapping introduced nonmanifold edges. Those variants were rejected. The bounded residual-loop cap closes this focused doorway case, but the full LOD matrix still fails. | +| Earlier native halo diagnostic | An initial process exited `0xC0000005`; an AddressSanitizer launch first failed `0xC0000135` because its runtime DLL was unavailable. After using the Visual Studio runtime path, ASan found the halo index overflow; the indexing was fixed, and subsequent harness runs completed. | +| W16 timing diagnostic | Repeated creation/timing of 16 OS workers caused a host access violation. W16 now uses one timed build; hash determinism remains checked. | +| Aggregation tools | Two PowerShell summary attempts had query errors, including a missing `vertices` field in `phase_sweep.csv`; corrected queries ran without launching a mesher. No `0xC0000142` retry was needed. | -Every final build/test/render process used BelowNormal priority and affinity 0xFF00, limiting it to eight of the 16 logical processors. The build/test/render scripts enforce an 8 GB private-memory guard and 15-minute timeout. No process hit either limit, crashed, or needed a 0xC0000142 retry. The earlier phase runs above exited normally; their topology/seam measurements failed or were superseded as noted. No viewer or owner editor was opened. No owner project was built and no .uasset was touched. +Every final build, mesher, analysis, and renderer process ran BelowNormal with affinity `0xFF00`; the scripts enforce an 8 GB private-memory limit and 15-minute timeout. Peak observed mesher private memory was under 0.2 GB. No final process crashed, exceeded a limit, or needed a retry. -## Files added for the spike +All edits and outputs are under `Plugins/VoxelForge/Standalone/Research/WorldAssemblerM2`, plus this dialogue log. No `Source/` file, Core source, owner project file, `.uasset`, or editor was touched. No commit, push, or stash was made. -New implementation and launch files: +## Files changed and new source files -- [SharpMesherSpike.cpp](SharpMesherSpike.cpp) -- [BuildSharpMesherSpike.ps1](BuildSharpMesherSpike.ps1) -- [RunSharpMesherSpike.ps1](RunSharpMesherSpike.ps1) -- [RenderSharpMesherCaptures.ps1](RenderSharpMesherCaptures.ps1) -- [RenderSharpMesherCaptures.py](RenderSharpMesherCaptures.py) +Updated existing standalone files: [SharpMesherSpike.cpp](SharpMesherSpike.cpp), [BuildSharpMesherSpike.ps1](BuildSharpMesherSpike.ps1), [RunSharpMesherSpike.ps1](RunSharpMesherSpike.ps1), and [RenderSharpMesherCaptures.py](RenderSharpMesherCaptures.py). Updated this report and appended the M2 round-2 dialogue entry. -The report, CSV measurements, exported OBJ meshes, offline captures, executable/object build outputs, and run logs are also in this WorldAssemblerM2 directory. No files under Source were changed. +**New source files this round: none.** CSVs, OBJ meshes, JPEG captures, logs, and local build outputs are generated research artifacts; none were committed. diff --git a/Standalone/Research/WorldAssemblerM2/RenderSharpMesherCaptures.py b/Standalone/Research/WorldAssemblerM2/RenderSharpMesherCaptures.py index 6391d59..f0f1658 100644 --- a/Standalone/Research/WorldAssemblerM2/RenderSharpMesherCaptures.py +++ b/Standalone/Research/WorldAssemblerM2/RenderSharpMesherCaptures.py @@ -19,6 +19,7 @@ PIECES = ( ("riser1m", "one metre riser"), ("cliff_overhang", "cliff ledge / overhang"), ("organic_cave_chamber", "organic cave chamber wall"), + ("native_surface_cliff", "sampled SurfaceWorld cliff"), ) WIDTH, HEIGHT = 288, 216 BG = (239, 242, 244) @@ -56,14 +57,26 @@ def read_obj(path: Path): return vertices, normals, faces -def project_mesh(vertices, faces, seam=False): +def project_mesh(vertices, faces, seam=False, target=None): # One view is reused for every candidate in a row: azimuth 38°, elevation 27°. - target = (0.0, 0.0, 0.0) + if target is None: + if vertices: + bounds_min = tuple(min(v[i] for v in vertices) for i in range(3)) + bounds_max = tuple(max(v[i] for v in vertices) for i in range(3)) + target = tuple((bounds_min[i] + bounds_max[i]) * 0.5 for i in range(3)) + else: + target = (0.0, 0.0, 0.0) view_dir = normalize((8.0, -10.0, 7.0)) forward = tuple(-c for c in view_dir) right = normalize(cross(forward, (0.0, 0.0, 1.0))) up = normalize(cross(right, forward)) - scale = 20.2 + projected_bounds = [] + for point in vertices: + rel = tuple(point[i] - target[i] for i in range(3)) + projected_bounds.append((dot(rel, right), dot(rel, up))) + span_x = max((point[0] for point in projected_bounds), default=0.0) - min((point[0] for point in projected_bounds), default=0.0) + span_y = max((point[1] for point in projected_bounds), default=0.0) - min((point[1] for point in projected_bounds), default=0.0) + scale = min(20.2, 0.84 * min((WIDTH - 24) / max(span_x, 1.0e-6), (HEIGHT - 24) / max(span_y, 1.0e-6))) projected = [] for p in vertices: rel = tuple(p[i] - target[i] for i in range(3)) @@ -102,7 +115,10 @@ def raster(path: Path, color_by_x=False): for i in range(1, len(clipped) - 1): clipped_faces.append(((first, first), (first + i, first + i), (first + i + 1, first + i + 1))) vertices, normals, faces = clipped_vertices, clipped_normals, clipped_faces - projected = project_mesh(vertices, faces, color_by_x) + bounds_min = tuple(min(v[i] for v in vertices) for i in range(3)) + bounds_max = tuple(max(v[i] for v in vertices) for i in range(3)) + target = tuple((bounds_min[i] + bounds_max[i]) * 0.5 for i in range(3)) + projected = project_mesh(vertices, faces, color_by_x, target) pixels = Image.new("RGB", (WIDTH, HEIGHT), BG) zbuf = [float("inf")] * (WIDTH * HEIGHT) px = pixels.load() @@ -146,7 +162,7 @@ def raster(path: Path, color_by_x=False): draw = ImageDraw.Draw(pixels) # Project a visible segment on the LOD boundary plane, x=0. seam_world = [(-0.0, y, z) for y, z in ((-4, -4), (4, -4), (4, 4), (-4, 4), (-4, -4))] - seam_points = project_mesh(seam_world, [], True) + seam_points = project_mesh(seam_world, [], True, target) draw.line([(p[0], p[1]) for p in seam_points], fill=(181, 55, 49), width=2) return pixels @@ -174,14 +190,14 @@ def main(): captures.append(row) seam_row = [] for method in METHODS: - obj = RESULTS / "meshes" / f"lodseam_cliff_overhang_{method}.obj" - image = capture_title(raster(obj, color_by_x=True), "LOD0 to LOD1 cliff seam", f"{method} | fine left, coarse right, x=0 seam") - path = CAPTURES / f"lodseam_{method}.jpg" + obj = RESULTS / "meshes" / f"lodseam_cliff_crossing_{method}.obj" + image = capture_title(raster(obj, color_by_x=True), "Sharp cliff across LOD border", f"{method} | LOD0 left, LOD1 right, x=0") + path = CAPTURES / f"lodseam_cliff_crossing_{method}.jpg" image.save(path, quality=86, optimize=True) seam_row.append(image) captures.append(seam_row) - sheet = Image.new("RGB", (WIDTH * 3, (HEIGHT + 30) * 6), (221, 227, 230)) + sheet = Image.new("RGB", (WIDTH * 3, (HEIGHT + 30) * len(captures)), (221, 227, 230)) draw = ImageDraw.Draw(sheet) for row_index, row in enumerate(captures): for col_index, image in enumerate(row): diff --git a/Standalone/Research/WorldAssemblerM2/RunSharpMesherSpike.ps1 b/Standalone/Research/WorldAssemblerM2/RunSharpMesherSpike.ps1 index 1c63234..2ed46f6 100644 --- a/Standalone/Research/WorldAssemblerM2/RunSharpMesherSpike.ps1 +++ b/Standalone/Research/WorldAssemblerM2/RunSharpMesherSpike.ps1 @@ -1,6 +1,8 @@ [CmdletBinding()] param( - [ValidateRange(2,5)][int]$PhaseCount = 5 + [ValidateRange(2,5)][int]$PhaseCount = 5, + [ValidateSet('all','seams','native','analysis')][string]$Mode = 'all', + [ValidateRange(0,1875)][int]$SeamRowLimit = 0 ) $ErrorActionPreference = 'Stop' @@ -23,7 +25,9 @@ do { Remove-Item -LiteralPath $Stdout, $Stderr -Force -ErrorAction SilentlyContinue $Info = [Diagnostics.ProcessStartInfo]::new() $Info.FileName = $Exe - $Info.Arguments = ('--out="{0}" --phase-count={1}' -f $OutDir, $PhaseCount) + $ModeArgument = if ($Mode -eq 'seams') { ' --seams-only' } elseif ($Mode -eq 'native') { ' --native-only' } elseif ($Mode -eq 'analysis') { ' --analysis-only' } else { '' } + $RowLimitArgument = if ($SeamRowLimit -gt 0) { ' --seam-row-limit={0}' -f $SeamRowLimit } else { '' } + $Info.Arguments = ('--out="{0}" --phase-count={1}{2}{3}' -f $OutDir, $PhaseCount, $ModeArgument, $RowLimitArgument) $Info.WorkingDirectory = [IO.Path]::GetFullPath((Join-Path $WorkDir '..\..')) $Info.UseShellExecute = $false $Info.CreateNoWindow = $true diff --git a/Standalone/Research/WorldAssemblerM2/SharpMesherSpike.cpp b/Standalone/Research/WorldAssemblerM2/SharpMesherSpike.cpp index fe9a707..b4d0f7a 100644 --- a/Standalone/Research/WorldAssemblerM2/SharpMesherSpike.cpp +++ b/Standalone/Research/WorldAssemblerM2/SharpMesherSpike.cpp @@ -1,5 +1,6 @@ #include "CoreMinimal.h" #include "MarchingCubesTables.h" +#include "VoxelForgeCore/VoxelForgeCore.h" #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +28,7 @@ namespace { constexpr double VoxelMeters = 0.25; constexpr double PositionQuantum = VoxelMeters / 65536.0; + constexpr double LodContourErrorBudgetMeters = 0.50; // One LOD1 cell (50 cm) physical mismatch. constexpr double NormalSplitCos = 0.86602540378443864676; // 30 degrees constexpr int CornerCount = 8; constexpr int EdgeCount = 12; @@ -155,7 +158,7 @@ namespace return out; } - enum class Piece : int { Corner=0, Doorway=1, Step=2, Ledge=3, Organic=4 }; + enum class Piece : int { Corner=0, Doorway=1, Step=2, Ledge=3, Organic=4, NativeSurface=5 }; const char* PieceName(Piece p) { switch(p) @@ -164,13 +167,15 @@ namespace case Piece::Doorway:return "doorway"; case Piece::Step:return "riser1m"; case Piece::Ledge:return "cliff_overhang"; - default:return "organic_cave_chamber"; + case Piece::Organic:return "organic_cave_chamber"; + default:return "native_surface_cliff"; } } struct Field { Piece piece=Piece::Corner; V3 phaseShift{}; + const VoxelForgeCore::Heightfield2D* nativeSurface=nullptr; std::array boxes{}; std::uint8_t boxCount=0; Box doorVoid{}; @@ -196,13 +201,33 @@ namespace AddBox({-3.0,-3.0,-2.0},{0.25,3.0,3.0},7); AddBox({-0.25,-3.0,1.5},{2.2,3.0,2.0},8); break; - case Piece::Organic:break; + case Piece::Organic:case Piece::NativeSurface:break; } } + explicit Field(const VoxelForgeCore::Heightfield2D& surface,V3 phase={}) + :piece(Piece::NativeSurface),phaseShift(phase),nativeSurface(&surface){} FieldSample Eval(V3 p) const { p=p+phaseShift; + if(nativeSurface) + { + constexpr double SubvoxelsPerMeter=1024.0; + constexpr std::int64_t GradientDeltaSubvoxels=64; + const auto x=static_cast(std::llround(p.x*SubvoxelsPerMeter)); + const auto y=static_cast(std::llround(p.y*SubvoxelsPerMeter)); + const auto z=static_cast(std::llround(p.z*SubvoxelsPerMeter)); + const auto center=nativeSurface->point(x,y,z); + FieldSample result;result.density=static_cast(center.densitySubvoxels)/SubvoxelsPerMeter; + const auto xp=nativeSurface->point(x+GradientDeltaSubvoxels,y,z).densitySubvoxels; + const auto xm=nativeSurface->point(x-GradientDeltaSubvoxels,y,z).densitySubvoxels; + const auto yp=nativeSurface->point(x,y+GradientDeltaSubvoxels,z).densitySubvoxels; + const auto ym=nativeSurface->point(x,y-GradientDeltaSubvoxels,z).densitySubvoxels; + const auto zp=nativeSurface->point(x,y,z+GradientDeltaSubvoxels).densitySubvoxels; + const auto zm=nativeSurface->point(x,y,z-GradientDeltaSubvoxels).densitySubvoxels; + result.gradient=Normalize({static_cast(xp-xm),static_cast(yp-ym),static_cast(zp-zm)}); + return result; + } if(piece==Piece::Organic) { const V3 d=p-V3{0,0,0}; @@ -262,6 +287,7 @@ namespace struct Grid { int nx=0,ny=0,nz=0,step=1; + std::array coreMin{{0,0,0}},coreMax{{0,0,0}}; V3 origin{}; std::vector density; std::size_t NodeIndex(int x,int y,int z) const @@ -270,18 +296,24 @@ namespace { return origin+V3{x*step*VoxelMeters,y*step*VoxelMeters,z*step*VoxelMeters}; } std::size_t CellIndex(int x,int y,int z) const { return (static_cast(z)*ny+y)*nx+x; } + bool IsCoreCell(int x,int y,int z) const + { return x>=coreMin[0]&&y>=coreMin[1]&&z>=coreMin[2]&& + x(nx)*ny*nz; } }; - Grid SampleGrid(const Field& field,int nx,int ny,int nz,int step,V3 origin,int workers) + Grid SampleGrid(const Field& field,int nx,int ny,int nz,int step,V3 origin,int workers,int halo=0) { - Grid grid;grid.nx=nx;grid.ny=ny;grid.nz=nz;grid.step=step;grid.origin=origin; - const std::size_t count=static_cast(nx+1)*(ny+1)*(nz+1); + Grid grid;grid.step=step; + grid.coreMin={halo,halo,halo};grid.coreMax={halo+nx,halo+ny,halo+nz}; + grid.nx=nx+2*halo;grid.ny=ny+2*halo;grid.nz=nz+2*halo; + grid.origin=origin-V3{halo*step*VoxelMeters,halo*step*VoxelMeters,halo*step*VoxelMeters}; + const std::size_t count=static_cast(grid.nx+1)*(grid.ny+1)*(grid.nz+1); grid.density.resize(count); ParallelFor(count,workers,[&](std::size_t index) { - const int x=static_cast(index%(nx+1)); - const int y=static_cast((index/(nx+1))%(ny+1)); - const int z=static_cast(index/((nx+1)*(ny+1))); + const int x=static_cast(index%(grid.nx+1)); + const int y=static_cast((index/(grid.nx+1))%(grid.ny+1)); + const int z=static_cast(index/((grid.nx+1)*(grid.ny+1))); const double density=field.Eval(grid.Position(x,y,z)).density; // Stable iso-level tie rule: zero remains air, matching the Core sign convention, but // receives a sub-cell positive bias so edge roots do not collapse onto one lattice @@ -543,7 +575,7 @@ namespace return h; } - Mesh BuildMesh(const Grid& grid,const Field& field,Method method,int workers) + Mesh BuildMesh(const Grid& grid,const Field& field,Method method,int workers,bool enforceQuadOwnership=true) { EdgeArrays edges=BuildRoots(grid,field,workers); std::vector cells(grid.CellCount()); @@ -710,6 +742,7 @@ namespace }; for(int z=0;z localVertices{};localVertices.fill(-1); @@ -778,6 +811,13 @@ namespace if(incidentComponents[i]==0xff){valid=false;break;} anyDC|=cells[incident[i]].componentUseDC[incidentComponents[i]]; } + // Own each dual quad by the lexicographically lowest incident cell. A one-cell + // sampled halo supplies the neighbour's QEF vertex, so independently built tiles + // emit the same complete border quad exactly once. + const int ownerX=std::min({around[0][0],around[1][0],around[2][0],around[3][0]}); + const int ownerY=std::min({around[0][1],around[1][1],around[2][1],around[3][1]}); + const int ownerZ=std::min({around[0][2],around[1][2],around[2][2],around[3][2]}); + if(enforceQuadOwnership&&!grid.IsCoreCell(ownerX,ownerY,ownerZ))continue; if(!valid||!anyDC||!cells[incident[0]].componentUseDC[incidentComponents[0]])continue; std::array v{}; for(int i=0;i<4;++i) @@ -855,6 +895,434 @@ namespace } return result; } + struct BoundaryLoop + { + std::vector keys; + std::vector points; + V3 center{}; + double perimeter=0.0; + }; + struct BoundaryLoopSet + { + std::vector loops; + std::uint64_t boundaryEdges=0,badVertices=0; + }; + std::pair OrderedEdge(GeomKey a,GeomKey b) + {if(b; + BoundaryLoopSet ExtractBoundaryLoops(const Mesh& mesh) + { + BoundaryLoopSet result; + const auto edges=GeomEdges(mesh); + std::map> adjacency; + for(const auto& [key,info]:edges)if(info.count==1) + { + ++result.boundaryEdges; + adjacency[key.first].push_back(key.second); + adjacency[key.second].push_back(key.first); + } + for(const auto& [key,neighbors]:adjacency)if(neighbors.size()!=2)++result.badVertices; + std::set> visited; + for(const auto& [start,startNeighbors]:adjacency) + { + for(const GeomKey first:startNeighbors) + { + if(visited.contains(OrderedEdge(start,first)))continue; + BoundaryLoop loop; + GeomKey previous=start,current=first; + visited.insert(OrderedEdge(previous,current)); + loop.keys.push_back(start); + bool closed=false; + for(std::size_t guard=0;guard<=result.boundaryEdges;++guard) + { + loop.keys.push_back(current); + const auto found=adjacency.find(current); + if(found==adjacency.end()||found->second.size()!=2)break; + const GeomKey next=found->second[0]==previous?found->second[1]:found->second[0]; + if(next==start){closed=true;break;} + const auto edge=OrderedEdge(current,next); + if(visited.contains(edge))break; + visited.insert(edge);previous=current;current=next; + } + if(!closed||loop.keys.size()<3)continue; + BoundaryLoop clean; + clean.keys=std::move(loop.keys); + for(const GeomKey& key:clean.keys) + { + const V3 p{key.x*PositionQuantum,key.y*PositionQuantum,key.z*PositionQuantum}; + clean.points.push_back(p);clean.center+=p; + } + clean.center=clean.center/static_cast(clean.points.size()); + for(std::size_t i=0;i0?(start+walked)%count:(start+count-(walked%count))%count; + const std::size_t next=direction>0?(current+1)%count:(current+count-1)%count; + const V3 a=loop.points[current],b=loop.points[next]; + const double edgeLength=Length(b-a); + if(remaining<=edgeLength||walked+1==count) + return edgeLength>0.0?a+(b-a)*(remaining/edgeLength):a; + remaining-=edgeLength; + } + return loop.points[start]; + } + int BestLoopDirection(const BoundaryLoop& a,const BoundaryLoop& b,std::size_t start) + { + double scores[2]{}; + constexpr int samples=24; + for(int dirIndex=0;dirIndex<2;++dirIndex) + { + const int direction=dirIndex==0?1:-1; + for(int i=0;i(i)/samples; + scores[dirIndex]+=Dot(PointAlongLoop(a,0,1,f)-PointAlongLoop(b,start,direction,f), + PointAlongLoop(a,0,1,f)-PointAlongLoop(b,start,direction,f)); + } + } + return scores[0]<=scores[1]?1:-1; + } + TransitionAudit StitchBoundaryLoops(Mesh& mesh,const BoundaryLoopSet& fine, + const BoundaryLoopSet& coarse,const Field& field) + { + TransitionAudit result; + const std::size_t patchStart=mesh.triangles.size(); + result.unmatchedLoops=fine.badVertices+coarse.badVertices; + std::map vertexByKey; + for(std::uint32_t i=0;i edges; + for(const BoundaryLoop& loop:loops.loops) + for(std::size_t i=0;i fineEdges=loopEdges(fine),coarseEdges=loopEdges(coarse); + std::set sharedEdges; + std::set_intersection(fineEdges.begin(),fineEdges.end(),coarseEdges.begin(),coarseEdges.end(), + std::inserter(sharedEdges,sharedEdges.end())); + constexpr double collarOffset=0.001; + if(!sharedEdges.empty()&&fine.loops.size()==coarse.loops.size()) + { + struct Chain{std::vector keys;}; + const auto buildChains=[&](const BoundaryLoopSet& source) + { + std::vector chains; + for(const BoundaryLoop& loop:source.loops) + { + const std::size_t n=loop.keys.size(); + std::vector isShared(n,false); + for(std::size_t i=0;i1)chains.push_back(std::move(chain)); + } + } + return chains; + }; + const std::vector fineChains=buildChains(fine),coarseChains=buildChains(coarse); + std::vector coarseUsed(coarseChains.size(),false); + const std::size_t transitionStart=mesh.triangles.size(); + const auto offsetChain=[&](const Chain& chain,double offset) + { + std::vector original,shifted; + for(const GeomKey& key:chain.keys) + { + const auto found=vertexByKey.find(key); + if(found==vertexByKey.end())return std::pair{original,shifted}; + original.push_back(found->second); + Vertex vertex=mesh.vertices[found->second];vertex.position.x+=offset; + const GeomKey shiftedKey=Key(vertex.position); + const auto existing=vertexByKey.find(shiftedKey); + if(existing!=vertexByKey.end())shifted.push_back(existing->second); + else + { + const auto index=static_cast(mesh.vertices.size()); + mesh.vertices.push_back(vertex);vertexByKey.emplace(shiftedKey,index);shifted.push_back(index); + } + } + for(std::size_t i=0;i+1::infinity(); + for(std::size_t j=0;j capRing{originalFine,fa,ca,originalCoarse}; + capRing.erase(std::unique(capRing.begin(),capRing.end(),[&](std::uint32_t a,std::uint32_t b) + {return Key(mesh.vertices[a].position)==Key(mesh.vertices[b].position);}),capRing.end()); + if(capRing.size()>2&&Key(mesh.vertices[capRing.front()].position)==Key(mesh.vertices[capRing.back()].position))capRing.pop_back(); + V3 capCenter{};for(const std::uint32_t index:capRing)capCenter+=mesh.vertices[index].position; + capCenter=capCenter/static_cast(capRing.size())+V3{0.0,0.001,0.001}; + Vertex capVertex;capVertex.position=capCenter; + capVertex.normal=Normalize(mesh.vertices[fa].normal+mesh.vertices[ca].normal); + const GeomKey capKey=Key(capVertex.position); + auto capFound=vertexByKey.find(capKey); + std::uint32_t cap=0; + if(capFound!=vertexByKey.end())cap=capFound->second; + else + { + cap=static_cast(mesh.vertices.size()); + mesh.vertices.push_back(capVertex);vertexByKey.emplace(capKey,cap); + } + for(std::size_t i=0;i cumulativeA(na,0.0),cumulativeB(nb,0.0); + for(std::size_t i=1;i::infinity(); + const double nextB=j+1::infinity(); + if(nextA<=nextB) + { + const V3 center=(mesh.vertices[fineOffset[i]].position+mesh.vertices[fineOffset[i+1]].position+ + mesh.vertices[coarseOffset[j]].position)/3.0; + const FieldSample sample=field.Eval(center); + AddTriangle(mesh,fineOffset[i],fineOffset[i+1],coarseOffset[j],sample.primaryFeature(),sample.gradient);++i; + } + else + { + const V3 center=(mesh.vertices[fineOffset[i]].position+mesh.vertices[coarseOffset[j+1]].position+ + mesh.vertices[coarseOffset[j]].position)/3.0; + const FieldSample sample=field.Eval(center); + AddTriangle(mesh,fineOffset[i],coarseOffset[j+1],coarseOffset[j],sample.primaryFeature(),sample.gradient);++j; + } + } + } + for(bool used:coarseUsed)if(!used)++result.unmatchedLoops; + result.pairedLoops=matchedChains; + result.transitionTriangles=mesh.triangles.size()-transitionStart; + return result; + } + // Give both sides a one-millimetre transition skin before zipping. This avoids degenerate + // triangles when otherwise independent contours share collinear runs. + const auto offsetLoops=[&](const BoundaryLoopSet& source,double offset) + { + std::vector output;output.reserve(source.loops.size()); + for(const BoundaryLoop& input:source.loops) + { + BoundaryLoop loop;bool valid=true; + for(const GeomKey& key:input.keys) + { + const auto found=vertexByKey.find(key); + if(found==vertexByKey.end()){valid=false;break;} + Vertex vertex=mesh.vertices[found->second];vertex.position.x+=offset; + const std::uint32_t index=static_cast(mesh.vertices.size()); + mesh.vertices.push_back(vertex);vertexByKey.try_emplace(Key(vertex.position),index); + loop.keys.push_back(Key(vertex.position));loop.points.push_back(vertex.position);loop.center+=vertex.position; + } + if(!valid||loop.points.size()<3){++result.unmatchedLoops;continue;} + loop.center=loop.center/static_cast(loop.points.size()); + for(std::size_t i=0;isecond,b=originalB->second,sa=shiftedA->second,sb=shiftedB->second; + const V3 center=(mesh.vertices[a].position+mesh.vertices[b].position+ + mesh.vertices[sb].position+mesh.vertices[sa].position)/4.0; + const FieldSample sample=field.Eval(center); + AddTriangle(mesh,a,b,sb,sample.primaryFeature(),sample.gradient); + AddTriangle(mesh,a,sb,sa,sample.primaryFeature(),sample.gradient); + } + output.push_back(std::move(loop)); + } + return output; + }; + const std::vector fineOffset=offsetLoops(fine,-collarOffset); + const std::vector coarseOffset=offsetLoops(coarse,collarOffset); + const auto capLoop=[&](const BoundaryLoop& loop) + { + if(loop.keys.size()<3)return false; + V3 center{};for(const V3 point:loop.points)center+=point; + center=center/static_cast(loop.points.size())+V3{0.001,0.001,0.001}; + Vertex centerVertex;centerVertex.position=center;centerVertex.normal=Normalize(field.Eval(center).gradient); + const GeomKey centerKey=Key(center); + auto centerIt=vertexByKey.find(centerKey);std::uint32_t centerIndex=0; + if(centerIt!=vertexByKey.end())centerIndex=centerIt->second; + else + { + centerIndex=static_cast(mesh.vertices.size()); + mesh.vertices.push_back(centerVertex);vertexByKey.emplace(centerKey,centerIndex); + } + const std::size_t before=mesh.triangles.size(); + for(std::size_t i=0;isecond].position+ + mesh.vertices[b->second].position)/3.0; + const FieldSample sample=field.Eval(at); + AddTriangle(mesh,centerIndex,a->second,b->second,sample.primaryFeature(),sample.gradient); + } + if(mesh.triangles.size()==before)return false; + ++result.cappedLoops;return true; + }; + std::vector used(coarseOffset.size(),false); + double totalResidual=0.0;std::uint64_t residualSamples=0; + for(const BoundaryLoop& a:fineOffset) + { + std::size_t match=coarseOffset.size();double best=std::numeric_limits::infinity(); + for(std::size_t j=0;j::infinity(); + for(std::size_t j=0;j aIndices,bIndices; + for(const GeomKey& key:a.keys) + { + const auto it=vertexByKey.find(key); + if(it==vertexByKey.end()){++result.unmatchedLoops;break;} + aIndices.push_back(it->second); + } + for(std::size_t j=0;j0?(bStart+j)%b.keys.size():(bStart+b.keys.size()-(j%b.keys.size()))%b.keys.size(); + const auto it=vertexByKey.find(b.keys[bi]); + if(it==vertexByKey.end()){++result.unmatchedLoops;break;} + bIndices.push_back(it->second); + } + if(aIndices.size()!=a.keys.size()||bIndices.size()!=b.keys.size())continue; + const std::size_t na=aIndices.size(),nb=bIndices.size(); + std::vector cumulativeA(na+1,0.0),cumulativeB(nb+1,0.0); + for(std::size_t i=0;i::infinity(); + const double nextB=j::infinity(); + if(nextA<=nextB) + { + const auto a0=aIndices[i%na],a1=aIndices[(i+1)%na],b0=bIndices[j%nb]; + const V3 center=(mesh.vertices[a0].position+mesh.vertices[a1].position+mesh.vertices[b0].position)/3.0; + const FieldSample sample=field.Eval(center); + AddTriangle(mesh,a0,a1,b0,sample.primaryFeature(),sample.gradient);++i; + } + else + { + const auto a0=aIndices[i%na],b1=bIndices[(j+1)%nb],b0=bIndices[j%nb]; + const V3 center=(mesh.vertices[a0].position+mesh.vertices[b1].position+mesh.vertices[b0].position)/3.0; + const FieldSample sample=field.Eval(center); + AddTriangle(mesh,a0,b1,b0,sample.primaryFeature(),sample.gradient);++j; + } + } + const std::size_t sampleCount=std::max(na,nb); + for(std::size_t s=0;s(s)/sampleCount; + const V3 pa=PointAlongLoop(a,0,1,f),pb=PointAlongLoop(b,bStart,bDirection,f); + const double d=Length(pa-pb);totalResidual+=d;result.contourMaximum=std::max(result.contourMaximum,d);++residualSamples; + } + } + for(std::size_t j=0;jhalfWidth||std::abs(bx-plane)>halfWidth)continue; + if(info.count==1)++out.boundaryEdges; + else if(info.count!=2)++out.nonManifoldEdges; + } + return out; + } + struct SeamCapAudit + { + std::uint64_t cappedLoops=0,unmatchedLoops=0,triangles=0; + }; + SeamCapAudit CapClosedBoundaryLoopsInXBand(Mesh& mesh,const Field& field,double plane,double halfWidth) + { + SeamCapAudit result; + std::map> adjacency; + std::set boundaryEdges; + std::map vertexByKey; + for(std::uint32_t i=0;i visited; + for(const EdgeKey& firstEdge:boundaryEdges) + { + if(visited.contains(firstEdge))continue; + std::vector loop{firstEdge.first}; + GeomKey previous=firstEdge.first,current=firstEdge.second; + visited.insert(firstEdge); + bool closed=false,valid=true; + for(std::size_t guard=0;guard<=boundaryEdges.size();++guard) + { + if(current==loop.front()){closed=true;break;} + loop.push_back(current); + const auto found=adjacency.find(current); + if(found==adjacency.end()||found->second.size()!=2){valid=false;break;} + const GeomKey next=found->second[0]==previous?found->second[1]:found->second[0]; + const EdgeKey edge=OrderedEdge(current,next); + if(visited.contains(edge)){valid=false;break;} + visited.insert(edge);previous=current;current=next; + } + if(!closed||!valid||loop.size()<3) + {++result.unmatchedLoops;continue;} + V3 center{};bool haveVertices=true; + for(const GeomKey& key:loop) + { + const auto found=vertexByKey.find(key); + if(found==vertexByKey.end()){haveVertices=false;break;} + center+=mesh.vertices[found->second].position; + } + if(!haveVertices){++result.unmatchedLoops;continue;} + center=center/static_cast(loop.size()); + center+=Normalize(field.Eval(center).gradient)*0.0005; + Vertex centerVertex;centerVertex.position=center;centerVertex.normal=Normalize(field.Eval(center).gradient); + const GeomKey centerKey=Key(center); + const std::size_t verticesBefore=mesh.vertices.size(); + auto centerIt=vertexByKey.find(centerKey);std::uint32_t centerIndex=0; + if(centerIt!=vertexByKey.end())centerIndex=centerIt->second; + else + { + centerIndex=static_cast(mesh.vertices.size()); + mesh.vertices.push_back(centerVertex);vertexByKey.emplace(centerKey,centerIndex); + } + const std::size_t before=mesh.triangles.size(); + for(std::size_t i=0;isecond].position+mesh.vertices[b->second].position)/3.0; + const FieldSample sample=field.Eval(samplePoint); + AddTriangle(mesh,centerIndex,a->second,b->second,sample.primaryFeature(),sample.gradient); + } + if(mesh.triangles.size()==before) + { + if(mesh.vertices.size()>verticesBefore) + {mesh.vertices.resize(verticesBefore);vertexByKey.erase(centerKey);} + ++result.unmatchedLoops;continue; + } + Mesh capOnly;capOnly.vertices=mesh.vertices; + capOnly.triangles.assign(mesh.triangles.begin()+static_cast(before),mesh.triangles.end()); + if(SurfaceError(capOnly,field).maximum>LodContourErrorBudgetMeters) + { + mesh.triangles.resize(before); + if(mesh.vertices.size()>verticesBefore) + {mesh.vertices.resize(verticesBefore);vertexByKey.erase(centerKey);} + ++result.unmatchedLoops;continue; + } + ++result.cappedLoops;result.triangles+=mesh.triangles.size()-before; + } + return result; + } std::uint64_t FnvBytes(const std::uint8_t* p,std::size_t n) { @@ -988,6 +1557,308 @@ namespace } result.hash=HashMesh(result);return result; } + Mesh ClipMeshAtX(const Mesh& source,const Field& field,bool keepLeft) + { + Mesh result; + std::map vertexByKey; + const auto addVertex=[&](Vertex vertex) + { + if(std::abs(vertex.position.x)second; + const auto index=static_cast(result.vertices.size()); + result.vertices.push_back(vertex);vertexByKey.emplace(key,index);return index; + }; + for(const Triangle& triangle:source.triangles) + { + std::vector polygon;polygon.reserve(4); + for(int edge=0;edge<3;++edge) + { + const Vertex a=source.vertices[triangle.index[edge]]; + const Vertex b=source.vertices[triangle.index[(edge+1)%3]]; + const bool insideA=keepLeft?a.position.x<=0.0:a.position.x>=0.0; + const bool insideB=keepLeft?b.position.x<=0.0:b.position.x>=0.0; + if(insideA)polygon.push_back(a); + if(insideA!=insideB) + { + const double t=std::clamp(-a.position.x/(b.position.x-a.position.x),0.0,1.0); + Vertex cut; + cut.position=a.position+(b.position-a.position)*t;cut.position.x=0.0; + cut.normal=Normalize(a.normal+(b.normal-a.normal)*t); + polygon.push_back(cut); + } + } + std::vector indices;indices.reserve(polygon.size()); + for(const Vertex& vertex:polygon) + { + const auto index=addVertex(vertex); + if(indices.empty()||indices.back()!=index)indices.push_back(index); + } + if(indices.size()>2&&indices.front()==indices.back())indices.pop_back(); + if(indices.size()<3)continue; + for(std::size_t i=1;i+1 BoundarySegmentsAtX(const Mesh& mesh,double xPlane) + { + std::vector segments; + for(const auto& [key,info]:GeomEdges(mesh)) + { + if(info.count!=1)continue; + const V3 a{key.first.x*PositionQuantum,key.first.y*PositionQuantum,key.first.z*PositionQuantum}; + const V3 b{key.second.x*PositionQuantum,key.second.y*PositionQuantum,key.second.z*PositionQuantum}; + if(std::abs(a.x-xPlane)<=PositionQuantum&&std::abs(b.x-xPlane)<=PositionQuantum) + segments.push_back({a,b}); + } + return segments; + } + struct BoundaryRepairAudit + { + std::uint64_t triangles=0,unmatchedEdges=0; + double maximumResidual=0.0; + }; + std::uint64_t ConformSeamBoundaryEdges(Mesh& a,Mesh& b,double xPlane,const Field& field) + { + std::map uniquePoints; + for(const Mesh* mesh:{&a,&b})for(const Vertex& vertex:mesh->vertices) + if(std::abs(vertex.position.x-xPlane)<=PositionQuantum) + uniquePoints.try_emplace(Key(vertex.position),vertex.position); + std::vector points;points.reserve(uniquePoints.size()); + for(const auto& [key,p]:uniquePoints)points.push_back(p); + const auto conform=[&](Mesh& source) + { + Mesh output;output.vertices=source.vertices; + std::map vertexByKey; + for(std::uint32_t i=0;isecond; + const auto index=static_cast(output.vertices.size()); + output.vertices.push_back(vertex);vertexByKey.emplace(key,index);return index; + }; + std::uint64_t splitTriangles=0; + for(const Triangle& triangle:source.triangles) + { + std::vector polygon;polygon.reserve(8);bool split=false; + for(int edge=0;edge<3;++edge) + { + const auto aIndex=triangle.index[edge],bIndex=triangle.index[(edge+1)%3]; + const V3 pa=source.vertices[aIndex].position,pb=source.vertices[bIndex].position; + polygon.push_back(addVertex(source.vertices[aIndex])); + if(std::abs(pa.x-xPlane)>PositionQuantum||std::abs(pb.x-xPlane)>PositionQuantum)continue; + const V3 direction=pb-pa;const double denominator=Dot(direction,direction); + if(denominator<=PositionQuantum*PositionQuantum)continue; + std::vector> interior; + for(const V3 q:points) + { + if(q.ystd::max(pa.y,pb.y)+PositionQuantum|| + q.zstd::max(pa.z,pb.z)+PositionQuantum)continue; + const double t=Dot(q-pa,direction)/denominator; + if(t<=1.0e-8||t>=1.0-1.0e-8)continue; + const V3 projected=pa+direction*t; + if(Length(projected-q)>PositionQuantum)continue; + interior.emplace_back(t,q); + } + std::sort(interior.begin(),interior.end(),[](const auto& x,const auto& y){return x.first=0.0&&std::abs(t-previous)<1.0e-8)continue; + Vertex vertex;vertex.position=p;vertex.normal=Normalize(source.vertices[aIndex].normal+ + (source.vertices[bIndex].normal-source.vertices[aIndex].normal)*t); + polygon.push_back(addVertex(vertex));previous=t;split=true; + } + } + if(!split){output.triangles.push_back(triangle);continue;} + ++splitTriangles; + const V3 center=(source.vertices[triangle.index[0]].position+ + source.vertices[triangle.index[1]].position+source.vertices[triangle.index[2]].position)/3.0; + Vertex centerVertex;centerVertex.position=center; + centerVertex.normal=Normalize(source.vertices[triangle.index[0]].normal+ + source.vertices[triangle.index[1]].normal+source.vertices[triangle.index[2]].normal); + const auto centerIndex=addVertex(centerVertex); + for(std::size_t i=0;i seamPoints; + for(const Mesh* mesh:{&a,&b})for(const Vertex& vertex:mesh->vertices) + if(std::abs(vertex.position.x-xPlane)<=PositionQuantum) + seamPoints.try_emplace(Key(vertex.position),vertex.position); + for(const Mesh* mesh:{&a,&b}) + for(const auto& [edge,info]:GeomEdges(*mesh)) + { + if(info.count!=1||std::abs(edge.first.x*PositionQuantum-xPlane)>PositionQuantum|| + std::abs(edge.second.x*PositionQuantum-xPlane)>PositionQuantum)continue; + const V3 p{edge.first.x*PositionQuantum,edge.first.y*PositionQuantum,edge.first.z*PositionQuantum}; + const V3 q{edge.second.x*PositionQuantum,edge.second.y*PositionQuantum,edge.second.z*PositionQuantum}; + const V3 d=q-p;const double denominator=Dot(d,d); + if(denominator<=PositionQuantum*PositionQuantum)continue; + for(const auto& [key,point]:seamPoints) + { + const double t=Dot(point-p,d)/denominator; + if(t<=1.0e-8||t>=1.0-1.0e-8)continue; + if(Length(p+d*t-point)<=PositionQuantum)return true; + } + } + return false; + } + BoundaryRepairAudit CloseOwnedSeamLoops(Mesh& owner,const Mesh& neighbor,double xPlane,const Field& field) + { + BoundaryRepairAudit result; + const Mesh merged=MergeMeshes(owner,neighbor); + const auto edges=GeomEdges(merged); + std::map> adjacency; + for(const auto& [edge,info]:edges)if(info.count==1&& + std::abs(edge.first.x*PositionQuantum-xPlane)<=PositionQuantum&& + std::abs(edge.second.x*PositionQuantum-xPlane)<=PositionQuantum) + { + adjacency[edge.first].push_back(edge.second);adjacency[edge.second].push_back(edge.first); + } + for(const auto& [key,neighbors]:adjacency)if(neighbors.size()!=2)++result.unmatchedEdges; + std::set visited; + std::map ownerVertices; + for(std::uint32_t i=0;i loop{start};GeomKey previous=start,current=first; + visited.insert(OrderedEdge(previous,current)); + bool closed=false; + for(std::size_t guard=0;guard<=adjacency.size();++guard) + { + loop.push_back(current); + const auto found=adjacency.find(current); + if(found==adjacency.end()||found->second.size()!=2)break; + const GeomKey next=found->second[0]==previous?found->second[1]:found->second[0]; + if(next==start){closed=true;break;} + const EdgeKey edge=OrderedEdge(current,next); + if(visited.contains(edge))break; + visited.insert(edge);previous=current;current=next; + } + if(!closed||loop.size()<3){++result.unmatchedEdges;continue;} + std::vector indices;indices.reserve(loop.size()); + for(const GeomKey& key:loop) + { + const auto existing=ownerVertices.find(key); + if(existing!=ownerVertices.end()){indices.push_back(existing->second);continue;} + const V3 p{key.x*PositionQuantum,key.y*PositionQuantum,key.z*PositionQuantum}; + const FieldSample sample=field.Eval(p); + const auto index=static_cast(owner.vertices.size()); + owner.vertices.push_back({p,Normalize(sample.gradient)});ownerVertices.emplace(key,index);indices.push_back(index); + } + for(std::size_t i=1;i+1(triangleStart),owner.triangles.end()); + result.maximumResidual=SurfaceError(added,field).maximum; + return result; + } + BoundaryRepairAudit ConformOpenTJunctions(Mesh& mesh,const Field& field) + { + BoundaryRepairAudit result; + for(int pass=0;pass<2;++pass) + { + const auto edgeMap=GeomEdges(mesh); + std::set boundaryEdges; + for(const auto& [edge,info]:edgeMap)if(info.count==1)boundaryEdges.insert(edge); + if(boundaryEdges.empty())break; + std::map pointMap; + for(const Vertex& vertex:mesh.vertices)pointMap.try_emplace(Key(vertex.position),vertex.position); + std::vector points;points.reserve(pointMap.size()); + for(const auto& [key,p]:pointMap)points.push_back(p); + Mesh output;output.vertices=mesh.vertices; + std::map vertexByKey; + for(std::uint32_t i=0;isecond; + const auto index=static_cast(output.vertices.size()); + output.vertices.push_back(vertex);vertexByKey.emplace(key,index);return index; + }; + std::uint64_t splitTriangles=0; + for(const Triangle& triangle:mesh.triangles) + { + std::vector polygon;polygon.reserve(8);bool split=false; + for(int edge=0;edge<3;++edge) + { + const auto aIndex=triangle.index[edge],bIndex=triangle.index[(edge+1)%3]; + const V3 pa=mesh.vertices[aIndex].position,pb=mesh.vertices[bIndex].position; + polygon.push_back(addVertex(mesh.vertices[aIndex])); + if(!boundaryEdges.contains(OrderedEdge(Key(pa),Key(pb))))continue; + const V3 direction=pb-pa;const double denominator=Dot(direction,direction); + if(denominator<=PositionQuantum*PositionQuantum)continue; + std::vector> interior; + for(const V3 q:points) + { + if(q.xstd::max(pa.x,pb.x)+PositionQuantum|| + q.ystd::max(pa.y,pb.y)+PositionQuantum|| + q.zstd::max(pa.z,pb.z)+PositionQuantum)continue; + const double t=Dot(q-pa,direction)/denominator; + if(t<=1.0e-8||t>=1.0-1.0e-8||Length(pa+direction*t-q)>PositionQuantum)continue; + interior.emplace_back(t,q); + } + std::sort(interior.begin(),interior.end(),[](const auto& x,const auto& y){return x.first=0.0&&std::abs(t-previous)<1.0e-8)continue; + Vertex vertex;vertex.position=p;vertex.normal=Normalize(mesh.vertices[aIndex].normal+ + (mesh.vertices[bIndex].normal-mesh.vertices[aIndex].normal)*t); + polygon.push_back(addVertex(vertex));previous=t;split=true; + } + } + if(!split){output.triangles.push_back(triangle);continue;} + ++splitTriangles; + Vertex center;center.position=(mesh.vertices[triangle.index[0]].position+ + mesh.vertices[triangle.index[1]].position+mesh.vertices[triangle.index[2]].position)/3.0; + center.normal=Normalize(mesh.vertices[triangle.index[0]].normal+mesh.vertices[triangle.index[1]].normal+ + mesh.vertices[triangle.index[2]].normal); + const auto centerIndex=addVertex(center); + for(std::size_t i=0;i times;Mesh last; - for(int repeat=0;repeat<3;++repeat) + // W16 is a determinism stress count under an eight-CPU affinity mask. Use one + // sample there: repeated creation of 16 OS threads adds noise and had caused + // an access violation on this host without changing the worker-count gate. + const int repeats=workers==16?1:3; + for(int repeat=0;repeat pieces{{Piece::Corner,Piece::Doorway,Piece::Step,Piece::Ledge,Piece::Organic}}; const std::array methods{{Method::MC,Method::Hybrid,Method::DC}}; const std::array phaseValues{{0.0,0.05,0.10,0.15,0.20}}; @@ -1082,40 +1958,87 @@ namespace const V3 phase{px,py,pz}; const Field field(piece,phase); const bool seamExpected=HasIsoContourOnX(field,0.0,-4.0,4.0,VoxelMeters); - const Grid tileLeft=SampleGrid(field,32,32,32,1,{-8.0,-4.0,-4.0},8); - const Grid tileRight=SampleGrid(field,32,32,32,1,{0.0,-4.0,-4.0},8); - const Grid lodFineLeft=SampleGrid(field,16,32,32,1,{-4.0,-4.0,-4.0},8); - const Grid lodCoarseRight=SampleGrid(field,8,16,16,2,{0.0,-4.0,-4.0},8); + const Grid tileLeft=SampleGrid(field,32,32,32,1,{-8.0,-4.0,-4.0},8,2); + const Grid tileRight=SampleGrid(field,32,32,32,1,{0.0,-4.0,-4.0},8,2); + const Grid lodFineLeft=SampleGrid(field,16,32,32,1,{-4.0,-4.0,-4.0},8,2); + const Grid lodCoarseRight=SampleGrid(field,8,16,16,2,{0.0,-4.0,-4.0},8,2); for(Method method:methods) { - const Mesh leftMesh=BuildMesh(tileLeft,field,method,8); - const Mesh rightMesh=BuildMesh(tileRight,field,method,8); - const Mesh lodFineMesh=BuildMesh(lodFineLeft,field,method,8); - const Mesh lodCoarseMesh=BuildMesh(lodCoarseRight,field,method,8); - const auto leftSegments=SeamSegments(leftMesh,0.0,-1); - const auto rightSegments=SeamSegments(rightMesh,0.0,1); - const auto lodFineSegments=SeamSegments(lodFineMesh,0.0,-1); - const auto lodCoarseSegments=SeamSegments(lodCoarseMesh,0.0,1); + Mesh leftMesh=BuildMesh(tileLeft,field,method,8); + Mesh rightMesh=BuildMesh(tileRight,field,method,8); + // Build both independently sampled halo meshes without border-quad ownership, + // then clip each complete surface at x=0. This gives the transition an exact + // geometric cut line instead of trying to pair unrelated interior holes. + const Mesh lodFineMesh=BuildMesh(lodFineLeft,field,method,8,false); + const Mesh lodCoarseMesh=BuildMesh(lodCoarseRight,field,method,8,false); + // Ownership suppresses the same border quad in one rendered tile. For the + // independent-data witness, re-emit halo quads in each temporary mesh and + // compare the two independently solved contours; these witness meshes are + // excluded from the merged topology audit. + Mesh leftWitness,rightWitness; + const Mesh* leftContourMesh=&leftMesh; + const Mesh* rightContourMesh=&rightMesh; + if(method!=Method::MC) + { + leftWitness=BuildMesh(tileLeft,field,method,8,false); + rightWitness=BuildMesh(tileRight,field,method,8,false); + leftContourMesh=&leftWitness;rightContourMesh=&rightWitness; + } + const auto leftSegments=SeamSegments(*leftContourMesh,0.0,-1); + const auto rightSegments=SeamSegments(*rightContourMesh,0.0,1); SeamMetric tile=CompareSegments(leftSegments,rightSegments); - SeamMetric lod=CompareSegments(lodFineSegments,lodCoarseSegments); + Mesh lodFineCut=ClipMeshAtX(lodFineMesh,field,true); + Mesh lodCoarseCut=ClipMeshAtX(lodCoarseMesh,field,false); + ConformOpenTJunctions(lodFineCut,field); + ConformOpenTJunctions(lodCoarseCut,field); + const auto lodFineSegments=BoundarySegmentsAtX(lodFineCut,0.0); + const auto lodCoarseSegments=BoundarySegmentsAtX(lodCoarseCut,0.0); + const SeamMetric rawLod=CompareSegments(lodFineSegments,lodCoarseSegments); + SeamMetric lod=rawLod; if(seamExpected&&leftSegments.empty()&&rightSegments.empty()) {tile.unmatched=1;tile.mean=-1;tile.maximum=-1;} if(seamExpected&&lodFineSegments.empty()&&lodCoarseSegments.empty()) {lod.unmatched=1;lod.mean=-1;lod.maximum=-1;} - const TopologyMetrics tileTopology=AnalyzeTopology(MergeMeshes(leftMesh,rightMesh)); - const TopologyMetrics lodTopology=AnalyzeTopology(MergeMeshes(lodFineMesh,lodCoarseMesh)); + Mesh tileMerged=MergeMeshes(leftMesh,rightMesh); + BoundaryRepairAudit tileRepair=ConformOpenTJunctions(tileMerged,field); + const TopologyMetrics tileTopology=AnalyzeTopology(tileMerged); + tile.unmatched+=tileRepair.unmatchedEdges+tileTopology.boundaryEdges+tileTopology.nonManifoldEdges; + Mesh lodMerged=MergeMeshes(lodFineCut,lodCoarseCut); + const TopologyMetrics lodBefore=AnalyzeTopology(lodMerged); + const BoundaryLoopSet fineLoops=ExtractBoundaryLoops(lodFineCut); + const BoundaryLoopSet coarseLoops=ExtractBoundaryLoops(lodCoarseCut); + const std::size_t firstTransitionTriangle=lodMerged.triangles.size(); + const TransitionAudit transition=StitchBoundaryLoops(lodMerged,fineLoops,coarseLoops,field); + const SeamCapAudit seamCaps=CapClosedBoundaryLoopsInXBand(lodMerged,field,0.0,0.003); + const TopologyMetrics lodAfter=AnalyzeTopology(lodMerged); + const TopologyMetrics lodSeamAfter=AnalyzeTopologyInXBand(lodMerged,0.0,0.003); + Mesh transitionOnly;transitionOnly.vertices=lodMerged.vertices; + transitionOnly.triangles.assign(lodMerged.triangles.begin()+static_cast(firstTransitionTriangle),lodMerged.triangles.end()); + const ErrorMetrics transitionError=SurfaceError(transitionOnly,field); + lod.unmatched=rawLod.unmatched+transition.unmatchedLoops+seamCaps.unmatchedLoops+ + lodSeamAfter.boundaryEdges+lodSeamAfter.nonManifoldEdges; + if(seamExpected&&rawLod.probes==0)++lod.unmatched; + if(seamExpected&&transition.pairedLoops==0&&transition.cappedLoops==0&&seamCaps.cappedLoops==0)++lod.unmatched; + if(rawLod.maximum>LodContourErrorBudgetMeters||transitionError.maximum>LodContourErrorBudgetMeters)++lod.unmatched; csv<0&&completed>=static_cast(rowLimit))return csv.good(); if(completed%75==0)std::cout<<"[SeamSweep] completed="<(transitionStart),merged.triangles.end()); + const ErrorMetrics residual=SurfaceError(transitionOnly,field); + const std::uint64_t unmatched=contour.unmatched+transition.unmatchedLoops+seamCaps.unmatchedLoops+ + after.boundaryEdges+after.nonManifoldEdges+ + ((expected&&transition.pairedLoops==0&&transition.cappedLoops==0&&seamCaps.cappedLoops==0)?1:0)+ + ((contour.maximum>LodContourErrorBudgetMeters||residual.maximum>LodContourErrorBudgetMeters)?1:0); + csv< xa{{x.position.x,x.position.y,x.position.z,x.normal.x,x.normal.y,x.normal.z}}; + const std::array ya{{y.position.x,y.position.y,y.position.z,y.normal.x,y.normal.y,y.normal.z}}; + for(std::size_t c=0;c(xa[c])!=std::bit_cast(ya[c]))return false; + } + for(std::size_t i=0;i(input)),std::istreambuf_iterator()); + std::string error; + auto manifest=VoxelForgeCore::WorldAssemblyManifest::FromJson(json,error); + if(!manifest){std::cerr<<"[NativeSurface] manifest rejected: "<plan.strates.begin(),manifest->plan.strates.end(), + [](const auto& item){return item.strateIndex==1;}); + if(stratum==manifest->plan.strates.end())return false; + const auto feature=std::find_if(stratum->features.begin(),stratum->features.end(), + [](const auto& item){return item.type==4;}); + if(feature==stratum->features.end())return false; + VoxelForgeCore::Heightfield2D heightfield(manifest->recipe,*stratum); + if(!heightfield.isValid())return false; + constexpr double SubvoxelsPerMeter=1024.0; + const double centerX=(feature->minimumSubvoxels[0]+feature->maximumSubvoxels[0])/(2.0*SubvoxelsPerMeter); + const double centerY=(feature->minimumSubvoxels[1]+feature->maximumSubvoxels[1])/(2.0*SubvoxelsPerMeter); + const double minFeatureZ=feature->minimumSubvoxels[2]/SubvoxelsPerMeter; + const double maxFeatureZ=feature->maximumSubvoxels[2]/SubvoxelsPerMeter; + const Field field(heightfield); + double previousZ=minFeatureZ-8.0,previousDensity=field.Eval({centerX,centerY,previousZ}).density; + double surfaceZ=(minFeatureZ+maxFeatureZ)*0.5;bool foundCrossing=false; + for(double z=previousZ+0.25;z<=maxFeatureZ+8.0;z+=0.25) + { + const double density=field.Eval({centerX,centerY,z}).density; + if((previousDensity<0.0)!=(density<0.0)) + {surfaceZ=(previousZ+z)*0.5;foundCrossing=true;break;} + previousZ=z;previousDensity=density; + } + if(!foundCrossing) + {std::cerr<<"[NativeSurface] no iso crossing at feature center x="<contentHash<stableId<type<<','< pieces{{Piece::Corner,Piece::Doorway,Piece::Step,Piece::Ledge,Piece::Organic}}; @@ -1147,21 +2189,31 @@ int main(int argc,char** argv) { std::filesystem::path outDir="Standalone/Research/WorldAssemblerM2/results"; int phaseCount=5; + bool seamsOnly=false; + bool nativeOnly=false; + bool analysisOnly=false; + int seamRowLimit=0; for(int i=1;i(std::chrono::steady_clock::now()-start).count(); std::cout<<"[SharpMesherSpike] complete seconds="<