Split jigsaw graph editor

- Move planar object resize geometry into JigsawStudioObjectResizer
- Move owned graph JSON document transforms into JigsawStudioGraphDocuments
- Move owned graph reference checks into JigsawStudioGraphInspector
- Point the graph editor resize test at the relocated resizer
- Add a scripted studio persistence session digest test over project and history bytes
This commit is contained in:
Brian Neumann-Fopiano
2026-09-08 06:20:54 -04:00
parent a4e22006e0
commit e7c6b6e08e
6 changed files with 1050 additions and 981 deletions
@@ -0,0 +1,305 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisStructure;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
final class JigsawStudioGraphDocuments {
static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private JigsawStudioGraphDocuments() {
}
static Map<String, JigsawPlanarArchetype> expectedThemeSetSources(
IrisStructure structure,
Set<String> requestedStableIds
) throws IOException {
Map<String, JigsawPlanarArchetype> expected = new LinkedHashMap<>();
if (structure.resolvedMode() == IrisJigsawMode.SPATIAL_JIGSAW) {
if (requestedStableIds.isEmpty()) {
throw new IOException("A spatial theme set requires at least one workcell source");
}
List<String> stableIds = new ArrayList<>(requestedStableIds);
stableIds.sort(Comparator.naturalOrder());
for (String stableId : stableIds) {
if (!stableId.equals(JigsawStudioLayout.SPATIAL_WORKCELL_ID)
&& !stableId.startsWith(JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/")) {
throw new IOException("Invalid spatial workcell source '" + stableId + "'");
}
expected.put(stableId, null);
}
return expected;
}
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells;
try {
workcells = PlanarJigsawWorkcellResolver.resolve(structure);
} catch (IllegalArgumentException exception) {
throw new IOException("Planar workcell configuration is invalid: "
+ exception.getMessage(), exception);
}
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = workcells.get(archetype.modelArchetype());
if (workcell != null && workcell.enabled()) {
expected.put(archetype.stableId(), archetype);
}
}
if (expected.isEmpty()) {
throw new IOException("A planar theme set requires at least one enabled workcell");
}
return expected;
}
static void requireExactThemeSetSources(
Map<String, String> requestedSources,
Set<String> expectedStableIds
) throws IOException {
Set<String> missing = new LinkedHashSet<>(expectedStableIds);
missing.removeAll(requestedSources.keySet());
Set<String> unexpected = new LinkedHashSet<>(requestedSources.keySet());
unexpected.removeAll(expectedStableIds);
if (!missing.isEmpty() || !unexpected.isEmpty()) {
throw new IOException("Theme-set sources must match enabled workcells exactly; missing="
+ missing + ", unexpected=" + unexpected);
}
}
static byte[] appendThemeSet(
byte[] content,
String themeKey,
String structureResource
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw structure is not a JSON object: " + structureResource);
}
JsonObject structure = parsed.getAsJsonObject();
JsonArray themeSets;
if (!structure.has("themeSets")) {
themeSets = new JsonArray();
structure.add("themeSets", themeSets);
} else if (!structure.get("themeSets").isJsonArray()) {
throw new IOException("Jigsaw structure has an invalid themeSets value: " + structureResource);
} else {
themeSets = structure.getAsJsonArray("themeSets");
}
for (JsonElement element : themeSets) {
if (element.isJsonObject()
&& element.getAsJsonObject().has("key")
&& themeKey.equals(element.getAsJsonObject().get("key").getAsString())) {
throw new IOException("Jigsaw structure already declares theme '" + themeKey + "'");
}
}
JsonObject theme = new JsonObject();
theme.addProperty("key", themeKey);
theme.addProperty("weight", 1);
themeSets.add(theme);
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
static byte[] duplicatePieceForTheme(
byte[] content,
String targetPieceKey,
String themeKey,
String sourcePieceResource
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw piece is not a JSON object: " + sourcePieceResource);
}
JsonObject piece = parsed.getAsJsonObject().deepCopy();
piece.addProperty("object", targetPieceKey);
JsonArray themes = new JsonArray();
themes.add(themeKey);
piece.add("themes", themes);
return (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8);
}
static byte[] duplicatePieceForVariant(
byte[] content,
String targetPieceKey,
String sourcePieceResource
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw piece is not a JSON object: " + sourcePieceResource);
}
JsonObject piece = parsed.getAsJsonObject().deepCopy();
piece.addProperty("object", targetPieceKey);
return (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8);
}
static PoolMembershipDuplication duplicatePoolMemberships(
byte[] content,
Map<String, String> targetPieceKeysBySource,
String poolResource
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw pool is not a JSON object: " + poolResource);
}
JsonObject pool = parsed.getAsJsonObject();
JsonArray pieces = pool.getAsJsonArray("pieces");
if (pieces == null) {
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolResource);
}
JsonArray expanded = new JsonArray();
int duplicatedEntries = 0;
for (JsonElement element : pieces) {
expanded.add(element.deepCopy());
if (!element.isJsonObject() || !element.getAsJsonObject().has("piece")) {
continue;
}
String sourcePieceKey = element.getAsJsonObject().get("piece").getAsString();
String targetPieceKey = targetPieceKeysBySource.get(sourcePieceKey);
if (targetPieceKey == null) {
continue;
}
JsonObject duplicate = element.getAsJsonObject().deepCopy();
duplicate.addProperty("piece", targetPieceKey);
expanded.add(duplicate);
duplicatedEntries++;
}
if (duplicatedEntries == 0) {
return new PoolMembershipDuplication(content, 0);
}
pool.add("pieces", expanded);
return new PoolMembershipDuplication(
(GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8),
duplicatedEntries);
}
static List<String> normalizeThemes(List<String> themes) {
Objects.requireNonNull(themes, "Jigsaw Studio piece themes");
Set<String> normalized = new LinkedHashSet<>();
for (String theme : themes) {
Objects.requireNonNull(theme, "Jigsaw Studio piece theme");
String key = theme.trim();
if (key.isEmpty() || !key.equals(theme)) {
throw new IllegalArgumentException(
"Jigsaw Studio piece themes must be non-blank and whitespace-normalized");
}
if (!normalized.add(key)) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio piece theme '" + key + "'");
}
}
return List.copyOf(normalized);
}
static PoolEntryRemoval removePoolEntries(
byte[] content,
String pieceKey,
String poolResource
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw pool is not a JSON object: " + poolResource);
}
JsonObject pool = parsed.getAsJsonObject();
JsonArray pieces = pool.getAsJsonArray("pieces");
if (pieces == null) {
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolResource);
}
int removed = 0;
for (int index = pieces.size() - 1; index >= 0; index--) {
JsonElement entry = pieces.get(index);
if (entry.isJsonObject()
&& entry.getAsJsonObject().has("piece")
&& pieceKey.equals(entry.getAsJsonObject().get("piece").getAsString())) {
pieces.remove(index);
removed++;
}
}
if (removed == 0) {
return new PoolEntryRemoval(content, 0);
}
return new PoolEntryRemoval(
(GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8),
removed);
}
static String resourceKey(String relativePath, String prefix, String suffix) {
return relativePath.substring(prefix.length(), relativePath.length() - suffix.length());
}
static byte[] addPoolEntry(
byte[] content,
String pieceKey,
int weight,
Path poolPath
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw pool is not a JSON object: " + poolPath);
}
JsonObject pool = parsed.getAsJsonObject();
if (!pool.has("pieces") || !pool.get("pieces").isJsonArray()) {
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolPath);
}
JsonArray pieces = pool.getAsJsonArray("pieces");
for (JsonElement element : pieces) {
if (element.isJsonObject()
&& element.getAsJsonObject().has("piece")
&& pieceKey.equals(element.getAsJsonObject().get("piece").getAsString())) {
throw new IOException("Pool already references piece '" + pieceKey + "'");
}
}
JsonObject entry = new JsonObject();
entry.addProperty("piece", pieceKey);
entry.addProperty("weight", weight);
pieces.add(entry);
return (GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8);
}
static String normalizeChannel(String channel) {
String normalized = channel == null ? "" : channel.trim();
if (normalized.equalsIgnoreCase("none")) {
return "";
}
if (normalized.length() > 128) {
throw new IllegalArgumentException("Jigsaw connector channels cannot exceed 128 characters");
}
for (int index = 0; index < normalized.length(); index++) {
if (Character.isWhitespace(normalized.charAt(index))) {
throw new IllegalArgumentException("Jigsaw connector channels cannot contain whitespace");
}
}
return normalized;
}
record PoolEntryRemoval(byte[] content, int removedEntries) {
PoolEntryRemoval {
Objects.requireNonNull(content, "Jigsaw Studio pool entry removal content");
if (removedEntries < 0) {
throw new IllegalArgumentException("Removed jigsaw pool entry count cannot be negative");
}
}
}
record PoolMembershipDuplication(byte[] content, int duplicatedEntries) {
PoolMembershipDuplication {
Objects.requireNonNull(content, "Jigsaw Studio duplicated pool content");
if (duplicatedEntries < 0) {
throw new IllegalArgumentException("Duplicated pool membership count cannot be negative");
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,153 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisStructure;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Map;
final class JigsawStudioGraphInspector {
private JigsawStudioGraphInspector() {
}
static void requireAvailableVariantTarget(
JigsawStudioGraphEditor.OwnedGraph graph,
String relativePath
) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path target = graph.root().resolve(relativePath).normalize();
if (!target.startsWith(graph.root())) {
throw new IOException("Variant target escapes the pack root: " + relativePath);
}
if (graph.manifest().resourceHashes().containsKey(relativePath)
|| Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Variant target already exists: " + relativePath);
}
}
static void requireAvailableThemeSetTarget(
JigsawStudioGraphEditor.OwnedGraph graph,
String relativePath
) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path target = graph.root().resolve(relativePath).normalize();
if (!target.startsWith(graph.root())) {
throw new IOException("Theme-set target escapes the pack root: " + relativePath);
}
if (graph.manifest().resourceHashes().containsKey(relativePath)
|| Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Theme-set target already exists: " + relativePath);
}
}
static boolean otherOwnedPieceReferencesObject(
JigsawStudioGraphEditor.OwnedGraph graph,
String targetPieceKey,
String objectKey
) throws IOException {
for (String relativePath : graph.manifest().resourceHashes().keySet()) {
if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) {
continue;
}
String pieceKey = JigsawStudioGraphDocuments.resourceKey(relativePath, "jigsaw-pieces/", ".json");
if (pieceKey.equals(targetPieceKey)) {
continue;
}
IrisJigsawPiece piece = JigsawStudioGraphEditor.readPiece(JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), relativePath), relativePath);
if (objectKey.equals(piece.getObject())) {
return true;
}
}
return false;
}
static void requireDeletableArchetype(
JigsawStudioGraphEditor.OwnedGraph graph,
String targetPieceKey,
IrisJigsawPiece targetPiece
) throws IOException {
String structureResource = "structures/" + graph.manifest().structure().path() + ".json";
if (!graph.manifest().resourceHashes().containsKey(structureResource)) {
throw new IOException("The owned graph manifest does not include " + structureResource);
}
IrisStructure structure = JigsawStudioGraphEditor.readStructure(
JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), structureResource),
structureResource);
if (structure.resolvedMode() == IrisJigsawMode.SPATIAL_JIGSAW) {
if (countOtherOwnedPieces(graph, targetPieceKey, null) == 0) {
throw new IOException("Cannot delete the final spatial jigsaw variant");
}
return;
}
IrisJigsawWorkcellArchetype archetype = IrisJigsawWorkcellArchetype.fromPiece(targetPiece);
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells;
try {
workcells = PlanarJigsawWorkcellResolver.resolve(structure);
} catch (IllegalArgumentException exception) {
throw new IOException("Planar workcell configuration is invalid: "
+ exception.getMessage(), exception);
}
PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = workcells.get(archetype);
if (workcell != null && workcell.enabled()
&& countOtherOwnedPieces(graph, targetPieceKey, archetype) == 0) {
throw new IOException("Cannot delete the final variant for enabled planar workcell "
+ JigsawPlanarArchetype.fromModel(archetype).stableId());
}
}
static int countOtherOwnedPieces(
JigsawStudioGraphEditor.OwnedGraph graph,
String targetPieceKey,
IrisJigsawWorkcellArchetype archetype
) throws IOException {
int count = 0;
for (String relativePath : graph.manifest().resourceHashes().keySet()) {
if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) {
continue;
}
String pieceKey = JigsawStudioGraphDocuments.resourceKey(relativePath, "jigsaw-pieces/", ".json");
if (pieceKey.equals(targetPieceKey)) {
continue;
}
if (archetype == null) {
count++;
continue;
}
IrisJigsawPiece piece = JigsawStudioGraphEditor.readPiece(JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), relativePath), relativePath);
if (IrisJigsawWorkcellArchetype.fromPiece(piece) == archetype) {
count++;
}
}
return count;
}
static void requireExclusiveObjectReference(
JigsawStudioGraphEditor.OwnedGraph graph,
String activePieceKey,
String objectKey
) throws IOException {
for (String relativePath : graph.manifest().resourceHashes().keySet()) {
if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) {
continue;
}
String pieceKey = relativePath.substring(
"jigsaw-pieces/".length(),
relativePath.length() - ".json".length());
if (pieceKey.equals(activePieceKey)) {
continue;
}
IrisJigsawPiece piece = JigsawStudioGraphEditor.readPiece(JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), relativePath), relativePath);
if (objectKey.equals(piece.getObject())) {
throw new IOException("Object '" + objectKey + "' is shared by piece '" + pieceKey
+ "'; duplicate the active variant before resizing its object bounds");
}
}
}
}
@@ -0,0 +1,548 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.math.IrisBlockVector;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
final class JigsawStudioObjectResizer {
private JigsawStudioObjectResizer() {
}
static PlanarPieceObjectResize resizePlanarPieceObject(
IrisObject source,
IrisJigsawPiece piece,
JigsawPlanarArchetype archetype,
JigsawStudioCellDimensions dimensions,
String pieceKey
) throws IOException {
IrisObject sourceObject = Objects.requireNonNull(source, "Planar Jigsaw Studio source object");
IrisJigsawPiece targetPiece = Objects.requireNonNull(piece, "Planar Jigsaw Studio piece");
JigsawPlanarArchetype targetArchetype = Objects.requireNonNull(
archetype,
"Planar Jigsaw Studio archetype");
JigsawStudioCellDimensions targetDimensions = Objects.requireNonNull(
dimensions,
"Planar Jigsaw Studio target dimensions");
String targetPieceKey = pieceKey == null || pieceKey.isBlank() ? "unknown" : pieceKey;
if (targetDimensions.width() < 3 || targetDimensions.depth() < 3) {
throw new IllegalArgumentException(
"Planar Jigsaw Studio workcell width and depth must each be at least 3 blocks");
}
if (sourceObject.getW() < 1 || sourceObject.getH() < 1 || sourceObject.getD() < 1) {
throw new IOException("Planar piece '" + targetPieceKey + "' has invalid object dimensions");
}
if (IrisJigsawWorkcellArchetype.fromPiece(targetPiece) != targetArchetype.modelArchetype()) {
throw new IOException("Planar piece '" + targetPieceKey + "' does not belong to "
+ targetArchetype.stableId());
}
int quarterTurns = targetArchetype.modelArchetype().sourceToCanonicalQuarterTurns(targetPiece);
JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions(
sourceObject.getW(),
sourceObject.getH(),
sourceObject.getD());
JigsawStudioCellDimensions canonicalDimensions = canonicalDimensions(sourceDimensions, quarterTurns);
Map<LocalPosition, PlatformBlockState> canonicalBlocks = canonicalBlocks(
sourceObject,
quarterTurns,
targetPieceKey);
Map<LocalPosition, TileData> canonicalTiles = canonicalTiles(
sourceObject,
quarterTurns,
targetPieceKey);
List<IrisJigsawConnector> connectors = targetPiece.getConnectors();
if (connectors == null) {
throw new IOException("Planar piece '" + targetPieceKey + "' has no connector list");
}
List<ConnectorResize> connectorResizes = planConnectorResizes(
connectors,
sourceDimensions,
canonicalDimensions,
targetDimensions,
quarterTurns,
targetPieceKey);
relocateConnectorPayloads(canonicalBlocks, canonicalTiles, connectorResizes, targetPieceKey);
requireContentInsideTarget(canonicalBlocks, canonicalTiles, targetDimensions, targetPieceKey);
JigsawStudioCellDimensions resizedSourceDimensions = sourceDimensions(targetDimensions, quarterTurns);
IrisObject resizedObject = rebuildSourceObject(
canonicalBlocks,
canonicalTiles,
resizedSourceDimensions,
quarterTurns,
targetPieceKey);
int relocatedConnectors = 0;
for (ConnectorResize connectorResize : connectorResizes) {
LocalPosition sourcePosition = toSource(
connectorResize.targetCanonical(),
resizedSourceDimensions,
quarterTurns);
connectorResize.connector().setPosition(sourcePosition.toIrisPosition());
if (!connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
relocatedConnectors++;
}
}
return new PlanarPieceObjectResize(resizedObject, relocatedConnectors);
}
private static Map<LocalPosition, PlatformBlockState> canonicalBlocks(
IrisObject source,
int quarterTurns,
String pieceKey
) throws IOException {
Map<LocalPosition, PlatformBlockState> blocks = new LinkedHashMap<>();
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : source.getBlocks()) {
LocalPosition sourcePosition = unsignedPosition(entry.getKey(), source);
requireInside(sourcePosition, source.getW(), source.getH(), source.getD(),
"stored block", pieceKey);
PlatformBlockState state = entry.getValue();
if (state == null) {
throw new IOException("Planar piece '" + pieceKey + "' contains a null stored block state");
}
LocalPosition canonicalPosition = toCanonical(
sourcePosition,
source.getW(),
source.getD(),
quarterTurns);
if (blocks.putIfAbsent(canonicalPosition, state) != null) {
throw new IOException("Planar piece '" + pieceKey
+ "' maps more than one stored block to " + canonicalPosition.describe());
}
}
return blocks;
}
private static Map<LocalPosition, TileData> canonicalTiles(
IrisObject source,
int quarterTurns,
String pieceKey
) throws IOException {
Map<LocalPosition, TileData> tiles = new LinkedHashMap<>();
for (Map.Entry<IrisBlockVector, TileData> entry : source.getStates()) {
LocalPosition sourcePosition = unsignedPosition(entry.getKey(), source);
requireInside(sourcePosition, source.getW(), source.getH(), source.getD(),
"tile data", pieceKey);
TileData tile = entry.getValue();
if (tile == null) {
throw new IOException("Planar piece '" + pieceKey + "' contains null tile data");
}
LocalPosition canonicalPosition = toCanonical(
sourcePosition,
source.getW(),
source.getD(),
quarterTurns);
if (tiles.putIfAbsent(canonicalPosition, tile.clone()) != null) {
throw new IOException("Planar piece '" + pieceKey
+ "' maps more than one tile payload to " + canonicalPosition.describe());
}
}
return tiles;
}
private static List<ConnectorResize> planConnectorResizes(
List<IrisJigsawConnector> connectors,
JigsawStudioCellDimensions sourceDimensions,
JigsawStudioCellDimensions canonicalDimensions,
JigsawStudioCellDimensions targetDimensions,
int quarterTurns,
String pieceKey
) throws IOException {
List<ConnectorResize> planned = new ArrayList<>(connectors.size());
Set<LocalPosition> sourcePositions = new LinkedHashSet<>();
Set<LocalPosition> targetPositions = new LinkedHashSet<>();
IrisPosition sourceSize = dimensionsPosition(sourceDimensions);
IrisPosition canonicalSize = dimensionsPosition(canonicalDimensions);
IrisPosition targetSize = dimensionsPosition(targetDimensions);
for (int index = 0; index < connectors.size(); index++) {
IrisJigsawConnector connector = connectors.get(index);
if (connector == null || connector.getPosition() == null || connector.getDirection() == null
|| connector.getDirection().isVertical()) {
throw new IOException("Planar piece '" + pieceKey + "' contains invalid connector " + index);
}
LocalPosition sourcePosition = LocalPosition.from(connector.getPosition());
requireInside(
sourcePosition,
sourceDimensions.width(),
sourceDimensions.height(),
sourceDimensions.depth(),
"connector " + index,
pieceKey);
if (!sourcePositions.add(sourcePosition)) {
throw new IOException("Planar piece '" + pieceKey
+ "' has multiple connectors at " + sourcePosition.describe());
}
LocalPosition sourceCanonical = toCanonical(
sourcePosition,
sourceDimensions.width(),
sourceDimensions.depth(),
quarterTurns);
IrisDirection canonicalDirection = rotateHorizontalDirection(
connector.getDirection(),
quarterTurns);
LocalPosition expectedSource = LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition(
sourceSize,
connector.getDirection()));
LocalPosition expectedCanonical = LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition(
canonicalSize,
canonicalDirection));
boolean canonicalConnector = sourcePosition.equals(expectedSource)
|| sourceCanonical.equals(expectedCanonical);
LocalPosition targetCanonical = canonicalConnector
? LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition(
targetSize,
canonicalDirection))
: sourceCanonical;
if (!inside(targetCanonical, targetDimensions)) {
throw new IOException("Planar piece '" + pieceKey + "' connector " + index + " at "
+ sourceCanonical.describe() + " would be cropped by the requested workcell bounds");
}
if (!targetPositions.add(targetCanonical)) {
throw new IOException("Planar piece '" + pieceKey
+ "' would place multiple connectors at " + targetCanonical.describe());
}
planned.add(new ConnectorResize(connector, sourceCanonical, targetCanonical));
}
return List.copyOf(planned);
}
private static void relocateConnectorPayloads(
Map<LocalPosition, PlatformBlockState> blocks,
Map<LocalPosition, TileData> tiles,
List<ConnectorResize> connectorResizes,
String pieceKey
) throws IOException {
Set<LocalPosition> relocatedSources = new LinkedHashSet<>();
for (ConnectorResize connectorResize : connectorResizes) {
if (tiles.containsKey(connectorResize.sourceCanonical())
|| tiles.containsKey(connectorResize.targetCanonical())) {
throw new IOException("Planar piece '" + pieceKey + "' has tile data at connector position "
+ connectorResize.sourceCanonical().describe()
+ "; connector tile data cannot be resized safely");
}
if (!connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
relocatedSources.add(connectorResize.sourceCanonical());
}
}
for (ConnectorResize connectorResize : connectorResizes) {
if (connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
continue;
}
if (blocks.containsKey(connectorResize.targetCanonical())
&& !relocatedSources.contains(connectorResize.targetCanonical())) {
throw new IOException("Planar piece '" + pieceKey + "' cannot relocate connector from "
+ connectorResize.sourceCanonical().describe() + " to "
+ connectorResize.targetCanonical().describe()
+ " because the destination contains a stored block");
}
}
List<BlockRelocation> payloads = new ArrayList<>(relocatedSources.size());
for (ConnectorResize connectorResize : connectorResizes) {
if (connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
continue;
}
boolean present = blocks.containsKey(connectorResize.sourceCanonical());
PlatformBlockState state = blocks.remove(connectorResize.sourceCanonical());
payloads.add(new BlockRelocation(connectorResize.targetCanonical(), state, present));
}
for (BlockRelocation payload : payloads) {
if (!payload.present()) {
continue;
}
if (blocks.putIfAbsent(payload.target(), payload.state()) != null) {
throw new IOException("Planar piece '" + pieceKey
+ "' has colliding connector block payloads at " + payload.target().describe());
}
}
}
private static void requireContentInsideTarget(
Map<LocalPosition, PlatformBlockState> blocks,
Map<LocalPosition, TileData> tiles,
JigsawStudioCellDimensions dimensions,
String pieceKey
) throws IOException {
for (LocalPosition position : blocks.keySet()) {
if (!inside(position, dimensions)) {
throw new IOException("Planar piece '" + pieceKey + "' has a stored block, including explicit air, at "
+ position.describe() + " that would be cropped by the requested workcell bounds");
}
}
for (LocalPosition position : tiles.keySet()) {
if (!inside(position, dimensions)) {
throw new IOException("Planar piece '" + pieceKey + "' has tile data at "
+ position.describe() + " that would be cropped by the requested workcell bounds");
}
}
}
private static IrisObject rebuildSourceObject(
Map<LocalPosition, PlatformBlockState> canonicalBlocks,
Map<LocalPosition, TileData> canonicalTiles,
JigsawStudioCellDimensions sourceDimensions,
int quarterTurns,
String pieceKey
) throws IOException {
IrisObject resized = new IrisObject(
sourceDimensions.width(),
sourceDimensions.height(),
sourceDimensions.depth());
Set<LocalPosition> sourcePositions = new LinkedHashSet<>();
for (Map.Entry<LocalPosition, PlatformBlockState> entry : canonicalBlocks.entrySet()) {
LocalPosition sourcePosition = toSource(entry.getKey(), sourceDimensions, quarterTurns);
if (!sourcePositions.add(sourcePosition)) {
throw new IOException("Planar piece '" + pieceKey
+ "' maps multiple stored blocks to " + sourcePosition.describe());
}
resized.setUnsigned(
sourcePosition.x(),
sourcePosition.y(),
sourcePosition.z(),
entry.getValue());
}
sourcePositions.clear();
for (Map.Entry<LocalPosition, TileData> entry : canonicalTiles.entrySet()) {
LocalPosition sourcePosition = toSource(entry.getKey(), sourceDimensions, quarterTurns);
if (!sourcePositions.add(sourcePosition)) {
throw new IOException("Planar piece '" + pieceKey
+ "' maps multiple tile payloads to " + sourcePosition.describe());
}
resized.setUnsignedTile(
sourcePosition.x(),
sourcePosition.y(),
sourcePosition.z(),
entry.getValue().clone());
}
return resized;
}
private static LocalPosition unsignedPosition(IrisBlockVector signed, IrisObject object) {
return new LocalPosition(
signed.getBlockX() + object.getCenter().getBlockX(),
signed.getBlockY() + object.getCenter().getBlockY(),
signed.getBlockZ() + object.getCenter().getBlockZ());
}
static JigsawStudioCellDimensions canonicalDimensions(
JigsawStudioCellDimensions source,
int quarterTurns
) {
return Math.floorMod(quarterTurns, 2) == 0
? source
: new JigsawStudioCellDimensions(source.depth(), source.height(), source.width());
}
private static JigsawStudioCellDimensions sourceDimensions(
JigsawStudioCellDimensions canonical,
int quarterTurns
) {
return Math.floorMod(quarterTurns, 2) == 0
? canonical
: new JigsawStudioCellDimensions(canonical.depth(), canonical.height(), canonical.width());
}
private static LocalPosition toCanonical(
LocalPosition source,
int sourceWidth,
int sourceDepth,
int quarterTurns
) {
return switch (Math.floorMod(quarterTurns, 4)) {
case 0 -> source;
case 1 -> new LocalPosition(sourceDepth - 1 - source.z(), source.y(), source.x());
case 2 -> new LocalPosition(
sourceWidth - 1 - source.x(),
source.y(),
sourceDepth - 1 - source.z());
case 3 -> new LocalPosition(source.z(), source.y(), sourceWidth - 1 - source.x());
default -> throw new IllegalStateException("Unreachable planar object rotation");
};
}
private static LocalPosition toSource(
LocalPosition canonical,
JigsawStudioCellDimensions sourceDimensions,
int quarterTurns
) {
return switch (Math.floorMod(quarterTurns, 4)) {
case 0 -> canonical;
case 1 -> new LocalPosition(
canonical.z(),
canonical.y(),
sourceDimensions.depth() - 1 - canonical.x());
case 2 -> new LocalPosition(
sourceDimensions.width() - 1 - canonical.x(),
canonical.y(),
sourceDimensions.depth() - 1 - canonical.z());
case 3 -> new LocalPosition(
sourceDimensions.width() - 1 - canonical.z(),
canonical.y(),
canonical.x());
default -> throw new IllegalStateException("Unreachable planar object rotation");
};
}
private static IrisDirection rotateHorizontalDirection(IrisDirection direction, int quarterTurns) {
JigsawPlanarDirection planarDirection = switch (direction) {
case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH;
case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST;
case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH;
case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST;
case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new IllegalArgumentException(
"Planar connector direction must be horizontal");
};
return planarDirection.rotateClockwise(quarterTurns).irisDirection();
}
static IrisPosition dimensionsPosition(JigsawStudioCellDimensions dimensions) {
return new IrisPosition(dimensions.width(), dimensions.height(), dimensions.depth());
}
private static boolean inside(LocalPosition position, JigsawStudioCellDimensions dimensions) {
return inside(position, dimensions.width(), dimensions.height(), dimensions.depth());
}
private static boolean inside(LocalPosition position, int width, int height, int depth) {
return position.x() >= 0 && position.x() < width
&& position.y() >= 0 && position.y() < height
&& position.z() >= 0 && position.z() < depth;
}
private static void requireInside(
LocalPosition position,
int width,
int height,
int depth,
String content,
String pieceKey
) throws IOException {
if (!inside(position, width, height, depth)) {
throw new IOException("Planar piece '" + pieceKey + "' has " + content + " at "
+ position.describe() + " outside its object bounds");
}
}
static IrisObject resizeObject(
IrisObject source,
JigsawStudioCellDimensions dimensions,
String pieceKey
) throws IOException {
IrisObject object = Objects.requireNonNull(source, "Jigsaw Studio source object");
JigsawStudioCellDimensions target = Objects.requireNonNull(
dimensions,
"Jigsaw Studio target object dimensions");
String normalizedPiece = pieceKey == null || pieceKey.isBlank() ? "unknown" : pieceKey;
IrisObject resized = new IrisObject(target.width(), target.height(), target.depth());
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : object.getBlocks()) {
IrisBlockVector position = entry.getKey();
LocalPosition unsigned = unsignedPosition(position, object);
if (!inside(unsigned, target)) {
throw new IOException("Spatial piece '" + normalizedPiece
+ "' has a stored block, including explicit air, at " + unsigned.describe()
+ " that would be cropped by the requested variant size");
}
resized.setUnsigned(
unsigned.x(),
unsigned.y(),
unsigned.z(),
entry.getValue());
}
for (Map.Entry<IrisBlockVector, TileData> entry : object.getStates()) {
IrisBlockVector position = entry.getKey();
LocalPosition unsigned = unsignedPosition(position, object);
if (!inside(unsigned, target)) {
throw new IOException("Spatial piece '" + normalizedPiece + "' has tile data at "
+ unsigned.describe() + " that would be cropped by the requested variant size");
}
resized.setUnsignedTile(
unsigned.x(),
unsigned.y(),
unsigned.z(),
entry.getValue().clone());
}
return resized;
}
static void requireConnectorsInside(
IrisJigsawPiece piece,
JigsawStudioCellDimensions dimensions,
String pieceKey
) throws IOException {
if (piece.getConnectors() == null) {
throw new IOException("Spatial piece '" + pieceKey + "' has no connector list");
}
for (int index = 0; index < piece.getConnectors().size(); index++) {
IrisJigsawConnector connector = piece.getConnectors().get(index);
if (connector == null || connector.getPosition() == null) {
throw new IOException("Spatial piece '" + pieceKey + "' contains invalid connector " + index);
}
LocalPosition position = LocalPosition.from(connector.getPosition());
if (!inside(position, dimensions)) {
throw new IOException("Spatial piece '" + pieceKey + "' connector " + index + " at "
+ position.describe() + " would be cropped by the requested variant size");
}
}
}
static String describeDimensions(JigsawStudioCellDimensions dimensions) {
return dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth();
}
record PlanarPieceObjectResize(IrisObject object, int relocatedConnectors) {
PlanarPieceObjectResize {
Objects.requireNonNull(object, "Resized planar Jigsaw Studio object");
if (relocatedConnectors < 0) {
throw new IllegalArgumentException("Relocated connector count cannot be negative");
}
}
}
private record ConnectorResize(
IrisJigsawConnector connector,
LocalPosition sourceCanonical,
LocalPosition targetCanonical
) {
private ConnectorResize {
Objects.requireNonNull(connector, "Planar Jigsaw Studio connector");
Objects.requireNonNull(sourceCanonical, "Planar Jigsaw Studio source connector position");
Objects.requireNonNull(targetCanonical, "Planar Jigsaw Studio target connector position");
}
}
private record BlockRelocation(LocalPosition target, PlatformBlockState state, boolean present) {
private BlockRelocation {
Objects.requireNonNull(target, "Planar Jigsaw Studio connector block target");
if (present) {
Objects.requireNonNull(state, "Planar Jigsaw Studio connector block state");
}
}
}
private record LocalPosition(int x, int y, int z) {
private static LocalPosition from(IrisPosition position) {
return new LocalPosition(position.getX(), position.getY(), position.getZ());
}
private IrisPosition toIrisPosition() {
return new IrisPosition(x, y, z);
}
private String describe() {
return x + "," + y + "," + z;
}
}
}
@@ -147,8 +147,8 @@ public class JigsawStudioGraphEditorResizeTest {
source.setUnsigned(4, 1, 1, stone);
IrisJigsawPiece piece = endPiece(IrisDirection.EAST_POSITIVE_X, new IrisPosition(6, 1, 2));
JigsawStudioGraphEditor.PlanarPieceObjectResize result =
JigsawStudioGraphEditor.resizePlanarPieceObject(
JigsawStudioObjectResizer.PlanarPieceObjectResize result =
JigsawStudioObjectResizer.resizePlanarPieceObject(
source,
piece,
JigsawPlanarArchetype.END,
@@ -175,7 +175,7 @@ public class JigsawStudioGraphEditorResizeTest {
IOException failure = assertThrows(
IOException.class,
() -> JigsawStudioGraphEditor.resizePlanarPieceObject(
() -> JigsawStudioObjectResizer.resizePlanarPieceObject(
source,
piece,
JigsawPlanarArchetype.BLANK,
@@ -199,7 +199,7 @@ public class JigsawStudioGraphEditorResizeTest {
IOException failure = assertThrows(
IOException.class,
() -> JigsawStudioGraphEditor.resizePlanarPieceObject(
() -> JigsawStudioObjectResizer.resizePlanarPieceObject(
source,
piece,
JigsawPlanarArchetype.END,