diff --git a/README.md b/README.md index 22e85d3..02d75da 100644 --- a/README.md +++ b/README.md @@ -20,13 +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 release est publiée et l'instance -**Sanctuary Beta** est synchronisée, avec ses sauvegardes et réglages conservés. +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à l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**. Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées. @@ -37,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, diff --git a/docs/sculpture-texture-grain-beta117.md b/docs/sculpture-texture-grain-beta117.md new file mode 100644 index 0000000..60ce594 --- /dev/null +++ b/docs/sculpture-texture-grain-beta117.md @@ -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. diff --git a/gradle.properties b/gradle.properties index 3399be3..a61cf9a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture116ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture116ClientChecks.java index 2adef86..0bd7c5e 100644 --- a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture116ClientChecks.java +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture116ClientChecks.java @@ -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();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(); 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"); diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture117Checks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture117Checks.java new file mode 100644 index 0000000..356ef2a --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture117Checks.java @@ -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(); + 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();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();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(); + mc.getModelManager().getBlockStateModelSet().get(state).collectParts(RandomSource.create(state.getSeed(BlockPos.ZERO)),parts); + var bySprite=new HashMap>(); + 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();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;im.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);} +} diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Statuary106ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Statuary106ClientChecks.java index 4a0b9ef..0f66b4e 100644 --- a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Statuary106ClientChecks.java +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Statuary106ClientChecks.java @@ -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();var uv=new ArrayList(); 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]}], diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java index 9ae29d2..18c8030 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java @@ -61,9 +61,10 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen(); + mc.getModelManager().getBlockStateModelSet().get(state).collectParts(RandomSource.create(state.getSeed(BlockPos.ZERO)),parts); + var materials=new LinkedHashSet(); + 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()(); - 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();int count=Math.min(SculpturePalette.MAX_COLORS,pixels.size()); for(int i=0;i 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>>24)>=128)pixels.add(color|0xFF000000); + } + } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/HeadBlockService.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/HeadBlockService.java index 960ffd1..73ecb7b 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/HeadBlockService.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/HeadBlockService.java @@ -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); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/ClayWorkshopMenu.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/ClayWorkshopMenu.java index 2a24a86..56f46e9 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/ClayWorkshopMenu.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/ClayWorkshopMenu.java @@ -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);} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculpturePalette.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculpturePalette.java index 170683c..3d79efb 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculpturePalette.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculpturePalette.java @@ -8,16 +8,24 @@ public record SculpturePalette(List 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();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(); - 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(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); } } diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 743cd20..6730844 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "beta.116" +version = "beta.117" pack-format = "packwiz:1.1.0" [index]