diff --git a/README.md b/README.md index a6bb50e..570aa84 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,18 @@ le relief et les souterrains de la 24, retire la montgolfière et travaille les marais souterrains et la restauration de vrais modèles d’épaves vanilla. Voir le [chantier et ses limites](docs/generation-alpha27.md). +## Alpha.30.5 — plateaux et atlas du terrain + +L’étirement vertical de la 30.4 est retiré. Les plateaux issus de la 24/27 +reçoivent une déformation 3D modérée dans de larges zones ; aucune hauteur +sommitale n’est imposée. Grottes, rivières courbes, brèches de cascade et +infrastructures sont conservées. Le plafond constructible reste Y640. + +La comparaison devient reproductible : cartes de relief, épaisseur rocheuse +et coupes de densité des quatre champs, aux mêmes coordonnées et graines. +Voir [le terrain](docs/generation-alpha30.5.md), [l’outil d’atlas](docs/terrain-atlas.md) +et [les vérifications](docs/testing-alpha30.5.md). **Nouveau monde requis.** + ## Alpha.30.4 — massifs et ciel constructible Les plateaux bas sont conservés ; les reliefs hauts se raccordent par des diff --git a/docs/backlog.md b/docs/backlog.md index 4e30288..db4fd3d 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -1037,3 +1037,13 @@ courbes sur les replats supérieurs et libérer de l’espace constructible jusq Y639. Stabiliser un seul relief avant d’ajouter des personnalisations. L’arène du destin reste une idée, sans structure ajoutée. Voir [la génération](generation-alpha30.4.md) et [les essais](testing-alpha30.4.md). + +### WG-37 — Plateaux, relief 3D modéré et atlas — alpha.30.5 + +Branche `codex/plateau-atlas-alpha30-5`. Retirer l’étirement des coques de la +30.4 ; repartir des plateaux avec une déformation du bruit natif, sans pousser +les sommets vers Y360. Conserver les grottes, rivières courbes, sorties d’eau, +secrets et traversées. Comparer les champs natifs 27, 30.1, 30.4 et 30.5 par +coupes et cartes scientifiques, archivées avec leurs sources et leurs graines. +Les expéditions anciennes et les îles supérieures ciblées restent à définir. +Voir [la génération](generation-alpha30.5.md) et [l’atlas](terrain-atlas.md). diff --git a/docs/generation-alpha30.5.md b/docs/generation-alpha30.5.md new file mode 100644 index 0000000..748fc9d --- /dev/null +++ b/docs/generation-alpha30.5.md @@ -0,0 +1,54 @@ +# Alpha.30.5 — plateaux et relief natif modéré + +## Décision + +La 30.4 est rejetée après essai : ses masses hautes étirent aussi les vides du +terrain, avec des coques entrecroisées peu praticables. Cette passe reprend les +plateaux de la base 24/27, les grottes et failles introduites en 28, et module +la déformation 3D native dans de grandes régions de l’île. Aucun nouveau bruit +fin de surface, cône, chambre artificielle ou quantification en étages n’est ajouté. + +Le déplacement dans les trois axes demeure celui de `CavesRelief28`, avec une +intensité maximale de 80 % et une répartition large calculée à l’échelle de +chaque taille. Certains secteurs gardent le plateau original. L’arrivée et la +bande prévue pour la traversée conservent leur protection. Les profondeurs +jusqu’à Y170 restent identiques au champ 30.1. + +Le remappage vertical vers Y360 et l’union entre terrain étiré et terrain bas +sont retirés du profil courant. Les sommets retrouvent la hauteur produite par +le champ natif ; **Y360 n’est plus un objectif imposé**. Le plafond de construction +reste Y640, avec les mêmes nuages et tailles 512 / 724 / 1 024. + +## Eau et exploration + +La recherche des rivières revient au parcours courbe sur les surfaces +naturelles disponibles, sans priorité artificielle aux replats au-dessus de +Y330. Les brèches courtes dans les berges demeurent, avec écoulement vanilla. +Les cavités, marais, champignons, filons, secrets, rails, traversées souterraines, +navires restaurés et îlots restent dans le périmètre existant. La montgolfière +retirée depuis la 27 ne revient pas. + +Le filtre borné des petits composants rocheux détachés reste actif. Il ne +prétend pas supprimer chaque fragment de chaque monde, ni les marches +inévitables de la représentation en blocs. + +## Nouvelle méthode de travail + +L’[atlas natif](terrain-atlas.md) compare quatre champs avant décoration : +base 27 issue du retour à la 24, bruit global 30.1, étirement 30.4 et profil +30.5. Les coupes, cartes d’altitude et épaisseurs sont archivées avec les données +brutes. Elles servent à examiner la géométrie avant d’évaluer le paysage en jeu. +Une métrique favorable seule ne valide pas son esthétique. + +L’ancienne déformation 30.4 reste disponible uniquement comme chemin de +comparaison scientifique dans le code. Le champ courant utilise toujours les +identifiants existants (`balanced_303` désigne le profil expérimental courant, +comme lors de la 30.4). Aucune migration n’est réalisée : **créer un nouveau +monde**. Cette révision des profils d’essai n’offre pas de continuité du terrain +pour les chunks futurs d’une ancienne sauvegarde. + +Les îles supérieures, expansions choisies à partir de la graine et histoires +d’expédition seront définies plus tard. Aucun récit, événement ou continent +supplémentaire n’est injecté dans cette livraison. + +Résultats et limites : [vérifications alpha.30.5](testing-alpha30.5.md). diff --git a/docs/terrain-atlas.md b/docs/terrain-atlas.md new file mode 100644 index 0000000..7dd241e --- /dev/null +++ b/docs/terrain-atlas.md @@ -0,0 +1,75 @@ +# Atlas scientifique du terrain + +## Acquisition native + +`TerrainAtlas305GameTests` charge les registres réels de Minecraft 26.3-pre-2 +et les ressources Sanctuary. Il crée le même `RandomState` que le générateur +pour chaque graine et taille. Il compile les fonctions de densité natives, +sans réimplémentation du bruit en Python. Le serveur de test utilise un monde +plat ignoré pour démarrer rapidement ; aucun chunk Sanctuary ni aucune +expansion n’est créé pour cet export. + +```sh +./gradlew :sanctuary:runGameTest -PsanctuaryAtlasOnly=true \ + -PsanctuaryAtlasSeeds=0,42,-7228211907433324401 \ + -PsanctuaryAtlasRevision=REVISION_DU_CODE +python3.14 -m venv build/atlas-venv +build/atlas-venv/bin/pip install -r scripts/terrain-atlas-requirements.txt +build/atlas-venv/bin/python scripts/terrain_atlas.py --output build/atlas305 \ + mods/sanctuary/build/run/gameTest/diagnostics/atlas305/terrain-*.json +``` + +Le tracé livré utilise Python 3.14 ; les versions de Matplotlib et NumPy +sont épinglées dans le fichier de dépendances. + +Les propriétés atlas sont réservées aux tests. Les tests de génération complets +restent actifs dans `check` sans ce paramètre. Ne pas lancer deux Gradle de test +simultanément : leur dossier de monde temporaire est partagé. Les diagnostics +existants peuvent rester dans le dossier ; vérifier graines, version et provenance +avant d’en faire une archive. Aucune sauvegarde personnelle n’est utilisée. + +## Lecture des planches + +- Cartes : nord en haut, X et Z en blocs, rapport d’aspect réel. Les quatre + profils partagent les mêmes échelles de couleur. La palette d’altitude ne + représente pas les blocs ou les biomes ; du blanc n’implique pas de neige. +- Altitude : dernier solide rencontré, recherche verticale par pas de 2 blocs. + Une cellule grise signifie qu’aucun solide n’a été échantillonné dans la colonne. +- Épaisseur cumulée : nombre d’échantillons solides multiplié par 2 sur Y2..382. + Ce n’est ni une densité physique, ni un volume exact de matériaux minables. +- Roche continue : épaisseur depuis le sommet jusqu’au premier vide échantillonné. + Un petit résultat peut signaler une voûte mince, un surplomb ou un fragment. +- Coupes : trois plans Z=0, Z=96 et Z≈−rayon/4, sur toute la hauteur naturelle. + Bleu pour la densité négative, rouge pour la positive ; frontière zéro en noir. + L’échelle colorée sature au-delà de ±0,3 ; les données conservées vont jusqu’à ±1. + +Le pas horizontal des cartes est de 12 blocs, le pas vertical de 2. Les coupes +sont échantillonnées tous les 2 blocs dans les deux axes. Les images ne sont +pas lissées. Le champ est quantifié à 0,0001 en conservant son signe autour de +zéro. Les petits détails peuvent être manqués : le contrôle des fragments en +volume utilise séparément une résolution d’un bloc dans les tests complets. + +L’eau, la lave, les matériaux de surface, végétaux, biomes et structures ne +figurent pas dans cet atlas de **densité avant décoration**. Les tests natifs +de rivières et de structures vérifient leur présence effective dans des chunks. +Une coupe 2D ne permet pas, seule, de conclure à la connectivité d’une grotte 3D. + +## Comparaison et conservation + +Les tableaux utilisent les mêmes coordonnées dans chaque variante. La part +des plateaux conservés compare les colonnes de base dont le sommet atteint +Y200, avec une tolérance de deux blocs. Les statistiques d’épaisseur du toit +utilisent uniquement les colonnes atteignant Y200 dans **les quatre variantes**. +Ce sous-ensemble ne représente pas toutes les falaises de l’île. + +L’archive de livraison contient JSON natifs, PNG, PDF, résumés, script de rendu, +versions Python et empreintes des fichiers. Elle est attachée à la release, +pour que les images restent récupérables même après nettoyage du dossier build. +Le manifeste relie les données au binaire livré et à la révision source. +Les vues sont générées par Matplotlib à partir des mesures ; aucune image +générative n’intervient. + +Le libellé « 24 / 27 » désigne le champ de la 27 restauré depuis la 24, pas +une simulation de tous les matériaux et structures historiques de la 24. +Les samplers 30.1 et 30.4 sont conservés pour comparer des transformations +exactes, et non des approximations dessinées pour illustrer un résultat attendu. diff --git a/docs/testing-alpha30.5.md b/docs/testing-alpha30.5.md new file mode 100644 index 0000000..4e529a9 --- /dev/null +++ b/docs/testing-alpha30.5.md @@ -0,0 +1,96 @@ +# Vérifications alpha.30.5 + +Essais natifs sur **Mac**, Minecraft 26.3-pre-2 et Java 25. Aucun essai Windows +natif. L’atlas est une mesure du champ de terrain avant décoration, pas une +capture du rendu Minecraft ni une validation esthétique du créateur. + +## Atlas natif + +Trois tailles × trois graines, quatre champs comparés par cas : base 27, +global 30.1, étirement 30.4 et profil 30.5. Les deux acquisitions atlas passent +chacune les deux tests requis du serveur plat. Aucun chunk Sanctuary n’est +généré pour cette acquisition. Les neuf exports prennent environ 3 min 30 s +de calcul natif au total, hors démarrage et tracé des planches. + +| Diamètre | Graine | Sommet échantillonné 30.5 | Plateaux conservés à ±2 blocs | +|---|---|---|---| +| 512 | `-7228211907433324401` | Y258 | 82.7 % | +| 724 | `-7228211907433324401` | Y276 | 77.6 % | +| 1024 | `-7228211907433324401` | Y274 | 73.9 % | +| 512 | `0` | Y272 | 84.4 % | +| 724 | `0` | Y272 | 80.9 % | +| 1024 | `0` | Y272 | 79.1 % | +| 512 | `42` | Y270 | 74.3 % | +| 724 | `42` | Y278 | 70.7 % | +| 1024 | `42` | Y278 | 71.1 % | + +« Plateaux » signifie ici les colonnes de base dont le sommet atteint Y200, +sur une grille horizontale de 12 blocs et verticale de 2 ; il ne s’agit pas +d’un inventaire de chaque bloc. Les coupes sont tracées avec des proportions +réelles et des échelles communes, sans interpolation d’affichage. + +L’épaisseur du toit ne s’améliore pas partout par rapport à la 30.4 : la part +des toits de huit blocs ou moins passe notamment de 10,2 à 11,5 % dans le +Grand 0, sur les colonnes supérieures communes. Le changement recherché est +le retrait de l’étirement et des grandes coques hautes, **pas le remplissage +des cavités naturelles**. Les rapports complets conservent ces contre-indications. + +Le calcul historique 30.4 et le calcul 30.1 sont comparés au relevé de la +30.4 livrée, Moyen 0, aux mêmes coordonnées : **79 860 signes de densité +identiques par variante, aucune différence**. Les témoins historiques de +l’atlas correspondent donc aux archives effectivement produites précédemment. +Reçu local : `build/alpha305-historical-equivalence.json`. + +L’archive contient neuf paires de planches PNG/PDF, les neuf JSON natifs, +leurs résumés et empreintes, le script Matplotlib et ses dépendances. +Voir [la méthode d’atlas](terrain-atlas.md). + +## Validation des chunks + +`./gradlew check build` réussit en **7 min 32 s**. Les vérifications complètes +utilisent le Grand 1 024, graine `-7228211907433324401`, PID 74819 : **24/24**. + +- Traversée de 374 blocs, 23 départs locaux, 52 chunks FULL ; un parcours + effectif de 532 pas relie ses deux accès en respectant le volume de collision + d’un joueur. 71 cibles de passage sont accessibles. +- Sanctuaire minéral : départ natif, sol central et espace du gardien présents. +- Rivière courbe à Y243 ; huit cellules d’eau observées sous les ouvertures + après ticks vanilla. Les témoins du lit conservent leur eau. +- Quatre lieux aériens, quatre coffres, deux villageois ; la cascade de l’îlot + et sa végétation sont vérifiées dans les chunks générés. +- Le bloc à Y639 est accepté, Y640 refusé. Le journal d’expansion ne change pas. +- 7 436 échantillons de densité profonde sont identiques à la 30.1. Le volume + de contrôle passe de 12 petits composants détachés à zéro ; 1 536 points + restent identiques en ordre inverse de lecture. + +Preuves : `build/alpha305-check-build.log` et `build/alpha305-evidence/large-user/`. +Le **Moyen 724, graine 0**, PID 75795, passe également **24/24** en 4 min 27 s. +Sa traversée de 272 blocs compte 19 départs et 30 chunks FULL, avec un parcours +de 426 pas entre les accès. Le sanctuaire minéral et les quatre lieux aériens +sont présents, avec quatre coffres et deux villageois. Rivière et lac restent +en eau à Y247 et Y251. Aucune brèche de berge n’est retenue par le plan sur +ce témoin : aucune cascade fluviale n’est donc revendiquée pour ce cas. +La cascade de l’îlot reste contrôlée séparément. + +Les 5 819 échantillons profonds restent identiques ; le volume ciblé passe +de cinq petits composants détachés à zéro, avec 1 536 points identiques en +ordre inverse. Preuves : `build/alpha305-medium0.log` et +`build/alpha305-evidence/medium0/`. + +`./gradlew assemblePack -x :sanctuary:check` réussit après le `check build`, +sans répéter les contrôles déjà passés. Le JAR, le MRpack et le ZIP d’amorçage +sont vérifiés localement : versions, Minecraft exact, codec, hauteur de 640, +dépendances épinglées et intégrité des archives. Les téléchargements publics +et la synchronisation Prism font l’objet de reçus de distribution séparés +référencés dans [packwiz](packwiz.md). + +## Limites + +Les coupes ne montrent ni la végétation, ni l’eau, ni les structures. Elles ne +prouvent pas la connectivité 3D des vides. Les grottes naturelles peuvent +encore produire des voûtes minces et des arches. Les tests de fragments portent +sur un volume ciblé à résolution d’un bloc, pas sur chaque bloc du monde. +Aucun test de rendu client ou shader nouveau n’est revendiqué dans cette passe. +Les contrôles fonctionnels du Blocodex sont côté serveur ; son interface n’a +pas été modifiée. Aucun essai Windows, aucune activation d’expansion ni +modification d’une sauvegarde personnelle. Créer un nouveau monde. diff --git a/gradle.properties b/gradle.properties index 7960173..fd247a7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,6 +8,6 @@ loader_version=0.19.5 loom_version=1.17.20 fabric_api_version=0.160.0+26.3 -mod_version=0.1.0-alpha.30.4 -pack_version=0.1.0-alpha.30.4 +mod_version=0.1.0-alpha.30.5 +pack_version=0.1.0-alpha.30.5 maven_group=fr.koka.sanctuary diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index af17e61..88317b9 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -36,6 +36,9 @@ tasks.register('cleanGameTestWorld', Delete) { } tasks.named('runGameTest') { dependsOn('cleanGameTestWorld') + systemProperty('sanctuary.test.atlasOnly', providers.gradleProperty('sanctuaryAtlasOnly').getOrElse('false')) + systemProperty('sanctuary.test.atlasSeeds', providers.gradleProperty('sanctuaryAtlasSeeds').getOrElse('0,42,-7228211907433324401')) + systemProperty('sanctuary.test.atlasRevision', providers.gradleProperty('sanctuaryAtlasRevision').getOrElse('working-tree')) systemProperty('sanctuary.test.seed', providers.gradleProperty('sanctuaryTestSeed').getOrElse('0')) systemProperty('sanctuary.test.players', providers.gradleProperty('sanctuaryTestPlayers').getOrElse('10')) systemProperty('sanctuary.test.resourceSurvey', providers.gradleProperty('sanctuaryResourceSurvey').getOrElse('sample')) @@ -84,6 +87,8 @@ tasks.named('runGameTest') { } tasks.named('processGametestResources') { + def atlasOnly = providers.gradleProperty('sanctuaryAtlasOnly').map { it.toBoolean() }.getOrElse(false) + inputs.property('atlasOnly', atlasOnly) def benchmark = providers.gradleProperty('sanctuaryBenchmark').map { it.toBoolean() }.getOrElse(false) inputs.property('benchmark', benchmark) def unified = providers.gradleProperty('sanctuaryUnifiedTests').map { it.toBoolean() }.getOrElse(true) @@ -127,10 +132,11 @@ tasks.named('processGametestResources') { def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false) inputs.property('voidReuse', voidReuse) filesMatching('fabric.mod.json') { - expand(gametest_entrypoint: aerial24Flow ? 'fr.koka.sanctuary.gametest.Aerial24FlowAdmissionGameTests' : aerial24 ? 'fr.koka.sanctuary.gametest.Aerial24WorldGameTests' : admission23 ? 'fr.koka.sanctuary.gametest.Expansion23AdmissionGameTests' : ocean23 ? 'fr.koka.sanctuary.gametest.Ocean23WorldGameTests' : secrets23 ? 'fr.koka.sanctuary.gametest.Secrets23WorldGameTests' : native23 ? 'fr.koka.sanctuary.gametest.Native23WorldGameTests' : selective22 ? 'fr.koka.sanctuary.gametest.Selective22WorldGameTests' : transit21 ? 'fr.koka.sanctuary.gametest.Transit21WorldGameTests' : lostCity21 ? 'fr.koka.sanctuary.gametest.LostCity21WorldGameTests' : heritage21 ? 'fr.koka.sanctuary.gametest.Heritage21WorldGameTests' : lostCity20 ? 'fr.koka.sanctuary.gametest.LostCity20WorldGameTests' : heritage20 ? 'fr.koka.sanctuary.gametest.Heritage20WorldGameTests' : heritage19 ? 'fr.koka.sanctuary.gametest.Heritage19WorldGameTests' : ruins17 ? 'fr.koka.sanctuary.gametest.Ruins17WorldGameTests' : voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : lostCity16 ? 'fr.koka.sanctuary.gametest.LostCity16WorldGameTests' : lostCity15 ? 'fr.koka.sanctuary.gametest.LostCity15WorldGameTests' : lostCity ? 'fr.koka.sanctuary.gametest.LostCityWorldGameTests' : expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark + expand(gametest_entrypoint: atlasOnly ? 'fr.koka.sanctuary.gametest.TerrainAtlas305GameTests' : aerial24Flow ? 'fr.koka.sanctuary.gametest.Aerial24FlowAdmissionGameTests' : aerial24 ? 'fr.koka.sanctuary.gametest.Aerial24WorldGameTests' : admission23 ? 'fr.koka.sanctuary.gametest.Expansion23AdmissionGameTests' : ocean23 ? 'fr.koka.sanctuary.gametest.Ocean23WorldGameTests' : secrets23 ? 'fr.koka.sanctuary.gametest.Secrets23WorldGameTests' : native23 ? 'fr.koka.sanctuary.gametest.Native23WorldGameTests' : selective22 ? 'fr.koka.sanctuary.gametest.Selective22WorldGameTests' : transit21 ? 'fr.koka.sanctuary.gametest.Transit21WorldGameTests' : lostCity21 ? 'fr.koka.sanctuary.gametest.LostCity21WorldGameTests' : heritage21 ? 'fr.koka.sanctuary.gametest.Heritage21WorldGameTests' : lostCity20 ? 'fr.koka.sanctuary.gametest.LostCity20WorldGameTests' : heritage20 ? 'fr.koka.sanctuary.gametest.Heritage20WorldGameTests' : heritage19 ? 'fr.koka.sanctuary.gametest.Heritage19WorldGameTests' : ruins17 ? 'fr.koka.sanctuary.gametest.Ruins17WorldGameTests' : voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : lostCity16 ? 'fr.koka.sanctuary.gametest.LostCity16WorldGameTests' : lostCity15 ? 'fr.koka.sanctuary.gametest.LostCity15WorldGameTests' : lostCity ? 'fr.koka.sanctuary.gametest.LostCityWorldGameTests' : expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark ? 'fr.koka.sanctuary.gametest.WorldgenPerformanceGameTests' : unified ? 'fr.koka.sanctuary.gametest.UnifiedWorldGameTests' : 'fr.koka.sanctuary.gametest.SanctuaryWorldGameTests') + if (atlasOnly) filter { line -> line.replaceAll(/("fabric-gametest": \["[^"]+")[^\]]*/, '$1') } } } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Terraces29WorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Terraces29WorldGameTests.java index 60d4b7f..a8fca11 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Terraces29WorldGameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Terraces29WorldGameTests.java @@ -16,13 +16,13 @@ import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; /** Compares registered native fields; does not place blocks or load additional chunks. */ public final class Terraces29WorldGameTests { @GameTest(maxTicks = 2400) - public void broadProvincesRestorePlateausAndRaiseNativeMasses(GameTestHelper helper) throws Exception { + public void broadProvincesPreservePlateausWithoutStretchingCaves(GameTestHelper helper) throws Exception { var level = helper.getLevel(); var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator(); var field = (PopulationIslandDensity) level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION) .getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_" + generator.capacity().players()))).value(); - helper.assertTrue(field.balanced303() && !field.terraces29() && !field.surface302(), "Alpha30.4 removes fine bands and uses broad geological provinces"); + helper.assertTrue(field.balanced303() && !field.terraces29() && !field.surface302(), "Alpha30.5 keeps broad plateaus and a moderate native 3D field"); var random = generator.starterContext().random(); var current = random.getSampler(field); var previous = random.getSampler(field.legacy301()); var context = SamplerContext.builder().enableCaches().build(); @@ -43,7 +43,7 @@ public final class Terraces29WorldGameTests { if (Math.hypot(x, z) <= 48) helper.assertTrue(old == now, "Arrival silhouette is unchanged"); } } - helper.assertTrue(changed > 0, "The upper field now combines broad plateaus and raised rock"); + helper.assertTrue(changed > 0, "The upper field now combines broad plateaus and native 3D rock"); report.put("unchanged_lower_density_samples", lowerSamples); report.put("unchanged_upper_samples", unchanged); report.put("changed_upper_samples", changed); var sections = new LinkedHashMap(); @@ -123,7 +123,7 @@ public final class Terraces29WorldGameTests { } report.put("sizes",sizes); report.put("passed", true); - Path path = Path.of("diagnostics/alpha304", "relief-" + generator.initialDiameter() + "-" + level.getSeed() + ".json"); + Path path = Path.of("diagnostics/alpha305", "relief-" + generator.initialDiameter() + "-" + level.getSeed() + ".json"); Files.createDirectories(path.getParent()); Files.writeString(path, new GsonBuilder().setPrettyPrinting().create().toJson(report)); helper.succeed(); diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/TerrainAtlas305GameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/TerrainAtlas305GameTests.java new file mode 100644 index 0000000..6df8be7 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/TerrainAtlas305GameTests.java @@ -0,0 +1,111 @@ +package fr.koka.sanctuary.gametest; + +import com.google.gson.Gson; +import com.mojang.serialization.MapCodec; +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.worldgen.PopulationIslandDensity; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.core.registries.Registries; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceKey; +import net.minecraft.util.Interval; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.densityfunction.*; + +/** Scientific export of native registered fields, before fluids, surfaces and structures. + * Flat test bootstrap only: no Sanctuary chunks or expansion reservations are needed. */ +public final class TerrainAtlas305GameTests { + @GameTest(maxTicks = 120000) + public void exportNativeAtlas(GameTestHelper helper) throws Exception { + var registries = helper.getLevel().registryAccess(); + String version = FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow() + .getMetadata().getVersion().getFriendlyString(); + for (String seedText : System.getProperty("sanctuary.test.atlasSeeds", "0").split(",")) { + long seed = Long.parseLong(seedText.trim()); + for (int players : new int[]{5, 10, 20}) { + long started = System.nanoTime(); + var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS).getOrThrow( + ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("unified_" + players))).value(); + var random = RandomState.create(registries.lookupOrThrow(Registries.NOISE), seed, settings); + var field = (PopulationIslandDensity) registries.lookupOrThrow(Registries.DENSITY_FUNCTION) + .getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, + SanctuaryMod.id("population_" + players))).value(); + int radius = (int)Math.round(field.capacity().radius()); + int bound = ((radius + 32 + 11) / 12) * 12; + var functions = new LinkedHashMap(); + functions.put("plateaus27", field.legacy27()); + functions.put("global301", field.legacy301()); + functions.put("stretch304", new Historical304(field)); + functions.put("current305", field); + var variants = new LinkedHashMap(); + for (var entry : functions.entrySet()) { + var sampler = random.getSampler(entry.getValue()); + var context = SamplerContext.builder().enableCaches().build(); + var columns = new ArrayList(); + for (int z = -bound; z <= bound; z += 12) for (int x = -bound; x <= bound; x += 12) { + int top = 0, rock = 0, skin = 0; + boolean roof = false; + for (int y = 382; y >= 2; y -= 2) { + boolean solid = sampler.sampleValue(context, x, y, z) > 0; + if (solid) { + rock += 2; + if (top == 0) { top = y; roof = true; } + if (roof) skin += 2; + } else if (top != 0) roof = false; + } + columns.add(List.of(x, z, top, rock, skin)); + } + var sections = new ArrayList(); + for (int z : new int[]{0, 96, -(radius / 4)}) { + var rows = new ArrayList(); + for (int y = 382; y >= 0; y -= 2) { + var row = new ArrayList(); + for (int x = -bound; x <= bound; x += 2) { + float density = sampler.sampleValue(context, x, y, z); + helper.assertTrue(Float.isFinite(density), "Native density is finite"); + // Quantization preserves the sign around the zero isosurface. + int encoded = Math.round(Math.clamp(density, -1, 1) * 10000); + if (density > 0 && encoded == 0) encoded = 1; + if (density < 0 && encoded == 0) encoded = -1; + row.add(encoded); + } + rows.add(row); + } + sections.add(Map.of("z", z, "min_x", -bound, "max_y", 382, "step", 2, "density", rows)); + } + variants.put(entry.getKey(), Map.of("columns", columns, "sections", sections)); + } + var report = new LinkedHashMap(); + report.put("schema", 1); report.put("version", version); + report.put("revision", System.getProperty("sanctuary.test.atlasRevision", "working-tree")); + report.put("minecraft", "26.3-pre-2"); report.put("seed", Long.toString(seed)); + report.put("diameter", radius * 2); report.put("process_id", ProcessHandle.current().pid()); + report.put("domain_y", List.of(0, 383)); report.put("build_ceiling_exclusive", 640); + report.put("map_step", 12); report.put("vertical_step", 2); report.put("density_scale", 10000); + report.put("columns", List.of("x", "z", "top_y", "sampled_solid_thickness", "uninterrupted_roof_thickness")); + report.put("scope", "Native density, before fluids, surface materials, vegetation and structures; no chunks generated for this export"); + report.put("variants", variants); + report.put("export_seconds", (System.nanoTime() - started) / 1e9); + Path path = Path.of("diagnostics/atlas305", "terrain-" + radius * 2 + "-" + seed + ".json"); + Files.createDirectories(path.getParent()); Files.writeString(path, new Gson().toJson(report)); + SanctuaryMod.LOGGER.info("Terrain atlas exported {} in {} s", path, report.get("export_seconds")); + } + } + helper.succeed(); + } + + private record Historical304(PopulationIslandDensity base) implements DensityFunction { + @Override public DensitySampler compileSampler(CompileContext context) { return base.compileHistorical304(context); } + @Override public DensityFunction rewriteChildren(DfRewriteRule rule) { + return new Historical304((PopulationIslandDensity)rule.rewrite(base)); + } + @Override public Interval range() { return base.range(); } + @Override public int domainAxes() { return ALL_AXES; } + // Never serialized: this wrapper only selects the exact diagnostic sampler. + @Override public MapCodec codec() { return base.codec(); } + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java index 7ce2217..35ef28b 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java @@ -46,6 +46,7 @@ public abstract class SanctuaryGameTestServerMixin { @Redirect(method = "lambda$create$1", at = @At(value = "FIELD", target = "Lnet/minecraft/world/level/levelgen/presets/WorldPresets;FLAT_ALL_DIMENSIONS:Lnet/minecraft/resources/ResourceKey;")) private static ResourceKey sanctuary$selectActualPreset() { + if (Boolean.getBoolean("sanctuary.test.atlasOnly")) return net.minecraft.world.level.levelgen.presets.WorldPresets.FLAT_ALL_DIMENSIONS; if (Boolean.getBoolean("sanctuary.test.unified")) { int generation = Integer.getInteger("sanctuary.test.generation", 24); if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22 && generation != 23 && generation != 24) throw new IllegalArgumentException("Unsupported test generation"); @@ -68,7 +69,7 @@ public abstract class SanctuaryGameTestServerMixin { private static net.minecraft.world.level.levelgen.WorldDimensions sanctuary$customizeSize(WorldPreset preset, net.minecraft.world.level.LevelSettings settings, net.minecraft.server.WorldLoader.DataLoadContext context) { var dimensions = preset.createWorldDimensions(); - if (Boolean.getBoolean("sanctuary.test.unified")) { + if (Boolean.getBoolean("sanctuary.test.unified") && !Boolean.getBoolean("sanctuary.test.atlasOnly")) { var generator = (fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator) dimensions.overworld(); return dimensions.replaceOverworldGenerator(context.datapackWorldRegistries(), generator.withInitialDiameter(Integer.getInteger("sanctuary.test.diameter", 724)) diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrologyRuntime.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrologyRuntime.java index 635c25c..f987e61 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrologyRuntime.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrologyRuntime.java @@ -52,8 +52,7 @@ public final class PopulationHydrologyRuntime { return sampler.sampleValue(SamplerContext.EMPTY_UNCACHED, (int) wx, y, (int) wz); }); long seed = PopulationIslandDensity.regionSeed(state.seed(), region); - var water = (current30(generator) ? PopulationHydrology.withHighPlateaus304(seed, memo) - : PopulationHydrology.create(seed, memo)).admitted(); + var water = PopulationHydrology.create(seed, memo).admitted(); if (current30(generator)) { water = BankOutlets30.openBanks(seed, water, memo, 6); } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java index a5702cc..b3e1935 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java @@ -49,6 +49,13 @@ public record PopulationIslandDensity(DensityFunction terrain, DensityFunction d } @Override public DensitySampler compileSampler(CompileContext context) { + return compileSampler(context, false); + } + /** Exact rejected30.4 field retained for the reproducible scientific comparison. */ + public DensitySampler compileHistorical304(CompileContext context) { + return compileSampler(context, true); + } + private DensitySampler compileSampler(CompileContext context, boolean historical304) { var raw = ExactInterpolationSampler.compileTerrain(terrain, context); var edge = distortion.compileSampler(context); var coarse = sculpt.compileSampler(context); var fine = detail.compileSampler(context); var bottom = underside.compileSampler(context); var warp = riftWarp.compileSampler(context); @@ -57,7 +64,10 @@ public record PopulationIslandDensity(DensityFunction terrain, DensityFunction d var ridges = geology28 ? ridges28.compileSampler(context) : null; var erosion = geology28 ? erosion28.compileSampler(context) : null; long seed = context.createRandom(SanctuaryMod.id("population_rifts_alpha10")).nextLong(); - var balanced = balanced303 ? new HighPlateaus304(coarse, capacity.radius(), seed) : null; + var balanced = balanced303 && historical304 ? new HighPlateaus304(coarse, capacity.radius(), seed) : null; + // A broad mask changes the strength of the native 3D deformation only. No Y stretch, + // union of two shells, summit cap or fine surface noise in the current profile. + var plateaus = balanced303 && !historical304 ? new BalancedRelief303(coarse, capacity.radius(), seed) : null; var layouts = new ConcurrentHashMap(); DensitySampler field = new DensitySampler() { private float terrainAt(SamplerContext sample, double x, double y, double z) { @@ -83,7 +93,8 @@ public record PopulationIslandDensity(DensityFunction terrain, DensityFunction d double province = balanced != null ? balanced.province(x, z) : 0; double mappedY = balanced != null && province > 0 ? HighPlateaus304.sourceY(y, province) : y; double weight = geology28 ? CavesRelief28.deformationWeight(x, y, z, capacity.radius()) : 0; - if (balanced != null) weight *= province; + if (plateaus != null) weight *= .8 * plateaus.province(x, z); + else if (balanced != null) weight *= province; else if (relief30) weight *= Relief30.influence(seed, x, z, capacity.radius()); double surfaceWeight = surface302 ? SurfaceRelief302.weight(x, y, z, capacity.radius()) * Relief30.influence(seed, x, z, capacity.radius()) : 0; diff --git a/packwiz/pack.toml b/packwiz/pack.toml index b87d94d..8a58e0e 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "0.1.0-alpha.30.4" +version = "0.1.0-alpha.30.5" pack-format = "packwiz:1.1.0" [index] diff --git a/scripts/archive_terrain_atlas.py b/scripts/archive_terrain_atlas.py new file mode 100644 index 0000000..88da977 --- /dev/null +++ b/scripts/archive_terrain_atlas.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Freeze a rendered native atlas with raw measurements, source revision and build hash.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import zipfile + +ROOT = Path(__file__).resolve().parents[1] + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--raw", type=Path, required=True) + parser.add_argument("--rendered", type=Path, required=True) + parser.add_argument("--jar", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.output.exists(): + raise SystemExit("Refusing to overwrite an existing immutable atlas") + status = subprocess.check_output(["git", "status", "--porcelain"], cwd=ROOT, text=True) + if status.strip(): + raise SystemExit("Commit atlas code and documentation before freezing the archive") + commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + jar_bytes = args.jar.read_bytes() + with zipfile.ZipFile(args.jar) as jar: + if jar.testzip() is not None: + raise SystemExit("Invalid built JAR") + version = json.loads(jar.read("fabric.mod.json"))["version"] + files = {} + cases = [] + for source in sorted(args.raw.glob("terrain-*.json")): + raw = source.read_bytes() + data = json.loads(raw) + if data["version"] != version: + raise SystemExit(f"Stale native export: {source}") + rendered = args.rendered / source.stem + provenance = json.loads((rendered / "provenance.json").read_text()) + if provenance["source_sha256"] != sha(raw): + raise SystemExit(f"Rendered atlas differs from source: {source}") + if provenance["script_sha256"] != sha((ROOT / "scripts/terrain_atlas.py").read_bytes()): + raise SystemExit("Rendering script changed after rendering") + files["raw/" + source.name] = raw + for name in ("maps.png", "maps.pdf", "sections.png", "sections.pdf", "summary.json", "provenance.json"): + files[f"figures/{source.stem}/{name}"] = (rendered / name).read_bytes() + cases.append({"seed": data["seed"], "diameter": data["diameter"], "directory": source.stem}) + identities = {(c["seed"], c["diameter"]) for c in cases} + if not cases or len(identities) != len(cases): + raise SystemExit("Empty atlas or duplicate seed/size") + for name in ("scripts/terrain_atlas.py", "scripts/terrain-atlas-requirements.txt", + "scripts/archive_terrain_atlas.py", "docs/terrain-atlas.md", + "docs/generation-alpha30.5.md", "docs/testing-alpha30.5.md"): + files[name] = (ROOT / name).read_bytes() + rows = ["# Sanctuary — atlas " + version, "", f"Source : `{commit}`.", "", + "Mesures natives avant décoration ; voir docs/terrain-atlas.md pour les échelles et limites.", "", + "| Diamètre | Graine | Cartes | Coupes |", "|---|---|---|---|"] + for c in cases: + base = "figures/" + c["directory"] + rows.append(f"| {c['diameter']} | {c['seed']} | [PNG]({base}/maps.png) / [PDF]({base}/maps.pdf) | [PNG]({base}/sections.png) / [PDF]({base}/sections.pdf) |") + rows += ["", "Reproduction des figures dans un environnement Python isolé :", "", "```sh", + "python -m pip install -r scripts/terrain-atlas-requirements.txt", + "python scripts/terrain_atlas.py --output rendered raw/terrain-*.json", "```", ""] + files["README.md"] = "\n".join(rows).encode() + files["manifest.json"] = (json.dumps({"version": version, "source_commit": commit, + "jar": args.jar.name, "jar_sha256": sha(jar_bytes), "cases": cases, + "files_sha256": {name: sha(content) for name, content in sorted(files.items())}}, indent=2) + "\n").encode() + args.output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(args.output, "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name, content in sorted(files.items()): + info = zipfile.ZipInfo(name, date_time=(2000, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, content) + with zipfile.ZipFile(args.output) as archive: + assert archive.testzip() is None + for name, content in files.items(): + assert archive.read(name) == content + print(json.dumps({"path": str(args.output), "cases": len(cases), "source_commit": commit, + "sha256": sha(args.output.read_bytes()), "bytes": args.output.stat().st_size})) + + +if __name__ == "__main__": + main() diff --git a/scripts/terrain-atlas-requirements.txt b/scripts/terrain-atlas-requirements.txt new file mode 100644 index 0000000..e123831 --- /dev/null +++ b/scripts/terrain-atlas-requirements.txt @@ -0,0 +1,2 @@ +matplotlib==3.11.2 +numpy==2.5.3 diff --git a/scripts/terrain_atlas.py b/scripts/terrain_atlas.py new file mode 100644 index 0000000..0ad8ceb --- /dev/null +++ b/scripts/terrain_atlas.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Render TerrainAtlas305GameTests native exports. No synthesized terrain or image smoothing. + +python scripts/terrain_atlas.py --output build/atlas305 [ ...] +See docs/terrain-atlas.md for acquisition, provenance and sampling limits. +""" +import argparse +import hashlib +import json +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.colors import TwoSlopeNorm +import numpy as np + +LABELS = { + "plateaus27": "Plateaux 24 / 27", + "global301": "Bruit 3D global · 30.1", + "stretch304": "Étirement vertical · 30.4", + "current305": "Plateaux + déformation 3D · 30.5", +} + + +def save(fig, directory, name): + fig.savefig(directory / f"{name}.png", dpi=150, facecolor="white") + fig.savefig(directory / f"{name}.pdf", facecolor="white") + plt.close(fig) + + +def render(source, output): + raw = source.read_bytes() + data = json.loads(raw) + if data["schema"] != 1 or list(data["variants"]) != list(LABELS): + raise ValueError(f"Unsupported atlas schema/variants: {source}") + target = output / source.stem + target.mkdir(parents=True, exist_ok=True) + title = f"Sanctuary · diamètre {data['diameter']} · graine {data['seed']}" + provenance = (f"Champ natif Minecraft {data['minecraft']} · {data['version']} · {data['revision']}\n" + "Avant eau, matériaux de surface, végétation et structures. Le plafond de construction reste Y640.") + variants = list(data["variants"].values()) + maps = [] + for variant in variants: + columns = np.asarray(variant["columns"], dtype=float) + xs, zs = np.unique(columns[:, 0]), np.unique(columns[:, 1]) + if columns.shape[0] != len(xs) * len(zs): + raise ValueError("Incomplete native map grid") + maps.append(columns.reshape(len(zs), len(xs), 5)) + step = data["map_step"] + extent = (xs[0] - step / 2, xs[-1] + step / 2, zs[-1] + step / 2, zs[0] - step / 2) + fig, axes = plt.subplots(3, 4, figsize=(18, 14), layout="constrained", sharex=True, sharey=True) + fig.suptitle(title + "\nRelief et épaisseur de roche", fontsize=18) + metrics = [(2, "Altitude du sommet (Y)", "terrain", 0, 360), + (3, "Épaisseur solide cumulée (blocs)", "viridis", 0, 300), + (4, "Roche continue sous le sommet (blocs)", "magma", 0, 100)] + for row, (index, label, cmap, vmin, vmax) in enumerate(metrics): + for col, (key, grid) in enumerate(zip(LABELS, maps)): + ax = axes[row, col] + values = np.ma.masked_where(grid[:, :, 2] == 0, grid[:, :, index]) + image = ax.imshow(values, extent=extent, origin="upper", interpolation="nearest", + cmap=cmap, vmin=vmin, vmax=vmax, aspect="equal") + ax.set_facecolor("#e8edf0") + ax.set_xlabel("X (blocs)") + if col == 0: + ax.set_ylabel("Z (blocs) · nord en haut") + if row == 0: + ax.set_title(LABELS[key], fontsize=11) + ax.plot(0, 0, "+", color="black", ms=8, label="Origine") + fig.colorbar(image, ax=axes[row, :], shrink=.85, label=label, extend="max") + fig.supxlabel(f"Pas horizontal : {step} blocs · vertical : {data['vertical_step']} blocs · gris = aucun solide échantillonné.\n" + provenance, fontsize=9) + save(fig, target, "maps") + + section_count = len(variants[0]["sections"]) + fig, axes = plt.subplots(4, section_count, figsize=(20, 12), layout="constrained", sharex=True, sharey=True) + fig.suptitle(title + "\nCoupes de densité · proportions spatiales conservées", fontsize=18) + for row, (key, variant) in enumerate(zip(LABELS, variants)): + for col, section in enumerate(variant["sections"]): + ax = axes[row, col] + density = np.asarray(section["density"], dtype=float) / data["density_scale"] + dy = section["step"] + x = section["min_x"] + np.arange(density.shape[1]) * dy + y = section["max_y"] - np.arange(density.shape[0]) * dy + image = ax.imshow(density, extent=(x[0]-dy/2, x[-1]+dy/2, y[-1]-dy/2, y[0]+dy/2), + interpolation="nearest", cmap="RdBu_r", norm=TwoSlopeNorm(0, -.3, .3), aspect="equal") + ax.contour(x, y, density, levels=[0], colors="#262626", linewidths=.35) + if row == 0: + ax.set_title(f"Plan vertical Z = {section['z']}") + if col == 0: + ax.set_ylabel(LABELS[key] + "\nY (blocs)", fontsize=10) + ax.set_xlabel("X (blocs)") + ax.set_ylim(0, 384) + fig.colorbar(image, ax=axes, shrink=.6, label="Densité sans unité · bleu : vide (≤ 0) · rouge : roche (> 0)", extend="both") + fig.supxlabel("Coupes échantillonnées tous les 2 blocs, sans interpolation d’affichage ; isosurface zéro en noir.\n" + provenance, fontsize=9) + save(fig, target, "sections") + + summary = {"seed": data["seed"], "diameter": data["diameter"], "variants": {}} + base = maps[0][:, :, 2] + common = np.logical_and.reduce([m[:, :, 2] >= 200 for m in maps]) + for key, grid in zip(LABELS, maps): + land = grid[:, :, 2] > 0 + uplands = base >= 200 + summary["variants"][key] = { + "sampled_land_columns": int(land.sum()), + "max_sampled_y": float(grid[:, :, 2].max()), + "original_uplands_within_2_blocks_fraction": float((np.abs(grid[:, :, 2]-base)[uplands] <= 2).mean()), + "common_upper_columns": int(common.sum()), + "median_roof_thickness_common_upper_columns": float(np.median(grid[:, :, 4][common])), + "roof_at_most_8_blocks_common_upper_fraction": float((grid[:, :, 4][common] <= 8).mean()), + } + (target / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") + (target / "provenance.json").write_text(json.dumps({ + "source": source.name, "source_sha256": hashlib.sha256(raw).hexdigest(), + "script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "matplotlib": matplotlib.__version__, "numpy": np.__version__, + "version": data["version"], "revision": data["revision"], "process_id": data["process_id"], + "scope": data["scope"], "map_step": step, "vertical_step": data["vertical_step"], + "density_quantization": 1 / data["density_scale"], "density_plot_saturation": [-.3, .3], + }, indent=2) + "\n") + print(json.dumps(summary)) + return summary + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("sources", nargs="+", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + summaries = [render(source, args.output) for source in args.sources] + (args.output / "summary.json").write_text(json.dumps(summaries, indent=2) + "\n") + + +if __name__ == "__main__": + main()