Compare commits

...
2 Commits
Author SHA1 Message Date
koka 0f1efdec83 Sample texture grain for sculptures and preserve worn models (beta.117)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 12:18:10 +02:00
koka 5c666a4ebc Record verified beta.116 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 07:50:34 +02:00
15 changed files with 332 additions and 34 deletions
+13 -6
View File
@@ -20,12 +20,13 @@ La [vision complète](docs/vision.md) conserve les intentions ; le
L’économie, les claims et les dimensions décrits dans la vision restent
à implémenter.
La version **beta.116** ouvre le Clay Workshop aux blocs de matière : les
nuances de leur texture sont appliquées au modèle, avec aperçu et fabrication
payée. Elle réunit aussi le correctif du Fourneau (cases décalées par trois,
combustible stable et explication de la chauffe).
Le [ticket de livraison](docs/sculpture-materials-beta116.md) conserve les
contrôles et le suivi de distribution.
La version **beta.117** répartit les couleurs exactes des textures de blocs
sur les sculptures, y compris les modèles unis, avec un calcul Java borné
et conservé. Elle corrige aussi les sculptures devenues invisibles après
équipement en chapeau.
Le [ticket de livraison](docs/sculpture-texture-grain-beta117.md) conserve les
contrôles et le suivi de distribution. La livraison est en préparation ;
l'instance **Sanctuary Beta** publiée reste beta.116 jusqu'à sa synchronisation.
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
latelier, les œufs et la neige saisonnière dans linstance **Sanctuary Beta**.
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
@@ -36,6 +37,12 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.117 — Grain des textures et sculptures en chapeau
Les petits cubes d'une sculpture reprennent plusieurs nuances des textures
de la matière. Le modèle reste intact dans le slot chapeau et au retrait.
[Contrat et vérifications](docs/sculpture-texture-grain-beta117.md).
## beta.116 — Matières de sculpture et Fourneau
Un bloc de matière devient une sculpture aux couleurs de sa texture. Hotbar,
+12
View File
@@ -14,6 +14,18 @@ JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
de l'ancien pack 26.2.
## beta.116 — palettes de sculpture et Fourneau
Le Clay Workshop utilise les nuances des textures de blocs ; le Fourneau
corrige les décalages de cases et explique la chauffe.
[Contrat et vérifications](sculpture-materials-beta116.md).
[Release beta.116](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.116)
publiée ; canal `c741870160f8a21f0a9793f20f0e624ebdbacdb6`. Deux
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
`sanctuary-backups/before-beta.116/`.
## beta.110 — version commune
Clay Workshop, Statuaire et import GLB intégrés aux œufs et à la neige saisonnière.
+17 -2
View File
@@ -83,5 +83,20 @@ Les classes de test et modèles GLB de test sont absents des archives.
- JAR Sanctuary :
`76a9205783c8b18506c32c9eef6c481d95fd3ba0746a2bdd558a194534a51a32`.
Reçu local : `build/sculpture116-artifact.json`. Publication et synchronisation
de l'instance en cours ; leurs résultats seront consignés après vérification.
Reçu local : `build/sculpture116-artifact.json`.
## Publication et instance
La [release beta.116](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.116)
est publiée avec le JAR, les deux MRpack et l'amorçage Prism. Le tag immuable
désigne `e3ca9fcd6383f7646e4fea7e5098e7097ebb30aa` ; téléchargements publics
et empreintes vérifiés. Le canal packwiz avance au commit
`c741870160f8a21f0a9793f20f0e624ebdbacdb6`.
Deux synchronisations isolées, puis deux dans l'unique instance **Sanctuary
Beta**, réussissent. La seconde passe laisse les fichiers gérés inchangés,
avec un seul JAR Sanctuary beta.116. Minecraft 26.3, Loader 0.19.5 et Fabric
API 0.160.5+26.3 conservés. Les 923 fichiers personnels et réglages suivis
restent identiques ; aucun monde personnel n'a été ouvert. Sauvegarde des
fichiers remplacés dans `sanctuary-backups/before-beta.116/` de cette instance.
Reçus : `build/sculpture116-isolated.json`, `build/sculpture116-prism.json`.
+96
View File
@@ -0,0 +1,96 @@
# beta.117 — Grain des textures et sculptures en chapeau
Ticket sur `codex/sculpture-texture-grain-beta117`, Minecraft 26.3.
En beta.116, une couleur du modèle source était associée à une seule nuance
de matière. Un modèle uni restait donc uni malgré les pixels différents de la
texture. La sélection utilise maintenant les textures des faces du modèle de
bloc, avec leurs propres teintes du pack actif. Un échantillonnage de pixels
répartit leurs couleurs exactes dans les voxels, sans couleur interpolée.
## Coût et comportement
Au maximum 32 matériaux de faces et 16 × 16 échantillons par matériau sont lus,
puis ramenés à la palette existante de 256 entrées. Les répétitions conservent
la fréquence des nuances. Les pixels transparents sont écartés ; les textures
animées utilisent la première image. Les blocs sans texture lisible gardent
le repli antérieur sur la texture représentative, puis la couleur de carte.
La sélection d'une nuance utilise un mélange entier déterministe des coordonnées
de chaque voxel. Un modèle uni utilise toute la palette ; un modèle ombré
utilise des plages de nuances qui suivent ses zones claires et sombres.
Ce grain est un transfert de palette, pas une projection UV conservant les
motifs ou les veinures du bloc. Les voxels restent opaques.
Le calcul utilise Java standard et les modèles/textures déjà chargés par
Minecraft, sans bibliothèque supplémentaire. Aucun calcul de matière dans
le rendu par image. Le client ne rééchantillonne qu'au changement de matière
ou de ressources ; une rotation réutilise la palette. Le serveur conserve
le modèle recoloré pour les fabrications suivantes avec la même sélection.
## Chapeaux et données
Le système de blocs portés retirait `BLOCK_ENTITY_DATA` des objets pour isoler
les inventaires des coffres et fourneaux. Sur une sculpture, ce composant est
la géométrie elle-même : le slot contenait encore un objet, devenu invisible.
Les sculptures conservent désormais ce composant à l'équipement et au retrait.
Si une ancienne sculpture équipée a perdu ce composant, mais que sa session
de chapeau possède encore le modèle, il est restauré depuis cette donnée
serveur. La restauration ne fabrique aucune géométrie manquante. Les objets
déjà retirés et dépourvus de toute copie du modèle ne sont pas reconstructibles
par ce correctif. Les règles de contenu des coffres restent inchangées.
Identifiants, schéma 1 des sculptures, structure des accessoires et paquet
de sélection inchangés. Les couleurs déjà sauvegardées restent intactes.
Les deux côtés utilisent beta.117 pour le même calcul de prévisualisation et
de fabrication. Aucun monde personnel ouvert ni migration de sauvegarde.
## Vérifications
Le scénario client natif complet passe en 2 min 1 s sur macOS avec serveur
intégré, monde plat jetable de graine 116. Il reprend le scénario beta.116
avec un GLB entièrement uni, plus les régressions de l'atelier 106/111/112/113 :
- 1 207 objets-blocs échantillonnés, huit fabrications payées avec aperçu
identique au résultat serveur, consommation exacte d'un bloc.
- Répartition 25 % / 75 % de deux couleurs de texture sur un modèle uni,
aucune couleur inventée, conservation des zones claires et sombres.
- Couleurs réellement présentes dans les textures natives des faces, avec
plusieurs matériaux d'une bûche ; grain présent sur pierre, bois et minerai.
- Motif indépendant de l'ordre des voxels, stable et sauvegardé ; rechargement
des ressources, palette bornée, inventaire plein et sélections périmées.
- Vrais clics des slots hotbar/chapeau, conservation exacte des composants
et du nom personnalisé, icône et modèle porté visibles, retrait/rééquipement.
- Restauration d'un objet privé de sa géométrie depuis une ancienne session
équipée, puis sauvegarde et reconnexion du client avec le chapeau intact.
- Pose, bord visé, orientation, casse/repose, reconnexion sans fichier source,
menus FR/EN et génération du plan du Métabli inchangés.
Mesure indicative après échauffement, modèle maximal de 4 096 voxels,
101 applications sur le Mac de développement : médiane **0,283 ms**, p95
**0,971 ms**. Ce n'est pas une garantie de temps sur d'autres machines ; le
calcul n'est exécuté ni chaque image ni à chaque objet fabriqué identique.
Marqueurs `SCULPTURE117_GRAIN_PASS`, `SCULPTURE117_HAT_PASS`,
`SCULPTURE117_HAT_RECONNECT_PASS`, `SCULPTURE116_PASS` dans
`build/sculpture117-final-client.log`. Captures relues dans
`build/sculpture117-evidence/`. Pas d'essai Windows ou LAN. Le GameTest dédié
reste exclu selon le refus antérieur de son EULA.
`check build assemblePack assembleTestPack -x :sanctuary:runGameTest` réussit
en **3 min 7 s**, 125 tâches dont 105 exécutées. JAR sources concordants,
versions, contenu et intégrité des deux archives vérifiés. La comparaison
avec beta.116 limite les changements de production au calcul des matières,
à son cache dans le menu et au traitement des sculptures en chapeau.
Les modèles de test et classes de GameTest sont absents de la livraison.
- `Sanctuary-beta.117.mrpack` : 10191137 octets, SHA-256
`adbe5a40bbd050ebc84ff8aca61a7a3643dcf25ec8f2ac59b91561e48275cbbb`.
- `Sanctuary-Test-beta.117.mrpack` : 10210059 octets, SHA-256
`0dacb12b30bde66c647871101a4f4c6b0b58e7ceb29f7743398f8a569b958a6e`.
- JAR Sanctuary :
`1ce63f97f17c6098f3c86c6753f9b912bce39d88e05ba240423c38bdc97307ee`.
Reçu : `build/sculpture117-artifact.json`. Publication et synchronisation
finales en cours.
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.116
pack_version=beta.116
mod_version=beta.117
pack_version=beta.117
resource_pack_version=beta.090
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
@@ -32,18 +32,19 @@ public final class Sculpture116ClientChecks implements FabricClientGameTest {
}});
Path file=PlanClient.directory().resolve("sculpture116-"+UUID.randomUUID()+".glb");
try{
Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture());var asset=GlbModel.load(file);var source=VoxelSculpture.of(asset.id(),asset.name(),StatueVoxelizer.miniature(asset.faces()));
Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture(true));var asset=GlbModel.load(file);var source=VoxelSculpture.of(asset.id(),asset.name(),StatueVoxelizer.miniature(asset.faces()));
try{new SculpturePalette(List.of());throw new AssertionError("Empty palette accepted");}catch(IllegalArgumentException expected){}
try{new SculpturePalette(Collections.nCopies(257,-1));throw new AssertionError("Oversized palette accepted");}catch(IllegalArgumentException expected){}
try{new SculpturePalette(List.of(0));throw new AssertionError("Transparent color accepted");}catch(IllegalArgumentException expected){}
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;ItemStack hat;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture116 "+UUID.randomUUID());s.setSeed("116");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
saved=world.getWorldSave();c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.getInventory().clearContent();p.teleportTo(.5,1,.5);for(int x=-4;x<8;x++)for(int z=-4;z<8;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(WORKSHOP,Statuary.WORKSHOP.defaultBlockState());Statuary.open(p,WORKSHOP);});
c.waitForScreen(ClayWorkshopScreen.class);c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();((ClayWorkshopScreen)m.gui.screen()).load(file);});
c.waitFor(m->!((ClayWorkshopScreen)m.gui.screen()).busy()&&((ClayWorkshopScreen)m.gui.screen()).selected()!=null,200);
c.runOnClient(m->{int count=0;for(var item:BuiltInRegistries.ITEM)if(item instanceof BlockItem){var stack=new ItemStack(item);check(ClayWorkshopMenu.material(stack),"Empty registered block accepted: "+item);var palette=SculptureMaterialSampler.sample(stack);check(palette!=null&&palette.colors().size()<=256&&palette.colors().stream().allMatch(v->(v>>>24)==255),"Bounded opaque palette: "+item);count++;}check(count>1000,"Whole block registry sampled");System.out.println("SCULPTURE116_PALETTES "+count+" registered block items");
var dark=new ItemStack(Items.SHULKER_BOX);dark.set(DataComponents.CONTAINER,ItemContainerContents.fromItems(List.of(new ItemStack(Items.DIAMOND))));check(!ClayWorkshopMenu.material(dark),"Filled container cannot lose contents");check(!ClayWorkshopMenu.material(new ItemStack(Items.DIAMOND)),"Non-block item refused");
var shades=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++){int v=x*17;shades.add(new VoxelSculpture.Cell(x,0,0,0xFF000000|v<<16|v<<8|v));}var ramp=new VoxelSculpture(source.modelId(),"Ramp",16,1,1,shades);var palette=SculptureMaterialSampler.sample(new ItemStack(Items.OAK_PLANKS));var recolored=palette.apply(ramp);check(recolored.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>2,"Material retains multiple texture shades");for(int x=1;x<16;x++)check(SculpturePalette.lightness(recolored.cells().get(x).color())>=SculpturePalette.lightness(recolored.cells().get(x-1).color()),"Model shading order retained");});
check(source.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()==1,"Native paid crafts start from a uniform model");Sculpture117Checks.run(source);});
var rendered=new HashSet<VoxelSculpture>();
for(var item:List.of(Items.STONE,Items.OAK_PLANKS,Items.GOLD_BLOCK,Items.WOOL.pick(DyeColor.RED),Items.GLASS,Items.OAK_LEAVES,Items.CLAY,Items.DEEPSLATE)){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(item,2));check(menu.getSlot(1).getItem().isEmpty(),"Previous material result invalidates immediately");menu.broadcastChanges();});
@@ -66,8 +67,14 @@ public final class Sculpture116ClientChecks implements FabricClientGameTest {
server.runOnServer(s->{var menu=(ClayWorkshopMenu)s.getPlayerList().getPlayers().getFirst().containerMenu;check(diamond.equals(StatuaryEntity.sculpture(menu.getSlot(1).getItem()))&&menu.getSlot(0).getItem().getCount()==1,"Forged menu token cannot change recipe or spend input");});
var before=c.computeOnClient(m->m.getModelManager().getBlockStateModelSet());var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(8);
c.runOnClient(m->{check(before!=m.getModelManager().getBlockStateModelSet(),"Actual resource reload replaces sampling generation");check(diamond.equals(StatuaryEntity.sculpture(((ClayWorkshopScreen)m.gui.screen()).preview())),"Preview refreshes after pack reload");});
hat=SculptureHat117Checks.exercise(c,server,diamond);
System.out.println("SCULPTURE116_MATERIALS_PASS all block palettes, eight native paid materials, preview equality, shading, stale selections, input identity, token, palette bounds, containers and resource reload");
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);c.waitTicks(12);
SculptureHat117Checks.verifyEquipped(c,world.getServer(),hat);
System.out.println("SCULPTURE117_HAT_RECONNECT_PASS equipped sculpture survives native save and fresh client reconnect");
}
}catch(java.io.IOException e){throw new AssertionError(e);}finally{intro.set(false);try{Files.deleteIfExists(file);}catch(java.io.IOException ignored){}}
new Statuary106ClientChecks().runTest(c);
System.out.println("SCULPTURE116_PASS materials plus full workshop/hotbar, placement, anchoring, drops, reconnect, FR/EN and Metabli regression");
@@ -0,0 +1,59 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.core.*;
import net.minecraft.util.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
/** Regression for flat-color models, exact texture colors, deterministic grain and bounded CPU work. */
final class Sculpture117Checks {
static void run(VoxelSculpture source){
var cells=new ArrayList<VoxelSculpture.Cell>();
for(int z=0;z<16;z++)for(int y=0;y<16;y++)for(int x=0;x<16;x++)cells.add(new VoxelSculpture.Cell(x,y,z,0xFF808080));
var flat=new VoxelSculpture(source.modelId(),"Flat",16,16,16,cells);
var colors=new ArrayList<Integer>();colors.addAll(Collections.nCopies(64,0xFF223344));colors.addAll(Collections.nCopies(192,0xFFCCBBAA));
var weighted=new SculpturePalette(colors);var out=weighted.apply(flat);
long dark=out.cells().stream().filter(v->v.color()==0xFF223344).count();
check(dark>900&&dark<1150,"Uniform source retains sampled 25% dark / 75% light texture distribution");
check(out.cells().stream().allMatch(v->colors.contains(v.color())),"Never invent an averaged color");
var reversed=new ArrayList<>(flat.cells());Collections.reverse(reversed);
var shuffled=weighted.apply(new VoxelSculpture(flat.modelId(),flat.name(),16,16,16,reversed));
check(Arrays.equals(out.packed(),shuffled.cells().reversed().stream().flatMapToInt(v->java.util.stream.IntStream.of(v.key(),v.color())).toArray()),"Sampling is independent of cell iteration order");
check(out.equals(weighted.apply(flat))&&out.equals(StatuaryEntity.decode(StatuaryEntity.encode(out))),"Stable pattern and persisted final colors");
var gradient=new ArrayList<Integer>();for(int i=0;i<256;i++)gradient.add(0xFF000000|i<<16|i<<8|i);
var palette=new SculpturePalette(gradient);
var shaded=new VoxelSculpture(flat.modelId(),flat.name(),16,16,16,cells.stream().map(v->new VoxelSculpture.Cell(v.x(),v.y(),v.z(),v.x()<8?0xFF000000:0xFFFFFFFF)).toList());
var result=palette.apply(shaded);
check(result.cells().stream().filter(v->v.x()<8).allMatch(v->SculpturePalette.lightness(v.color())<.5),"Dark regions sample dark shades");
check(result.cells().stream().filter(v->v.x()>=8).allMatch(v->SculpturePalette.lightness(v.color())>=.5),"Light regions sample light shades");
check(result.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>200,"Grain remains inside source shading regions");
var single=new SculpturePalette(List.of(0xFF123456));check(single.apply(flat).cells().stream().allMatch(v->v.color()==0xFF123456),"Truly uniform block textures remain uniform");
for(var item:List.of(Items.STONE,Items.OAK_PLANKS,Items.COAL_ORE,Items.OAK_LOG)){
var sampled=SculptureMaterialSampler.sample(new ItemStack(item));var sculpture=sampled.apply(flat);
check(sculpture.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>2,"Actual texture grain on flat model: "+item);
check(sculpture.cells().stream().allMatch(v->sampled.colors().contains(v.color())),"Exact sampled pixels: "+item);
}
// Log tops and bark are separate native face materials; both must supply exact colors.
var mc=Minecraft.getInstance();var state=Blocks.OAK_LOG.defaultBlockState();var parts=new ArrayList<BlockStateModelPart>();
mc.getModelManager().getBlockStateModelSet().get(state).collectParts(RandomSource.create(state.getSeed(BlockPos.ZERO)),parts);
var bySprite=new HashMap<String,Set<Integer>>();
for(var part:parts)for(var face:Direction.values())for(var quad:part.getQuads(face)){
var sample=PlanMaterials.faceSample(state,quad.materialInfo());var set=bySprite.computeIfAbsent(quad.materialInfo().sprite().contents().name().toString(),k->new HashSet<>());
for(int pixel:sample.pixels().argb()){int color=ARGB.multiply(pixel,sample.tint());if((color>>>24)>=128)set.add(color|0xFF000000);}
}
check(bySprite.size()>=2,"Native log has distinct face textures");var sampled=SculptureMaterialSampler.sample(new ItemStack(Items.OAK_LOG));
var union=new HashSet<Integer>();bySprite.values().forEach(union::addAll);
check(sampled.colors().stream().allMatch(union::contains),"All palette colors belong to baked face textures");
for(var faceColors:bySprite.values())check(sampled.colors().stream().anyMatch(faceColors::contains),"Each log texture contributes pixels");
for(int i=0;i<100;i++)palette.apply(flat);
long[] times=new long[101];for(int i=0;i<times.length;i++){long start=System.nanoTime();var measured=palette.apply(flat);times[i]=System.nanoTime()-start;check(measured.cells().size()==4096,"Full bounded model");}
Arrays.sort(times);
System.out.printf(java.util.Locale.ROOT,"SCULPTURE117_PERF 4096 voxels median %.3f ms, p95 %.3f ms%n",times[50]/1e6,times[95]/1e6);
System.out.println("SCULPTURE117_GRAIN_PASS flat model, weighted exact pixels, dark/light regions, deterministic cells, persistence and native face materials");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,50 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.plans.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
/** Actual accessory-slot transfers and recovery from a saved beta.116 head session. */
final class SculptureHat117Checks {
static ItemStack exercise(ClientGameTestContext c,TestServerContext server,VoxelSculpture sculpture){
var original=StatuaryEntity.item(sculpture);original.set(DataComponents.CUSTOM_NAME,Component.literal("Sculpture chapeau 117"));
c.runOnClient(m->m.gui.screen().onClose());
server.waitFor(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.containerMenu==p.inventoryMenu;},100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();p.getInventory().setItem(0,original.copy());p.inventoryMenu.broadcastFullState();});
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(36).getItem(),original),100);
c.runOnClient(m->m.gui.setScreen(new InventoryScreen(m.player)));
click(c,36);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Statuary.ITEM),100);
click(c,66);c.waitTicks(12);verifyEquipped(c,server,original);
c.takeScreenshot("sculpture117-hat-inventory");
click(c,66);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Statuary.ITEM),100);
click(c,36);
server.waitFor(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.inventoryMenu.getCarried().isEmpty()&&HeadEquipment.get(p).isEmpty()&&ItemStack.matches(p.getInventory().getItem(0),original);},100);
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(36).getItem(),original)&&m.player.inventoryMenu.getSlot(66).getItem().isEmpty(),100);
c.runOnClient(m->check(!new StatuaryRenderer.ItemRenderer().extractArgument(m.player.inventoryMenu.getSlot(36).getItem()).isEmpty(),"Returned sculpture still renders in hotbar"));
click(c,36);click(c,66);c.waitTicks(8);verifyEquipped(c,server,original);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var saved=HeadBlockService.save(p);check(saved!=null,"Authoritative worn model is saved");
var damaged=original.copy();damaged.remove(DataComponents.BLOCK_ENTITY_DATA);
AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,damaged);HeadEquipment.retainData(p,saved);
HeadBlockService.session(p);p.inventoryMenu.broadcastChanges();
check(ItemStack.matches(HeadEquipment.get(p),original),"Recover already stripped sculpture from its saved worn model");
});
c.waitTicks(8);verifyEquipped(c,server,original);
c.runOnClient(m->m.gui.screen().onClose());
System.out.println("SCULPTURE117_HAT_PASS native equip/remove/re-equip, slot mesh, exact components and old worn-session recovery");
return original;
}
static void verifyEquipped(ClientGameTestContext c,TestServerContext server,ItemStack original){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(ItemStack.matches(HeadEquipment.get(p),original),"Equipped stack retains its geometry and name");check(p.getInventory().getItem(0).isEmpty()&&p.inventoryMenu.getCarried().isEmpty(),"Exactly one sculpture moved into head slot");});
c.waitFor(m->ItemStack.matches(m.player.inventoryMenu.getSlot(66).getItem(),original)&&StatuaryEntity.sculpture(original).equals(StatuaryEntity.sculpture(HeadCosmeticService.appearance(m.player))),150);
c.runOnClient(m->check(!new StatuaryRenderer.ItemRenderer().extractArgument(m.player.inventoryMenu.getSlot(66).getItem()).isEmpty(),"Equipped slot retains visible item geometry"));
}
private static void click(ClientGameTestContext c,int slot){c.runOnClient(m->m.gameMode.handleContainerInput(m.player.inventoryMenu.containerId,slot,0,ContainerInput.PICKUP,m.player));}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -228,13 +228,18 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
}
/** A tiny authored cat built from triangle boxes, embedded 2×2 texture and a translated scene node. */
static byte[] fixture()throws IOException{
return fixture(false);
}
static byte[] fixture(boolean uniform)throws IOException{
var xyz=new ArrayList<Float>();var uv=new ArrayList<Float>();
for(float[] box:List.of(new float[]{.2f,0,.1f,.8f,1,.65f},new float[]{.1f,1,.05f,.9f,1.7f,.7f},new float[]{.1f,1.7f,.1f,.3f,2.1f,.5f},new float[]{.7f,1.7f,.1f,.9f,2.1f,.5f},new float[]{.75f,.1f,.2f,1.1f,.65f,.5f})){
float x=box[0],y=box[1],z=box[2],X=box[3],Y=box[4],Z=box[5];float[][] v={{x,y,z},{X,y,z},{X,Y,z},{x,Y,z},{x,y,Z},{X,y,Z},{X,Y,Z},{x,Y,Z}};
int[][] quads={{0,3,2,1},{5,6,7,4},{4,7,3,0},{1,2,6,5},{3,7,6,2},{4,0,1,5}};
for(var q:quads)for(int i:new int[]{0,1,2,0,2,3}){for(float f:v[q[i]])xyz.add(f);uv.add(i==0||i==1?0f:1f);uv.add(i==0||i==3?1f:0f);}
}
var image=new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_ARGB);image.setRGB(0,0,0xFFFF9933);image.setRGB(1,0,0xFF44CCAA);image.setRGB(0,1,0xFF4477EE);image.setRGB(1,1,0xFFFFFFEE);var png=new ByteArrayOutputStream();javax.imageio.ImageIO.write(image,"png",png);
var image=new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_ARGB);image.setRGB(0,0,0xFFFF9933);image.setRGB(1,0,0xFF44CCAA);image.setRGB(0,1,0xFF4477EE);image.setRGB(1,1,0xFFFFFFEE);
if(uniform)for(int y=0;y<2;y++)for(int x=0;x<2;x++)image.setRGB(x,y,0xFF808080);
var png=new ByteArrayOutputStream();javax.imageio.ImageIO.write(image,"png",png);
int pbytes=xyz.size()*4,ubytes=uv.size()*4,ibytes=png.size(),total=pbytes+ubytes+ibytes;var binary=ByteBuffer.allocate((total+3)/4*4).order(ByteOrder.LITTLE_ENDIAN);for(float f:xyz)binary.putFloat(f);for(float f:uv)binary.putFloat(f);binary.put(png.toByteArray());
String json="""
{"asset":{"version":"2.0"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"mesh":0,"translation":[3,2,-1]}],
@@ -61,9 +61,10 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
@Override protected void containerTick(){
super.containerTick();rotate.active=source!=null&&!busy;
var material=menu.getSlot(0).getItem();var resources=minecraft.getModelManager().getBlockStateModelSet();
if(previewSource!=source||!ItemStack.isSameItemSameComponents(lastMaterial,material)||sampledResources!=resources){
boolean changedMaterial=!ItemStack.isSameItemSameComponents(lastMaterial,material)||sampledResources!=resources;
if(previewSource!=source||changedMaterial){
previewSource=source;lastMaterial=material.copy();sampledResources=resources;
palette=SculptureMaterialSampler.sample(material);
if(changedMaterial)palette=SculptureMaterialSampler.sample(material);
var colored=source==null?null:palette==null?source:palette.apply(source);
preview=colored==null?ItemStack.EMPTY:StatuaryEntity.item(colored);pending=true;
if(source!=null&&!busy)status=palette==null?text("choose_material"):text("material_ready",material.getHoverName());
@@ -2,25 +2,46 @@ package fr.koka.sanctuary.client;
import fr.koka.sanctuary.plans.*;
import java.util.*;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.client.resources.model.geometry.BakedQuad;
import net.minecraft.core.*;
import net.minecraft.util.ARGB;
import net.minecraft.util.RandomSource;
import net.minecraft.world.item.*;
/** Representative texture pixels from the active pack, including biome tint and animated first frame. */
/** Bounded samples of the baked faces' actual textures, with their own tint from the active pack. */
final class SculptureMaterialSampler {
private static final int MAX_MATERIALS=32,SAMPLE_EDGE=16;
static SculpturePalette sample(ItemStack stack){
if(!ClayWorkshopMenu.material(stack))return null;
var state=((BlockItem)stack.getItem()).getBlock().defaultBlockState();var sample=PlanMaterials.sample(state);
var mc=net.minecraft.client.Minecraft.getInstance();var tints=mc.getBlockColors().getTintSources(state);
int tint=tints.isEmpty()?0xFFFFFFFF:tints.getFirst().colorInWorld(state,mc.level,mc.player.blockPosition());
var mc=net.minecraft.client.Minecraft.getInstance();
var parts=new ArrayList<BlockStateModelPart>();
mc.getModelManager().getBlockStateModelSet().get(state).collectParts(RandomSource.create(state.getSeed(BlockPos.ZERO)),parts);
var materials=new LinkedHashSet<BakedQuad.MaterialInfo>();
for(var part:parts)for(int face=0;face<=6;face++)for(var quad:part.getQuads(face==6?null:Direction.values()[face])){
if(materials.size()<MAX_MATERIALS)materials.add(quad.materialInfo());
}
var pixels=new ArrayList<Integer>();
for(int pixel:sample.pixels().argb()){
int color=ARGB.multiply(pixel,tint);
if((color>>>24)>=128)pixels.add(color|0xFF000000);
for(var material:materials)add(pixels,PlanMaterials.faceSample(state,material));
if(pixels.isEmpty()){
var tints=mc.getBlockColors().getTintSources(state);
int tint=tints.isEmpty()?0xFFFFFFFF:tints.getFirst().colorInWorld(state,mc.level,mc.player.blockPosition());
add(pixels,new PlanMaterials.Sample(sample.pixels(),tint,sample.average()));
}
if(pixels.isEmpty())pixels.add(sample.average()|0xFF000000);
// Keep repeated pixels: they retain the sampled frequency of each shade.
pixels.sort(Comparator.comparingDouble(SculpturePalette::lightness).thenComparingInt(Integer::intValue));
var palette=new ArrayList<Integer>();int count=Math.min(SculpturePalette.MAX_COLORS,pixels.size());
for(int i=0;i<count;i++)palette.add(pixels.get((int)((i+.5)*pixels.size()/count)));
return new SculpturePalette(palette);
}
private static void add(List<Integer> pixels,PlanMaterials.Sample sample){
var texture=sample.pixels();int width=Math.min(SAMPLE_EDGE,texture.width()),height=Math.min(SAMPLE_EDGE,texture.height());
for(int y=0;y<height;y++)for(int x=0;x<width;x++){
int sx=(2*x+1)*texture.width()/(2*width),sy=(2*y+1)*texture.height()/(2*height);
int color=ARGB.multiply(texture.argb()[sy*texture.width()+sx],sample.tint());
if((color>>>24)>=128)pixels.add(color|0xFF000000);
}
}
}
@@ -264,8 +264,13 @@ public final class HeadBlockService {
var data=stack.get(DataComponents.BLOCK_ENTITY_DATA);
if(data!=null)data.loadInto(entity,level().registryAccess());
}
// Ordinary inventory data belongs to the equipped session, not its transferable item.
if(!keepsContents()){stack.remove(DataComponents.CONTAINER);stack.remove(DataComponents.BLOCK_ENTITY_DATA);}
// Storage belongs to the equipped session. A sculpture's block-entity data is
// its actual geometry, not inventory: keep it on the transferable item.
if(!keepsContents()){
stack.remove(DataComponents.CONTAINER);
if(!(entity instanceof fr.koka.sanctuary.plans.StatuaryEntity))stack.remove(DataComponents.BLOCK_ENTITY_DATA);
}
restoreSculptureData();
}
}
}
@@ -321,6 +326,15 @@ public final class HeadBlockService {
}
public void flushItem(){
if((keepsContents()||entity instanceof BeehiveBlockEntity)&&entity!=null)stack.applyComponents(entity.collectComponents());
restoreSculptureData();
}
private void restoreSculptureData(){
// Older equipped sessions may still hold the model after the stack lost it.
// Repair only from that authoritative data, and only once; never invent geometry.
if(entity instanceof fr.koka.sanctuary.plans.StatuaryEntity sculpture&&!stack.has(DataComponents.BLOCK_ENTITY_DATA)){
var data=sculpture.item().get(DataComponents.BLOCK_ENTITY_DATA);
if(data!=null)stack.set(DataComponents.BLOCK_ENTITY_DATA,data);
}
}
public CompoundTag save(){
flushItem();var tag=new CompoundTag();tag.putInt("schema",1);
@@ -46,6 +46,7 @@ public final class ClayWorkshopMenu extends AbstractContainerMenu {
private final SimpleContainer input=new SimpleContainer(1){@Override public void setChanged(){super.setChanged();ClayWorkshopMenu.this.slotsChanged(this);}};
private final SimpleContainer result=new SimpleContainer(1);
private VoxelSculpture selected;
private VoxelSculpture selectedResult;
private ItemStack lastMaterial=ItemStack.EMPTY;
private Item selectedMaterial=Items.AIR;
private SculpturePalette selectedPalette;
@@ -73,11 +74,13 @@ public final class ClayWorkshopMenu extends AbstractContainerMenu {
if(UseBlockCallback.EVENT.invoker().interact(p,level,InteractionHand.MAIN_HAND,hit)!=InteractionResult.PASS||!stillValid(p))return;
lastSelection=level.getGameTime();
if(sculpture!=null&&(!material(input.getItem(0))||!input.getItem(0).is(material)||palette==null))return;
if(!Objects.equals(selected,sculpture)||!Objects.equals(selectedPalette,palette))
selectedResult=sculpture==null||palette==null?null:palette.apply(sculpture);
selected=sculpture;selectedMaterial=material;selectedPalette=palette;refresh();broadcastChanges();
}
private void refresh(){
if(level==null)return;var item=input.getItem(0);
var sculpture=selected!=null&&selectedPalette!=null&&material(item)&&item.is(selectedMaterial)?selectedPalette.apply(selected):null;lastMaterial=item.copy();
var sculpture=material(item)&&item.is(selectedMaterial)?selectedResult:null;lastMaterial=item.copy();
result.setItem(0,sculpture==null?ItemStack.EMPTY:StatuaryEntity.item(sculpture));
}
@Override public void slotsChanged(Container container){if(container==input)refresh();super.slotsChanged(container);}
@@ -8,16 +8,24 @@ public record SculpturePalette(List<Integer> colors) {
public SculpturePalette {
if(colors==null||colors.isEmpty()||colors.size()>MAX_COLORS||colors.stream().anyMatch(c->c==null||(c>>>24)!=255))
throw new IllegalArgumentException("sculpture_palette");
colors=colors.stream().sorted(Comparator.comparingDouble(SculpturePalette::lightness).thenComparingInt(Integer::intValue)).toList();
colors=colors.stream().sorted(Comparator.comparingInt(SculpturePalette::luminance).thenComparingInt(Integer::intValue)).toList();
}
public static double lightness(int color){return StatueVoxelizer.colorSpace(color)[0];}
// Integer Rec.709 weights: no color-space arrays or transcendental math per voxel.
private static int luminance(int color){return 54*(color>>16&255)+183*(color>>8&255)+19*(color&255);}
public static double lightness(int color){return luminance(color)/65280.;}
public VoxelSculpture apply(VoxelSculpture source){
if(source==null)return null;
var shades=new HashMap<Integer,Double>();for(var cell:source.cells())shades.computeIfAbsent(cell.color(),SculpturePalette::lightness);
double min=shades.values().stream().mapToDouble(Double::doubleValue).min().orElse(0),max=shades.values().stream().mapToDouble(Double::doubleValue).max().orElse(0);
var matches=new HashMap<Integer,Integer>();
shades.forEach((color,l)->{double fraction=max-min<.00001?.5:(l-min)/(max-min);matches.put(color,colors.get(Math.clamp((int)Math.round(fraction*(colors.size()-1)),0,colors.size()-1)));});
return new VoxelSculpture(source.modelId(),source.name(),source.width(),source.height(),source.depth(),source.cells().stream()
.map(c->new VoxelSculpture.Cell(c.x(),c.y(),c.z(),matches.get(c.color()))).toList());
int min=65280,max=0;for(var cell:source.cells()){int l=luminance(cell.color());min=Math.min(min,l);max=Math.max(max,l);}
// Flat models sample the entire texture distribution. Shaded models use overlapping
// half-palette bands: retain large light/dark regions while giving each region grain.
int span=max==min?colors.size():(colors.size()+1)/2;
var cells=new ArrayList<VoxelSculpture.Cell>(source.cells().size());
for(var cell:source.cells()){
int start=max==min?0:(luminance(cell.color())-min)*(colors.size()-span)/(max-min);
int hash=cell.key()+0x9E3779B9;hash=(hash^(hash>>>16))*0x7FEB352D;hash=(hash^(hash>>>15))*0x846CA68B;hash^=hash>>>16;
int offset=(int)((Integer.toUnsignedLong(hash)*span)>>>32);
cells.add(new VoxelSculpture.Cell(cell.x(),cell.y(),cell.z(),colors.get(start+offset)));
}
return new VoxelSculpture(source.modelId(),source.name(),source.width(),source.height(),source.depth(),cells);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.116"
version = "beta.117"
pack-format = "packwiz:1.1.0"
[index]