diff --git a/vcell-core/src/test/java/cbit/vcell/geometry/GeometrySurfaceGolden.java b/vcell-core/src/test/java/cbit/vcell/geometry/GeometrySurfaceGolden.java new file mode 100644 index 0000000000..ef692204d1 --- /dev/null +++ b/vcell-core/src/test/java/cbit/vcell/geometry/GeometrySurfaceGolden.java @@ -0,0 +1,470 @@ +package cbit.vcell.geometry; + +import cbit.image.VCImage; +import cbit.image.VCImageUncompressed; +import cbit.vcell.geometry.surface.GeometricRegion; +import cbit.vcell.geometry.surface.GeometrySurfaceDescription; +import cbit.vcell.geometry.surface.Node; +import cbit.vcell.geometry.surface.Polygon; +import cbit.vcell.geometry.surface.Quadrilateral; +import cbit.vcell.geometry.surface.Surface; +import cbit.vcell.geometry.surface.SurfaceCollection; +import cbit.vcell.geometry.surface.SurfaceGeometricRegion; +import cbit.vcell.geometry.surface.VolumeGeometricRegion; +import cbit.vcell.parser.Expression; +import org.vcell.util.Extent; +import org.vcell.util.ISize; +import org.vcell.util.Origin; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Deterministic descriptions of what geometry surface generation produces, and the fixtures to + * generate them from. + * + * VCell's regression suites are math-generation centric and mostly non-spatial, so nothing pins the + * output of {@code RegionImage} and {@code SurfaceCollection}. Any change to region finding, + * surface tessellation, smoothing or membrane adjacency could alter every spatial model silently. + * These descriptions are the pin; {@code GeometrySurfaceRegressionTest} compares them against + * committed goldens. + * + * The description deliberately includes both halves of the picture: + * + * - what VCML's own {@code } records — sample size, cutoff, the volume and + * membrane regions with their sizes — because that is what a stored document carries; + * - and what it does NOT record: the mesh itself, the region-label map, membrane adjacency. That + * is where the algorithms actually live. + * + * On floating point: node coordinates are quantised to 1e-9 before hashing. Java arithmetic is + * IEEE-754 and strict since 17, so +-*\/ and sqrt reproduce across platforms, but quantising keeps a + * last-ulp difference from turning into a spurious golden mismatch while still being far tighter + * than any real algorithmic change. Aggregate figures (bounding box, total area) are printed in + * full so a human can see HOW a golden moved, not merely that it did. + * + * Regenerate goldens with {@code GeometrySurfaceRegressionTest.main}. + */ +public class GeometrySurfaceGolden { + + /** Quantum for coordinate hashing; see the class comment. */ + private static final double QUANTUM = 1e-9; + + // ------------------------------------------------------------------ fixtures + + /** name -> geometry supplier. LinkedHashMap so golden files are produced in a stable order. */ + public static Map fixtures() { + Map map = new LinkedHashMap<>(); + map.put("image2d_two_subvolumes", () -> { + // GeometryTest.getImageExample2D() does not build surfaces; without updateAll() this + // fixture records "regionImage none / surfaceCollection none" and pins nothing. The + // negative control caught that -- it was the one fixture that did not notice a + // deliberate node perturbation. + Geometry geometry = GeometryTest.getImageExample2D(); + geometry.getGeometrySurfaceDescription().updateAll(); + return geometry; + }); + map.put("image3d_nested_spheres", () -> nestedSpheres(32, 0.6)); + map.put("image3d_nested_spheres_smoothed", () -> nestedSpheres(32, 0.3)); + map.put("image3d_four_shells", () -> shells(24, 4)); + map.put("image2d_stripes", () -> stripes(48)); + map.put("analytic3d_sphere", () -> analyticSphere()); + return map; + } + + public interface GeometryFactory { + Geometry create() throws Exception; + } + + /** Two concentric spheres in a cube: three subvolumes, curved interfaces, nested regions. */ + private static Geometry nestedSpheres(int edge, double cutoff) throws Exception { + byte[] pixels = new byte[edge * edge * edge]; + double c = (edge - 1) / 2.0, rOuter = edge * 0.40, rInner = edge * 0.22; + int i = 0; + for (int z = 0; z < edge; z++) { + for (int y = 0; y < edge; y++) { + for (int x = 0; x < edge; x++, i++) { + double d = (x - c) * (x - c) + (y - c) * (y - c) + (z - c) * (z - c); + pixels[i] = (byte) (d < rInner * rInner ? 2 : (d < rOuter * rOuter ? 1 : 0)); + } + } + } + return imageGeometry("nested_spheres", pixels, edge, edge, edge, cutoff); + } + + /** Concentric shells: several subvolumes, so several distinct interfaces. */ + private static Geometry shells(int edge, int numShells) throws Exception { + byte[] pixels = new byte[edge * edge * edge]; + double c = (edge - 1) / 2.0, maxR = edge * 0.5; + int i = 0; + for (int z = 0; z < edge; z++) { + for (int y = 0; y < edge; y++) { + for (int x = 0; x < edge; x++, i++) { + double r = Math.sqrt((x - c) * (x - c) + (y - c) * (y - c) + (z - c) * (z - c)); + int band = (int) (Math.min(r, maxR - 1e-9) / maxR * numShells); + pixels[i] = (byte) Math.min(band, numShells - 1); + } + } + } + return imageGeometry("four_shells", pixels, edge, edge, edge, 0.6); + } + + /** Flat interfaces, and several disconnected regions of the same pixel value. */ + private static Geometry stripes(int edge) throws Exception { + byte[] pixels = new byte[edge * edge]; + for (int y = 0; y < edge; y++) { + for (int x = 0; x < edge; x++) { + pixels[x + edge * y] = (byte) ((x / 8) % 2); + } + } + return imageGeometry("stripes", pixels, edge, edge, 1, 0.6); + } + + private static Geometry imageGeometry(String name, byte[] pixels, int nx, int ny, int nz, + double cutoff) throws Exception { + VCImage image = new VCImageUncompressed(null, pixels, new Extent(1, 1, 1), nx, ny, nz); + Geometry geometry = new Geometry(name, image); + geometry.getGeometrySpec().setOrigin(new Origin(0, 0, 0)); + int sv = 0; + for (SubVolume subVolume : geometry.getGeometrySpec().getSubVolumes()) { + subVolume.setName("sv" + (sv++)); + } + geometry.getGeometrySurfaceDescription().setFilterCutoffFrequency(cutoff); + geometry.getGeometrySurfaceDescription().updateAll(); + return geometry; + } + + /** Not image based: exercises the analytic/CSG sampling path into RegionImage. */ + private static Geometry analyticSphere() throws Exception { + Geometry geometry = new Geometry("analytic_sphere", 3); + geometry.getGeometrySpec().setExtent(new Extent(1, 1, 1)); + geometry.getGeometrySpec().setOrigin(new Origin(0, 0, 0)); + // Handles must be set explicitly: the AnalyticSubVolume(name, expression) constructor + // leaves handle = -1, which GeometrySpec.vetoableChange rejects. + AnalyticSubVolume inside = new AnalyticSubVolume("inside", + new Expression("((x-0.5)^2 + (y-0.5)^2 + (z-0.5)^2) < 0.09")); + inside.setHandle(0); + AnalyticSubVolume outside = new AnalyticSubVolume("outside", new Expression("1.0")); + outside.setHandle(1); + geometry.getGeometrySpec().setSubVolumes(new SubVolume[]{inside, outside}); + geometry.getGeometrySurfaceDescription().setVolumeSampleSize(new ISize(24, 24, 24)); + geometry.getGeometrySurfaceDescription().setFilterCutoffFrequency(0.6); + geometry.getGeometrySurfaceDescription().updateAll(); + return geometry; + } + + // ------------------------------------------------------------------ description + + public static String describe(Geometry geometry) throws Exception { + StringBuilder out = new StringBuilder(); + GeometrySpec spec = geometry.getGeometrySpec(); + GeometrySurfaceDescription gsd = geometry.getGeometrySurfaceDescription(); + + line(out, "geometry", geometry.getName(), "dimension=" + geometry.getDimension()); + line(out, "extent", fmt(spec.getExtent().getX()), fmt(spec.getExtent().getY()), + fmt(spec.getExtent().getZ())); + line(out, "origin", fmt(spec.getOrigin().getX()), fmt(spec.getOrigin().getY()), + fmt(spec.getOrigin().getZ())); + + VCImage image = spec.getImage(); + if (image == null) { + line(out, "image", "none"); + } else { + line(out, "image", image.getNumX() + "x" + image.getNumY() + "x" + image.getNumZ(), + "pixelClasses=" + image.getNumPixelClasses(), + "pixelsSHA=" + sha(image.getPixels())); + } + + SubVolume[] subVolumes = spec.getSubVolumes(); + line(out, "subVolumes", String.valueOf(subVolumes.length)); + String[] svNames = new String[subVolumes.length]; + for (int i = 0; i < subVolumes.length; i++) { + svNames[i] = subVolumes[i].getName() + "(handle=" + subVolumes[i].getHandle() + ")"; + } + Arrays.sort(svNames); + for (String s : svNames) { + line(out, " subVolume", s); + } + + ISize sample = gsd.getVolumeSampleSize(); + line(out, "sampleSize", sample.getX() + "x" + sample.getY() + "x" + sample.getZ()); + line(out, "cutoffFrequency", fmt(gsd.getFilterCutoffFrequency())); + + describeRegionImage(out, gsd); + describeSurfaces(out, gsd); + describeGeometricRegions(out, gsd); + describeSurfaceClasses(out, gsd); + return out.toString(); + } + + private static void describeRegionImage(StringBuilder out, GeometrySurfaceDescription gsd) + throws Exception { + RegionImage regionImage = gsd.getRegionImage(); + if (regionImage == null) { + line(out, "regionImage", "none"); + return; + } + RegionImage.RegionInfo[] infos = regionImage.getRegionInfos(); + line(out, "regionImage", "regions=" + regionImage.getNumRegions(), + "dims=" + regionImage.getNumX() + "x" + regionImage.getNumY() + "x" + regionImage.getNumZ()); + + // A self-consistency check rather than a pin: every pixel must belong to exactly one + // region, so the region sizes must account for the whole volume. A golden can only ever + // say "this is what it did"; this line says whether what it did is coherent. + long pixelSum = 0; + for (RegionImage.RegionInfo info : infos) { + pixelSum += info.getNumPixels(); + } + long totalPixels = (long) regionImage.getNumX() * regionImage.getNumY() * regionImage.getNumZ(); + line(out, "regionImage.pixelPartition", + "sumOfRegions=" + pixelSum, "totalPixels=" + totalPixels, + "complete=" + (pixelSum == totalPixels)); + + // sorted by regionIndex, which the implementation already assigns densely + List rows = new ArrayList<>(); + for (RegionImage.RegionInfo info : infos) { + rows.add(String.format(Locale.ROOT, " region index=%d pixelValue=%d numPixels=%d", + info.getRegionIndex(), info.getPixelValue(), info.getNumPixels())); + } + rows.sort(null); + for (String r : rows) { + out.append(r).append('\n'); + } + // The per-pixel region assignment, which nothing else here would notice changing. + line(out, "regionImage.encodedRegionIndexSHA", + sha(regionImage.getShortEncodedRegionIndexImage())); + } + + private static void describeSurfaces(StringBuilder out, GeometrySurfaceDescription gsd) { + SurfaceCollection surfaces = gsd.getSurfaceCollection(); + if (surfaces == null) { + line(out, "surfaceCollection", "none"); + return; + } + line(out, "surfaceCollection", "surfaces=" + surfaces.getSurfaceCount(), + "nodes=" + surfaces.getNodeCount()); + + double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE, minZ = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE, maxZ = -Double.MAX_VALUE; + StringBuilder nodeText = new StringBuilder(); + for (int i = 0; i < surfaces.getNodeCount(); i++) { + Node n = surfaces.getNodes(i); + minX = Math.min(minX, n.getX()); + maxX = Math.max(maxX, n.getX()); + minY = Math.min(minY, n.getY()); + maxY = Math.max(maxY, n.getY()); + minZ = Math.min(minZ, n.getZ()); + maxZ = Math.max(maxZ, n.getZ()); + nodeText.append(quant(n.getX())).append(',') + .append(quant(n.getY())).append(',') + .append(quant(n.getZ())).append(';'); + } + if (surfaces.getNodeCount() > 0) { + line(out, " nodeBounds", + "x=[" + fmt(minX) + "," + fmt(maxX) + "]", + "y=[" + fmt(minY) + "," + fmt(maxY) + "]", + "z=[" + fmt(minZ) + "," + fmt(maxZ) + "]"); + } + line(out, " nodeCoordsSHA", sha(nodeText.toString().getBytes(StandardCharsets.UTF_8))); + + double totalArea = 0; + StringBuilder polyText = new StringBuilder(); + // Which two volume elements each membrane quad separates. This is the region adjacency the + // mesh itself carries, and it is NOT implied by the node indices: a change that kept every + // polygon in place but re-assigned its volume neighbours would otherwise pass unnoticed. + StringBuilder volNeighbourText = new StringBuilder(); + for (int s = 0; s < surfaces.getSurfaceCount(); s++) { + Surface surface = surfaces.getSurfaces(s); + double area = 0; + for (int p = 0; p < surface.getPolygonCount(); p++) { + Polygon polygon = surface.getPolygons(p); + area += polygon.getArea(); + for (Node n : polygon.getNodes()) { + polyText.append(n.getGlobalIndex()).append(','); + } + polyText.append(';'); + if (polygon instanceof Quadrilateral) { + Quadrilateral quad = (Quadrilateral) polygon; + volNeighbourText.append(quad.getVolIndexNeighbor1()).append(':') + .append(quad.getVolIndexNeighbor2()).append(';'); + } + } + totalArea += area; + line(out, " surface[" + s + "]", + "interiorRegion=" + surface.getInteriorRegionIndex(), + "exteriorRegion=" + surface.getExteriorRegionIndex(), + "polygons=" + surface.getPolygonCount(), + "area=" + fmt(area)); + } + line(out, " polygonNodeIndicesSHA", sha(polyText.toString().getBytes(StandardCharsets.UTF_8))); + line(out, " polygonVolumeNeighborsSHA", + sha(volNeighbourText.toString().getBytes(StandardCharsets.UTF_8))); + line(out, " totalArea", fmt(totalArea)); + describeMembraneEdgeNeighbors(out, surfaces); + } + + /** + * The membrane connectivity graph: which membrane element abuts which, along which edge. + * + * Solvers use this for membrane diffusion, and RegionImage builds it in its own pass + * (calculateNeighbors) from an edge map. None of it is implied by the polygon list, so without + * this a change to adjacency would leave every other line of the golden untouched. + */ + private static void describeMembraneEdgeNeighbors(StringBuilder out, SurfaceCollection surfaces) { + ArrayList[][] neighbors = surfaces.getMembraneEdgeNeighbors(); + if (neighbors == null) { + line(out, " membraneEdgeNeighbors", "none"); + return; + } + long total = 0; + StringBuilder text = new StringBuilder(); + for (int s = 0; s < neighbors.length; s++) { + if (neighbors[s] == null) { + continue; + } + for (int p = 0; p < neighbors[s].length; p++) { + List list = neighbors[s][p]; + if (list == null) { + continue; + } + total += list.size(); + // Sorted: the adjacency SET is the meaningful thing, and the order the edge map + // happens to yield it in is not something worth pinning. + List entries = new ArrayList<>(); + for (RegionImage.MembraneEdgeNeighbor n : list) { + RegionImage.MembraneElementIdentifier id = n.getMembraneElementIdentifier(); + entries.add(String.format(Locale.ROOT, "%d/%d/%d-%d->%s", + s, p, n.edgeBaseNodeIndex, n.edgeOtherNodeIndex, + id == null ? "null" + : id.surfaceIndex + "." + id.nonMasterPolygonIndex + "." + + id.planePerpendicularToAxis)); + } + entries.sort(null); + for (String e : entries) { + text.append(e).append(';'); + } + } + } + line(out, " membraneEdgeNeighbors", "total=" + total, + "SHA=" + sha(text.toString().getBytes(StandardCharsets.UTF_8))); + } + + private static void describeGeometricRegions(StringBuilder out, GeometrySurfaceDescription gsd) { + GeometricRegion[] regions = gsd.getGeometricRegions(); + if (regions == null) { + line(out, "geometricRegions", "none"); + return; + } + line(out, "geometricRegions", String.valueOf(regions.length)); + List rows = new ArrayList<>(); + for (GeometricRegion region : regions) { + List adjacent = new ArrayList<>(); + GeometricRegion[] adj = region.getAdjacentGeometricRegions(); + if (adj != null) { + for (GeometricRegion a : adj) { + adjacent.add(a.getName()); + } + } + adjacent.sort(null); + String type = region instanceof SurfaceGeometricRegion ? "surface" + : (region instanceof VolumeGeometricRegion ? "volume" : "other"); + rows.add(String.format(Locale.ROOT, " %s %s size=%s adjacent=%s", + type, region.getName(), fmt(region.getSize()), adjacent)); + } + rows.sort(null); + for (String r : rows) { + out.append(r).append('\n'); + } + } + + private static void describeSurfaceClasses(StringBuilder out, GeometrySurfaceDescription gsd) { + SurfaceClass[] classes = gsd.getSurfaceClasses(); + if (classes == null) { + line(out, "surfaceClasses", "none"); + return; + } + line(out, "surfaceClasses", String.valueOf(classes.length)); + List rows = new ArrayList<>(); + for (SurfaceClass surfaceClass : classes) { + List adjacent = new ArrayList<>(); + for (SubVolume sv : surfaceClass.getAdjacentSubvolumes()) { + adjacent.add(sv.getName()); + } + adjacent.sort(null); + rows.add(" " + surfaceClass.getName() + " adjacent=" + adjacent); + } + rows.sort(null); + for (String r : rows) { + out.append(r).append('\n'); + } + } + + // ------------------------------------------------------------------ helpers + + private static void line(StringBuilder out, String key, String... values) { + out.append(key); + for (String v : values) { + out.append(' ').append(v); + } + out.append('\n'); + } + + /** Fixed-form, locale-independent, and never scientific notation for readability. */ + private static String fmt(double v) { + if (Double.isNaN(v)) return "NaN"; + if (Double.isInfinite(v)) return v > 0 ? "Inf" : "-Inf"; + return String.format(Locale.ROOT, "%.9f", v); + } + + private static String fmt(Double v) { + return v == null ? "null" : fmt(v.doubleValue()); + } + + /** Quantise before hashing so a last-ulp difference cannot flip a golden. */ + private static String quant(double v) { + return String.format(Locale.ROOT, "%.0f", Math.rint(v / QUANTUM)); + } + + static String sha(byte[] data) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] d = md.digest(data); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 12; i++) { + sb.append(String.format("%02x", d[i])); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static Path goldenDir() { + return Path.of("src", "test", "resources", "cbit", "vcell", "geometry", "surface-golden"); + } + + public static Path goldenPath(String fixture) { + return goldenDir().resolve(fixture + ".txt"); + } + + /** Writes every fixture's description to the golden directory. */ + public static void writeGoldens() throws Exception { + Files.createDirectories(goldenDir()); + for (Map.Entry e : fixtures().entrySet()) { + String text = describe(e.getValue().create()); + Files.writeString(goldenPath(e.getKey()), text, StandardCharsets.UTF_8); + System.out.printf("wrote %-38s %d bytes%n", e.getKey(), text.length()); + } + } + + public static void main(String[] args) throws Exception { + writeGoldens(); + } +} diff --git a/vcell-core/src/test/java/cbit/vcell/geometry/GeometrySurfaceRegressionTest.java b/vcell-core/src/test/java/cbit/vcell/geometry/GeometrySurfaceRegressionTest.java new file mode 100644 index 0000000000..1c6fc21c03 --- /dev/null +++ b/vcell-core/src/test/java/cbit/vcell/geometry/GeometrySurfaceRegressionTest.java @@ -0,0 +1,109 @@ +package cbit.vcell.geometry; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pins the output of geometry surface generation against committed goldens. + * + * Why this exists: VCell's regression suites are math-generation centric and mostly non-spatial, so + * a change to region finding, surface tessellation, Taubin smoothing or membrane adjacency could + * alter every spatial model without a single test noticing. The goldens were generated from + * pre-merge master ({@code f35beaddcd}) — the behaviour that was deployed before the #2026 / + * #2027 memory work — so they record the old behaviour rather than blessing the new one. + * + * What a failure means: surface generation now produces a different answer than the deployed + * implementation did. That is not automatically wrong — an intentional improvement will fail these + * too — but it must be a decision, with the golden updated deliberately and the diff reviewed. + * + * Regenerating (only after deciding the new output is correct): + *
+ *   mvn -q -pl vcell-core exec:java -Dexec.classpathScope=test \
+ *       -Dexec.mainClass=cbit.vcell.geometry.GeometrySurfaceGolden
+ * 
+ * then read the diff before committing it. See {@link GeometrySurfaceGolden} for what is captured + * and how floating point is handled. + */ +@Tag("Fast") +public class GeometrySurfaceRegressionTest { + + private static final String RESOURCE_DIR = "/cbit/vcell/geometry/surface-golden/"; + + @TestFactory + public List surfaceDescriptionsMatchTheDeployedImplementation() { + List tests = new ArrayList<>(); + for (Map.Entry entry + : GeometrySurfaceGolden.fixtures().entrySet()) { + String fixture = entry.getKey(); + tests.add(DynamicTest.dynamicTest(fixture, () -> { + String expected = readGolden(fixture); + String actual = GeometrySurfaceGolden.describe(entry.getValue().create()); + assertEquals(expected, actual, () -> describeDifference(fixture, expected, actual)); + })); + } + return tests; + } + + /** + * Guards the guard. If a fixture is added without a golden, or a golden goes missing, the + * factory above would simply produce fewer tests and the suite would still be green. + */ + @Test + public void everyFixtureHasAGolden() { + List missing = new ArrayList<>(); + for (String fixture : GeometrySurfaceGolden.fixtures().keySet()) { + if (GeometrySurfaceRegressionTest.class.getResourceAsStream(RESOURCE_DIR + fixture + ".txt") == null) { + missing.add(fixture); + } + } + assertTrue(missing.isEmpty(), + "fixtures with no committed golden (run GeometrySurfaceGolden.main): " + missing); + assertFalse(GeometrySurfaceGolden.fixtures().isEmpty(), "there must be fixtures to compare"); + } + + private static String readGolden(String fixture) throws Exception { + try (InputStream in = GeometrySurfaceRegressionTest.class + .getResourceAsStream(RESOURCE_DIR + fixture + ".txt")) { + assertNotNull(in, "no golden for fixture '" + fixture + "'"); + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** + * A readable report. assertEquals on two multi-line blocks prints both in full and leaves the + * reader to find the difference; surface descriptions are long enough that this matters. + */ + private static String describeDifference(String fixture, String expected, String actual) { + String[] want = expected.split("\n", -1); + String[] got = actual.split("\n", -1); + StringBuilder sb = new StringBuilder(); + sb.append("geometry surface output changed for fixture '").append(fixture).append("'.\n"); + sb.append("This is a change against pre-merge master f35beaddcd. If it is intentional, ") + .append("regenerate the golden with GeometrySurfaceGolden.main and review the diff.\n"); + int shown = 0; + for (int i = 0; i < Math.max(want.length, got.length) && shown < 12; i++) { + String w = i < want.length ? want[i] : ""; + String g = i < got.length ? got[i] : ""; + if (!w.equals(g)) { + sb.append(" line ").append(i + 1).append('\n') + .append(" golden: ").append(w).append('\n') + .append(" actual: ").append(g).append('\n'); + shown++; + } + } + if (shown == 0) { + sb.append(" (no line differs — trailing whitespace or line endings?)\n"); + } + return sb.toString(); + } +} diff --git a/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/README.md b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/README.md new file mode 100644 index 0000000000..d690d8c291 --- /dev/null +++ b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/README.md @@ -0,0 +1,85 @@ +# Geometry surface goldens + +Each `.txt` here is a deterministic description of what geometry surface generation produced for one +fixture. `GeometrySurfaceRegressionTest` compares them; `GeometrySurfaceGolden` produces them and +defines the fixtures. + +They exist because VCell's regression suites are math-generation centric and mostly non-spatial. +Nothing else pins `RegionImage` or `SurfaceCollection`, so a change to region finding, surface +tessellation, Taubin smoothing or membrane adjacency could alter every spatial model silently. + +## Where these came from, and why it matters + +**Generated on `f35beaddcd`** — master as it stood before the #2026 / #2027 memory work. They record +the behaviour that was already deployed. They do **not** bless whatever the current code happens to +do. + +That distinction is the whole value of the suite, and it is easy to lose. A golden generated by the +same code it is meant to check proves nothing: it just photographs the current answer, including any +regression already present. + +## Adding fixtures later — branch from the golden base + +**`test/geometry-goldens-base`** exists for this. It is anchored at `f35beaddcd` and carries this +suite, so it is a standing checkout of the old implementation with the harness already on it. Branch +from it, never from current master: + +```bash +# 1. branch from the golden base, which still runs the pre-change implementation +git checkout -b test/geometry-more-fixtures test/geometry-goldens-base + +# 2. add fixtures to GeometrySurfaceGolden.fixtures(), then generate goldens +# HERE, with the old implementation +mvn -q -pl vcell-core exec:java -Dexec.classpathScope=test \ + -Dexec.mainClass=cbit.vcell.geometry.GeometrySurfaceGolden +mvn -o surefire:test -pl vcell-core -Dtest=GeometrySurfaceRegressionTest # must pass here +git commit -am "more geometry fixtures + goldens from the pre-change implementation" + +# 3. NOW bring current master in. This merge is the experiment. +git merge master +mvn -o surefire:test -pl vcell-core -Dtest=GeometrySurfaceRegressionTest +``` + +If step 3 passes, the new fixtures confirm current master matches the older behaviour. If it fails, +the suite has found a real difference and the diff says exactly which quantity moved. + +Then cherry-pick the fixture-and-golden commit onto a branch off master and open the PR from +there: + +```bash +git checkout -b test/geometry-more-fixtures-pr origin/master +git cherry-pick +``` + +Cherry-picking rather than merging keeps `test/geometry-goldens-base` pinned at the old +implementation instead of dragging master into it, so it stays usable as a generation base for the +next round. + +## When a golden legitimately changes + +An intentional improvement will fail these tests — that is correct, not a nuisance. Regenerate +deliberately: + +```bash +mvn -q -pl vcell-core exec:java -Dexec.classpathScope=test \ + -Dexec.mainClass=cbit.vcell.geometry.GeometrySurfaceGolden +git diff -- vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden +``` + +**Read the diff before committing it.** The files are plain text and the numbers are physically +checkable, which is the point — for the nested spheres, subvolume sizes sum to exactly 1.0 (the unit +cube), and the smoothed sphere area of 2.100 is close to the analytic 4πr² = 2.011 while the +unsmoothed staircase value is 3.272. A change that moves a surface area away from its analytic value +is a bug report, not a golden update. + +## What is captured + +- what VCML's own `` carries: sample size, cutoff frequency, volume and membrane + regions with their sizes and adjacency; +- and what it does not: the region-label map, the mesh (node count, quantised coordinate digest, + polygon node-index digest, per-surface polygon counts and areas), and the surface classes. + +Floating point: node coordinates are quantised to 1e-9 before hashing, so a last-ulp difference +cannot flip a golden while a real change still does — verified by perturbing the node origin by +~3e-9, which fails every fixture. Aggregate figures (bounding box, per-surface and total area) are +printed at full precision so a reviewer can see *how* a golden moved, not merely that it did. diff --git a/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/analytic3d_sphere.txt b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/analytic3d_sphere.txt new file mode 100644 index 0000000000..f2813d9722 --- /dev/null +++ b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/analytic3d_sphere.txt @@ -0,0 +1,28 @@ +geometry analytic_sphere dimension=3 +extent 1.000000000 1.000000000 1.000000000 +origin 0.000000000 0.000000000 0.000000000 +image none +subVolumes 2 + subVolume inside(handle=0) + subVolume outside(handle=1) +sampleSize 24x24x24 +cutoffFrequency 0.600000000 +regionImage regions=2 dims=24x24x24 +regionImage.pixelPartition sumOfRegions=13824 totalPixels=13824 complete=true + region index=0 pixelValue=1 numPixels=12448 + region index=1 pixelValue=0 numPixels=1376 +regionImage.encodedRegionIndexSHA e36b2ed0fa1e8f347e3b7e61 +surfaceCollection surfaces=1 nodes=890 + nodeBounds x=[0.195652174,0.804347826] y=[0.195652174,0.804347826] z=[0.195652174,0.804347826] + nodeCoordsSHA 80c718e6f08c532f929a1cca + surface[0] interiorRegion=0 exteriorRegion=1 polygons=888 area=1.678638941 + polygonNodeIndicesSHA 60f0a5fd12528abc7e67a2c9 + polygonVolumeNeighborsSHA c2439cb259bb025495adc0a2 + totalArea 1.678638941 + membraneEdgeNeighbors total=3552 SHA=572f8d13b9334e41e8133081 +geometricRegions 3 + surface membrane_outside0_inside1 size=1.678638941 adjacent=[inside1, outside0] + volume inside1 size=0.113092792 adjacent=[membrane_outside0_inside1] + volume outside0 size=0.886907208 adjacent=[membrane_outside0_inside1] +surfaceClasses 1 + inside_outside_membrane adjacent=[inside, outside] diff --git a/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image2d_stripes.txt b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image2d_stripes.txt new file mode 100644 index 0000000000..7759dcda98 --- /dev/null +++ b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image2d_stripes.txt @@ -0,0 +1,44 @@ +geometry stripes dimension=2 +extent 1.000000000 1.000000000 1.000000000 +origin 0.000000000 0.000000000 0.000000000 +image 48x48x1 pixelClasses=2 pixelsSHA=ead7676c3957ff75a1cf4427 +subVolumes 2 + subVolume sv0(handle=0) + subVolume sv1(handle=1) +sampleSize 48x48x1 +cutoffFrequency 0.600000000 +regionImage regions=6 dims=48x48x1 +regionImage.pixelPartition sumOfRegions=2304 totalPixels=2304 complete=true + region index=0 pixelValue=0 numPixels=384 + region index=1 pixelValue=1 numPixels=384 + region index=2 pixelValue=0 numPixels=384 + region index=3 pixelValue=1 numPixels=384 + region index=4 pixelValue=0 numPixels=384 + region index=5 pixelValue=1 numPixels=384 +regionImage.encodedRegionIndexSHA 6bf8a6c0043ad9fdc6314810 +surfaceCollection surfaces=5 nodes=490 + nodeBounds x=[0.159574468,0.840425532] y=[0.000000000,1.000000000] z=[0.000000000,1.000000000] + nodeCoordsSHA 707b60085919b98b4aa3010a + surface[0] interiorRegion=0 exteriorRegion=1 polygons=48 area=1.000000000 + surface[1] interiorRegion=1 exteriorRegion=2 polygons=48 area=1.000000000 + surface[2] interiorRegion=2 exteriorRegion=3 polygons=48 area=1.000000000 + surface[3] interiorRegion=3 exteriorRegion=4 polygons=48 area=1.000000000 + surface[4] interiorRegion=4 exteriorRegion=5 polygons=48 area=1.000000000 + polygonNodeIndicesSHA 3b342984907dd091edeb25d6 + polygonVolumeNeighborsSHA 3905179d58a75905667120d1 + totalArea 5.000000000 + membraneEdgeNeighbors total=960 SHA=220b176b1b8208b61eca1f48 +geometricRegions 11 + surface membrane_sv00_sv11 size=1.000000000 adjacent=[sv00, sv11] + surface membrane_sv02_sv13 size=1.000000000 adjacent=[sv02, sv13] + surface membrane_sv04_sv15 size=1.000000000 adjacent=[sv04, sv15] + surface membrane_sv11_sv02 size=1.000000000 adjacent=[sv02, sv11] + surface membrane_sv13_sv04 size=1.000000000 adjacent=[sv04, sv13] + volume sv00 size=0.159574468 adjacent=[membrane_sv00_sv11] + volume sv02 size=0.170212766 adjacent=[membrane_sv02_sv13, membrane_sv11_sv02] + volume sv04 size=0.170212766 adjacent=[membrane_sv04_sv15, membrane_sv13_sv04] + volume sv11 size=0.170212766 adjacent=[membrane_sv00_sv11, membrane_sv11_sv02] + volume sv13 size=0.170212766 adjacent=[membrane_sv02_sv13, membrane_sv13_sv04] + volume sv15 size=0.159574468 adjacent=[membrane_sv04_sv15] +surfaceClasses 1 + sv0_sv1_membrane adjacent=[sv0, sv1] diff --git a/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image2d_two_subvolumes.txt b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image2d_two_subvolumes.txt new file mode 100644 index 0000000000..da9e957925 --- /dev/null +++ b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image2d_two_subvolumes.txt @@ -0,0 +1,28 @@ +geometry getImageExample() dimension=2 +extent 10.000000000 10.000000000 1.000000000 +origin -5.000000000 -5.000000000 -5.000000000 +image 100x100x1 pixelClasses=2 pixelsSHA=4ffef933700bbece710f0271 +subVolumes 2 + subVolume cytosol(handle=1) + subVolume ec(handle=0) +sampleSize 100x100x1 +cutoffFrequency 0.300000000 +regionImage regions=2 dims=100x100x1 +regionImage.pixelPartition sumOfRegions=10000 totalPixels=10000 complete=true + region index=0 pixelValue=0 numPixels=5100 + region index=1 pixelValue=1 numPixels=4900 +regionImage.encodedRegionIndexSHA 318883260e11af650c232a68 +surfaceCollection surfaces=1 nodes=202 + nodeBounds x=[0.101010101,0.101010101] y=[-5.000000000,5.000000000] z=[-5.000000000,-4.000000000] + nodeCoordsSHA 9c58406fc98754b7d60eecef + surface[0] interiorRegion=0 exteriorRegion=1 polygons=100 area=10.000000000 + polygonNodeIndicesSHA 3fd21f4c2560b06bfd6edae7 + polygonVolumeNeighborsSHA ad0be0c96bddd90b79391a15 + totalArea 10.000000000 + membraneEdgeNeighbors total=400 SHA=91131fc70c55ba4304546308 +geometricRegions 3 + surface membrane_ec0_cytosol1 size=10.000000000 adjacent=[cytosol1, ec0] + volume cytosol1 size=48.989898990 adjacent=[membrane_ec0_cytosol1] + volume ec0 size=51.010101010 adjacent=[membrane_ec0_cytosol1] +surfaceClasses 1 + cytosol_ec_membrane adjacent=[cytosol, ec] diff --git a/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image3d_four_shells.txt b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image3d_four_shells.txt new file mode 100644 index 0000000000..eae9b75347 --- /dev/null +++ b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image3d_four_shells.txt @@ -0,0 +1,40 @@ +geometry four_shells dimension=3 +extent 1.000000000 1.000000000 1.000000000 +origin 0.000000000 0.000000000 0.000000000 +image 24x24x24 pixelClasses=4 pixelsSHA=1cd858149324189245432594 +subVolumes 4 + subVolume sv0(handle=0) + subVolume sv1(handle=1) + subVolume sv2(handle=2) + subVolume sv3(handle=3) +sampleSize 24x24x24 +cutoffFrequency 0.600000000 +regionImage regions=4 dims=24x24x24 +regionImage.pixelPartition sumOfRegions=13824 totalPixels=13824 complete=true + region index=0 pixelValue=0 numPixels=10712 + region index=1 pixelValue=1 numPixels=2200 + region index=2 pixelValue=2 numPixels=776 + region index=3 pixelValue=3 numPixels=136 +regionImage.encodedRegionIndexSHA 78227dcd08be8851eac9bd1d +surfaceCollection surfaces=3 nodes=2406 + nodeBounds x=[0.108695652,0.891304348] y=[0.108695652,0.891304348] z=[0.108695652,0.891304348] + nodeCoordsSHA 1c392601d61c1393e628cfcc + surface[0] interiorRegion=0 exteriorRegion=1 polygons=1536 area=2.903591682 + surface[1] interiorRegion=1 exteriorRegion=2 polygons=672 area=1.270321361 + surface[2] interiorRegion=2 exteriorRegion=3 polygons=192 area=0.362948960 + polygonNodeIndicesSHA 076d7c92b816796302497b33 + polygonVolumeNeighborsSHA e46494f5dac1c2e984cf9afd + totalArea 4.536862004 + membraneEdgeNeighbors total=9600 SHA=b3c8ab3063190be41ee71349 +geometricRegions 7 + surface membrane_sv00_sv11 size=2.903591682 adjacent=[sv00, sv11] + surface membrane_sv11_sv22 size=1.270321361 adjacent=[sv11, sv22] + surface membrane_sv22_sv33 size=0.362948960 adjacent=[sv22, sv33] + volume sv00 size=0.744226186 adjacent=[membrane_sv00_sv11] + volume sv11 size=0.180816964 adjacent=[membrane_sv00_sv11, membrane_sv11_sv22] + volume sv22 size=0.063779075 adjacent=[membrane_sv11_sv22, membrane_sv22_sv33] + volume sv33 size=0.011177776 adjacent=[membrane_sv22_sv33] +surfaceClasses 3 + sv0_sv1_membrane adjacent=[sv0, sv1] + sv1_sv2_membrane adjacent=[sv1, sv2] + sv2_sv3_membrane adjacent=[sv2, sv3] diff --git a/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image3d_nested_spheres.txt b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image3d_nested_spheres.txt new file mode 100644 index 0000000000..99aee996be --- /dev/null +++ b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image3d_nested_spheres.txt @@ -0,0 +1,34 @@ +geometry nested_spheres dimension=3 +extent 1.000000000 1.000000000 1.000000000 +origin 0.000000000 0.000000000 0.000000000 +image 32x32x32 pixelClasses=3 pixelsSHA=4274e9387b06b21d167545c7 +subVolumes 3 + subVolume sv0(handle=0) + subVolume sv1(handle=1) + subVolume sv2(handle=2) +sampleSize 32x32x32 +cutoffFrequency 0.600000000 +regionImage regions=3 dims=32x32x32 +regionImage.pixelPartition sumOfRegions=32768 totalPixels=32768 complete=true + region index=0 pixelValue=0 numPixels=24024 + region index=1 pixelValue=1 numPixels=7272 + region index=2 pixelValue=2 numPixels=1472 +regionImage.encodedRegionIndexSHA 5bc769104026854750567df3 +surfaceCollection surfaces=2 nodes=4084 + nodeBounds x=[0.080645161,0.919354839] y=[0.080645161,0.919354839] z=[0.080645161,0.919354839] + nodeCoordsSHA 805dc1773900a860552cadc9 + surface[0] interiorRegion=0 exteriorRegion=1 polygons=3144 area=3.271592092 + surface[1] interiorRegion=1 exteriorRegion=2 polygons=936 area=0.973985432 + polygonNodeIndicesSHA dfe85130e97edec42004c6ef + polygonVolumeNeighborsSHA 0d026ac161c086ca1ee5a362 + totalArea 4.245577523 + membraneEdgeNeighbors total=16320 SHA=a47e4a57c3cd17224f6f71ca +geometricRegions 5 + surface membrane_sv00_sv11 size=3.271592092 adjacent=[sv00, sv11] + surface membrane_sv11_sv22 size=0.973985432 adjacent=[sv11, sv22] + volume sv00 size=0.706488537 adjacent=[membrane_sv00_sv11] + volume sv11 size=0.244100567 adjacent=[membrane_sv00_sv11, membrane_sv11_sv22] + volume sv22 size=0.049410896 adjacent=[membrane_sv11_sv22] +surfaceClasses 2 + sv0_sv1_membrane adjacent=[sv0, sv1] + sv1_sv2_membrane adjacent=[sv1, sv2] diff --git a/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image3d_nested_spheres_smoothed.txt b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image3d_nested_spheres_smoothed.txt new file mode 100644 index 0000000000..ac34b5eb0a --- /dev/null +++ b/vcell-core/src/test/resources/cbit/vcell/geometry/surface-golden/image3d_nested_spheres_smoothed.txt @@ -0,0 +1,34 @@ +geometry nested_spheres dimension=3 +extent 1.000000000 1.000000000 1.000000000 +origin 0.000000000 0.000000000 0.000000000 +image 32x32x32 pixelClasses=3 pixelsSHA=4274e9387b06b21d167545c7 +subVolumes 3 + subVolume sv0(handle=0) + subVolume sv1(handle=1) + subVolume sv2(handle=2) +sampleSize 32x32x32 +cutoffFrequency 0.300000000 +regionImage regions=3 dims=32x32x32 +regionImage.pixelPartition sumOfRegions=32768 totalPixels=32768 complete=true + region index=0 pixelValue=0 numPixels=24024 + region index=1 pixelValue=1 numPixels=7272 + region index=2 pixelValue=2 numPixels=1472 +regionImage.encodedRegionIndexSHA 5bc769104026854750567df3 +surfaceCollection surfaces=2 nodes=4084 + nodeBounds x=[0.084051409,0.915948591] y=[0.084051409,0.915948591] z=[0.084051409,0.915948591] + nodeCoordsSHA 1f61174ec5d2c570e4b1ba33 + surface[0] interiorRegion=0 exteriorRegion=1 polygons=3144 area=2.100087610 + surface[1] interiorRegion=1 exteriorRegion=2 polygons=936 area=0.613390859 + polygonNodeIndicesSHA dfe85130e97edec42004c6ef + polygonVolumeNeighborsSHA 0d026ac161c086ca1ee5a362 + totalArea 2.713478469 + membraneEdgeNeighbors total=16320 SHA=a47e4a57c3cd17224f6f71ca +geometricRegions 5 + surface membrane_sv00_sv11 size=2.100087610 adjacent=[sv00, sv11] + surface membrane_sv11_sv22 size=0.613390859 adjacent=[sv11, sv22] + volume sv00 size=0.706488537 adjacent=[membrane_sv00_sv11] + volume sv11 size=0.244100567 adjacent=[membrane_sv00_sv11, membrane_sv11_sv22] + volume sv22 size=0.049410896 adjacent=[membrane_sv11_sv22] +surfaceClasses 2 + sv0_sv1_membrane adjacent=[sv0, sv1] + sv1_sv2_membrane adjacent=[sv1, sv2]