diff --git a/megameklab/src/megameklab/printing/InventoryWriter.java b/megameklab/src/megameklab/printing/InventoryWriter.java index 1652300bd0d..63e452aa237 100644 --- a/megameklab/src/megameklab/printing/InventoryWriter.java +++ b/megameklab/src/megameklab/printing/InventoryWriter.java @@ -39,9 +39,12 @@ import static megameklab.printing.PrintRecordSheet.svgNS; import java.text.NumberFormat; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.TreeMap; @@ -60,6 +63,7 @@ import megamek.common.equipment.MiscType; import megamek.common.equipment.Mounted; import megamek.common.equipment.WeaponMounted; +import megamek.common.equipment.WeaponType; import megamek.common.equipment.enums.MiscTypeFlag; import megamek.common.units.Aero; import megamek.common.units.Entity; @@ -94,7 +98,7 @@ public class InventoryWriter { /** * The minimum font size to use when scaling inventory text to fit into available space */ - private static final float MIN_FONT_SIZE = 4.5f; + static final float MIN_FONT_SIZE = 4.5f; private static final float QUIRKS_FONT_SCALING = 0.9f; private static final float FOOTER_TEXT_WIDTH_RATIO = 0.95f; @@ -440,12 +444,11 @@ private void parseBays() { standardWeapons.add(m); } } - List ammoMountedList = sheet.getEntity().getAmmo(); - List list = computeWeaponBayTexts(capitalWeapons, ammoMountedList); + List list = computeWeaponBayTexts(capitalWeapons); for (WeaponBayText text : list) { capitalBays.add(new WeaponBayInventoryEntry((Aero) sheet.getEntity(), ++weaponBayIndex, text, true)); } - list = computeWeaponBayTexts(standardWeapons, ammoMountedList); + list = computeWeaponBayTexts(standardWeapons); boolean artemisIV = false; boolean artemisV = false; boolean apollo = false; @@ -476,37 +479,68 @@ private void parseBays() { * * @return A list of bays condensed by weapon type and symmetric location */ - private List computeWeaponBayTexts(List weapons, List ammoMountedList) { + static List computeWeaponBayTexts(List weapons) { List weaponBayTexts = new ArrayList<>(); - // Collection info on weapons to print + Map> uncombinedBays = new HashMap<>(); + // Collect info on weapons to print. for (WeaponMounted bay : weapons) { WeaponBayText wbt = new WeaponBayText(bay.getLocation(), bay.isRearMounted()); + Map> ammoByCompatibility = ammoByCompatibility(bay.getBayAmmo()); for (WeaponMounted weaponMounted : bay.getBayWeapons()) { - if (!wbt.addBayWeapon(weaponMounted)) {continue;} - for (AmmoMounted ammo : ammoMountedList) { - if (ammo.getLocation() == weaponMounted.getLocation() - && weaponMounted.getType().getAmmoType() == ammo.getType().getAmmoType()) { - wbt.addBayAmmo(weaponMounted.getType(), ammo); - } + if (!wbt.addBayWeapon(weaponMounted)) { + continue; } - } - // Combine or add - boolean combined = false; - for (WeaponBayText combine : weaponBayTexts) { - if (combine.canCombine(wbt)) { - combine.combine(wbt); - combined = true; - break; + WeaponType weaponType = weaponMounted.getType(); + for (AmmoMounted ammo : ammoByCompatibility.getOrDefault( + new AmmoKey(weaponType.getAmmoType(), weaponType.getRackSize()), List.of())) { + wbt.addBayAmmo(weaponType, ammo); } } - if (!combined) { + int location = bay.getLocation(); + int opposingLocation = WeaponBayText.opposingLocation(location); + if (opposingLocation < 0) { + weaponBayTexts.add(wbt); + continue; + } + WeaponBayText.CombinationKey signature = wbt.combinationKey(); + // Only front-side/wing bays distinguish rear mounts. A single FIFO for the other + // locations preserves the first compatible row, regardless of its rear flag. + boolean rear = WeaponBayText.rearMustMatch(location) && bay.isRearMounted(); + CandidateKey opposingKey = new CandidateKey(signature, opposingLocation, rear); + Deque candidates = uncombinedBays.get(opposingKey); + if (candidates == null) { weaponBayTexts.add(wbt); + CandidateKey key = new CandidateKey(signature, location, rear); + uncombinedBays.computeIfAbsent(key, ignored -> new ArrayDeque<>()) + .addLast(wbt); + } else { + candidates.removeFirst().combine(wbt); + if (candidates.isEmpty()) { + uncombinedBays.remove(opposingKey); + } } } Collections.sort(weaponBayTexts); return weaponBayTexts; } + private record AmmoKey(AmmoType.AmmoTypeEnum type, int rackSize) { } + + private record CandidateKey(WeaponBayText.CombinationKey signature, int location, boolean rear) { } + + /** Same type/rack compatibility as AmmoType.isAmmoValid, preserving bin order within each bucket. */ + private static Map> ammoByCompatibility(List ammo) { + Map> result = new HashMap<>(); + for (AmmoMounted mounted : ammo) { + AmmoType type = mounted.getType(); + if (type != null) { + result.computeIfAbsent(new AmmoKey(type.getAmmoType(), type.getRackSize()), key -> new ArrayList<>()) + .add(mounted); + } + } + return result; + } + public double startingY() { return viewY + sheet.getFontHeight(FONT_SIZE_MEDIUM) * 1.2; } @@ -661,7 +695,7 @@ private boolean hasFooterContent() { static private final float INITIAL_LINE_SPACING = 1.2f; // the initial line spacing factor static private final float LINE_SPACING_REDUCTION_STEP = 0.01f; // tiny spacing steps avoid visual jumps static private final float FONT_SIZE_REDUCTION_STEP = 0.05f; // small steps keep font changes visually smooth - static private final float MIN_LINE_HEIGHT_TO_FONT_SIZE = 0.93f; + static final float MIN_LINE_HEIGHT_TO_FONT_SIZE = 0.93f; static private final float MAX_LINE_HEIGHT_TO_FONT_SIZE = 1.35f; /** @@ -679,10 +713,16 @@ public float[] scaleText(double height, Function calcLines) { private float[] scaleText(double height, Function calcLines, Function calcLinePadding) { + return scaleText(height, calcLines, calcLinePadding, sheet::getFontHeight); + } + + /** Also used to plan continuation pages before a sheet's SVG drawing context exists. */ + static float[] scaleText(double height, Function calcLines, + Function calcLinePadding, Function fontHeights) { float fontSize = FONT_SIZE_MEDIUM; while (true) { - double lineCount = scaledLineCount(fontSize, calcLines, calcLinePadding); - float fontHeight = sheet.getFontHeight(fontSize); + double lineCount = calcLines.apply(fontSize) + calcLinePadding.apply(fontSize); + float fontHeight = fontHeights.apply(fontSize); float minLineSpacing = minLineSpacing(fontSize, fontHeight); float maxLineSpacing = maxLineSpacing(fontSize, fontHeight, minLineSpacing); @@ -702,27 +742,21 @@ private float[] scaleText(double height, Function calcLines, } } - private boolean fits(double height, float fontHeight, double lineCount, float lineSpacing) { + private static boolean fits(double height, float fontHeight, double lineCount, float lineSpacing) { return (lineCount <= 0) || (fontHeight * lineSpacing * lineCount <= height); } - private float minLineSpacing(float fontSize, float fontHeight) { + private static float minLineSpacing(float fontSize, float fontHeight) { // One font size needs at least about one font-size of baseline distance. Convert that real distance to a factor. return fontSize * MIN_LINE_HEIGHT_TO_FONT_SIZE / fontHeight; } - private float maxLineSpacing(float fontSize, float fontHeight, float minLineSpacing) { + private static float maxLineSpacing(float fontSize, float fontHeight, float minLineSpacing) { // Small fonts should not get huge airy rows, so cap max spacing by the font's own size too. float fontSizedMaxSpacing = (fontSize * MAX_LINE_HEIGHT_TO_FONT_SIZE) / fontHeight; return Math.max(minLineSpacing, Math.min(INITIAL_LINE_SPACING, fontSizedMaxSpacing)); } - private double scaledLineCount(float fontSize, Function calcLines, - Function calcLinePadding) { - // Most callers count whole rows. Inventory can also reserve half-row visual padding. - return calcLines.apply(fontSize) + calcLinePadding.apply(fontSize); - } - /** * Displays ammo, fuel, features, and quirks as free-flowing text at the bottom of the inventory box * diff --git a/megameklab/src/megameklab/printing/PrintBuilding.java b/megameklab/src/megameklab/printing/PrintBuilding.java new file mode 100644 index 00000000000..4d90899272f --- /dev/null +++ b/megameklab/src/megameklab/printing/PrintBuilding.java @@ -0,0 +1,628 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.printing; + +import java.awt.font.FontRenderContext; +import java.awt.geom.Rectangle2D; +import java.awt.print.PageFormat; +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import megamek.common.bays.Bay; +import megamek.common.board.Coords; +import megamek.common.board.CubeCoords; +import megamek.common.equipment.AmmoType; +import megamek.common.equipment.Mounted; +import megamek.common.equipment.PowerGeneratorType; +import megamek.common.equipment.WeaponType; +import megamek.common.units.AbstractBuildingEntity; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.BuildingDesign; +import megamek.common.units.IBuilding; +import megamek.common.units.MobileStructure; +import megameklab.util.BuildingMap; +import megameklab.util.BuildingMap.Feature; +import megameklab.util.BuildingUtil; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.w3c.dom.svg.SVGRectElement; + +/** Full-page structure record sheet, using the same template/print pipeline as other entities. */ +public class PrintBuilding extends PrintEntity { + private static final int LEVELS_PER_PAGE = 6; + private static final FontRenderContext TEXT_CONTEXT = new FontRenderContext(null, true, true); + private List plannedInventory; + private final AbstractBuildingEntity building; + private int currentPage; + + public PrintBuilding(AbstractBuildingEntity building, int firstPage, RecordSheetOptions options) { + super(firstPage, options); + this.building = building; + } + + @Override + public AbstractBuildingEntity getEntity() { + return building; + } + + @Override + public int getPageCount() { + return Math.max(Math.max((BuildingConstruction.mapLevels(building).size() + LEVELS_PER_PAGE - 1) / LEVELS_PER_PAGE, + (protectionRows().size() + 35) / 36), inventoryPages().size()); + } + + @Override + protected void processImage(int pageNum, PageFormat pageFormat) { + currentPage = pageNum; + super.processImage(pageNum, pageFormat); + setTextField("pageNumber", "Page " + (pageNum + 1) + " / " + getPageCount()); + // Keep the selected sheet font, with a PDF-safe sans-serif fallback when it is not installed. + NodeList textElements = getSVGDocument().getElementsByTagName("text"); + for (int i = 0; i < textElements.getLength(); i++) { + ((Element) textElements.item(i)).setAttribute("font-family", getTypeface() + ",Helvetica,sans-serif"); + } + } + + @Override + protected String getSVGFileName(int pageNumber) { + return "building_default.svg"; + } + + @Override + protected String getRecordSheetTitle() { + if (building instanceof MobileStructure) { + return "Mobile Structure Record Sheet"; + } + return "Structure Record Sheet"; + } + + @Override + protected boolean supportsAlternateArmorGrouping() { + return false; + } + + @Override + protected boolean includeReferenceCharts() { + return false; + } + + @Override + protected void writeTextFields() { + setTextField(TITLE, getRecordSheetTitle().toUpperCase()); + setTextField(TYPE, building.getShortNameRaw(), true); + setTextField("levels", building.getBldgClass() == IBuilding.BRIDGE ? "Decks: " + BuildingConstruction.mapLevels(building).stream() + .map(building::getLevelLabel).collect(Collectors.joining(",")) : Integer.toString(building.getInternalBuilding().getBuildingHeight())); + setTextField(MP_WALK, building instanceof MobileStructure mobile + ? NumberFormat.getNumberInstance().format(mobile.getMaximumMP()) : "0"); + setTextField("movementType", building instanceof MobileStructure mobile ? mobile.getMovementModeAsString() : "Static"); + if (!building.isClan() && !building.isMixedTech()) { + hideElement("techClanCheck"); + } + if (building.isClan() && !building.isMixedTech()) { + hideElement("techISCheck"); + } + setTextField(COST, formatCost()); + setTextField(BV, NumberFormat.getInstance().format(building.calculateBattleValue(true, !showPilotInfo()))); + String generators = building.getEquipment().stream().filter(m -> m.getType() instanceof PowerGeneratorType) + .map(Mounted::getName).distinct().collect(Collectors.joining(", ")); + if (building instanceof MobileStructure mobile) { + generators = mobile.getPowerSystem().getEngineName(); + } + setTextField("powerplant", BuildingConstruction.hasNoInterior(building) || BuildingConstruction.usesHexsides(building) + ? "NA" : generators.isBlank() ? "External supply" : generators, true); + setTextField("buildingCrew", Integer.toString(building.getNCrew())); + if (showPilotInfo()) { + setTextField("buildingGunnery", Integer.toString(building.getCrew().getGunnery())); + } + } + + @Override + protected void drawArmor() { + Rectangle2D box = getRectBBox((SVGRectElement) getSVGDocument().getElementById("buildingProtection")); + Element canvas = (Element) getSVGDocument().getElementById("buildingProtection").getParentNode(); + List allProtection = protectionRows(); + List protection = allProtection.stream().skip(currentPage * 36L).limit(36).toList(); + int rows = Math.max(1, (protection.size() + 1) / 2); + double step = Math.min(12, (box.getHeight() - 16) / rows); + float font = (float) Math.min(6.6, step * .65); + double columnWidth = box.getWidth() / 2; + for (int column = 0; column < 2; column++) { + double x = box.getX() + column * columnWidth; + text(canvas, x + 13, box.getY() + 7, 26, BuildingConstruction.usesHexsides(building) ? "Hex/Side" : "Hex", 6.7f, "middle", "bold"); + text(canvas, x + columnWidth * .52, box.getY() + 7, 25, building.getConstructionCFScale() == 10 ? "CF*" : "CF", 6.7f, "middle", "bold"); + text(canvas, x + columnWidth * .84, box.getY() + 7, 27, "Armor", 6.7f, "middle", "bold"); + for (int row = 0; row < rows; row++) { + double y = box.getY() + 18 + row * step; + if (y > box.getMaxY() - 2) { + break; + } + int index = column * rows + row; + if (row >= rows || index >= protection.size()) { + continue; + } + Protection entry = protection.get(index); + int loc = building.getInternalBuilding().getOriginalCoordsList().indexOf(entry.hex()) + * building.getInternalBuilding().getBuildingHeight(); + text(canvas, x + 13, y, 26, entry.label(), font, "middle", "normal"); + text(canvas, x + columnWidth * .52, y, 25, + Integer.toString(options.showDamage() ? building.getInternal(loc) : building.getOInternal(loc)), + font, "middle", "normal"); + text(canvas, x + columnWidth * .84, y, 27, + Integer.toString(options.showDamage() ? Math.max(0, building.getArmor(loc)) : building.getOArmor(loc)), + font, "middle", "normal"); + } + } + } + + private record Protection(CubeCoords hex, String label) { } + + private List protectionRows() { + List hexes = building.getInternalBuilding().getOriginalCoordsList(); + BuildingUtil.SheetGrid grid = BuildingUtil.sheetGrid(hexes); + List result = new ArrayList<>(); + for (CubeCoords hex : hexes) { + if (BuildingConstruction.usesHexsides(building)) { + for (int side = 0; side < 6; side++) { + if ((building.getDesign().wallSides(hex) & (1 << side)) != 0) { + result.add(new Protection(hex, grid.label(hex) + "/" + BuildingUtil.facingLabel(side))); + } + } + } else { + result.add(new Protection(hex, grid.label(hex))); + } + } + return result; + } + + @Override + protected void drawStructure() { + Element region = getSVGDocument().getElementById("structureMap"); + Rectangle2D box = getRectBBox((SVGRectElement) region); + List hexes = building.getInternalBuilding().getOriginalCoordsList(); + BuildingUtil.SheetGrid grid = BuildingUtil.sheetGrid(hexes); + Map occupied = new LinkedHashMap<>(); + hexes.forEach(hex -> occupied.put(grid.position(hex), hex)); + double width = 30 * (grid.columns() - 1) + 40 + 6 * (grid.rows() - 1); + double height = 12 * (grid.rows() + .5); + List mapLevels = BuildingConstruction.mapLevels(building).stream().skip((long) currentPage * LEVELS_PER_PAGE) + .limit(LEVELS_PER_PAGE).toList(); + int levels = mapLevels.size(); + if (levels <= 0) { + return; + } + List mapDoors = building.getDesign().getMapDoors(); + BuildingMap.FeatureIndex featureIndex = BuildingMap.featureIndex(building, mapDoors); + LinkedHashSet symbols = new LinkedHashSet<>(); + for (CubeCoords hex : hexes) { + for (int level : mapLevels) { + symbols.addAll(featureIndex.features(hex, level)); + } + } + int keyColumns = Math.max(1, (int) (box.getWidth() / 110)); + double keyHeight = symbols.isEmpty() ? 0 : Math.ceil((double) symbols.size() / keyColumns) * 16 + 12; + double scale = Math.min((box.getWidth() - 8) / width, (box.getHeight() - keyHeight - levels * 18) / (levels * height)); + double layerHeight = height * scale + 18; + double headerGap = Math.min(24, Math.max(0, box.getHeight() - keyHeight - levels * layerHeight)); + double[][] corners = { { -20, 0 }, { -7, -6 }, { 13, -6 }, { 20, 0 }, { 7, 6 }, { -13, 6 } }; + for (int layerIndex = 0; layerIndex < levels; layerIndex++) { + int level = mapLevels.get(layerIndex); + Element layer = element((Element) region.getParentNode(), "g", "class", "building-map-layer", + "data-building-floor", Integer.toString(level), "transform", "translate(%s %s)".formatted( + box.getX() + (box.getWidth() - width * scale) / 2, + box.getY() + headerGap + layerIndex * layerHeight)); + Element background = element(layer, "g"); + Element footprint = element(layer, "g"); + Element annotations = element(layer, "g"); + for (int column = 0; column < grid.columns(); column++) { + for (int row = 0; row < grid.rows(); row++) { + CubeCoords hex = occupied.get(new Coords(column, row)); + boolean present = hex != null && BuildingConstruction.occupiesMapLevel(building, hex, level) + && BuildingConstruction.segmentsInHex(building, hex) > 0; + boolean wall = BuildingConstruction.usesHexsides(building); + List cellFeatures = present ? featureIndex.features(hex, level) : List.of(); + Feature fill = BuildingMap.fill(cellFeatures); + double staggeredRow = row + (column & 1) * .5; + double x = (column * 30 - staggeredRow * 6 + 20 + 6 * (grid.rows() - 1)) * scale; + double y = (staggeredRow * 12 + 6) * scale; + StringBuilder points = new StringBuilder(); + for (double[] corner : corners) { + points.append(x + corner[0] * scale).append(',').append(y + corner[1] * scale).append(' '); + } + Element polygon = element(present ? footprint : background, "polygon", "points", points.toString(), + "fill", fill == null ? "none" : fill.color, + "stroke", present && !wall ? "#000" : "#bbb", "stroke-width", present && !wall ? "1.5" : ".35", + "stroke-linejoin", "round", "class", present ? "building-hex occupied" : "building-hex"); + if (present) { + if (wall) { + for (int side = 0; side < 6; side++) { + if ((building.getDesign().wallSides(hex) & (1 << side)) != 0) { + double[] a = corners[(side + 1) % 6]; + double[] b = corners[(side + 2) % 6]; + element(annotations, "line", "x1", Double.toString(x + a[0] * scale), "y1", Double.toString(y + a[1] * scale), + "x2", Double.toString(x + b[0] * scale), "y2", Double.toString(y + b[1] * scale), + "stroke", "#000", "stroke-width", "1.8", "data-building-side", Integer.toString(side), + "data-building-hex", grid.label(hex)); + } + } + } + polygon.setAttribute("data-building-hex", grid.label(hex)); + polygon.setAttribute("data-building-features", cellFeatures.stream().map(Feature::symbol).collect(Collectors.joining(" "))); + List glyphs = cellFeatures.stream().filter(symbol -> !symbol.glyph.isBlank()).toList(); + for (BuildingMap.DoorMarker door : featureIndex.doors(hex, level)) { + if (door.facing() < 0 || door.facing() > 5) { + continue; + } + double[] a = corners[(door.facing() + 1) % 6]; + double[] b = corners[(door.facing() + 2) % 6]; + double[][] arrow = BuildingMap.doorPoints(a, b); + Element marker = mapPolygon(annotations, arrow, x, y, scale, door.feature().color); + marker.setAttribute("data-building-symbol", door.feature().symbol()); + marker.setAttribute("data-building-facing", Integer.toString(door.facing())); + } + if (glyphs.isEmpty()) { + text(annotations, x, y + 2.3 * scale, 30 * scale, grid.label(hex), (float) (6.5 * scale), "middle", "normal"); + } else { + // Center the complete text run, rather than positioning the symbol and coordinate separately. + Element label = element(annotations, "text", "x", Double.toString(x), "y", Double.toString(y + 2.3 * scale), + "font-size", Double.toString(6.5 * scale), "text-anchor", "middle"); + for (int index = 0; index < glyphs.size(); index++) { + Element glyph = element(label, "tspan", "data-building-symbol", glyphs.get(index).symbol(), + "font-size", Double.toString(5.5 * scale), "font-weight", "bold", "dx", Double.toString(index == 0 ? 0 : scale)); + glyph.setTextContent(glyphs.get(index).glyph); + } + element(label, "tspan", "dx", Double.toString(1.5 * scale)).setTextContent(grid.label(hex)); + } + } + } + } + text(layer, width * scale, height * scale + 10, width * scale, + "Level: " + building.getLevelLabel(level, true), 7, "end", "bold"); + } + if (!symbols.isEmpty()) { + Element key = element((Element) region.getParentNode(), "g", "class", "building-map-key", "transform", + "translate(%s %s)".formatted(box.getX() + 8, box.getY() + headerGap + levels * layerHeight + 6)); + int index = 0; + for (Feature symbol : symbols) { + double x = index % keyColumns * ((box.getWidth() - 16) / keyColumns), y = index / keyColumns * 16; + mapKeySymbol(key, symbol, x + 6, y + 4); + text(key, x + 17, y + 6, box.getWidth() / keyColumns - 22, symbol.label, 6.5f, "start", "normal"); + index++; + } + } + } + + private void mapKeySymbol(Element parent, Feature symbol, double x, double y) { + if (!symbol.glyph.isBlank()) { + mapPolygon(parent, new double[][] { { -6, 0 }, { -3, -4 }, { 3, -4 }, { 6, 0 }, { 3, 4 }, { -3, 4 } }, x, y, 1, symbol.color); + } + Element glyph = element(parent, "g", "data-building-symbol", symbol.symbol()); + if (symbol.glyph.isBlank()) { + mapPolygon(glyph, new double[][] { { 0, -4 }, { 3.5, 3 }, { -3.5, 3 } }, x, y, 1, symbol.color); + } else { + text(glyph, x, y + 2, 7, symbol.glyph, 6, "middle", "bold"); + } + } + + private Element mapPolygon(Element parent, double[][] points, double x, double y, double scale, String fill) { + StringBuilder polygon = new StringBuilder(); + for (double[] point : points) { + polygon.append(x + point[0] * scale).append(',').append(y + point[1] * scale).append(' '); + } + return element(parent, "polygon", "points", polygon.toString(), "fill", fill, "stroke", "#000", "stroke-width", ".8"); + } + + private record EquipmentKey(String internalName, int location) { + } + + /** Quantity groups deliberately use the equipment's internal id and exact hex/level. */ + List>> inventoryGroups() { + Map>> groups = new LinkedHashMap<>(); + for (Mounted mount : building.getEquipment()) { + if (!mount.isOneShotAmmo() && !mount.isWeaponGroup()) { + groups.computeIfAbsent(new EquipmentKey(mount.getType().getInternalName(), mount.getLocation()), + key -> new ArrayList<>()).add(mount); + } + } + return List.copyOf(groups.values()); + } + + private record InventoryGroup(String id, int location, List rows, boolean destroyed) { + } + + private List inventoryEntries() { + List entries = new ArrayList<>(); + for (List> group : inventoryGroups()) { + Mounted first = group.getFirst(); + StandardInventoryEntry entry = new StandardInventoryEntry(first); + List rows = new ArrayList<>(); + for (int row = 0; row < entry.nRows(); row++) { + String name = entry.getNameField(row); + if (row == 0 && first.getType().isVariableSize()) { + name = first.getType().getName(); + } + if (row == 0 && first.getType() instanceof AmmoType) { + int shots = group.stream().mapToInt(m -> options.showDamage() ? m.getBaseShotsLeft() : m.getOriginalShots()).sum(); + name = first.getType().getShortName() + " (" + shots + ")"; + } + rows.add(new String[] { row == 0 ? Integer.toString(group.size()) : "", name, + row == 0 ? BuildingUtil.locationLabel(building, first.getLocation()) : "", entry.getDamageField(row), + entry.getMinField(row), entry.getShortField(row), entry.getMediumField(row), entry.getLongField(row) }); + } + entries.add(new InventoryGroup(first.getType().getInternalName(), first.getLocation(), rows, + group.stream().allMatch(m -> m.isDestroyed() || m.isMissing()))); + Map placements = group.stream().filter(m -> m.getType() instanceof WeaponType) + .map(this::mountDescription).filter(s -> !s.isBlank()) + .collect(Collectors.groupingBy(s -> s, LinkedHashMap::new, Collectors.counting())); + if (!placements.isEmpty()) { + entries.add(note("mount-" + first.getType().getInternalName(), placements.entrySet().stream() + .map(placement -> placement.getValue() + " × " + placement.getKey()).collect(Collectors.joining("; ")), "")); + } + if (first.getType().isVariableSize()) { + group.stream().collect(Collectors.groupingBy(Mounted::getSize, LinkedHashMap::new, Collectors.counting())) + .forEach((size, count) -> entries.add(note("equipment-size", "Size " + size + " (×" + count + ")", + BuildingUtil.locationLabel(building, first.getLocation())))); + } + for (Mounted mount : group) { + List spaces = building.getDesign().getEquipmentSpace().get(mount); + if (spaces != null && !spaces.isEmpty()) { + for (BuildingDesign.Position position : spaces) { + entries.add(note("space-" + building.getEquipmentNum(mount), "Mass share: %.2f t".formatted(mount.getTonnage() / spaces.size()), + BuildingUtil.locationLabel(building, BuildingConstruction.location(building, position)))); + } + } + if (building.getDesign().getPcmtSources().containsKey(mount)) { + entries.add(note("pcmt-source", "PCMT source: " + building.getDesign().getPcmtSources().get(mount) + " t", "")); + } + } + } + for (Bay bay : building.getTransportBays()) { + String name = "%s (%s t)".formatted(bay.getTransporterType(), NumberFormat.getInstance().format(bay.getWeight())); + entries.add(new InventoryGroup("bay-" + bay.getBayNumber(), -1, + List.of(new String[] { "1", name, "—", "", "", "", "", "" }), false)); + if (building.getDesign().getBaySpace().containsKey(bay)) { + for (BuildingDesign.Space space : BuildingConstruction.baySpaces(building, bay)) { + entries.add(note("bay-space", "Space: %.2f t".formatted(space.tons()), + BuildingUtil.locationLabel(building, BuildingConstruction.location(building, space.position())))); + } + } + } + if (building.getTroopCarryingSpace() > 0) { + String name = "Infantry compartment (%s t)".formatted(NumberFormat.getInstance().format(building.getTroopCarryingSpace())); + entries.add(new InventoryGroup("infantry-compartment", -1, + List.of(new String[] { "1", name, "—", "", "", "", "", "" }), false)); + } + appendDesign(entries); + return entries; + } + + private InventoryGroup note(String id, String description, String location) { + return new InventoryGroup(id, -1, List.of(new String[] { "", (location.isBlank() || location.equals("All") ? "" : location + ": ") + + description, "", "", "", "", "", "" }), false); + } + + private String mountDescription(Mounted mount) { + String result = BuildingConstruction.isCapital(mount.getType()) ? "Upward (capital)" + : mount.isSponsonTurretMounted() ? "Roof turret (T)" : mount.getFacing() >= 0 && mount.getFacing() < 6 + ? BuildingUtil.facingLabel(mount.getFacing()) + (mount.isPintleTurretMounted() ? " (P)" : " fixed") : ""; + return result + (building.getDesign().getAutomatedWeapons().contains(mount) ? "; auto, Gunnery 5" : ""); + } + + private void appendDesign(List entries) { + BuildingDesign design = building.getDesign(); + entries.add(note("classification", (building.getBldgClass() == IBuilding.TENT || building.getBldgClass() == IBuilding.FENCE + ? "" : building.getBuildingType() + " / ") + (building.getBldgClass() == IBuilding.STANDARD + ? "Standard" : IBuilding.className(building.getBldgClass())), "")); + if (building.getConstructionCFScale() == 10) { + entries.add(note("capital-protection", "CF and armor: capital points (×10 standard)", "All")); + } + if (building.hasEnvironmentalSealing()) { + entries.add(note("sealing", "Environmental sealing", "All")); + } + if (design.hasHeavyMetal()) { + entries.add(note("heavy-metal", "Heavy-metal superstructure", "All")); + } + if (design.isTunnel()) { + entries.add(note("tunnel", "Tunnel construction", "All")); + } + if (design.isOpenSpace()) { + entries.add(note("open-space", "Open-space: 600 t total; lowest floor equipment", "All")); + } + if (BuildingConstruction.usesHexsides(building)) { + entries.add(note("hexsides", "CF / armor / capacity apply per hexside", "All")); + } + if (building.getBldgClass() == IBuilding.BRIDGE) { + entries.add(note("bridge", "Decks only; ends must meet map terrain", "All")); + } + if (design.hasRoofClearance()) { + entries.add(note("roof-clearance", "Cave: ≥1 level roof clearance", "All")); + } + if (design.getCeiling() != BuildingDesign.Ceiling.STANDARD) { + entries.add(note("ceiling", design.getCeiling() == BuildingDesign.Ceiling.HIGH ? "High ceilings" : "Low ceilings", "All")); + } + if (design.getSite() != BuildingDesign.Site.SURFACE) { + entries.add(note("site", design.getSite() + "; cover " + design.getDepth() + " levels", "All")); + } + for (BuildingDesign.Door door : design.getMapDoors()) { + String side = BuildingUtil.facingLabel(door.facing()); + entries.add(note("door", "Door " + side + "; " + door.height() + " levels high", + BuildingUtil.locationLabel(building, BuildingConstruction.location(building, door.position())))); + } + for (BuildingDesign.Elevator lift : design.getElevators()) { + String hex = BuildingUtil.sheetGrid(building.getInternalBuilding().getOriginalCoordsList()).label(lift.hex()); + entries.add(note("elevator", "Elevator: " + lift.capacity() + " t", hex)); + lift.exits().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(exit -> { + List sides = new ArrayList<>(); + for (int side = 0; side < 6; side++) { + if ((exit.getValue() & (1 << side)) != 0) { + sides.add(BuildingUtil.facingLabel(side)); + } + } + String level = BuildingUtil.roofLevelLabel(building, exit.getKey()); + entries.add(note("elevator-stop", "Lift access: " + String.join(", ", sides), hex + "/" + level)); + }); + entries.add(note("elevator-current", "Current elevator level: ______", hex)); + } + } + + private record InventoryPage(List groups, float font, float step) { } + + private List inventoryPages() { + if (plannedInventory != null) { + return plannedInventory; + } + Document template = getSVGDocument() == null ? loadTemplate(getFirstPage(), new PageFormat(), false) : getSVGDocument(); + Rectangle2D box = getRectBBox((SVGRectElement) template.getElementById(INVENTORY)); + double available = box.getHeight() - 14; + int capacity = (int) Math.floor(available / (InventoryWriter.MIN_FONT_SIZE * InventoryWriter.MIN_LINE_HEIGHT_TO_FONT_SIZE)); + List pages = new ArrayList<>(); + List page = new ArrayList<>(); + int rows = 0; + for (InventoryGroup entry : inventoryEntries()) { + InventoryGroup wrapped = wrapInventory(List.of(entry), InventoryWriter.MIN_FONT_SIZE, box.getWidth()).getFirst(); + int size = wrapped.rows().size(); + if (rows + size > capacity && !page.isEmpty()) { + pages.add(fitInventory(page, box)); + page = new ArrayList<>(); + rows = 0; + } + if (size > capacity) { + for (int start = 0; start < size; start += capacity) { + pages.add(fitInventory(List.of(new InventoryGroup(entry.id(), entry.location(), + wrapped.rows().subList(start, Math.min(size, start + capacity)), entry.destroyed())), box)); + } + } else { + page.add(entry); + rows += size; + } + } + if (!page.isEmpty() || pages.isEmpty()) { + pages.add(fitInventory(page, box)); + } + plannedInventory = List.copyOf(pages); + return plannedInventory; + } + + private InventoryPage fitInventory(List groups, Rectangle2D box) { + float[] metrics = InventoryWriter.scaleText(box.getHeight() - 14, + font -> wrapInventory(groups, font, box.getWidth()).stream().mapToInt(group -> group.rows().size()).sum(), + ignored -> 0.0, font -> getNormalFont(font).getLineMetrics("M", TEXT_CONTEXT).getHeight()); + return new InventoryPage(wrapInventory(groups, metrics[0], box.getWidth()), metrics[0], metrics[1]); + } + + private List wrapInventory(List groups, float font, double width) { + List result = new ArrayList<>(); + for (InventoryGroup group : groups) { + List rows = new ArrayList<>(); + for (String[] values : group.rows()) { + boolean note = values[0].isEmpty() && values[2].isEmpty() && values[3].isEmpty(); + double nameWidth = width * (note ? .90 : .34); + String[] row = values.clone(); + String line = ""; + for (String word : values[1].split("\\s+")) { + String next = line.isEmpty() ? word : line + " " + word; + if (!line.isEmpty() && getNormalFont(font).getStringBounds(next, TEXT_CONTEXT).getWidth() > nameWidth) { + row[1] = line; + rows.add(row); + row = new String[] { "", "", "", "", "", "", "", "" }; + line = word; + } else { + line = next; + } + } + row[1] = line; + rows.add(row); + } + result.add(new InventoryGroup(group.id(), group.location(), rows, group.destroyed())); + } + return result; + } + + @Override + protected void writeEquipment(SVGRectElement rect) { + Rectangle2D box = getRectBBox(rect); + Element canvas = (Element) rect.getParentNode(); + List pages = inventoryPages(); + InventoryPage page = currentPage < pages.size() ? pages.get(currentPage) : new InventoryPage(List.of(), 6.76f, 9); + double step = page.step(); + float font = page.font(); + double[] x = { .025, .065, .485, .625, .725, .805, .885, .97 }; + double[] widths = { .04, .34, .145, .11, .07, .07, .07, .07 }; + String[] headers = { "Qty", "Type", "Hex/Loc", "Dmg", "Min", "Sht", "Med", "Lng" }; + for (int i = 0; i < headers.length; i++) { + text(canvas, box.getX() + x[i] * box.getWidth(), box.getY() + 6, widths[i] * box.getWidth(), + headers[i], 5.8f, i == 1 ? "start" : "middle", "bold"); + } + double y = box.getY() + 12 + font; + for (InventoryGroup inventoryGroup : page.groups()) { + Element rowGroup = element(canvas, "g", "class", "building-inventory-entry", + "data-equipment-id", inventoryGroup.id(), "data-location", Integer.toString(inventoryGroup.location())); + for (String[] values : inventoryGroup.rows()) { + for (int column = 0; column < values.length; column++) { + boolean note = column == 1 && values[0].isEmpty() && values[2].isEmpty() && values[3].isEmpty(); + text(rowGroup, box.getX() + x[column] * box.getWidth(), y, (note ? .90 : widths[column]) * box.getWidth(), + values[column], font, column == 1 ? "start" : "middle", "normal"); + } + if (options.showDamage() && inventoryGroup.destroyed()) { + addLineThrough(rowGroup, box.getX(), y - font * .3, box.getWidth()); + } + y += step; + } + } + } + + private void text(Element parent, double x, double y, double width, String value, float font, + String anchor, String weight) { + addTextElementToFit(parent, x, y, width, value, font, anchor, weight); + } + + private void line(Element parent, double x1, double y1, double x2, double y2, String stroke, double width) { + element(parent, "line", "x1", Double.toString(x1), "y1", Double.toString(y1), "x2", Double.toString(x2), + "y2", Double.toString(y2), "stroke", stroke, "stroke-width", Double.toString(width)); + } + + private Element element(Element parent, String name, String... attributes) { + Element element = getSVGDocument().createElementNS(svgNS, name); + for (int i = 0; i < attributes.length; i += 2) { + element.setAttribute(attributes[i], attributes[i + 1]); + } + parent.appendChild(element); + return element; + } +} diff --git a/megameklab/src/megameklab/printing/PrintInfantry.java b/megameklab/src/megameklab/printing/PrintInfantry.java index 24ec13b7710..33ef66f4dfe 100644 --- a/megameklab/src/megameklab/printing/PrintInfantry.java +++ b/megameklab/src/megameklab/printing/PrintInfantry.java @@ -502,8 +502,9 @@ private String rangeMod(int range, InfantryWeapon weapon, InfantryWeapon otherWe || (otherWeapon != null && otherWeapon.hasFlag(WeaponType.F_INF_POINT_BLANK))) { mod++; } - if (weapon.hasFlag(WeaponType.F_INF_ENCUMBER) - || (otherWeapon != null && otherWeapon.hasFlag(WeaponType.F_INF_ENCUMBER))) { + if (weapon.hasFlag(WeaponType.F_INF_ENCUMBER) || weapon.getCrew() > 1 + || (otherWeapon != null && (otherWeapon.hasFlag(WeaponType.F_INF_ENCUMBER) + || otherWeapon.getCrew() > 1))) { mod++; } } diff --git a/megameklab/src/megameklab/printing/SVGMassPrinter.java b/megameklab/src/megameklab/printing/SVGMassPrinter.java index 40145274076..3150be7a1d2 100644 --- a/megameklab/src/megameklab/printing/SVGMassPrinter.java +++ b/megameklab/src/megameklab/printing/SVGMassPrinter.java @@ -148,7 +148,7 @@ record AlphaStrikeConversion(AlphaStrikeElement element, String report) {} private static boolean SKIP_UNIT_FILES = true; // Set to true to skip BLK/MTF re-save generation private static boolean SKIP_DETAILED_CALCULATIONS = true; // Set to true to skip the detailed BV/Cost calculations private static final boolean EXPORT_CALCULATION_DETAILS_TO_FILES = true; // Set to true to not embed the detailed BV/Cost calculations into the units.json but in a subfolder keyed by name - private static boolean EXPORT_CALCULATIONS_AS_TEXT = false; + private static boolean EXPORT_CALCULATIONS_AS_TEXT = true; private static String RULES_SYSTEM = OptionsConstants.RULES_CORE; private static final MMLogger logger = MMLogger.create(SVGMassPrinter.class); @@ -1040,7 +1040,7 @@ private void addPhysicalWeapon(Map list, Entity en public static class UnitData { public String name; // Unique name of the unit, used for deduplication public String uuid; - public int id; // Unique identifier for the unit on MUL + public Integer mul1id; // MUL1 database reference; null when no positive ID is assigned public String chassis; // Name of the unit (Chassis) public String model; // Model of the unit public int year; // Year of introduction @@ -1056,7 +1056,7 @@ public static class UnitData { public boolean mixed; public String techRating; public String engine; - public int engineRating; + public double engineRating; public String type; // Major type, "Mek", "Vehicle", etc. public String subtype; // Subtype, "Assault", "Light", etc. public int omni; // 1 if the unit is Omni @@ -1510,16 +1510,17 @@ private static boolean isArmTorsoPair(int firstLocation, int secondLocation) { } public UnitData(MekSummary mekSummary, Entity entity, RecordSheetOptions options) { - this.uuid = entity.getUnitFileUUID(); - this.id = entity.getMulId(); - this.chassis = entity.getFullChassis(); - this.model = entity.getModel(); - this.year = entity.getYear(); - this.weightClass = entity.getWeightClassName(); - this.tons = entity.getWeight(); + this(entity); + readMetadata(mekSummary, entity, options); + } + + /** Capture gameplay calculations before construction preparation removes ammo or adds implicit equipment. */ + UnitData(Entity entity) { this.loadoutTons = calculateLoadoutTonnage(entity); ExportCalculationReport bvReport = new ExportCalculationReport(); - this.bv = entity.getBvCalculator().calculateBV(true, true, bvReport); + int calculatedBv = entity.getBvCalculator().calculateBV(true, true, bvReport); + // Keep the calculated breakdown, but publish the authored override when enabled. + this.bv = entity.getUseManualBV() ? entity.getManualBV() : calculatedBv; if (!SKIP_DETAILED_CALCULATIONS) { this.bvDetails = formatBVDetails(bvReport.getDetails()); this.bvDetailText = bvReport.getText(); @@ -1530,14 +1531,33 @@ public UnitData(MekSummary mekSummary, Entity entity, RecordSheetOptions options if (!SKIP_DETAILED_CALCULATIONS) { this.costDetail = formatCostDetails(costReport.getDetails()); this.costDetailText = costReport.getText(); + this.weightBreakdown = createWeightBreakdown(entity); } + this.walk = entity.getWalkMP(); + this.walk2 = entity.getWalkMP(MPCalculationSetting.BV_CALCULATION); + this.run = entity.getRunMPWithoutMASC(); + this.run2 = entity.getRunMP(MPCalculationSetting.BV_CALCULATION); + this.jump = entity.getJumpMP(); + this.jump2 = entity.getAnyTypeMaxJumpMP(); + this.umu = entity.getActiveUMUCount(); + } + + /** Read display inventory after the separate construction/printing preparation step. */ + void readMetadata(MekSummary mekSummary, Entity entity, RecordSheetOptions options) { + this.uuid = entity.getUnitFileUUID(); + this.mul1id = entity.getMulId() > 0 ? entity.getMulId() : null; + this.chassis = entity.getFullChassis(); + this.model = entity.getModel(); + this.year = entity.getYear(); + this.weightClass = entity.getWeightClassName(); + this.tons = entity.getWeight(); this.techBase = formatTechBase(entity); this.mixed = entity.isMixedTech(); this.techRating = entity.getFullRatingName(); this.level = formatRulesLevel(entity, options); if (entity.hasEngine() && !(entity instanceof SmallCraft || entity instanceof Jumpship)) { Engine unitEngine = entity.getEngine(); - this.engineRating = unitEngine.getRating(); + this.engineRating = unitEngine.getRating(entity); this.engine = Engine.getEngineTypeName(unitEngine.getEngineType()).trim(); if (this.engine.equals("XL") || this.engine.equals("XXL")) { this.engine+=(unitEngine.isClan() ? " (Clan)" : " (IS)"); @@ -1606,13 +1626,6 @@ public UnitData(MekSummary mekSummary, Entity entity, RecordSheetOptions options this.dissipation = null; } this.moveType = getMoveType(entity); - this.walk = entity.getWalkMP(); - this.walk2 = entity.getWalkMP(MPCalculationSetting.BV_CALCULATION); - this.run = entity.getRunMPWithoutMASC(); - this.run2 = entity.getRunMP(MPCalculationSetting.BV_CALCULATION); - this.jump = entity.getJumpMP(); - this.jump2 = entity.getAnyTypeMaxJumpMP(); - this.umu = entity.getActiveUMUCount(); this.crewSize = entity.getCrew().getSlotCount(); Components components = new Components(entity); this.comp = components.getComp(); @@ -1642,7 +1655,6 @@ public UnitData(MekSummary mekSummary, Entity entity, RecordSheetOptions options this.sheets = new ArrayList<>(); this.loadASUnitData(entity); if (!SKIP_DETAILED_CALCULATIONS) { - this.weightBreakdown = createWeightBreakdown(entity); this.techLevelBreakdown = createTechLevelBreakdown(entity); } // final MekView mekView = new MekView(entity, false, false, ViewFormatting.HTML); @@ -2769,6 +2781,7 @@ public static void main(String[] args) { // export used by MekBay. return null; } + UnitData unitData = new UnitData(entity); synchronized (updateUnitLock) { UnitUtil.updateLoadedUnit(entity); } @@ -2821,7 +2834,7 @@ public static void main(String[] args) { } } - UnitData unitData = new UnitData(mekSummary, entity, recordSheetOptions); + unitData.readMetadata(mekSummary, entity, recordSheetOptions); unitData.unitFile = relativeUnitFilePath; unitData.name = name; boolean isSmallUnit = entity.isBattleArmor() || entity.isProtoMek() || entity.isInfantry(); diff --git a/megameklab/src/megameklab/printing/WeaponBayInventoryEntry.java b/megameklab/src/megameklab/printing/WeaponBayInventoryEntry.java index e5294ae1790..f46fc5b26d8 100644 --- a/megameklab/src/megameklab/printing/WeaponBayInventoryEntry.java +++ b/megameklab/src/megameklab/printing/WeaponBayInventoryEntry.java @@ -293,7 +293,7 @@ private String formatAV(double av, double stdAV) { return DASH; } if (isCapital) { - return String.valueOf((int) av); + return String.valueOf(Math.round(av)); } return ((int) av) + " (" + ((int) stdAV) + ")"; } diff --git a/megameklab/src/megameklab/printing/WeaponBayText.java b/megameklab/src/megameklab/printing/WeaponBayText.java index f5dcbcfc6d4..ae44af846a7 100644 --- a/megameklab/src/megameklab/printing/WeaponBayText.java +++ b/megameklab/src/megameklab/printing/WeaponBayText.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2025 The MegaMek Team. All Rights Reserved. + * Copyright (C) 2008-2026 The MegaMek Team. All Rights Reserved. * * This file is part of MegaMekLab. * @@ -33,7 +33,6 @@ package megameklab.printing; import java.util.ArrayList; -import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -94,23 +93,16 @@ public WeaponBayText(int l, boolean rear) { * * @param weapon The weapon to add to the bay * - * @return Whether true if the weapon was added as new, false if it was already in the bay and just incremented + * @return Whether this is the first weapon of its type in the bay */ public boolean addBayWeapon(Mounted weapon) { - boolean asNew; WeaponType weaponType = (WeaponType) weapon.getType(); - if (weapons.containsKey(weaponType)) { - weapons.put(weaponType, weapons.get(weaponType) + 1); - asNew = false; - } else { - weapons.put(weaponType, 1); - asNew = true; - } + int count = weapons.merge(weaponType, 1, Integer::sum); if (null != weapon.getLinkedBy()) { - augmentations.putIfAbsent(weaponType, new HashMap<>()); - augmentations.get(weaponType).merge(weapon.getLinkedBy().getType(), 1, Integer::sum); + augmentations.computeIfAbsent(weaponType, ignored -> new HashMap<>()) + .merge(weapon.getLinkedBy().getType(), 1, Integer::sum); } - return asNew; + return count == 1; } /** @@ -119,128 +111,55 @@ public boolean addBayWeapon(Mounted weapon) { * @param ammo The ammo to add to the bay */ public void addBayAmmo(WeaponType weaponType, AmmoMounted ammo) { - if (weaponType instanceof AmmoWeapon) { - if (ammo.getBaseShotsLeft() > 0) { - if (weaponAmmo.containsKey(weaponType)) { - // If the ammo is already in the bay, just add to the count. - List> ammoList = weaponAmmo.get(weaponType); - ammoList.add(ammo); - } else { - // If the ammo isn't in the bay, add it to the list. - List> ammoList = new ArrayList<>(); - ammoList.add(ammo); - weaponAmmo.put(weaponType, ammoList); - } - } - // If the ammo is empty, we don't want to add it to the bay. + if (weaponType instanceof AmmoWeapon && ammo.getBaseShotsLeft() > 0) { + weaponAmmo.computeIfAbsent(weaponType, ignored -> new ArrayList<>()).add(ammo); } - // If the weapon isn't an ammo weapon, we don't want to add it to the bay. - } - - /** - * Determines if two WeaponBayTexts are laterally similar and hence can be combined. That is, if there is a weapon - * bay on the left side that is identical to one on the right side, then those two can be combined in a location - * like FRS/FLS. This allows weapon lists to be compacted. - * - * @param other The other instance - * - * @return Whether the two bays are identical - */ - public boolean canCombine(WeaponBayText other) { - // Check for opposing sides - return loc.size() == 1 - && checkOpposingSide(loc.getFirst(), other.loc.getFirst(), rear, other.rear) - && weapons.equals(other.weapons) - && ammunitionMatch(other) && augmentations.equals(other.augmentations); } - /** - * Used to compare ammunition across WeaponBayTexts. Since Mounted.equals isn't implemented, we can't directly use - * Map.equals. - * - * @param other The other bay - * - * @return Whether the ammo types and number of shots per type match - */ - private boolean ammunitionMatch(WeaponBayText other) { - // If the number is different, the ammo doesn't match - if ((weaponAmmo.size() != other.weaponAmmo.size())) { - return false; - } - // We then check the keys if they match - if (!weaponAmmo.keySet().equals(other.weaponAmmo.keySet())) { - return false; - } - // Now we compare the ammo sets of each weapon - for (WeaponType weaponType : weaponAmmo.keySet()) { - List> ammoListThis = weaponAmmo.get(weaponType); - List> ammoListOther = other.weaponAmmo.get(weaponType); - if (ammoListThis == null || ammoListOther == null) { - return false; - } - // If the number of ammo types is different, the ammo doesn't match - if (ammoListThis.size() != ammoListOther.size()) { - return false; - } - boolean[] otherMatched = new boolean[ammoListOther.size()]; - Arrays.fill(otherMatched, false); - - for (Mounted mountedThis : ammoListThis) { - if (!(mountedThis instanceof AmmoMounted ammoThis)) { - return false; // Should not happen - } - final String nameThis = ammoThis.getType().getShortName(); - int shotsThis = ammoThis.getBaseShotsLeft(); - boolean foundMatch = false; - for (int i = 0; i < ammoListOther.size(); i++) { - if (otherMatched[i]) { - continue; // Skip already matched items - } - - Mounted mountedOther = ammoListOther.get(i); - if (!(mountedOther instanceof AmmoMounted ammoOther)) { - // This shouldn't happen - return false; - } - - String nameOther = ammoOther.getType().getShortName(); - int shotsOther = ammoOther.getBaseShotsLeft(); - - // Check if names and shots match - if (nameThis.equals(nameOther) && shotsThis == shotsOther) { - otherMatched[i] = true; - foundMatch = true; - break; // Found a match, move to the next item - } - } - - // If no match was found in the other list - if (!foundMatch) { - return false; - } + /** Immutable contents, excluding location. Ammo is a multiset of individual bins, not a shot total. */ + CombinationKey combinationKey() { + Map> ammo = new HashMap<>(); + for (Map.Entry>> entry : weaponAmmo.entrySet()) { + Map counts = new HashMap<>(); + for (Mounted mounted : entry.getValue()) { + counts.merge(new AmmoDescriptor(mounted.getType().getShortName(), mounted.getBaseShotsLeft()), 1, + Integer::sum); } + ammo.put(entry.getKey(), Map.copyOf(counts)); } - // we matched all - return true; + Map> augmentations = new HashMap<>(); + for (Map.Entry> entry : this.augmentations.entrySet()) { + augmentations.put(entry.getKey(), Map.copyOf(entry.getValue())); + } + return new CombinationKey(Map.copyOf(weapons), Map.copyOf(ammo), Map.copyOf(augmentations)); } - private boolean checkOpposingSide(int loc1, int loc2, boolean rear1, boolean rear2) { - return switch (loc1) { - // Jumpship.LOC_FLS and Jumpship.LOC_FRS are the same indices as - // Dropship.LOC_LEFT_WING and Dropship.LOC_RIGHT_WING - case Jumpship.LOC_FLS -> loc2 == Jumpship.LOC_FRS && rear1 == rear2; - case Jumpship.LOC_FRS -> loc2 == Jumpship.LOC_FLS && rear1 == rear2; - case Jumpship.LOC_ALS -> loc2 == Jumpship.LOC_ARS; - case Jumpship.LOC_ARS -> loc2 == Jumpship.LOC_ALS; - case Warship.LOC_LBS -> loc2 == Warship.LOC_RBS; - case Warship.LOC_RBS -> loc2 == Warship.LOC_LBS; - default -> false; + record CombinationKey(Map weapons, + Map> ammo, + Map> augmentations) { } + + record AmmoDescriptor(String name, int shots) { } + + static int opposingLocation(int location) { + return switch (location) { + case Jumpship.LOC_FLS -> Jumpship.LOC_FRS; + case Jumpship.LOC_FRS -> Jumpship.LOC_FLS; + case Jumpship.LOC_ALS -> Jumpship.LOC_ARS; + case Jumpship.LOC_ARS -> Jumpship.LOC_ALS; + case Warship.LOC_LBS -> Warship.LOC_RBS; + case Warship.LOC_RBS -> Warship.LOC_LBS; + default -> -1; }; } + static boolean rearMustMatch(int location) { + // The front-side indices also represent the left/right wings on a DropShip. + return location == Jumpship.LOC_FLS || location == Jumpship.LOC_FRS; + } + /** * Combine two WeaponBayTexts. Since they should both contain the same weapons, the only thing that needs to be - * updated is the locations. This should only be called if canCombine returns true for both WeaponBayTexts. + * updated is the locations. The caller must first match their contents and opposing locations. * * @param other The other bay to combine with this one */ diff --git a/megameklab/src/megameklab/ui/MegaMekLabTabbedUI.java b/megameklab/src/megameklab/ui/MegaMekLabTabbedUI.java index 1032fe377b2..9a3791defb2 100644 --- a/megameklab/src/megameklab/ui/MegaMekLabTabbedUI.java +++ b/megameklab/src/megameklab/ui/MegaMekLabTabbedUI.java @@ -317,6 +317,8 @@ private JPopupMenu createNewUnitPopupMenu() { menu.add(newUnitItem("New Advanced Aerospace", Entity.ETYPE_JUMPSHIP, false)); menu.add(newUnitItem("New Handheld Weapon", Entity.ETYPE_HANDHELD_WEAPON, false)); menu.add(newUnitItem("New Gun Emplacement", Entity.ETYPE_GUN_EMPLACEMENT, false)); + menu.add(newUnitItem("New Building", Entity.ETYPE_BUILDING_ENTITY, false)); + menu.add(newUnitItem("New Mobile Structure", Entity.ETYPE_MOBILE_STRUCTURE, false)); menu.add(newUnitItem("New Battlefield Support Asset", Entity.ETYPE_BATTLEFIELD_SUPPORT_ASSET, false)); JMenu primitive = new JMenu("New Primitive..."); diff --git a/megameklab/src/megameklab/ui/MenuBar.java b/megameklab/src/megameklab/ui/MenuBar.java index 259da705faa..acb68e2c72c 100644 --- a/megameklab/src/megameklab/ui/MenuBar.java +++ b/megameklab/src/megameklab/ui/MenuBar.java @@ -205,6 +205,7 @@ private JMenu createFileMenu() { miNewTab.add(newUnitItem("ProtoMek", KeyEvent.VK_P, Entity.ETYPE_PROTOMEK, false)); miNewTab.add(newUnitItem("Handheld Weapon", KeyEvent.VK_H, Entity.ETYPE_HANDHELD_WEAPON, false)); miNewTab.add(newUnitItem("Gun Emplacement", KeyEvent.VK_G, Entity.ETYPE_GUN_EMPLACEMENT, false)); + miNewTab.add(newUnitItem("Building", KeyEvent.VK_U, Entity.ETYPE_BUILDING_ENTITY, false)); miNewTab.add(newUnitItem("Battlefield Support Asset", KeyEvent.VK_S, Entity.ETYPE_BATTLEFIELD_SUPPORT_ASSET, false)); diff --git a/megameklab/src/megameklab/ui/StartupGUI.java b/megameklab/src/megameklab/ui/StartupGUI.java index 52f2ed9c366..feecce49f58 100644 --- a/megameklab/src/megameklab/ui/StartupGUI.java +++ b/megameklab/src/megameklab/ui/StartupGUI.java @@ -302,6 +302,10 @@ public void paint(Graphics g) { UIComponents.MainMenuButton.getComp(), true); btnNewPbi.addActionListener(evt -> createNewUnit(Entity.ETYPE_INFANTRY)); + MegaMekButton btnNewBuilding = new MegaMekButton("New Building", + UIComponents.MainMenuButton.getComp(), true); + btnNewBuilding.addActionListener(evt -> createNewUnit(Entity.ETYPE_BUILDING_ENTITY)); + MegaMekButton btnQuit = new MegaMekButton(resourceMap.getString("btnQuit.text"), UIComponents.MainMenuButton.getComp(), true); btnQuit.addActionListener(evt -> System.exit(0)); @@ -330,6 +334,8 @@ public void paint(Graphics g) { btnNewPbi.setPreferredSize(minButtonDim); btnNewProto.setMinimumSize(minButtonDim); btnNewProto.setPreferredSize(minButtonDim); + btnNewBuilding.setMinimumSize(minButtonDim); + btnNewBuilding.setPreferredSize(minButtonDim); btnQuit.setMinimumSize(minButtonDim); btnQuit.setPreferredSize(minButtonDim); @@ -344,7 +350,7 @@ public void paint(Graphics g) { c.weightx = 3.0; c.weighty = 1.0; c.gridwidth = 1; - c.gridheight = 12; + c.gridheight = 13; add(splashPanel, c); // Right Column (Buttons) @@ -380,6 +386,8 @@ public void paint(Graphics g) { c.gridy++; add(btnNewLargeCraft, c); c.gridy++; + add(btnNewBuilding, c); + c.gridy++; add(btnQuit, c); frame.getContentPane().setLayout(new BorderLayout()); diff --git a/megameklab/src/megameklab/ui/building/BuildingEquipmentTab.java b/megameklab/src/megameklab/ui/building/BuildingEquipmentTab.java new file mode 100644 index 00000000000..6d67f0c98ac --- /dev/null +++ b/megameklab/src/megameklab/ui/building/BuildingEquipmentTab.java @@ -0,0 +1,303 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package megameklab.ui.building; + +import static megameklab.ui.util.EquipmentTableModel.*; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.util.Collection; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.DefaultCellEditor; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTable; +import javax.swing.table.AbstractTableModel; + +import megamek.common.equipment.AmmoType; +import megamek.common.equipment.BuildingEquipmentType; +import megamek.common.equipment.EquipmentType; +import megamek.common.equipment.Mounted; +import megamek.common.equipment.PowerGeneratorType; +import megamek.common.equipment.WeaponType; +import megamek.common.equipment.enums.StructureEngine; +import megamek.common.exceptions.LocationFullException; +import megamek.common.units.AbstractBuildingEntity; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.Entity; +import megameklab.ui.util.AbstractEquipmentDatabaseView; +import megameklab.util.BuildingUtil; +import megameklab.util.UnitUtil; + +class BuildingEquipmentTab extends JPanel { + static final String[] FACINGS = BuildingUtil.FACINGS.toArray(String[]::new); + private static final String[] MOUNTS = { "Fixed", "Roof turret", "Pintle" }; + private final BuildingMainUI editor; + private final Database database; + private List> mounts = List.of(); + private final Loadout model = new Loadout(); + private final JTable table = new JTable(model); + private final JCheckBox allLocations = new JCheckBox("Show all hexes and floors"); + + BuildingEquipmentTab(BuildingMainUI editor) { + this.editor = editor; + database = new Database(editor); + database.setRefresh(editor); + JPanel loadout = new JPanel(new BorderLayout()); + loadout.setBorder(BorderFactory.createTitledBorder("Equipment placement")); + allLocations.setName("Show all building locations"); + allLocations.addActionListener(e -> refreshPlacement()); + loadout.add(allLocations, BorderLayout.NORTH); + table.setName("Building equipment"); + table.putClientProperty("terminateEditOnFocusLost", true); + table.setRowHeight(24); + table.getColumnModel().getColumn(0).setPreferredWidth(240); + table.getColumnModel().getColumn(2).setCellEditor(new DefaultCellEditor(new JComboBox<>(FACINGS))); + table.getColumnModel().getColumn(3).setCellEditor(new DefaultCellEditor(new JComboBox<>(MOUNTS))); + loadout.add(new JScrollPane(table), BorderLayout.CENTER); + JButton remove = new JButton("Remove selected equipment"); + remove.addActionListener(e -> { + for (int row : table.getSelectedRows()) { + UnitUtil.removeMounted(editor.getEntity(), mounts.get(table.convertRowIndexToModel(row))); + } + editor.scheduleRefresh(); + }); + JPanel actions = new JPanel(); + actions.add(remove); + JButton move = new JButton("Move to editing location"); + move.addActionListener(e -> { + BuildingPlacementDialogs.stopEditing(table); + for (int row : table.getSelectedRows()) { + BuildingUtil.assignEquipment(editor.getEntity(), mounts.get(table.convertRowIndexToModel(row)), editor.selectedLocation()); + } + editor.scheduleRefresh(); + }); + actions.add(move); + JButton distribute = new JButton("Distribute equipment mass…"); + distribute.addActionListener(e -> { + if (table.getSelectedRow() >= 0) { + Mounted mount = mounts.get(table.convertRowIndexToModel(table.getSelectedRow())); + if (BuildingConstruction.canSpread(mount.getType())) { + BuildingPlacementDialogs.equipment(editor, mount); + } else { + JOptionPane.showMessageDialog(this, "Generators, capital weapons and decks can span multiple hexes."); + } + } + }); + actions.add(distribute); + JButton sizeGenerator = new JButton("Size generator for building"); + sizeGenerator.addActionListener(e -> { + if (table.getSelectedRow() >= 0) { + Mounted mount = mounts.get(table.convertRowIndexToModel(table.getSelectedRow())); + if (mount.getType() instanceof PowerGeneratorType generator) { + UnitUtil.resizeMount(mount, BuildingConstruction.generatorTons(editor.getEntity(), generator.getStructureEngine())); + editor.scheduleRefresh(); + } + } + }); + actions.add(sizeGenerator); + loadout.add(actions, BorderLayout.SOUTH); + loadout.setPreferredSize(new Dimension(850, 250)); + JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, database, loadout); + split.setResizeWeight(.6); + setLayout(new BorderLayout()); + add(split, BorderLayout.CENTER); + } + + void refresh() { + refreshPlacement(); + database.refreshTable(); + } + + void refreshPlacement() { + if (table.isEditing()) { + table.getCellEditor().stopCellEditing(); + } + mounts = editor.getEntity().getEquipment().stream().filter(m -> !m.isOneShotAmmo() && !m.isWeaponGroup() + && (allLocations.isSelected() || BuildingConstruction.equipmentPositions(editor.getEntity(), m).stream() + .anyMatch(p -> p.hex().equals(editor.selectedHex()) && p.level() == editor.selectedFloor()))).toList(); + JComboBox locations = new JComboBox<>(); + for (int loc = 0; loc < editor.getEntity().locations(); loc++) { + locations.addItem(BuildingUtil.locationLabel(editor.getEntity(), loc)); + } + table.getColumnModel().getColumn(1).setCellEditor(new DefaultCellEditor(locations)); + model.fireTableDataChanged(); + } + + private class Loadout extends AbstractTableModel { + private static final String[] COLUMNS = { "Equipment", "Primary hex/level", "Facing", "Mount", "Size", "Shots", "Item tons", "Automated", "PCMT source (t)" }; + + @Override + public Class getColumnClass(int column) { + return column == 7 ? Boolean.class : Object.class; + } + + @Override + public int getRowCount() { + return mounts.size(); + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Object getValueAt(int row, int column) { + Mounted mount = mounts.get(row); + return switch (column) { + case 0 -> mount.getName(); + case 1 -> BuildingUtil.locationLabel(editor.getEntity(), mount.getLocation()); + case 2 -> BuildingConstruction.isCapital(mount.getType()) ? "Upward" + : mount.isSponsonTurretMounted() ? "360°" + : mount.getType() instanceof WeaponType && mount.getFacing() >= 0 ? FACINGS[mount.getFacing() % 6] : ""; + case 3 -> mount.getType() instanceof WeaponType + ? MOUNTS[mount.isPintleTurretMounted() ? 2 : mount.isSponsonTurretMounted() ? 1 : 0] : ""; + case 4 -> mount.getType().isVariableSize() ? mount.getSize() : ""; + case 5 -> mount.getType() instanceof AmmoType ? mount.getBaseShotsLeft() : ""; + case 6 -> mount.getTonnage(); + case 7 -> editor.getEntity().getDesign().getAutomatedWeapons().contains(mount); + case 8 -> isPcmt(mount.getType()) ? editor.getEntity().getDesign().getPcmtSources().getOrDefault(mount, 0.0) : ""; + default -> ""; + }; + } + + @Override + public boolean isCellEditable(int row, int column) { + EquipmentType equipment = mounts.get(row).getType(); + return column == 1 || (column == 3 && equipment instanceof WeaponType) + || (column == 2 && equipment instanceof WeaponType && !BuildingConstruction.isCapital(equipment) + && !mounts.get(row).isSponsonTurretMounted()) + || (column == 4 && equipment.isVariableSize()) || (column == 5 && equipment instanceof AmmoType) + || (column == 7 && BuildingConstruction.canAutomate(equipment)) || (column == 8 && isPcmt(equipment)); + } + + @Override + public void setValueAt(Object value, int row, int column) { + Mounted mount = mounts.get(row); + switch (column) { + case 1 -> { + int location = Entity.LOC_NONE; + for (int loc = 0; loc < editor.getEntity().locations(); loc++) { + if (BuildingUtil.locationLabel(editor.getEntity(), loc).equals(value)) { + location = loc; + break; + } + } + if (location != Entity.LOC_NONE) { + BuildingUtil.assignEquipment(editor.getEntity(), mount, location); + } + } + case 2 -> mount.setFacing(List.of(FACINGS).indexOf(value)); + case 3 -> { + mount.setSponsonTurretMounted(MOUNTS[1].equals(value)); + mount.setPintleTurretMounted(MOUNTS[2].equals(value)); + } + case 7 -> { + if (Boolean.TRUE.equals(value)) { + editor.getEntity().getDesign().getAutomatedWeapons().add(mount); + } else { + editor.getEntity().getDesign().getAutomatedWeapons().remove(mount); + } + } + case 4, 5, 8 -> { + try { + double number = Double.parseDouble(value.toString()); + if (!Double.isFinite(number) || number < 0 || (column == 5 && number != Math.rint(number))) { + throw new NumberFormatException(); + } + if (column == 4) { + double step = mount.getType().variableStepSize(); + number = Math.max(step, mount.getType() instanceof PowerGeneratorType ? Math.ceil(number) + : Math.floor(number / step) * step); + if (mount.getType().variableMaxSize() != null) { + number = Math.min(number, mount.getType().variableMaxSize()); + } + UnitUtil.resizeMount(mount, number); + } else if (column == 5) { + number = Math.min(number, ((AmmoType) mount.getType()).getShots()); + mount.setOriginalShots((int) number); + mount.setShotsLeft((int) number); + } else { + editor.getEntity().getDesign().getPcmtSources().put(mount, number); + } + } catch (NumberFormatException ex) { + JOptionPane.showMessageDialog(BuildingEquipmentTab.this, + column == 5 ? "Enter a non-negative whole number of shots." + : column == 8 ? "Enter a non-negative transmitter mass in tons." : "Enter a non-negative size."); + return; + } + } + default -> { + return; + } + } + editor.scheduleRefresh(); + } + } + + private static boolean isPcmt(EquipmentType equipment) { + return equipment instanceof PowerGeneratorType generator && generator.getStructureEngine() == StructureEngine.EXTERNAL_PCMT; + } + + private static class Database extends AbstractEquipmentDatabaseView { + private final BuildingMainUI editor; + + Database(BuildingMainUI editor) { + super(editor); + this.editor = editor; + setBorder(BorderFactory.createTitledBorder("Equipment Database")); + } + + @Override + protected Collection getVisibleTableColumns(boolean stats) { + return stats ? List.of(COL_NAME, COL_DAMAGE, COL_HEAT, COL_RANGE, COL_SHOTS, COL_TECH, COL_TON, COL_REF) + : List.of(COL_NAME, COL_TECH, COL_TECH_LEVEL, COL_TECH_RATING, COL_DATE_PROTOTYPE, + COL_DATE_PRODUCTION, COL_DATE_COMMON, COL_COST); + } + + @Override + protected boolean shouldShow(EquipmentType equipment) { + return getEntity() instanceof AbstractBuildingEntity building && !BuildingConstruction.hasNoInterior(building) + && BuildingConstruction.canMount(equipment) && super.shouldShow(equipment); + } + + @Override + protected void addEquipment(EquipmentType equipment, int count) { + for (int i = 0; i < count; i++) { + try { + Mounted mount = Mounted.createMounted(getEntity(), equipment); + UnitUtil.setVariableSizeMiscTypeMinimumSize(mount); + if (equipment instanceof BuildingEquipmentType facility + && facility.getFacility() == BuildingEquipmentType.Facility.LANDING_DECK) { + mount.setSize(7); + } + int location = editor.selectedLocation(); + getEntity().addEquipment(mount, location, false); + mount.setFacing(BuildingConstruction.isCapital(equipment) ? -1 + : BuildingUtil.exteriorFacing(editor.getEntity(), editor.selectedHex())); + if (equipment instanceof PowerGeneratorType generator) { + UnitUtil.resizeMount(mount, BuildingConstruction.generatorTons(editor.getEntity(), generator.getStructureEngine())); + } + UnitUtil.removeHiddenAmmo(mount); + UnitUtil.changeMountStatus(getEntity(), mount, location, Entity.LOC_NONE, false); + } catch (LocationFullException ex) { + throw new IllegalStateException("Unable to add building equipment", ex); + } + } + } + } +} diff --git a/megameklab/src/megameklab/ui/building/BuildingMainUI.java b/megameklab/src/megameklab/ui/building/BuildingMainUI.java new file mode 100644 index 00000000000..b2e7eb73fba --- /dev/null +++ b/megameklab/src/megameklab/ui/building/BuildingMainUI.java @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.ui.building; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSplitPane; + +import megamek.common.board.CubeCoords; +import megamek.common.equipment.Mounted; +import megamek.common.interfaces.ITechManager; +import megamek.common.units.AbstractBuildingEntity; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.Entity; +import megamek.common.units.MobileStructure; +import megameklab.ui.MegaMekLabMainUI; +import megameklab.ui.generalUnit.FluffTab; +import megameklab.ui.generalUnit.RecordSheetPreviewPanel; +import megameklab.ui.util.TabScrollPane; +import megameklab.util.BuildingUtil; + +/** Construction editor for advanced buildings. */ +public class BuildingMainUI extends MegaMekLabMainUI { + private BuildingStructureTab structure; + private BuildingEquipmentTab equipment; + private BuildingTransportTab transport; + private BuildingSystemsTab systems; + private FluffTab fluff; + private RecordSheetPreviewPanel preview; + private JLabel status; + private boolean refreshing; + private JSplitPane editorSplit; + private JLabel editingLocation; + private CubeCoords selectedHex; + private int selectedFloor; + private boolean absoluteCoordinates; + + public BuildingMainUI() { + createNewUnit(Entity.ETYPE_BUILDING_ENTITY); + } + + public BuildingMainUI(boolean mobile) { + createNewUnit(mobile ? Entity.ETYPE_MOBILE_STRUCTURE : Entity.ETYPE_BUILDING_ENTITY); + } + + public BuildingMainUI(Entity entity, String filename) { + setEntity(entity, filename); + } + + @Override + public AbstractBuildingEntity getEntity() { + return (AbstractBuildingEntity) super.getEntity(); + } + + void changeStructureType(boolean mobile) { + if ((getEntity() instanceof MobileStructure) == mobile) { + return; + } + createNewUnit(mobile ? Entity.ETYPE_MOBILE_STRUCTURE : Entity.ETYPE_BUILDING_ENTITY, + false, false, getEntity()); + reloadTabs(); + } + + @Override + protected FluffTab getFluffTab() { + return fluff; + } + + @Override + public void reloadTabs() { + int navigatorWidth = editorSplit == null ? 360 : editorSplit.getRightComponent().getWidth(); + configPane.removeAll(); + removeAll(); + structure = new BuildingStructureTab(this); + equipment = new BuildingEquipmentTab(this); + transport = new BuildingTransportTab(this); + systems = new BuildingSystemsTab(this); + fluff = new FluffTab(this); + fluff.setRefreshedListener(this); + preview = new RecordSheetPreviewPanel(); + preview.setFullAsyncMode(true); + status = new JLabel(); + status.setBorder(BorderFactory.createEmptyBorder(6, 10, 6, 10)); + configPane.addTab("Structure", structure); + configPane.addTab("Equipment", equipment); + configPane.addTab("Transport & Quarters", new TabScrollPane(transport)); + configPane.addTab("Capacity, crew, power & validation", systems.getTotalsPanel()); + configPane.addTab("Construction & Services", systems); + configPane.addTab("Fluff", new TabScrollPane(fluff)); + configPane.addTab("Record Sheet", preview); + JPanel navigator = createLocationNavigator(); + navigator.setPreferredSize(new Dimension(Math.max(240, navigatorWidth), 450)); + navigator.setMinimumSize(new Dimension(240, 160)); + configPane.setMinimumSize(new Dimension(400, 160)); + editorSplit = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, configPane, navigator); + editorSplit.setName("Building editor split"); + editorSplit.setBorder(BorderFactory.createEmptyBorder()); + editorSplit.setContinuousLayout(true); + editorSplit.setResizeWeight(1); + add(editorSplit, BorderLayout.CENTER); + add(status, BorderLayout.SOUTH); + preview.addComponentListener(new java.awt.event.ComponentAdapter() { + @Override + public void componentShown(java.awt.event.ComponentEvent event) { + preview.setEntity(getEntity()); + } + }); + refreshAll(); + revalidate(); + } + + @Override + public void refreshAll() { + super.refreshAll(); + if (structure == null || refreshing) { + return; + } + refreshing = true; + getEntity().getDesign().removeDeletedComponents(getEntity()); + int crewSize = megamek.common.compute.Compute.getFullCrewSize(getEntity()); + getEntity().getCrew().setSize(crewSize); + getEntity().getCrew().setCurrentSize(crewSize); + refreshLocationNavigator(); + structure.refresh(); + equipment.refresh(); + transport.refresh(); + systems.refresh(); + fluff.refresh(); + if (preview.isShowing()) { + preview.setEntity(getEntity()); + } + List issues = BuildingUtil.constructionIssues(getEntity()); + status.setText("Installed: %.2f / %.2f tons | Power: %s | %s".formatted( + BuildingUtil.equipmentWeight(getEntity()), getEntity().getWeight(), + BuildingUtil.powerDescription(getEntity()), issues.isEmpty() ? "" : issues.getFirst())); + status.setToolTipText("" + String.join("
", issues) + ""); + refreshing = false; + refreshHeader(); + } + + private JPanel createLocationNavigator() { + JPanel panel = new JPanel(new BorderLayout(0, 6)); + panel.setName("Building location navigator"); + panel.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createTitledBorder("Pancake view"), + BorderFactory.createEmptyBorder(6, 6, 6, 6))); + editingLocation = new JLabel(); + editingLocation.setName("Editing location"); + panel.add(editingLocation, BorderLayout.NORTH); + panel.add(structure.createPancakePane(), BorderLayout.CENTER); + return panel; + } + + private void refreshLocationNavigator() { + if (editingLocation == null) { + return; + } + var hexes = getEntity().getInternalBuilding().getOriginalCoordsList(); + if (!hexes.contains(selectedHex)) { + selectedHex = hexes.getFirst(); + } + int height = getEntity().getInternalBuilding().getHeight(selectedHex); + selectedFloor = Math.max(0, Math.min(selectedFloor, height - 1)); + boolean bridge = getEntity().getBldgClass() == megamek.common.units.IBuilding.BRIDGE; + int level = bridge ? getEntity().getDesign().bridgeDeck(selectedHex) : selectedFloor; + editingLocation.setText("Editing: " + hexLabel(selectedHex) + " / " + (bridge ? "Deck " : "Level ") + + getEntity().getLevelLabel(level)); + } + + CubeCoords selectedHex() { + return selectedHex == null ? CubeCoords.ZERO : selectedHex; + } + + boolean absoluteCoordinates() { + return absoluteCoordinates; + } + + void setAbsoluteCoordinates(boolean absolute) { + absoluteCoordinates = absolute; + refreshLocationNavigator(); + structure.refresh(); + } + + String hexLabel(CubeCoords hex) { + return absoluteCoordinates ? BuildingUtil.absoluteHexLabel(hex) + : BuildingUtil.sheetGrid(getEntity().getInternalBuilding().getOriginalCoordsList()).label(hex); + } + + int selectedFloor() { + return selectedFloor; + } + + int selectedLocation() { + return getEntity().getInternalBuilding().getOriginalCoordsList().indexOf(selectedHex()) + * getEntity().getInternalBuilding().getBuildingHeight() + selectedFloor; + } + + void selectLocation(CubeCoords hex, int floor) { + selectedHex = hex; + selectedFloor = floor; + refreshLocationNavigator(); + // Navigation is not a construction change: do not schedule an undo snapshot or dirty the unit. + structure.refresh(); + equipment.refreshPlacement(); + } + + void showEquipment() { + configPane.setSelectedComponent(equipment); + } + + @Override + public void refreshBuild() { + scheduleRefresh(); + } + + @Override + public void refreshEquipment() { + scheduleRefresh(); + } + + @Override + public void refreshEquipmentTable() { + scheduleRefresh(); + } + + @Override + public void refreshStatus() { + if (!refreshing) { + scheduleRefresh(); + } + } + + @Override + public void refreshStructure() { + if (!refreshing) { + scheduleRefresh(); + } + } + + @Override + public void refreshPreview() { + if (!refreshing) { + scheduleRefresh(); + } + } + + @Override + public void refreshSummary() { + scheduleRefresh(); + } + + @Override + public JDialog getFloatingEquipmentDatabase() { + return null; + } + + @Override + public List> getUnallocatedMounted() { + return getEntity().getEquipment().stream().filter(m -> m.getLocation() == Entity.LOC_NONE).toList(); + } + + @Override + public void createNewUnit(long entityType, boolean primitive, boolean industrial, Entity oldUnit) { + AbstractBuildingEntity building = entityType == Entity.ETYPE_MOBILE_STRUCTURE + ? BuildingUtil.newMobileStructure() : BuildingUtil.newBuilding(); + if (oldUnit != null) { + copyUnitBasics(building, oldUnit); + } + setEntity(building, ""); + forceDirtyUntilNextSave(); + } + + @Override + public ITechManager getTechManager() { + return structure.getTechManager(); + } +} diff --git a/megameklab/src/megameklab/ui/building/BuildingPlacementDialogs.java b/megameklab/src/megameklab/ui/building/BuildingPlacementDialogs.java new file mode 100644 index 00000000000..53a2067a235 --- /dev/null +++ b/megameklab/src/megameklab/ui/building/BuildingPlacementDialogs.java @@ -0,0 +1,303 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package megameklab.ui.building; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import javax.swing.DefaultCellEditor; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSpinner; +import javax.swing.JTable; +import javax.swing.SpinnerNumberModel; +import javax.swing.table.DefaultTableModel; + +import megamek.common.bays.Bay; +import megamek.common.equipment.Mounted; +import megamek.common.units.AbstractBuildingEntity; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.BuildingDesign; +import megameklab.util.BuildingUtil; + +/** Location editors display sheet coordinates; native cube coordinates never need to be entered by hand. */ +final class BuildingPlacementDialogs { + private BuildingPlacementDialogs() { } + + static void equipment(BuildingMainUI editor, Mounted mount) { + var entity = editor.getEntity(); + var hexes = entity.getInternalBuilding().getOriginalCoordsList(); + var grid = BuildingUtil.sheetGrid(hexes); + var selected = BuildingConstruction.equipmentPositions(entity, mount); + var anchor = BuildingConstruction.position(entity, mount.getLocation()); + var model = new DefaultTableModel(new String[] { "Use", "Hex", "Floor" }, 0) { + @Override + public Class getColumnClass(int column) { + return column == 0 ? Boolean.class : String.class; + } + + @Override + public boolean isCellEditable(int row, int column) { + var hex = hexes.get(row); + return column != 1 && !hex.equals(anchor.hex()) && (!BuildingConstruction.isCapital(mount.getType()) + || anchor.hex().neighbors().contains(hex)); + } + }; + for (var hex : hexes) { + var position = selected.stream().filter(p -> p.hex().equals(hex)).findFirst(); + if (hex.equals(anchor.hex())) { + position = java.util.Optional.of(anchor); + } + model.addRow(new Object[] { position.isPresent(), grid.label(hex), + entity.getLevelLabel(position.map(BuildingDesign.Position::level).orElse(editor.selectedFloor())) }); + } + JTable table = new JTable(model); + JComboBox floors = new JComboBox<>(); + for (int floor = entity.getInternalBuilding().getBuildingHeight() - 1; floor >= 0; floor--) { + floors.addItem(entity.getLevelLabel(floor)); + } + table.getColumnModel().getColumn(2).setCellEditor(new DefaultCellEditor(floors)); + JPanel panel = tablePanel(table, "One item, with its mass divided evenly across the selected hexes. Include its primary hex/floor."); + if (JOptionPane.showConfirmDialog(editor, panel, "Distribute " + mount.getName(), JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE) == JOptionPane.OK_OPTION) { + stopEditing(table); + List positions = new ArrayList<>(); + for (int row = 0; row < model.getRowCount(); row++) { + if (Boolean.TRUE.equals(model.getValueAt(row, 0))) { + positions.add(new BuildingDesign.Position(hexes.get(row), floor(entity, model.getValueAt(row, 2).toString()))); + } + } + if (positions.equals(List.of(anchor))) { + entity.getDesign().getEquipmentSpace().remove(mount); + } else { + entity.getDesign().getEquipmentSpace().put(mount, positions); + } + editor.scheduleRefresh(); + } + } + + static void bay(BuildingMainUI editor, Bay bay) { + var entity = editor.getEntity(); + var spaces = BuildingConstruction.baySpaces(entity, bay); + var model = new DefaultTableModel(new String[] { "Hex/Floor", "Tons" }, 0) { + @Override + public Class getColumnClass(int column) { + return column == 0 ? String.class : Double.class; + } + + @Override + public boolean isCellEditable(int row, int column) { + return column == 1; + } + }; + for (int loc = 0; loc < entity.locations(); loc++) { + var position = BuildingConstruction.position(entity, loc); + model.addRow(new Object[] { BuildingUtil.locationLabel(entity, loc), spaces.stream() + .filter(space -> space.position().equals(position)).mapToDouble(BuildingDesign.Space::tons).sum() }); + } + JTable table = new JTable(model); + JPanel panel = tablePanel(table, "Allocate " + bay.getWeight() + " tons. Crew and residential quarters may span hexes and floors."); + JPanel buttons = new JPanel(); + JButton even = new JButton("Distribute evenly"); + even.addActionListener(e -> { + stopEditing(table); + for (int row = 0; row < model.getRowCount(); row++) { + model.setValueAt(bay.getWeight() / model.getRowCount(), row, 1); + } + }); + JButton here = new JButton("Place all at editing location"); + here.addActionListener(e -> { + stopEditing(table); + for (int row = 0; row < model.getRowCount(); row++) { + model.setValueAt(row == editor.selectedLocation() ? bay.getWeight() : 0.0, row, 1); + } + }); + buttons.add(even); + buttons.add(here); + panel.add(buttons, BorderLayout.SOUTH); + while (JOptionPane.showConfirmDialog(editor, panel, "Bay and quarters placement", JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE) == JOptionPane.OK_OPTION) { + stopEditing(table); + List result = new ArrayList<>(); + for (int loc = 0; loc < model.getRowCount(); loc++) { + double weight = ((Number) model.getValueAt(loc, 1)).doubleValue(); + if (weight != 0) { + result.add(new BuildingDesign.Space(BuildingConstruction.position(entity, loc), weight)); + } + } + if (result.stream().anyMatch(space -> !Double.isFinite(space.tons()) || space.tons() < 0) + || Math.abs(result.stream().mapToDouble(BuildingDesign.Space::tons).sum() - bay.getWeight()) > .00001) { + JOptionPane.showMessageDialog(editor, "Distribute exactly " + bay.getWeight() + " tons using non-negative values."); + continue; + } + entity.getDesign().getBaySpace().put(bay, result); + editor.scheduleRefresh(); + return; + } + } + + static void portalTemplates(BuildingMainUI editor) { + var entity = editor.getEntity(); + var hexes = entity.getInternalBuilding().getOriginalCoordsList(); + var grid = BuildingUtil.sheetGrid(hexes); + var labels = new ArrayList(); + labels.add("Not assigned"); + hexes.forEach(hex -> labels.add(grid.label(hex))); + var hex2 = new JComboBox<>(labels.toArray(String[]::new)); + var hex3 = new JComboBox<>(labels.toArray(String[]::new)); + hex2.setSelectedIndex(hexes.indexOf(entity.getDesign().getPortalHex2()) + 1); + hex3.setSelectedIndex(hexes.indexOf(entity.getDesign().getPortalHex3()) + 1); + JPanel panel = new JPanel(new java.awt.GridLayout(0, 2, 8, 8)); + panel.add(new JLabel("Portal Hex 2 equipment template")); + panel.add(hex2); + panel.add(new JLabel("Portal Hex 3 equipment template")); + panel.add(hex3); + panel.add(new JLabel("TO:AUE p.76: author identifies the template hexes.")); + panel.add(new JLabel("Tunnel sections repeat Hex 3 then Hex 2; equipment is not generated.")); + if (JOptionPane.showConfirmDialog(editor, panel, "Large Portal tunnel equipment", JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE) == JOptionPane.OK_OPTION) { + entity.getDesign().setPortalHex2(hex2.getSelectedIndex() <= 0 ? null : hexes.get(hex2.getSelectedIndex() - 1)); + entity.getDesign().setPortalHex3(hex3.getSelectedIndex() <= 0 ? null : hexes.get(hex3.getSelectedIndex() - 1)); + editor.scheduleRefresh(); + } + } + static void bayDoors(BuildingMainUI editor, Bay bay) { + var entity = editor.getEntity(); + var positions = java.util.stream.IntStream.range(0, entity.locations()).mapToObj(loc -> BuildingConstruction.position(entity, loc)) + .filter(position -> position.level() < entity.getInternalBuilding().getHeight(position.hex())).distinct().toList(); + var grid = BuildingUtil.sheetGrid(entity.getInternalBuilding().getOriginalCoordsList()); + var labels = new ArrayList(); + labels.add("Unassigned"); + positions.forEach(position -> labels.add(grid.label(position.hex()) + "/" + entity.getLevelLabel(position.level()))); + var old = megamek.common.units.BuildingBayDoors.placements(entity, bay); + var model = new DefaultTableModel(new String[] { "Door", "Hex/Floor", "Facing" }, 0) { + @Override + public boolean isCellEditable(int row, int column) { return column > 0; } + }; + for (int i = 0; i < bay.getDoors(); i++) { + var door = i < old.size() ? old.get(i) : null; + model.addRow(new Object[] { i + 1, door == null ? "Unassigned" : labels.get(positions.indexOf(door.position()) + 1), + BuildingEquipmentTab.FACINGS[door == null ? 0 : door.facing()] }); + } + JTable table = new JTable(model); + table.getColumnModel().getColumn(1).setCellEditor(new DefaultCellEditor(new JComboBox<>(labels.toArray(String[]::new)))); + table.getColumnModel().getColumn(2).setCellEditor(new DefaultCellEditor(new JComboBox<>(BuildingEquipmentTab.FACINGS))); + JPanel panel = tablePanel(table, "Place this bay's doors on exterior edges. Joined modular-linkage hexes cannot use their doors."); + while (JOptionPane.showConfirmDialog(editor, panel, "Bay " + bay.getBayNumber() + " doors", JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE) == JOptionPane.OK_OPTION) { + stopEditing(table); + List result = new ArrayList<>(); + for (int row = 0; row < model.getRowCount(); row++) { + int position = labels.indexOf(model.getValueAt(row, 1).toString()) - 1; + int facing = java.util.Arrays.asList(BuildingEquipmentTab.FACINGS).indexOf(model.getValueAt(row, 2).toString()); + if (position >= 0) { + result.add(new BuildingDesign.BayDoor(bay.getBayNumber(), positions.get(position), facing)); + } + } + entity.getDesign().getBayDoors().removeIf(door -> door.bayNumber() == bay.getBayNumber()); + entity.getDesign().getBayDoors().addAll(result); + var issues = new ArrayList<>(megamek.common.units.BuildingBayDoors.validationIssues(entity, false)); + if (!result.isEmpty() && result.size() != bay.getDoors()) { + issues.add("Assign all doors of this bay, or leave all unassigned."); + } + if (!issues.isEmpty()) { + entity.getDesign().getBayDoors().removeIf(door -> door.bayNumber() == bay.getBayNumber()); + entity.getDesign().getBayDoors().addAll(old); + JOptionPane.showMessageDialog(editor, String.join("\n", issues)); + continue; + } + editor.scheduleRefresh(); + return; + } + } + static void elevator(BuildingMainUI editor, int index) { + var entity = editor.getEntity(); + var lifts = entity.getDesign().getElevators(); + var old = index < 0 ? null : lifts.get(index); + var hex = old == null ? editor.selectedHex() : old.hex(); + int height = entity.getInternalBuilding().getBuildingHeight(); + var model = new DefaultTableModel(new String[] { "Stop", "Floor", "N", "NE", "SE", "S", "SW", "NW" }, 0) { + @Override + public Class getColumnClass(int column) { + return column == 1 ? String.class : Boolean.class; + } + + @Override + public boolean isCellEditable(int row, int column) { + return column == 0 || (column >= 2 && entity.getInternalBuilding().getOriginalCoordsList() + .contains(hex.toOffset().translated(column - 2).toCube())); + } + }; + int internalSides = 0; + for (int side = 0; side < 6; side++) { + if (entity.getInternalBuilding().getOriginalCoordsList().contains(hex.toOffset().translated(side).toCube())) { + internalSides |= 1 << side; + } + } + for (int floor = height; floor >= 0; floor--) { + int mask = old == null ? internalSides : old.exits().getOrDefault(floor, 0); + model.addRow(new Object[] { old == null ? floor < height : old.exits().containsKey(floor), + BuildingUtil.roofLevelLabel(entity, floor), (mask & 1) != 0, (mask & 2) != 0, + (mask & 4) != 0, (mask & 8) != 0, (mask & 16) != 0, (mask & 32) != 0 }); + } + JTable table = new JTable(model); + JPanel panel = tablePanel(table, "Select a continuous range of floors and internal access sides; the shaft must be clear."); + JSpinner capacity = new JSpinner(new SpinnerNumberModel(old == null ? 20.0 : old.capacity(), 1.0, 1000.0, 1.0)); + JPanel top = new JPanel(); + top.add(new JLabel("Hex " + BuildingUtil.sheetGrid(entity.getInternalBuilding().getOriginalCoordsList()).label(hex) + " Lift capacity (t):")); + top.add(capacity); + panel.add(top, BorderLayout.SOUTH); + if (JOptionPane.showConfirmDialog(editor, panel, "Industrial elevator", JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE) == JOptionPane.OK_OPTION) { + stopEditing(table); + var exits = new HashMap(); + for (int row = 0; row < model.getRowCount(); row++) { + if (Boolean.TRUE.equals(model.getValueAt(row, 0))) { + int mask = 0; + for (int side = 0; side < 6; side++) { + if (Boolean.TRUE.equals(model.getValueAt(row, side + 2))) { + mask |= 1 << side; + } + } + exits.put(height - row, mask); + } + } + var lift = new BuildingDesign.Elevator(hex, ((Number) capacity.getValue()).doubleValue(), exits); + if (old == null) { + lifts.add(lift); + } else { + lifts.set(index, lift); + } + editor.scheduleRefresh(); + } + } + + private static JPanel tablePanel(JTable table, String description) { + table.setRowHeight(24); + table.putClientProperty("terminateEditOnFocusLost", true); + JPanel panel = new JPanel(new BorderLayout(6, 8)); + JScrollPane scroll = new JScrollPane(table); + scroll.setPreferredSize(new Dimension(650, 320)); + panel.add(new JLabel(description), BorderLayout.NORTH); + panel.add(scroll, BorderLayout.CENTER); + return panel; + } + + static void stopEditing(JTable table) { + if (table.isEditing()) { + table.getCellEditor().stopCellEditing(); + } + } + + static int floor(AbstractBuildingEntity entity, String text) { + return Math.toIntExact(("Ground".equals(text) ? 0 : Long.parseLong(text)) - BuildingConstruction.baseLevel(entity)); + } +} diff --git a/megameklab/src/megameklab/ui/building/BuildingStructureTab.java b/megameklab/src/megameklab/ui/building/BuildingStructureTab.java new file mode 100644 index 00000000000..b5e2651ed07 --- /dev/null +++ b/megameklab/src/megameklab/ui/building/BuildingStructureTab.java @@ -0,0 +1,1110 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.ui.building; + +import java.awt.BasicStroke; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.GridLayout; +import java.awt.Insets; +import java.awt.Polygon; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.AffineTransform; +import java.awt.geom.Path2D; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultComboBoxModel; +import javax.swing.DefaultListCellRenderer; +import javax.swing.Icon; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSpinner; +import javax.swing.JTextArea; +import javax.swing.Scrollable; +import javax.swing.SpinnerNumberModel; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; + +import megamek.client.ui.WrapLayout; +import megamek.common.SimpleTechLevel; +import megamek.common.board.CubeCoords; +import megamek.common.enums.BuildingType; +import megamek.common.enums.Faction; +import megamek.common.equipment.EquipmentType; +import megamek.common.equipment.enums.StructureEngine; +import megamek.common.interfaces.ITechManager; +import megamek.common.units.AbstractBuildingEntity; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.BuildingDesign; +import megamek.common.units.EntityMovementMode; +import megamek.common.units.IBuilding; +import megamek.common.units.MobileStructure; +import megamek.common.units.UnitRole; +import megamek.common.verifier.TestBuilding; +import megameklab.ui.generalUnit.BasicInfoView; +import megameklab.ui.generalUnit.IconView; +import megameklab.ui.listeners.BuildListener; +import megameklab.ui.util.TabScrollPane; +import megameklab.ui.util.WidthControlComponent; +import megameklab.util.BuildingMap; +import megameklab.util.BuildingUtil; +import megameklab.util.UnitUtil; + +class BuildingStructureTab extends JPanel implements BuildListener { + private final BuildingMainUI editor; + private final BasicInfoView basicInfo; + private final JComboBox structureType = new JComboBox<>(new String[] { "Structure", "Mobile Structure" }); + private final IconView icon = new IconView(); + private final JComboBox type = new JComboBox<>(new BuildingType[] { + BuildingType.LIGHT, BuildingType.MEDIUM, BuildingType.HEAVY, BuildingType.HARDENED, BuildingType.RAIL }); + private final JComboBox buildingClass = new JComboBox<>(new String[] { + "Standard", "Hangar", "Fortress", "Gun Emplacement", "Castles Brian", "Tent", "Wall", "Fence", "Bridge" }); + private final JSpinner levels = new JSpinner(new SpinnerNumberModel(1, 1, 100, 1)); + private final JComboBox motive = new JComboBox<>(new EntityMovementMode[] { + EntityMovementMode.TRACKED, EntityMovementMode.VTOL, EntityMovementMode.NAVAL, EntityMovementMode.SUBMARINE }); + private final JComboBox mobilePower = new JComboBox<>(StructureEngine.values()); + private final JSpinner maximumMP = new JSpinner(new SpinnerNumberModel(1.0, .25, 4.0, .25)); + private final JSpinner operatingRange = new JSpinner(new SpinnerNumberModel(0.0, 0.0, null, 100.0)); + private final JCheckBox uniformFuel = new JCheckBox("Distribute fuel evenly"); + private final JSpinner hexFuel = new JSpinner(new SpinnerNumberModel(0.0, 0.0, null, .5)); + private final JLabel fuelAllocation = new JLabel(); + private final JSpinner hexHeight = new JSpinner(new SpinnerNumberModel(1, 1, 100, 1)); + private final JSpinner baseLevel = new JSpinner(new SpinnerNumberModel(0, null, null, 1)); + private final JCheckBox automaticBaseLevel = new JCheckBox("Automatic from site"); + private final JLabel baseLevelLabel; + private final JSpinner cf = new JSpinner(new SpinnerNumberModel(40, 1, 1000, 1)); + private final JSpinner armor = new JSpinner(new SpinnerNumberModel(0, 0, 1000, 1)); + private final JLabel cfLabel; + private final JLabel armorLabel; + private final JLabel selection = new JLabel(); + private final JLabel limits = new JLabel(); + private final JButton remove = new JButton("Remove selected hex"); + private final Footprint footprint = new Footprint(false); + private final Footprint pancake = new Footprint(true); + private final JPanel legend = new JPanel(new WrapLayout(FlowLayout.LEFT, 12, 4)); + private final Map legendEntries = new EnumMap<>(BuildingMap.Feature.class); + private final JPanel pancakeLegend = new JPanel(new WrapLayout(FlowLayout.LEFT, 12, 4)); + private final Map pancakeLegendEntries = new EnumMap<>(BuildingMap.Feature.class); + private final JCheckBox absoluteCoordinates = new JCheckBox("Absolute coordinates"); + private final JPanel sideControls = new JPanel(new WrapLayout(FlowLayout.LEFT)); + private final JCheckBox[] sides = new JCheckBox[6]; + private final JPanel bridgeControls = new JPanel(new WrapLayout(FlowLayout.LEFT)); + private final JSpinner deckLevel = new JSpinner(new SpinnerNumberModel(0, 0, 100, 1)); + private final JSpinner bridgeStart = new JSpinner(new SpinnerNumberModel(0, 0, 100, 1)); + private final JSpinner bridgeEnd = new JSpinner(new SpinnerNumberModel(0, 0, 100, 1)); + private final JLabel bridgeEndpoints = new JLabel(); + private final JLabel protectionScale = new JLabel(); + private final JTextArea geometryHint = new JTextArea(3, 0); + private boolean refreshing; + + BuildingStructureTab(BuildingMainUI editor) { + this.editor = editor; + basicInfo = new BasicInfoView(entity().getConstructionTechAdvancement()); + setLayout(new BorderLayout(15, 10)); + JPanel identity = new JPanel(new GridBagLayout()); + mobilePower.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof StructureEngine engine) { + setText(engine.getEngineName()); + } + return this; + } + }); + basicInfo.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createTitledBorder("Basic Information"), + BorderFactory.createEmptyBorder(4, 4, 4, 4))); + addStructureTypeField(); + structureType.setToolTipText("Choose between a stationary structure and a mobile structure."); + structureType.addActionListener(event -> { + if (!refreshing) { + editor.changeStructureType(structureType.getSelectedIndex() == 1); + } + }); + icon.setFromEntity(entity()); + icon.setRefreshedListener(editor); + icon.setBorder(BorderFactory.createCompoundBorder(icon.getBorder(), BorderFactory.createEmptyBorder(4, 4, 4, 4))); + JPanel settings = new JPanel(new BorderLayout(0, 10)); + settings.setName("Building superstructure"); + settings.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createTitledBorder("Superstructure"), + BorderFactory.createEmptyBorder(6, 6, 6, 6))); + JPanel fields = new JPanel(new GridBagLayout()); + addField(fields, "Building type", type); + addField(fields, "Classification", buildingClass); + addField(fields, "Building levels", levels).setText("Levels:"); + levels.setToolTipText("Number of floors. Set their numbering with Lowest floor level."); + JPanel floorNumbering = new JPanel(new GridLayout(0, 1, 0, 4)); + baseLevel.setName("Lowest floor level"); + baseLevel.setToolTipText("Actual lowest-floor elevation relative to the map surface. 0 = Ground. For a connected basement, choose Underground, turn off Automatic, and set this to minus the number of levels. Deploy the surface building first, then place this part on the same hexes."); + automaticBaseLevel.setName("Automatic floor numbering"); + automaticBaseLevel.setToolTipText("Surface floors start at Ground. Underground/underwater floors use roof cover depth."); + floorNumbering.add(baseLevel); + floorNumbering.add(automaticBaseLevel); + baseLevelLabel = addField(fields, "Lowest floor level (0 = Ground)", floorNumbering); + cfLabel = addField(fields, "CF per hex", cf); + armorLabel = addField(fields, "Armor points per hex", armor); + if (entity() instanceof MobileStructure) { + buildingClass.setModel(new DefaultComboBoxModel<>(new String[] { "Standard", "Hangar", "Fortress" })); + addField(fields, "Motive system", motive); + addField(fields, "Power system", mobilePower); + addField(fields, "Maximum MP", maximumMP); + addField(fields, "Operating range (km)", operatingRange); + JPanel fuel = new JPanel(new GridLayout(0, 1, 0, 4)); + uniformFuel.setName("Distribute mobile fuel evenly"); + hexFuel.setName("Selected hex fuel tons"); + hexFuel.setToolTipText("Fuel stored in the selected hex. Total allocated fuel must match the operating range."); + fuel.add(uniformFuel); + fuel.add(hexFuel); + fuel.add(fuelAllocation); + addField(fields, "Selected hex fuel (tons)", fuel); + addField(fields, "Selected hex levels", hexHeight); + motive.addActionListener(event -> applyPropulsion()); + mobilePower.addActionListener(event -> applyPropulsion()); + maximumMP.addChangeListener(event -> applyPropulsion()); + operatingRange.addChangeListener(event -> applyPropulsion()); + uniformFuel.addActionListener(event -> applyFuelDistribution()); + hexFuel.addChangeListener(event -> applyFuelDistribution()); + hexHeight.addChangeListener(event -> { + if (!refreshing) { + BuildingUtil.setHexHeight(entity(), editor.selectedHex(), (Integer) hexHeight.getValue()); + editor.scheduleRefresh(); + } + }); + } + GridBagConstraints fieldWidth = new GridBagConstraints(); + fieldWidth.gridx = 1; + fieldWidth.gridy = fields.getComponentCount() / 2; + fields.add(new WidthControlComponent(), fieldWidth); + settings.add(fields, BorderLayout.CENTER); + JPanel notes = new JPanel(new GridLayout(0, 1, 0, 6)); + notes.add(limits); + notes.add(protectionScale); + settings.add(notes, BorderLayout.SOUTH); + GridBagConstraints row = new GridBagConstraints(); + row.gridx = 0; + row.gridy = 0; + row.weightx = 1; + row.fill = GridBagConstraints.HORIZONTAL; + row.insets = new Insets(0, 0, 8, 0); + for (Component panel : List.of(basicInfo, icon, settings)) { + identity.add(panel, row); + row.gridy++; + } + row.weighty = 1; + identity.add(Box.createVerticalGlue(), row); + TabScrollPane properties = new TabScrollPane(identity); + properties.setName("Building properties"); + properties.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + add(properties, BorderLayout.WEST); + + JPanel geometry = new JPanel(new BorderLayout(5, 10)); + JPanel map = new JPanel(new BorderLayout(0, 6)); + map.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createTitledBorder("Footprint"), + BorderFactory.createEmptyBorder(6, 6, 6, 6))); + legend.setName("Building footprint legend"); + int markerHeight = legend.getFontMetrics(legend.getFont()).getHeight(); + for (BuildingMap.Feature feature : BuildingMap.Feature.values()) { + JLabel entry = new JLabel(feature.label, new FeatureIcon(feature, markerHeight), SwingConstants.LEADING); + entry.setIconTextGap(6); + legend.add(entry); + legendEntries.put(feature, entry); + } + absoluteCoordinates.setName("Absolute coordinates"); + absoluteCoordinates.setToolTipText("Show authored coordinates (q,r). Sheets always use sheet coordinates."); + absoluteCoordinates.addActionListener(event -> editor.setAbsoluteCoordinates(absoluteCoordinates.isSelected())); + JPanel mapHeader = new JPanel(new BorderLayout(8, 4)); + mapHeader.add(legend, BorderLayout.CENTER); + map.add(mapHeader, BorderLayout.NORTH); + map.add(new TabScrollPane(footprint), BorderLayout.CENTER); + geometry.add(map, BorderLayout.CENTER); + JPanel actions = new JPanel(); + actions.setLayout(new BoxLayout(actions, BoxLayout.Y_AXIS)); + geometryHint.setEditable(false); + geometryHint.setFocusable(false); + geometryHint.setOpaque(false); + geometryHint.setFont(selection.getFont()); + geometryHint.setLineWrap(true); + geometryHint.setWrapStyleWord(true); + actions.add(geometryHint); + JPanel buttons = new JPanel(new WrapLayout(FlowLayout.LEFT)); + remove.addActionListener(e -> { + if (entity().getInternalBuilding().getCoordsList().size() > 1) { + List hexes = new ArrayList<>(entity().getInternalBuilding().getCoordsList()); + hexes.remove(editor.selectedHex()); + configure(hexes); + } + }); + JButton rotate = new JButton("Rotate clockwise"); + rotate.addActionListener(e -> { + transform(c -> new CubeCoords(-(int) c.r(), -(int) c.s(), -(int) c.q()), facing -> (facing + 1) % 6); + }); + buttons.add(selection); + buttons.add(remove); + buttons.add(rotate); + JButton mirror = new JButton("Mirror"); + mirror.addActionListener(e -> { + transform(c -> new CubeCoords(-(int) c.q(), -(int) c.s(), -(int) c.r()), + facing -> (6 - facing) % 6); + }); + buttons.add(mirror); + actions.add(buttons); + JPanel moveControls = new JPanel(new WrapLayout(FlowLayout.LEFT)); + JComboBox direction = new JComboBox<>(BuildingEquipmentTab.FACINGS); + JButton move = new JButton("Move selected hex"); + move.addActionListener(e -> { + CubeCoords old = editor.selectedHex(); + CubeCoords target = old.toOffset().translated(direction.getSelectedIndex()).toCube(); + if (!entity().getInternalBuilding().getCoordsList().contains(target)) { + transform(hex -> hex.equals(old) ? target : hex, facing -> facing); + } + }); + moveControls.add(direction); + moveControls.add(move); + actions.add(moveControls); + sideControls.add(new JLabel("Wall/fence sides at selected hex:")); + for (int side = 0; side < 6; side++) { + int facing = side; + sides[side] = new JCheckBox(BuildingEquipmentTab.FACINGS[side]); + sides[side].setName("Structure side " + side); + sides[side].addActionListener(e -> { + if (!refreshing) { + BuildingDesign design = entity().getDesign(); + CubeCoords hex = editor.selectedHex(); + int mask = design.wallSides(hex) ^ (1 << facing); + design.getWallSides().put(hex, mask); + // The same physical side is shared by two hexes; keep one owner. + CubeCoords neighbor = hex.toOffset().translated(facing).toCube(); + if ((mask & (1 << facing)) != 0 && entity().getInternalBuilding().getCoordsList().contains(neighbor)) { + design.getWallSides().put(neighbor, design.wallSides(neighbor) & ~(1 << ((facing + 3) % 6))); + } + editor.scheduleRefresh(); + } + }); + sideControls.add(sides[side]); + } + actions.add(sideControls); + JLabel deckLabel = new JLabel("Deck elevation at selected hex (0 = Ground)"); + deckLabel.setLabelFor(deckLevel); + deckLevel.setName("Bridge deck elevation"); + deckLevel.setEnabled(false); + bridgeControls.add(deckLabel); + bridgeControls.add(deckLevel); + bridgeStart.setName("Bridge start elevation"); + bridgeEnd.setName("Bridge end elevation"); + bridgeControls.add(bridgeEndpoints); + bridgeControls.add(bridgeStart); + bridgeControls.add(bridgeEnd); + javax.swing.event.ChangeListener slopeChanged = e -> { + if (!refreshing) { + BuildingConstruction.BridgeSpan span = BuildingConstruction.bridgeSpan(entity().getInternalBuilding().getOriginalCoordsList()); + if (span == null) { + return; + } + // Keep endpoints editable for an invalid requested rise; the verifier reports it. + span.distances().keySet().forEach(hex -> entity().getDesign().getBridgeDecks().put(hex, + span.level(hex, (int) bridgeStart.getValue(), (int) bridgeEnd.getValue()))); + editor.scheduleRefresh(); + } + }; + bridgeStart.addChangeListener(slopeChanged); + bridgeEnd.addChangeListener(slopeChanged); + actions.add(bridgeControls); + geometry.add(actions, BorderLayout.SOUTH); + add(geometry, BorderLayout.CENTER); + setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + basicInfo.addListener(this); + type.addActionListener(e -> applySettings()); + buildingClass.addActionListener(e -> applySettings()); + levels.addChangeListener(e -> applySettings()); + baseLevel.addChangeListener(e -> applyFloorNumbering()); + automaticBaseLevel.addActionListener(e -> applyFloorNumbering()); + cf.addChangeListener(e -> applySettings()); + armor.addChangeListener(e -> applySettings()); + refresh(); + properties.setPreferredSize(new Dimension(identity.getPreferredSize().width + + properties.getVerticalScrollBar().getPreferredSize().width, 0)); + } + + private static JLabel addField(JPanel panel, String label, javax.swing.JComponent field) { + JLabel name = new JLabel(label + ":", SwingConstants.RIGHT); + name.setLabelFor(field); + field.setName(label); + GridBagConstraints cell = new GridBagConstraints(); + cell.gridx = 0; + cell.gridy = panel.getComponentCount() / 2; + cell.anchor = GridBagConstraints.EAST; + cell.insets = new Insets(2, 0, 2, 8); + panel.add(name, cell); + cell.gridx = 1; + cell.weightx = 1; + cell.fill = GridBagConstraints.HORIZONTAL; + cell.insets = new Insets(2, 0, 2, 0); + panel.add(field, cell); + return name; + } + + private void addStructureTypeField() { + JLabel name = new JLabel("Structure type: ", SwingConstants.RIGHT); + name.setLabelFor(structureType); + structureType.setName("Structure type"); + GridBagConstraints cell = new GridBagConstraints(); + cell.gridx = 0; + cell.gridy = 13; + cell.weightx = 1; + cell.fill = GridBagConstraints.HORIZONTAL; + cell.anchor = GridBagConstraints.EAST; + cell.insets = new Insets(2, 2, 2, 2); + basicInfo.add(name, cell); + cell.gridx = 1; + cell.weightx = 0; + cell.anchor = GridBagConstraints.WEST; + basicInfo.add(structureType, cell); + } + + private AbstractBuildingEntity entity() { + return editor.getEntity(); + } + + ITechManager getTechManager() { + return basicInfo; + } + + JPanel createPancakePane() { + JPanel panel = new JPanel(new BorderLayout(0, 6)); + JPanel header = new JPanel(new BorderLayout()); + header.add(absoluteCoordinates, BorderLayout.NORTH); + pancakeLegend.setName("Building pancake legend"); + int markerHeight = pancakeLegend.getFontMetrics(pancakeLegend.getFont()).getHeight(); + for (BuildingMap.Feature feature : BuildingMap.Feature.values()) { + JLabel entry = new JLabel(feature.label, new FeatureIcon(feature, markerHeight), SwingConstants.LEADING); + entry.setIconTextGap(6); + pancakeLegend.add(entry); + pancakeLegendEntries.put(feature, entry); + } + header.add(pancakeLegend, BorderLayout.CENTER); + panel.add(header, BorderLayout.NORTH); + TabScrollPane scroll = new TabScrollPane(pancake); + scroll.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + panel.add(scroll, BorderLayout.CENTER); + return panel; + } + + void refresh() { + refreshing = true; + basicInfo.removeListener(this); + basicInfo.setFromEntity(entity()); + basicInfo.addListener(this); + structureType.setSelectedIndex(entity() instanceof MobileStructure ? 1 : 0); + icon.refresh(); + type.setModel(new DefaultComboBoxModel<>(java.util.Arrays.stream(BuildingType.values()) + .filter(value -> TestBuilding.limits(entity(), value, entity().getBldgClass()) != null || value == entity().getBuildingType()) + .toArray(BuildingType[]::new))); + type.setSelectedItem(entity().getBuildingType()); + buildingClass.setSelectedIndex(entity().getBldgClass() >= 0 && entity().getBldgClass() < buildingClass.getItemCount() + ? entity().getBldgClass() : -1); + levels.setValue(entity().getInternalBuilding().getBuildingHeight()); + if (entity() instanceof MobileStructure mobile) { + motive.setSelectedItem(mobile.getMovementMode()); + mobilePower.setModel(new DefaultComboBoxModel<>(java.util.Arrays.stream(StructureEngine.values()) + .filter(power -> power.mobilePowerMultiplier(mobile.getMovementMode(), mobile.isClan()) > 0) + .toArray(StructureEngine[]::new))); + mobilePower.setSelectedItem(mobile.getPowerSystem()); + maximumMP.setValue(mobile.getMaximumMP()); + operatingRange.setValue(mobile.getOperatingRange()); + uniformFuel.setSelected(mobile.getFuelLocations().isEmpty()); + hexFuel.setEnabled(!uniformFuel.isSelected()); + hexFuel.setValue(mobile.fuelWeightInHex(editor.selectedHex())); + double allocatedFuel = mobile.getInternalBuilding().getOriginalCoordsList().stream() + .mapToDouble(mobile::fuelWeightInHex).sum(); + fuelAllocation.setText("Allocated: %.2f / %.2f tons".formatted(allocatedFuel, mobile.getFuelWeight())); + ((SpinnerNumberModel) hexHeight.getModel()).setMaximum(entity().getInternalBuilding().getBuildingHeight()); + hexHeight.setValue(entity().getInternalBuilding().getHeight(editor.selectedHex())); + } + baseLevel.setValue(BuildingConstruction.baseLevel(entity())); + automaticBaseLevel.setSelected(entity().getDesign().getBaseLevel() == null); + baseLevel.setEnabled(!automaticBaseLevel.isSelected()); + baseLevelLabel.setVisible(entity().getBldgClass() != IBuilding.BRIDGE); + baseLevel.getParent().setVisible(entity().getBldgClass() != IBuilding.BRIDGE); + cf.setValue(entity().getOInternal(0)); + armor.setValue(entity().getOArmor(0)); + cfLabel.setText(BuildingConstruction.usesHexsides(entity()) ? "CF per hexside:" : "CF per hex:"); + armorLabel.setText(BuildingConstruction.usesHexsides(entity()) ? "Armor per hexside:" : "Armor per hex:"); + TestBuilding.Limits rule = TestBuilding.limits(entity()); + limits.setText("Construction limits
" + (rule == null ? "Invalid type/class combination" : "CF %d–%d; %s; %d %s" + .formatted(rule.minimumCF(), rule.maximumCF(), rule.hexes() == Integer.MAX_VALUE ? "no length limit" + : "up to " + rule.hexes() + " hexes", rule.levels(), entity().getBldgClass() == IBuilding.BRIDGE ? "deck" : "levels")) + ""); + protectionScale.setText(entity().getConstructionCFScale() == 10 ? "Capital CF and armor
1 point = 10 standard points" + : BuildingConstruction.usesHexsides(entity()) ? "Standard CF and armor per occupied hexside" : "Standard CF and armor per hex"); + geometryHint.setText("Click + to add a hex; click a hex to select it; double-click to edit its equipment.\n" + + (entity().getBldgClass() == IBuilding.BRIDGE + ? "Bridge decks follow a steady slope. Their ends must meet the underlying map terrain." + : BuildingConstruction.usesHexsides(entity()) ? "Select occupied hexsides below. All segments share CF, armor and height." + : entity() instanceof MobileStructure ? "Select a hex to set its individual height. Capacity uses the structure's maximum height." + : "All hexes share the same height. Stepped buildings are separate buildings in a complex.")); + sideControls.setVisible(BuildingConstruction.usesHexsides(entity())); + for (int side = 0; side < 6; side++) { + sides[side].setSelected((entity().getDesign().wallSides(editor.selectedHex()) & (1 << side)) != 0); + } + bridgeControls.setVisible(entity().getBldgClass() == IBuilding.BRIDGE); + deckLevel.setValue(entity().getDesign().bridgeDeck(editor.selectedHex())); + BuildingConstruction.BridgeSpan span = entity().getBldgClass() == IBuilding.BRIDGE + ? BuildingConstruction.bridgeSpan(entity().getInternalBuilding().getOriginalCoordsList()) : null; + bridgeStart.setEnabled(span != null); + bridgeEnd.setEnabled(span != null && span.length() > 0); + if (span != null) { + bridgeEndpoints.setText("Steady slope: " + editor.hexLabel(span.start()) + " → " + editor.hexLabel(span.end())); + bridgeStart.setValue(entity().getDesign().bridgeDeck(span.start())); + bridgeEnd.setValue(entity().getDesign().bridgeDeck(span.end())); + } else { + bridgeEndpoints.setText("Connect the bridge hexes to set a slope"); + } + levels.setEnabled(entity().getBldgClass() != IBuilding.BRIDGE && entity().getBldgClass() != IBuilding.TENT + && entity().getBldgClass() != IBuilding.GUN_EMPLACEMENT); + type.setEnabled(entity().getBldgClass() != IBuilding.TENT && entity().getBldgClass() != IBuilding.FENCE); + absoluteCoordinates.setSelected(editor.absoluteCoordinates()); + selection.setText("Editing: " + editor.hexLabel(editor.selectedHex()) + "/" + + entity().getLevelLabel(displayedLevel(editor.selectedHex()))); + remove.setEnabled(entity().getInternalBuilding().getCoordsList().size() > 1); + EnumSet features = EnumSet.noneOf(BuildingMap.Feature.class); + EnumSet allFeatures = EnumSet.noneOf(BuildingMap.Feature.class); + List mapDoors = entity().getDesign().getMapDoors(); + BuildingMap.FeatureIndex featureIndex = BuildingMap.featureIndex(entity(), mapDoors); + for (CubeCoords hex : entity().getInternalBuilding().getCoordsList()) { + for (int level : BuildingConstruction.mapLevels(entity())) { + if (BuildingConstruction.occupiesMapLevel(entity(), hex, level)) { + allFeatures.addAll(featureIndex.features(hex, level)); + if (level == displayedLevel(hex)) { + features.addAll(featureIndex.features(hex, level)); + } + } + } + } + legendEntries.forEach((feature, entry) -> entry.setVisible(features.contains(feature))); + legend.setVisible(!features.isEmpty()); + legend.revalidate(); + pancakeLegendEntries.forEach((feature, entry) -> entry.setVisible(allFeatures.contains(feature))); + pancakeLegend.setVisible(!allFeatures.isEmpty()); + pancakeLegend.revalidate(); + footprint.revalidate(); + footprint.repaint(); + pancake.revalidate(); + pancake.repaint(); + refreshing = false; + } + + private void applySettings() { + if (!refreshing && type.getSelectedItem() != null && buildingClass.getSelectedIndex() >= 0) { + if (buildingClass.getSelectedIndex() != entity().getBldgClass()) { + refreshing = true; + BuildingType chosen = (BuildingType) type.getSelectedItem(); + if (TestBuilding.limits(entity(), chosen, buildingClass.getSelectedIndex()) == null) { + chosen = java.util.Arrays.stream(BuildingType.values()) + .filter(value -> TestBuilding.limits(entity(), value, buildingClass.getSelectedIndex()) != null).findFirst().orElseThrow(); + type.setSelectedItem(chosen); + } + TestBuilding.Limits rule = TestBuilding.limits(entity(), chosen, buildingClass.getSelectedIndex()); + cf.setValue(Math.clamp((int) cf.getValue(), rule.minimumCF(), rule.maximumCF())); + levels.setValue(Math.min((int) levels.getValue(), rule.levels())); + refreshing = false; + } + configure(List.copyOf(entity().getInternalBuilding().getCoordsList())); + } + } + + private void applyFloorNumbering() { + if (!refreshing) { + entity().getDesign().setBaseLevel(automaticBaseLevel.isSelected() ? null : (Integer) baseLevel.getValue()); + editor.scheduleRefresh(); + } + } + + private void applyPropulsion() { + if (!refreshing && entity() instanceof MobileStructure mobile) { + mobile.setMovementMode((EntityMovementMode) motive.getSelectedItem()); + mobile.setMaximumMP(((Number) maximumMP.getValue()).doubleValue()); + mobile.setPowerSystem((StructureEngine) mobilePower.getSelectedItem()); + mobile.setOperatingRange(((Number) operatingRange.getValue()).doubleValue()); + editor.scheduleRefresh(); + } + } + + private void applyFuelDistribution() { + if (!refreshing && entity() instanceof MobileStructure mobile) { + Map allocations = new LinkedHashMap<>(); + if (!uniformFuel.isSelected()) { + mobile.getInternalBuilding().getOriginalCoordsList().forEach(hex -> + allocations.put(hex, mobile.fuelWeightInHex(hex))); + allocations.put(editor.selectedHex(), ((Number) hexFuel.getValue()).doubleValue()); + } + mobile.setFuelLocations(allocations); + editor.scheduleRefresh(); + } + } + + private void configure(List hexes) { + // Materialize implicit north sides before transformations so their orientation follows the building. + if (BuildingConstruction.usesHexsides(entity())) { + entity().getInternalBuilding().getOriginalCoordsList().forEach(hex -> + entity().getDesign().getWallSides().putIfAbsent(hex, 1)); + } + BuildingUtil.configure(entity(), (BuildingType) type.getSelectedItem(), buildingClass.getSelectedIndex(), + (int) levels.getValue(), (int) cf.getValue(), (int) armor.getValue(), hexes); + editor.scheduleRefresh(); + } + + private void transform(java.util.function.UnaryOperator transform, java.util.function.IntUnaryOperator facing) { + CubeCoords selected = transform.apply(editor.selectedHex()); + List hexes = entity().getInternalBuilding().getOriginalCoordsList().stream().map(transform).toList(); + CubeCoords origin = hexes.contains(CubeCoords.ZERO) ? CubeCoords.ZERO : hexes.getFirst(); + BuildingUtil.transform(entity(), transform, facing); + editor.selectLocation(selected.subtract(origin), editor.selectedFloor()); + editor.scheduleRefresh(); + } + + private int displayedLevel(CubeCoords hex) { + return entity().getBldgClass() == IBuilding.BRIDGE ? entity().getDesign().bridgeDeck(hex) : editor.selectedFloor(); + } + + private record FeatureIcon(BuildingMap.Feature feature, int getIconHeight) implements Icon { + @Override + public int getIconWidth() { + return feature.glyph.isBlank() ? getIconHeight * 4 / 3 + : (int) Math.ceil(2 * (getIconHeight - 2) / Math.sqrt(3)) + 2; + } + + @Override + public void paintIcon(Component component, Graphics graphics, int x, int y) { + Graphics2D g = (Graphics2D) graphics.create(); + g.translate(x, y); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + int w = getIconWidth() - 2, h = getIconHeight - 2; + Path2D marker = new Path2D.Double(); + if (feature.glyph.isBlank()) { + marker.append(new Polygon(new int[] { w / 2, w - 1, 1 }, new int[] { 1, h, h }, 3), false); + } else { + // Legend hexes use a regular top view, independent of the pancake projection. + double radius = h / Math.sqrt(3); + for (int corner = 0; corner < 6; corner++) { + double px = getIconWidth() / 2.0 + radius * Math.cos(corner * Math.PI / 3); + double py = getIconHeight / 2.0 + radius * Math.sin(corner * Math.PI / 3); + if (corner == 0) { + marker.moveTo(px, py); + } else { + marker.lineTo(px, py); + } + } + marker.closePath(); + } + g.setColor(Color.decode(feature.color)); + g.fill(marker); + g.setColor(Color.BLACK); + g.draw(marker); + g.setFont(component.getFont().deriveFont((float) h - 2)); + FontMetrics metrics = g.getFontMetrics(); + g.drawString(feature.glyph, (getIconWidth() - metrics.stringWidth(feature.glyph)) / 2f, + (getIconHeight - metrics.getHeight()) / 2f + metrics.getAscent()); + g.dispose(); + } + } + + private class Footprint extends JPanel implements Scrollable { + private final boolean pancakeView; + private final Map cells = new LinkedHashMap<>(); + private final List layers = new ArrayList<>(); + private BuildingDesign.Position revealedSelection; + + private record Layer(int level, Rectangle2D bounds, Map cells) { } + + Footprint(boolean pancakeView) { + this.pancakeView = pancakeView; + setName(pancakeView ? "Building pancake" : "Building footprint"); + setToolTipText(""); + addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent event) { + if (!javax.swing.SwingUtilities.isLeftMouseButton(event)) { + return; + } + if (pancakeView) { + for (Layer layer : layers) { + if (layer.bounds().contains(event.getPoint())) { + CubeCoords hex = layer.cells().entrySet().stream() + .filter(cell -> cell.getValue().contains(event.getPoint())) + .map(Map.Entry::getKey).findFirst().orElse(null); + if (hex != null) { + editor.selectLocation(hex, entity().getBldgClass() == IBuilding.BRIDGE ? 0 : layer.level()); + } + return; + } + } + return; + } + for (Map.Entry cell : cells.entrySet()) { + if (cell.getValue().contains(event.getPoint())) { + CubeCoords selected = cell.getKey(); + List hexes = new ArrayList<>(entity().getInternalBuilding().getCoordsList()); + if (!hexes.contains(selected)) { + hexes.add(selected); + configure(hexes); + editor.selectLocation(selected, editor.selectedFloor()); + } else { + editor.selectLocation(selected, editor.selectedFloor()); + if (event.getClickCount() == 2) { + editor.showEquipment(); + } + } + return; + } + } + } + }); + } + + @Override + public String getToolTipText(MouseEvent event) { + if (pancakeView) { + return layers.stream().flatMap(layer -> layer.cells().entrySet().stream() + .filter(cell -> cell.getValue().contains(event.getPoint())) + .map(cell -> editor.hexLabel(cell.getKey()) + "/" + entity().getLevelLabel(layer.level()) + + " — click to select hex and level")) + .findFirst().orElse(null); + } + return cells.entrySet().stream().filter(cell -> cell.getValue().contains(event.getPoint())) + .map(cell -> editor.hexLabel(cell.getKey()) + "/" + entity().getLevelLabel(displayedLevel(cell.getKey()))) + .findFirst().orElse(null); + } + + @Override + public Dimension getPreferredSize() { + if (editor == null || !pancakeView) { + return new Dimension(560, 370); + } + Rectangle2D bounds = pancakeBounds(); + int height = (int) Math.ceil(BuildingConstruction.mapLevels(entity()).size() + * (bounds.getHeight() * pancakeSize(bounds) + 44) + 20); + return new Dimension(320, height); + } + + @Override + public Dimension getPreferredScrollableViewportSize() { + return new Dimension(pancakeView ? 320 : 560, 370); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) { + return 24; + } + + @Override + public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) { + return Math.max(24, (orientation == SwingConstants.VERTICAL ? visible.height : visible.width) - 24); + } + + @Override + public boolean getScrollableTracksViewportWidth() { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() { + return !pancakeView || (getParent() != null && getParent().getHeight() >= getPreferredSize().height); + } + + @Override + protected void paintComponent(Graphics graphics) { + super.paintComponent(graphics); + Graphics2D g = (Graphics2D) graphics.create(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + cells.clear(); + layers.clear(); + if (pancakeView) { + paintPancake(g); + } else { + paintTop(g); + } + g.dispose(); + } + + private void paintTop(Graphics2D g) { + List hexes = entity().getInternalBuilding().getCoordsList(); + BuildingMap.FeatureIndex featureIndex = BuildingMap.featureIndex(entity(), entity().getDesign().getMapDoors()); + LinkedHashSet visible = new LinkedHashSet<>(hexes); + hexes.forEach(hex -> visible.addAll(hex.neighbors())); + // Keep the construction origin and the first two added rings fixed while editing. + double width = 11, height = 7; + for (CubeCoords hex : visible) { + width = Math.max(width, 3 * Math.abs(hex.q()) + 2); + height = Math.max(height, 2 * Math.abs(hex.r() + hex.q() / 2) + 1); + } + double size = Math.max(1, Math.min((getWidth() - 30.0) / width, + (getHeight() - 30.0) / (Math.sqrt(3) * height))); + AffineTransform transform = new AffineTransform(size, 0, 0, size, getWidth() / 2.0, getHeight() / 2.0); + cells.putAll(paintHexes(g, List.copyOf(visible), editor.selectedFloor(), transform, size, false, featureIndex)); + } + + private Rectangle2D pancakeBounds() { + List hexes = entity().getInternalBuilding().getCoordsList(); + double minX = Double.POSITIVE_INFINITY, minY = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY, maxY = Double.NEGATIVE_INFINITY; + for (CubeCoords hex : hexes) { + double y = Math.sqrt(3) * (hex.r() + hex.q() / 2); + double x = 1.5 * hex.q() - .35 * y; + minX = Math.min(minX, x - 1.35); + maxX = Math.max(maxX, x + 1.35); + minY = Math.min(minY, .38 * y - .38); + maxY = Math.max(maxY, .38 * y + .38); + } + return new Rectangle2D.Double(minX, minY, maxX - minX, maxY - minY); + } + + private double pancakeSize(Rectangle2D bounds) { + int width = getParent() == null ? getWidth() : getParent().getWidth(); + return Math.max(1, Math.min(56, ((width > 0 ? width : 320) - 48) / bounds.getWidth())); + } + + private void paintPancake(Graphics2D g) { + Rectangle2D bounds = pancakeBounds(); + double size = pancakeSize(bounds), step = bounds.getHeight() * size + 44; + List levels = BuildingConstruction.mapLevels(entity()); + BuildingMap.FeatureIndex featureIndex = BuildingMap.featureIndex(entity(), entity().getDesign().getMapDoors()); + double top = Math.max(10, (getHeight() - levels.size() * step) / 2); + double x = (getWidth() - bounds.getWidth() * size) / 2 - bounds.getX() * size; + for (int index = levels.size() - 1; index >= 0; index--) { + int level = levels.get(index); + double y = top + index * step; + Rectangle2D hit = new Rectangle2D.Double(12, y, Math.max(1, getWidth() - 24), step - 6); + if (level == displayedLevel(editor.selectedHex())) { + g.setColor(new Color(65, 125, 190)); + g.setStroke(new BasicStroke(1.5f)); + g.drawRoundRect(12, (int) y, Math.max(1, getWidth() - 24), (int) step - 6, 8, 8); + } + List hexes = entity().getInternalBuilding().getCoordsList().stream() + .filter(hex -> BuildingConstruction.occupiesMapLevel(entity(), hex, level)).toList(); + g.setColor(getForeground()); + g.drawString("Level: " + entity().getLevelLabel(level), 24, (float) y + 18); + String count = hexes.stream().mapToLong(hex -> equipmentCount(hex, level)).sum() + " equipped"; + g.drawString(count, getWidth() - 24 - g.getFontMetrics().stringWidth(count), (float) y + 18); + AffineTransform transform = new AffineTransform(size, 0, -.35 * size, .38 * size, + x, y + 32 - bounds.getY() * size); + Map polygons = paintHexes(g, hexes, level, transform, size, true, featureIndex); + layers.add(new Layer(level, hit, polygons)); + // Outgoing shafts cover this floor; the next higher floor then covers the incoming shafts. + if (index > 0) { + int upper = levels.get(index - 1); + g.setColor(Color.decode(BuildingMap.Feature.ELEVATOR.color)); + g.setStroke(new BasicStroke(2f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10f, + new float[] { 4f, 4f }, 0f)); + for (BuildingDesign.Elevator lift : entity().getDesign().getElevators()) { + if (lift.reaches(level) && lift.reaches(upper) + && BuildingConstruction.occupiesMapLevel(entity(), lift.hex(), level) + && BuildingConstruction.occupiesMapLevel(entity(), lift.hex(), upper)) { + Point2D center = center(transform, lift.hex()); + for (int side : new int[] { -1, 1 }) { + double edge = center.getX() + side * size; + g.draw(new java.awt.geom.Line2D.Double(edge, center.getY(), edge, center.getY() - step)); + } + } + } + } + } + revealSelection(); + } + + private void revealSelection() { + BuildingDesign.Position selection = new BuildingDesign.Position(editor.selectedHex(), + displayedLevel(editor.selectedHex())); + if (selection.equals(revealedSelection)) { + return; + } + for (Layer layer : layers) { + Polygon selected = layer.cells().get(selection.hex()); + if (layer.level() == selection.level() && selected != null) { + revealedSelection = selection; + Rectangle bounds = selected.getBounds(); + bounds.grow(8, 8); + // Wait until painting finishes before moving the viewport. Manual scrolling stays untouched + // until the editing location changes again. + SwingUtilities.invokeLater(() -> { + if (selection.equals(revealedSelection)) { + scrollRectToVisible(bounds); + } + }); + return; + } + } + } + + private Point2D center(AffineTransform transform, CubeCoords hex) { + return transform.transform(new Point2D.Double(1.5 * hex.q(), Math.sqrt(3) * (hex.r() + hex.q() / 2)), null); + } + + private long equipmentCount(CubeCoords hex, int level) { + int floor = entity().getBldgClass() == IBuilding.BRIDGE ? 0 : level; + return entity().getEquipmentInHex(hex).stream().filter(mount -> BuildingConstruction.equipmentPositions(entity(), mount) + .stream().anyMatch(position -> position.hex().equals(hex) && position.level() == floor)).count(); + } + + private Map paintHexes(Graphics2D g, List visible, int floor, + AffineTransform transform, double size, boolean pancake, BuildingMap.FeatureIndex featureIndex) { + Map polygons = new LinkedHashMap<>(); + List hexes = entity().getInternalBuilding().getCoordsList(); + BuildingUtil.SheetGrid labels = BuildingUtil.sheetGrid(hexes); + for (CubeCoords hex : visible) { + Point2D center = center(transform, hex); + double x = center.getX(), y = center.getY(); + Polygon polygon = new Polygon(); + for (int i = 0; i < 6; i++) { + Point2D point = transform.deltaTransform(new Point2D.Double(Math.cos(i * Math.PI / 3), Math.sin(i * Math.PI / 3)), null); + polygon.addPoint((int) (x + point.getX()), (int) (y + point.getY())); + } + polygons.put(hex, polygon); + boolean occupied = hexes.contains(hex); + int level = pancake ? floor : displayedLevel(hex); + boolean selected = occupied && hex.equals(editor.selectedHex()) && level == displayedLevel(editor.selectedHex()); + List features = occupied ? featureIndex.features(hex, level) : List.of(); + BuildingMap.Feature fill = BuildingMap.fill(features); + g.setColor(fill != null ? Color.decode(fill.color) : selected ? new Color(180, 210, 240) + : occupied ? new Color(230, 230, 230) : Color.WHITE); + g.fill(polygon); + boolean wall = occupied && BuildingConstruction.usesHexsides(entity()); + g.setStroke(new BasicStroke(occupied && !wall ? 2f : 1f)); + g.setColor(occupied && !wall ? Color.BLACK : Color.GRAY); + g.draw(polygon); + } + // Paint selection after adjacent fills, but underneath labels, doors and wall edges. Keep the halo + // inside the hex so it cannot cover the incoming elevator shafts outside a pancake layer. + Polygon selected = polygons.get(editor.selectedHex()); + if (selected != null && (!pancake || floor == displayedLevel(editor.selectedHex()))) { + Graphics2D highlight = (Graphics2D) g.create(); + highlight.clip(selected); + highlight.setColor(Color.WHITE); + highlight.setStroke(new BasicStroke(7f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + highlight.draw(selected); + highlight.setColor(new Color(30, 105, 210)); + highlight.setStroke(new BasicStroke(3f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + highlight.draw(selected); + highlight.dispose(); + } + for (CubeCoords hex : polygons.keySet()) { + Point2D center = center(transform, hex); + double x = center.getX(), y = center.getY(); + boolean occupied = hexes.contains(hex); + int level = pancake ? floor : displayedLevel(hex); + List features = occupied ? featureIndex.features(hex, level) : List.of(); + g.setColor(occupied ? Color.BLACK : Color.GRAY); + String symbols = features.stream().filter(feature -> !feature.glyph.isBlank()).map(feature -> feature.glyph) + .collect(java.util.stream.Collectors.joining(" ")); + String coordinate = editor.absoluteCoordinates() ? BuildingUtil.absoluteHexLabel(hex) : labels.label(hex); + String text = occupied ? (symbols.isEmpty() ? "" : symbols + " ") + coordinate + "/" + entity().getLevelLabel(level, true) : "+"; + long count = occupied ? equipmentCount(hex, level) : 0; + if (pancake && count > 0) { + text += " · " + count; + } + Font font = g.getFont(); + int textWidth = g.getFontMetrics().stringWidth(text); + if (occupied && textWidth > size * 1.6) { + g.setFont(font.deriveFont((float) (font.getSize2D() * size * 1.6 / textWidth))); + } + g.drawString(text, (float) (x - g.getFontMetrics().stringWidth(text) / 2.0), (float) (y + (pancake ? 3 : 0))); + g.setFont(font); + if (occupied && !pancake) { + String equipment = count + " items"; + g.drawString(equipment, (float) (x - g.getFontMetrics().stringWidth(equipment) / 2.0), (float) (y + 12)); + } + } + // Decorations follow every fill so adjacent hexes cannot erase edge symbols. + for (Map.Entry cell : polygons.entrySet()) { + int level = pancake ? floor : displayedLevel(cell.getKey()); + Polygon polygon = cell.getValue(); + Point2D center = center(transform, cell.getKey()); + double x = center.getX(), y = center.getY(); + for (BuildingMap.DoorMarker door : featureIndex.doors(cell.getKey(), level)) { + if (door.facing() < 0 || door.facing() > 5) { + continue; + } + int a = (door.facing() + 4) % 6, b = (a + 1) % 6; + Polygon triangle = new Polygon(); + for (double[] point : BuildingMap.doorPoints(new double[] { polygon.xpoints[a] - x, polygon.ypoints[a] - y }, + new double[] { polygon.xpoints[b] - x, polygon.ypoints[b] - y })) { + triangle.addPoint((int) Math.round(x + point[0]), (int) Math.round(y + point[1])); + } + g.setStroke(new BasicStroke(1.5f)); + g.setColor(Color.decode(door.feature().color)); + g.fill(triangle); + g.setColor(Color.BLACK); + g.draw(triangle); + } + } + if (BuildingConstruction.usesHexsides(entity())) { + g.setColor(Color.BLACK); + g.setStroke(new BasicStroke(pancake ? 3f : 4f)); + for (CubeCoords hex : hexes) { + Polygon polygon = polygons.get(hex); + if (polygon == null) { + continue; + } + for (int side = 0; side < 6; side++) { + if ((entity().getDesign().wallSides(hex) & (1 << side)) != 0) { + int a = (side + 4) % 6, b = (a + 1) % 6; + g.drawLine(polygon.xpoints[a], polygon.ypoints[a], polygon.xpoints[b], polygon.ypoints[b]); + } + } + } + } + return polygons; + } + } + + @Override + public void chassisChanged(String chassis) { + entity().setChassis(chassis); + editor.scheduleRefresh(); + } + + @Override + public void modelChanged(String model) { + entity().setModel(model); + editor.scheduleRefresh(); + } + + @Override + public void yearChanged(int year) { + entity().setYear(year); + updateTechLevel(); + } + + @Override + public void buildYearChanged(int year) { + entity().setOriginalBuildYear(year); + editor.scheduleRefresh(); + } + + @Override + public void updateTechLevel() { + entity().setTechLevel(basicInfo.getTechLevel().getCompoundTechLevel(basicInfo.useClanTechBase())); + editor.scheduleRefresh(); + } + + @Override + public void sourceChanged(String source) { + entity().setSource(source); + editor.scheduleRefresh(); + } + + @Override + public void publishedChanged(String published) { + entity().setPublished(published); + editor.scheduleRefresh(); + } + + @Override + public void factionChanged(Faction faction) { + entity().setTechFaction(faction); + editor.scheduleRefresh(); + } + + @Override + public void mulIdChanged(int mulId) { + entity().setMulId(mulId); + editor.scheduleRefresh(); + } + + @Override + public void techBaseChanged(boolean clan, boolean mixed) { + entity().setMixedTech(mixed); + updateTechLevel(); + } + + @Override + public void techLevelChanged(SimpleTechLevel techLevel) { + updateTechLevel(); + } + + @Override + public void roleChanged(UnitRole role) { + entity().setUnitRole(role); + editor.scheduleRefresh(); + } + + @Override + public void manualBVChanged(int manualBV) { + UnitUtil.setManualBV(manualBV, entity()); + editor.scheduleRefresh(); + } + + @Override + public void refreshSummary() { + editor.scheduleRefresh(); + } + + @Override + public void walkChanged(int walkMP) { + } + + @Override + public void jumpChanged(int jumpMP, EquipmentType jumpJet) { + } + + @Override + public void jumpTypeChanged(EquipmentType jumpJet) { + } +} diff --git a/megameklab/src/megameklab/ui/building/BuildingSystemsTab.java b/megameklab/src/megameklab/ui/building/BuildingSystemsTab.java new file mode 100644 index 00000000000..96d92886ff6 --- /dev/null +++ b/megameklab/src/megameklab/ui/building/BuildingSystemsTab.java @@ -0,0 +1,444 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.ui.building; + +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.awt.GridLayout; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.DefaultCellEditor; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSpinner; +import javax.swing.JTable; +import javax.swing.JTextArea; +import javax.swing.SpinnerNumberModel; +import javax.swing.table.AbstractTableModel; + +import megamek.client.ui.WrapLayout; +import megamek.common.equipment.enums.StructureEngine; +import megamek.common.units.AbstractBuildingEntity; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.BuildingDesign; +import megameklab.util.BuildingUtil; + +/** Optional construction, service access and a complete, always available construction report. */ +class BuildingSystemsTab extends JPanel { + private final BuildingMainUI editor; + private final JCheckBox sealing = new JCheckBox("Environmental sealing"); + private final JCheckBox heavyMetal = new JCheckBox("Heavy-metal superstructure"); + private final JCheckBox officers = new JCheckBox("Include officers for civilian operations"); + private final JCheckBox tunnel = new JCheckBox("Tunnel construction"); + private final JCheckBox openSpace = new JCheckBox("Open-space construction"); + private final JLabel openSpaceDescription = new JLabel(); + private final JButton portalTemplates = new JButton("Assign portal equipment templates…"); + private final JLabel portalTemplateDescription = new JLabel("Choose the installed equipment used by each large portal template."); + private final JCheckBox roofClearance = new JCheckBox("Roof has clearance in a larger cave"); + private final JComboBox ceiling = new JComboBox<>(new String[] { "Standard", "High", "Low" }); + private final JComboBox site = new JComboBox<>(new String[] { "Surface", "Underground", "Underwater" }); + private final JSpinner depth = new JSpinner(new SpinnerNumberModel(1, 1, 1000, 1)); + private final JSpinner combatHours = new JSpinner(new SpinnerNumberModel(0, 0, 24, 1)); + private final JCheckBox minimumCrew = new JCheckBox("Use minimum operating crew", true); + private final JSpinner crewCount = new JSpinner(new SpinnerNumberModel(0, 0, Integer.MAX_VALUE, 1)); + private final JPanel totals = new JPanel(new BorderLayout(8, 8)); + private final JTextArea report = new JTextArea(); + private final Doors doors = new Doors(); + private final JTable doorTable = new JTable(doors); + private final Elevators elevators = new Elevators(); + private final JTable elevatorTable = new JTable(elevators); + private boolean refreshing; + + BuildingSystemsTab(BuildingMainUI editor) { + this.editor = editor; + setLayout(new BorderLayout(8, 8)); + setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + JPanel options = new JPanel(new GridLayout(0, 2, 8, 6)); + options.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder("Construction options — Tactical Operations: Advanced Rules"), + BorderFactory.createEmptyBorder(6, 6, 6, 6))); + sealing.setName("Environmental sealing"); + heavyMetal.setName("Heavy-metal superstructure"); + officers.setName("Civilian officers"); + tunnel.setName("Tunnel construction"); + roofClearance.setName("Cave roof clearance"); + ceiling.setName("Building ceiling"); + site.setName("Building site"); + depth.setName("Building cover depth"); + options.add(sealing); + options.add(new JLabel("No internal mass; structure cost ×1.5")); + options.add(heavyMetal); + options.add(new JLabel("Heavy/Hardened only; 75% capacity; structure cost ×1.25")); + options.add(new JLabel("Ceiling height (Standard/Fortress only)")); + options.add(ceiling); + options.add(new JLabel("Construction site")); + options.add(site); + options.add(new JLabel("Levels above roof to ground/water surface")); + options.add(depth); + options.add(roofClearance); + options.add(new JLabel("Underground only; at least one level above the roof permits rooftop equipment.")); + options.add(tunnel); + options.add(new JLabel("Hangar only; doors at connections; no equipment; structure cost ×1.875")); + openSpace.setName("Open-space construction"); + options.add(openSpace); + options.add(openSpaceDescription); + options.add(officers); + options.add(new JLabel("Military structures include officers automatically.")); + options.add(portalTemplates); + options.add(portalTemplateDescription); + portalTemplates.setName("Portal equipment templates"); + portalTemplates.addActionListener(event -> BuildingPlacementDialogs.portalTemplates(editor)); + add(options, BorderLayout.NORTH); + + JPanel services = new JPanel(new GridLayout(1, 2, 8, 8)); + services.setName("Building service sections"); + totals.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder("Capacity, crew, power & validation"), + BorderFactory.createEmptyBorder(8, 8, 8, 8))); + JPanel planning = new JPanel(new WrapLayout(FlowLayout.LEFT)); + planning.add(new JLabel("Combat hours per day:")); + planning.add(combatHours); + minimumCrew.setName("Use minimum operating crew"); + crewCount.setName("Building crew count"); + planning.add(minimumCrew); + planning.add(new JLabel("Crew:")); + planning.add(crewCount); + totals.add(planning, BorderLayout.NORTH); + report.setEditable(false); + report.setName("Building construction report"); + report.setFont(new java.awt.Font(java.awt.Font.MONOSPACED, java.awt.Font.PLAIN, 12)); + report.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + totals.add(new JScrollPane(report), BorderLayout.CENTER); + services.add(doorPanel()); + services.add(elevatorPanel()); + add(services, BorderLayout.CENTER); + sealing.addActionListener(e -> apply()); + heavyMetal.addActionListener(e -> apply()); + officers.addActionListener(e -> apply()); + tunnel.addActionListener(e -> apply()); + openSpace.addActionListener(e -> apply()); + roofClearance.addActionListener(e -> apply()); + ceiling.addActionListener(e -> apply()); + site.addActionListener(e -> apply()); + depth.addChangeListener(e -> apply()); + combatHours.addChangeListener(e -> refreshReport()); + minimumCrew.addActionListener(e -> applyCrew()); + crewCount.addChangeListener(e -> applyCrew()); + } + + JPanel getTotalsPanel() { + return totals; + } + + private void applyCrew() { + if (!refreshing) { + editor.getEntity().setCrewCount(minimumCrew.isSelected() + ? AbstractBuildingEntity.CREW_FROM_MINIMUM_CREW_TABLE : (int) crewCount.getValue()); + editor.scheduleRefresh(); + } + } + + private JPanel doorPanel() { + JPanel panel = new JPanel(new BorderLayout(8, 8)); + panel.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createTitledBorder("Large doors"), + BorderFactory.createEmptyBorder(8, 8, 8, 8))); + panel.add(serviceHint("One door per exterior hexside. Set its starting floor, facing, and height."), BorderLayout.NORTH); + doorTable.setName("Building doors"); + doorTable.setRowHeight(24); + doorTable.putClientProperty("terminateEditOnFocusLost", true); + doorTable.getColumnModel().getColumn(1).setCellEditor(new DefaultCellEditor(new JComboBox<>(BuildingEquipmentTab.FACINGS))); + panel.add(new JScrollPane(doorTable), BorderLayout.CENTER); + JPanel actions = new JPanel(new WrapLayout(FlowLayout.LEFT)); + JButton add = new JButton("Add at editing location"); + add.addActionListener(e -> { + editor.getEntity().getDesign().getDoors().add(new BuildingDesign.Door( + BuildingConstruction.position(editor.getEntity(), editor.selectedLocation()), + BuildingUtil.exteriorFacing(editor.getEntity(), editor.selectedHex()), 1)); + editor.scheduleRefresh(); + }); + JButton remove = new JButton("Remove selected doors"); + remove.addActionListener(e -> { + int[] rows = doorTable.getSelectedRows(); + for (int i = rows.length - 1; i >= 0; i--) { + editor.getEntity().getDesign().getDoors().remove(rows[i]); + } + editor.scheduleRefresh(); + }); + actions.add(add); + actions.add(remove); + panel.add(actions, BorderLayout.SOUTH); + return panel; + } + + private JPanel elevatorPanel() { + JPanel panel = new JPanel(new BorderLayout(8, 8)); + panel.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createTitledBorder("Industrial elevators"), + BorderFactory.createEmptyBorder(8, 8, 8, 8))); + panel.add(serviceHint("Lift capacity is limited to CF. Each served level occupies the entire hex."), BorderLayout.NORTH); + elevatorTable.setName("Building elevators"); + elevatorTable.setRowHeight(24); + panel.add(new JScrollPane(elevatorTable), BorderLayout.CENTER); + JPanel actions = new JPanel(new WrapLayout(FlowLayout.LEFT)); + JButton add = new JButton("Add in editing hex"); + add.addActionListener(e -> BuildingPlacementDialogs.elevator(editor, -1)); + JButton edit = new JButton("Edit selected elevator"); + edit.addActionListener(e -> { + if (elevatorTable.getSelectedRow() >= 0) { + BuildingPlacementDialogs.elevator(editor, elevatorTable.getSelectedRow()); + } + }); + JButton remove = new JButton("Remove selected elevators"); + remove.addActionListener(e -> { + int[] rows = elevatorTable.getSelectedRows(); + for (int i = rows.length - 1; i >= 0; i--) { + editor.getEntity().getDesign().getElevators().remove(rows[i]); + } + editor.scheduleRefresh(); + }); + actions.add(add); + actions.add(edit); + actions.add(remove); + panel.add(actions, BorderLayout.SOUTH); + return panel; + } + + private JTextArea serviceHint(String text) { + var hint = new JTextArea(text, 3, 0); + hint.setEditable(false); + hint.setFocusable(false); + hint.setOpaque(false); + hint.setFont(getFont()); + hint.setLineWrap(true); + hint.setWrapStyleWord(true); + return hint; + } + + private void apply() { + if (refreshing) { + return; + } + var design = editor.getEntity().getDesign(); + design.setEnvironmentalSealing(sealing.isEnabled() && sealing.isSelected()); + design.setHeavyMetal(heavyMetal.isSelected()); + design.setCivilianOfficers(officers.isSelected()); + design.setTunnel(tunnel.isSelected()); + design.setOpenSpace(openSpace.isSelected()); + design.setRoofClearance(roofClearance.isSelected()); + design.setCeiling(BuildingDesign.Ceiling.values()[ceiling.getSelectedIndex()]); + design.setSite(BuildingDesign.Site.values()[site.getSelectedIndex()]); + design.setDepth((int) depth.getValue()); + editor.scheduleRefresh(); + } + + void refresh() { + refreshing = true; + var entity = editor.getEntity(); + var design = entity.getDesign(); + sealing.setSelected(entity.hasEnvironmentalSealing()); + sealing.setEnabled(entity.getConstructionCFScale() == 1); + heavyMetal.setSelected(design.hasHeavyMetal()); + officers.setSelected(design.hasCivilianOfficers()); + tunnel.setSelected(design.isTunnel()); + openSpace.setSelected(design.isOpenSpace()); + boolean mobile = entity instanceof megamek.common.units.MobileStructure; + openSpace.setEnabled(entity.getConstructionCFScale() == 10 || design.isOpenSpace() + || mobile && entity.getBldgClass() == megamek.common.units.IBuilding.HANGAR); + openSpace.setText(mobile ? "Large Portal" : "Open-space construction"); + openSpaceDescription.setText(mobile ? "Hangar with open-space construction" + : "600 t total; equipment on the lowest floor only"); + openSpace.setToolTipText(mobile ? "Deploy an underground open-space Castles Brian tunnel first. Place this portal flat against its hillside entrance, facing away from the hill. Move it completely aside; other structures may enter starting next turn." : null); + portalTemplates.setVisible(mobile && design.isOpenSpace()); + portalTemplateDescription.setVisible(portalTemplates.isVisible()); + heavyMetal.setEnabled(!mobile || design.hasHeavyMetal()); + ceiling.setEnabled(!mobile || design.getCeiling() != BuildingDesign.Ceiling.STANDARD); + tunnel.setEnabled(!mobile || design.isTunnel()); + site.setEnabled(!mobile || design.getSite() != BuildingDesign.Site.SURFACE); + roofClearance.setEnabled(!mobile || design.hasRoofClearance()); + roofClearance.setSelected(design.hasRoofClearance()); + ceiling.setSelectedIndex(design.getCeiling().ordinal()); + site.setSelectedIndex(design.getSite().ordinal()); + depth.setValue(design.getDepth()); + depth.setEnabled(design.getSite() != BuildingDesign.Site.SURFACE); + minimumCrew.setSelected(!entity.hasExplicitCrewCount()); + crewCount.setValue(entity.getNCrew()); + crewCount.setEnabled(entity.hasExplicitCrewCount()); + BuildingPlacementDialogs.stopEditing(doorTable); + JComboBox locations = new JComboBox<>(); + for (int loc = 0; loc < entity.locations(); loc++) { + locations.addItem(BuildingUtil.locationLabel(entity, loc)); + } + doorTable.getColumnModel().getColumn(0).setCellEditor(new DefaultCellEditor(locations)); + doors.fireTableDataChanged(); + elevators.fireTableDataChanged(); + refreshReport(); + refreshing = false; + } + + private void refreshReport() { + var entity = editor.getEntity(); + var crew = entity.calculateMinimumCrewRequirements(); + var hexes = entity.getInternalBuilding().getOriginalCoordsList(); + var grid = BuildingUtil.sheetGrid(hexes); + StringBuilder text = new StringBuilder("PER-HEX CAPACITY (all floors combined)\n"); + for (var hex : hexes) { + double installed = BuildingConstruction.installedWeightInHex(entity, hex); + double capacity = BuildingConstruction.capacityInHex(entity, hex); + text.append("%s Installed %9.2f t Capacity %9.2f t Free %9.2f t%n".formatted( + grid.label(hex), installed, capacity, capacity - installed)); + } + text.append("%nMINIMUM OPERATING CREW%nCrew: %d Gunners: %d Officers: %d Total: %d%n".formatted( + crew.crew(), crew.gunners(), crew.officers(), crew.total())); + if (entity.hasExplicitCrewCount()) { + text.append("Crew specified in unit file: %d%n".formatted(entity.getNCrew())); + } + text.append("Quarters are optional; staff may commute. Automated weapons use Gunnery 5.\n"); + text.append("%nHEAT & POWER%nEnergy weapon heat: %d Heat dissipation: %d%n".formatted( + BuildingConstruction.energyHeat(entity), BuildingConstruction.heatDissipation(entity))); + text.append(entity.hasFusionOrFissionPower() ? "Fusion/fission power: no minimum heat-sink requirement.\n" + : "Provide enough heat sinks for all energy weapons firing together.\n"); + text.append("Current supply: ").append(BuildingUtil.powerDescription(entity)).append('\n'); + text.append("\nGENERATOR SIZING FOR THIS BUILDING\n"); + for (var engine : StructureEngine.values()) { + double fuel = BuildingConstruction.dailyFuel(entity, engine, (int) combatHours.getValue()); + text.append("%-22s %7.0f t Fuel/day %8.2f t Fuel/30 days %9.2f t%n".formatted( + engine.name().replace('_', ' '), BuildingConstruction.generatorTons(entity, engine), fuel, fuel * 30)); + } + text.append("Use Liquid Cargo bays for liquid fuel; Cargo bays for solid fuel. Storage may be off site.\n"); + text.append("Liquid fuel needs storage mass of fuel / 0.91; liquid-storage-only buildings need no power.\n"); + text.append("External receivers store one hour of power per five tons, rounded up.\n"); + if (entity.getDesign().isTunnel()) { + text.append("Tunnel doors must connect to separate buildings when assembling the complex.\n"); + } + text.append("\nCONSTRUCTION CHECKS\n"); + List issues = BuildingUtil.constructionIssues(entity); + text.append(issues.isEmpty() ? "Construction checks pass.\n" : String.join("\n", issues)); + report.setText(text.toString()); + report.setCaretPosition(0); + } + + private class Doors extends AbstractTableModel { + private static final String[] COLUMNS = { "Hex/Floor", "Facing", "Height (levels)" }; + + @Override + public int getRowCount() { + return editor == null ? 0 : editor.getEntity().getDesign().getDoors().size(); + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Object getValueAt(int row, int column) { + var door = editor.getEntity().getDesign().getDoors().get(row); + return switch (column) { + case 0 -> BuildingUtil.locationLabel(editor.getEntity(), BuildingConstruction.location(editor.getEntity(), door.position())); + case 1 -> door.facing() >= 0 && door.facing() < 6 ? BuildingEquipmentTab.FACINGS[door.facing()] : ""; + default -> door.height(); + }; + } + + @Override + public boolean isCellEditable(int row, int column) { + return true; + } + + @Override + public void setValueAt(Object value, int row, int column) { + var list = editor.getEntity().getDesign().getDoors(); + var old = list.get(row); + var position = old.position(); + int facing = old.facing(); + int height = old.height(); + if (column == 0) { + for (int loc = 0; loc < editor.getEntity().locations(); loc++) { + if (BuildingUtil.locationLabel(editor.getEntity(), loc).equals(value)) { + position = BuildingConstruction.position(editor.getEntity(), loc); + } + } + } else if (column == 1) { + facing = List.of(BuildingEquipmentTab.FACINGS).indexOf(value); + } else { + try { + height = Integer.parseInt(value.toString()); + } catch (NumberFormatException ex) { + return; + } + } + list.set(row, new BuildingDesign.Door(position, facing, height)); + editor.scheduleRefresh(); + } + } + + private class Elevators extends AbstractTableModel { + private static final String[] COLUMNS = { "Hex", "Capacity (t)", "Served floors", "Weight (t)" }; + + @Override + public int getRowCount() { + return editor == null ? 0 : editor.getEntity().getDesign().getElevators().size(); + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Object getValueAt(int row, int column) { + var lift = editor.getEntity().getDesign().getElevators().get(row); + return switch (column) { + case 0 -> BuildingUtil.sheetGrid(editor.getEntity().getInternalBuilding().getOriginalCoordsList()).label(lift.hex()); + case 1 -> lift.capacity(); + case 2 -> lift.exits().keySet().stream().sorted().map(level -> BuildingUtil.roofLevelLabel(editor.getEntity(), level)) + .collect(java.util.stream.Collectors.joining(", ")); + default -> lift.weight(); + }; + } + } +} diff --git a/megameklab/src/megameklab/ui/building/BuildingTransportTab.java b/megameklab/src/megameklab/ui/building/BuildingTransportTab.java new file mode 100644 index 00000000000..58bb83e8698 --- /dev/null +++ b/megameklab/src/megameklab/ui/building/BuildingTransportTab.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package megameklab.ui.building; + +import java.awt.BorderLayout; +import java.awt.GridLayout; +import java.util.List; +import java.util.function.IntFunction; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; + +import megamek.common.bays.Bay; +import megamek.common.bays.FirstClassQuartersCargoBay; +import megamek.common.bays.SecondClassQuartersCargoBay; +import megamek.common.bays.SteerageQuartersCargoBay; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.BuildingDesign; +import megameklab.ui.generalUnit.TransportTab; + +/** Native transport controls plus the optional passenger/residential quarters used by buildings (TO:AR p. 129). */ +class BuildingTransportTab extends JPanel { + private record Quarters(String name, Class type, IntFunction create) { + } + + private static final List QUARTERS = List.of( + new Quarters("First class", FirstClassQuartersCargoBay.class, FirstClassQuartersCargoBay::new), + new Quarters("Second class", SecondClassQuartersCargoBay.class, SecondClassQuartersCargoBay::new), + new Quarters("Steerage", SteerageQuartersCargoBay.class, SteerageQuartersCargoBay::new)); + private final BuildingMainUI editor; + private final TransportTab transport; + private final JSpinner[] occupants = new JSpinner[QUARTERS.size()]; + private boolean refreshing; + private final JComboBox baySelector = new JComboBox<>(); + + BuildingTransportTab(BuildingMainUI editor) { + this.editor = editor; + setLayout(new BorderLayout(10, 10)); + transport = new TransportTab(editor); + transport.addRefreshedListener(editor); + JPanel quarters = new JPanel(new GridLayout(1, QUARTERS.size() * 2, 8, 0)); + quarters.setBorder(BorderFactory.createTitledBorder("Quarters (people)")); + for (int index = 0; index < QUARTERS.size(); index++) { + Quarters type = QUARTERS.get(index); + JSpinner count = new JSpinner(new SpinnerNumberModel(0, 0, 100000, 1)); + occupants[index] = count; + count.setName(type.name() + " quarters"); + JLabel label = new JLabel(type.name()); + label.setLabelFor(count); + quarters.add(label); + quarters.add(count); + count.addChangeListener(event -> { + if (!refreshing) { + var oldBays = editor.getEntity().getTransportBays().stream().filter(type.type()::isInstance).toList(); + double oldWeight = oldBays.stream().mapToDouble(Bay::getWeight).sum(); + var spaces = oldBays.stream().flatMap(bay -> BuildingConstruction.baySpaces(editor.getEntity(), bay).stream()).toList(); + boolean placed = oldBays.stream().anyMatch(editor.getEntity().getDesign().getBaySpace()::containsKey); + oldBays.forEach(editor.getEntity()::removeTransporter); + if ((int) count.getValue() > 0) { + var bay = type.create().apply((int) count.getValue()); + editor.getEntity().addTransporter(bay); + if (placed && oldWeight > 0) { + editor.getEntity().getDesign().getBaySpace().put(bay, spaces.stream().map(space -> + new BuildingDesign.Space(space.position(), space.tons() * bay.getWeight() / oldWeight)).toList()); + } + } + editor.scheduleRefresh(); + } + }); + } + add(quarters, BorderLayout.NORTH); + add(transport, BorderLayout.CENTER); + JPanel placement = new JPanel(); + placement.setBorder(BorderFactory.createTitledBorder("Bay and quarters placement")); + baySelector.setName("Building bay placement"); + placement.add(baySelector); + JButton allocate = new JButton("Assign hexes and floors…"); + allocate.addActionListener(e -> { + int index = baySelector.getSelectedIndex(); + if (index >= 0) { + BuildingPlacementDialogs.bay(editor, editor.getEntity().getTransportBays().get(index)); + } + }); + placement.add(allocate); + JButton doors = new JButton("Assign doors…"); + doors.setName("Assign building bay doors"); + doors.addActionListener(event -> { + int index = baySelector.getSelectedIndex(); + if (index >= 0) { + BuildingPlacementDialogs.bayDoors(editor, editor.getEntity().getTransportBays().get(index)); + } + }); + placement.add(doors); + add(placement, BorderLayout.SOUTH); + } + + void refresh() { + refreshing = true; + for (int index = 0; index < QUARTERS.size(); index++) { + Quarters type = QUARTERS.get(index); + int count = (int) editor.getEntity().getTransportBays().stream().filter(type.type()::isInstance) + .mapToDouble(Bay::getCapacity).sum(); + occupants[index].setValue(count); + } + transport.refresh(); + int selected = baySelector.getSelectedIndex(); + baySelector.removeAllItems(); + for (var bay : editor.getEntity().getTransportBays()) { + baySelector.addItem(bay.getUnusedString() + " (" + bay.getWeight() + " t)"); + } + if (selected >= 0 && selected < baySelector.getItemCount()) { + baySelector.setSelectedIndex(selected); + } + refreshing = false; + } +} diff --git a/megameklab/src/megameklab/ui/dialog/UiLoader.java b/megameklab/src/megameklab/ui/dialog/UiLoader.java index 41d175e9e6e..286a0c01375 100644 --- a/megameklab/src/megameklab/ui/dialog/UiLoader.java +++ b/megameklab/src/megameklab/ui/dialog/UiLoader.java @@ -53,6 +53,7 @@ import megameklab.ui.battleArmor.BAMainUI; import megameklab.ui.battlefieldSupport.BFSLinkedOpen; import megameklab.ui.battlefieldSupport.BFSMainUI; +import megameklab.ui.building.BuildingMainUI; import megameklab.ui.combatVehicle.CVMainUI; import megameklab.ui.combatVehicle.GEMainUI; import megameklab.ui.fighterAero.ASMainUI; @@ -229,6 +230,10 @@ public static MegaMekLabMainUI getUI(long type, boolean primitive, boolean indus return new WSMainUI(primitive); } else if (type == Entity.ETYPE_HANDHELD_WEAPON) { return new HHWMainUI(); + } else if (type == Entity.ETYPE_BUILDING_ENTITY) { + return new BuildingMainUI(); + } else if (type == Entity.ETYPE_MOBILE_STRUCTURE) { + return new BuildingMainUI(true); } else if (type == Entity.ETYPE_GUN_EMPLACEMENT) { return new GEMainUI(); } else if (type == Entity.ETYPE_BATTLEFIELD_SUPPORT_ASSET) { @@ -351,6 +356,8 @@ private static MegaMekLabMainUI constructUI(Entity entity, String filename) { return new WSMainUI(entity, filename); } else if (type == Entity.ETYPE_HANDHELD_WEAPON) { return new HHWMainUI(entity, filename); + } else if (type == Entity.ETYPE_BUILDING_ENTITY || type == Entity.ETYPE_MOBILE_STRUCTURE) { + return new BuildingMainUI(entity, filename); } else if (type == Entity.ETYPE_GUN_EMPLACEMENT) { return new GEMainUI(entity, filename); } else if (type == Entity.ETYPE_BATTLEFIELD_SUPPORT_ASSET) { diff --git a/megameklab/src/megameklab/ui/generalUnit/TransportTab.java b/megameklab/src/megameklab/ui/generalUnit/TransportTab.java index be1fb666dfa..b8e4f4b59e7 100644 --- a/megameklab/src/megameklab/ui/generalUnit/TransportTab.java +++ b/megameklab/src/megameklab/ui/generalUnit/TransportTab.java @@ -61,6 +61,7 @@ import megamek.common.bays.InfantryBay; import megamek.common.equipment.DockingCollar; import megamek.common.equipment.Transporter; +import megamek.common.units.AbstractBuildingEntity; import megamek.common.units.Entity; import megamek.common.units.EntityWeightClass; import megamek.common.units.InfantryCompartment; @@ -448,6 +449,9 @@ private void rebuildBays() { Bay newBay = bayType.newBay(bay.getUnusedSlots(), bayNum); newBay.setDoors(bay.getDoors()); newBay.setFacing(bay.getFacing()); + if (getEntity() instanceof AbstractBuildingEntity building) { + building.getDesign().replaceBay(bay, newBay); + } if (getEntity().isPodMountedTransport(bay)) { podList.add(newBay); } else { @@ -509,11 +513,13 @@ public void actionPerformed(ActionEvent evt) { if (size > 0) { int selected = tblInstalled.getSelectedRow(); Bay bay; + Bay previousBay = null; int bayNum = 1; if ((selected >= 0) && (modelInstalled .getBayType(tblInstalled.convertRowIndexToModel(selected)) == BayData.CARGO)) { bay = modelInstalled.getBay(tblInstalled.convertRowIndexToModel(selected)); + previousBay = bay; size += bay.getCapacity(); bayNum = bay.getBayNumber(); removeBay(bay); @@ -523,6 +529,9 @@ public void actionPerformed(ActionEvent evt) { } } bay = BayData.CARGO.newBay(size, bayNum); + if (previousBay != null && getEntity() instanceof AbstractBuildingEntity building) { + building.getDesign().replaceBay(previousBay, bay); + } addBay(bay, false); refresh(); } @@ -847,6 +856,9 @@ public void stateChanged(ChangeEvent e) { Bay newBay = bayType.newBay(size, bay.getBayNumber()); newBay.setDoors(bay.getDoors()); newBay.setFacing(bay.getFacing()); + if (getEntity() instanceof AbstractBuildingEntity building) { + building.getDesign().replaceBay(bay, newBay); + } removeBay(bay); addBay(newBay, pod); modelInstalled.bayList.set(row, newBay); diff --git a/megameklab/src/megameklab/ui/util/EquipmentDatabaseCategory.java b/megameklab/src/megameklab/ui/util/EquipmentDatabaseCategory.java index 35ae72be6ab..de943764a86 100644 --- a/megameklab/src/megameklab/ui/util/EquipmentDatabaseCategory.java +++ b/megameklab/src/megameklab/ui/util/EquipmentDatabaseCategory.java @@ -55,6 +55,7 @@ import megamek.common.equipment.WeaponType; import megamek.common.equipment.enums.BombType; import megamek.common.equipment.enums.MiscTypeFlag; +import megamek.common.units.AbstractBuildingEntity; import megamek.common.units.Aero; import megamek.common.units.Entity; import megamek.common.units.EntityWeightClass; @@ -62,6 +63,7 @@ import megamek.common.units.Mek; import megamek.common.units.ProtoMek; import megamek.common.units.Tank; +import megamek.common.weapons.infantry.InfantryWeapon; import megamek.common.weapons.tag.TAGWeapon; import megameklab.util.BattleArmorUtil; import megameklab.util.UnitUtil; @@ -92,7 +94,7 @@ public enum EquipmentDatabaseCategory { CAPITAL("Capital", (eq, en) -> (eq instanceof WeaponType) && ((WeaponType) eq).isCapital() || eq.is("Screen Launcher"), - Entity::isLargeCraft), + e -> e.isLargeCraft() || e instanceof AbstractBuildingEntity), PHYSICAL("Physical", (eq, en) -> UnitUtil.isPhysicalWeapon(eq) || isIndustrialEquipment(eq), @@ -110,7 +112,7 @@ public enum EquipmentDatabaseCategory { (eq, en) -> ((eq instanceof MiscType) && !UnitUtil.isPhysicalWeapon(eq) && !UnitUtil.isJumpJet(eq) - && !UnitUtil.isHeatSink(eq) + && (!UnitUtil.isHeatSink(eq) || en instanceof AbstractBuildingEntity) && !(isIndustrialEquipment(eq) && ((en instanceof Tank) || en.isSupportVehicle() || en instanceof Mek)) && !eq.is(MECHANICAL_JUMP_BOOSTER) && !eq.hasFlag(F_LAM_FUEL_TANK) @@ -128,6 +130,7 @@ public enum EquipmentDatabaseCategory { && !(eq.hasFlag(F_PARTIAL_WING) && en.hasETypeFlag(Entity.ETYPE_PROTOMEK)) && !(eq.hasFlag(F_SPONSON_TURRET) && en.isSupportVehicle()) && !eq.hasFlag(F_PINTLE_TURRET)) + || (en instanceof AbstractBuildingEntity && eq instanceof InfantryWeapon) || eq.is(CL_BA_BOMB_RACK) || eq.is(COOLANT_POD) || eq.is(BattleArmor.MINE_LAUNCHER) diff --git a/megameklab/src/megameklab/util/AeroUtil.java b/megameklab/src/megameklab/util/AeroUtil.java index 7e1699432c7..7c27d22af8f 100644 --- a/megameklab/src/megameklab/util/AeroUtil.java +++ b/megameklab/src/megameklab/util/AeroUtil.java @@ -35,7 +35,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.WeakHashMap; import megamek.common.bays.Bay; import megamek.common.equipment.AmmoType; @@ -62,10 +61,14 @@ import megamek.common.weapons.missiles.rocketLauncher.RLWeapon; import megamek.common.weapons.srms.SRMWeapon; import megamek.common.weapons.srms.SRTWeapon; +import org.apache.commons.collections4.map.AbstractReferenceMap.ReferenceStrength; +import org.apache.commons.collections4.map.ReferenceIdentityMap; public final class AeroUtil { - private static final Map AUTO_FILLED_CREW = new WeakHashMap<>(); + // Entity equality uses mutable game IDs; separate unassigned craft all have ID -1. + private static final Map AUTO_FILLED_CREW = + new ReferenceIdentityMap<>(ReferenceStrength.WEAK, ReferenceStrength.HARD); public static boolean isAeroWeapon(EquipmentType eq, Aero unit) { if (!(eq instanceof WeaponType weaponType) || (eq instanceof InfantryWeapon)) { diff --git a/megameklab/src/megameklab/util/BuildingMap.java b/megameklab/src/megameklab/util/BuildingMap.java new file mode 100644 index 00000000000..d82e1709b7f --- /dev/null +++ b/megameklab/src/megameklab/util/BuildingMap.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.util; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import megamek.common.bays.Bay; +import megamek.common.board.CubeCoords; +import megamek.common.equipment.BuildingEquipmentType; +import megamek.common.equipment.Mounted; +import megamek.common.units.AbstractBuildingEntity; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.BuildingDesign; +import megamek.common.units.IBuilding; + +/** Feature identity shared by the editing map and printed structure maps. */ +public final class BuildingMap { + private static final List FILL_PRIORITY = List.of(Feature.ELEVATOR, Feature.BAY, Feature.DECK, Feature.TURRET); + + private BuildingMap() { } + + public enum Feature { + BAY("Bay / quarters", "#f3b0b4", "B"), ELEVATOR("Elevator", "#efcb8d", "E"), DECK("Roof facility", "#b5d1bf", "D"), + TURRET("Roof turret", "#aca6d2", "T"), DOOR("Door", "#ffffff", ""), + ELEVATOR_DOOR("Elevator door", "#efcb8d", ""); + + public final String label; + public final String color; + public final String glyph; + + Feature(String label, String color, String glyph) { + this.label = label; + this.color = color; + this.glyph = glyph; + } + + public String symbol() { + return name().toLowerCase(Locale.ROOT).replace('_', '-'); + } + } + + /** A rendered door on one hexside; elevator access remains separate from structural doors in the design. */ + public record DoorMarker(int facing, Feature feature) { } + + /** Immutable map-feature snapshot for one render or refresh; rebuild after editing the building. */ + public static final class FeatureIndex { + private final Map> features = new HashMap<>(); + private final Map> doors = new HashMap<>(); + + private FeatureIndex(AbstractBuildingEntity building, List mapDoors) { + Map roofLevels = new HashMap<>(); + for (CubeCoords hex : building.getInternalBuilding().getOriginalCoordsList()) { + roofLevels.put(hex, building.getInternalBuilding().getHeight(hex) - 1); + } + Map> indexed = new HashMap<>(); + for (Bay transportBay : building.getTransportBays()) { + for (BuildingDesign.Space space : BuildingConstruction.baySpaces(building, transportBay)) { + if (space.tons() > 0) { + add(indexed, space.position(), Feature.BAY); + } + } + } + + for (BuildingDesign.Elevator lift : building.getDesign().getElevators()) { + if (!lift.exits().isEmpty()) { + int firstLevel = Math.max(firstMapLevel(building, lift.hex()), lift.lowerLevel()); + long endLevel = Math.min(endMapLevel(building, lift.hex()), (long) lift.upperLevel() + 1); + for (long level = firstLevel; level < endLevel; level++) { + add(indexed, new BuildingDesign.Position(lift.hex(), (int) level), Feature.ELEVATOR); + } + } + } + + for (Mounted mount : building.getEquipment()) { + if (mount.isOneShotAmmo() || mount.isWeaponGroup()) { + continue; + } + boolean deck = mount.getType() instanceof BuildingEquipmentType facility && facility.getFacility().isRoof(); + boolean turret = mount.isSponsonTurretMounted(); + if (!deck && !turret) { + continue; + } + for (BuildingDesign.Position position : BuildingConstruction.equipmentPositions(building, mount)) { + Integer roofLevel = roofLevels.get(position.hex()); + if (roofLevel == null) { + continue; + } + BuildingDesign.Position roof = new BuildingDesign.Position(position.hex(), roofLevel); + if (deck) { + add(indexed, roof, Feature.DECK); + } + if (turret) { + add(indexed, roof, Feature.TURRET); + } + } + } + + for (BuildingDesign.Door door : mapDoors) { + long start = door.position().level(); + long end = start + (long) door.height(); + long firstLevel = Math.max(firstMapLevel(building, door.position().hex()), start); + long endLevel = Math.min(endMapLevel(building, door.position().hex()), end); + for (long level = firstLevel; level < endLevel; level++) { + BuildingDesign.Position position = new BuildingDesign.Position(door.position().hex(), (int) level); + add(indexed, position, Feature.DOOR); + doors.computeIfAbsent(position, ignored -> new ArrayList<>()).add(new DoorMarker(door.facing(), Feature.DOOR)); + } + } + + Map elevatorAccess = new HashMap<>(); + for (BuildingDesign.Elevator lift : building.getDesign().getElevators()) { + lift.exits().forEach((level, mask) -> { + if (level >= firstMapLevel(building, lift.hex()) && level < endMapLevel(building, lift.hex())) { + elevatorAccess.merge(new BuildingDesign.Position(lift.hex(), level), mask, (a, b) -> a | b); + } + }); + } + elevatorAccess.forEach((position, mask) -> { + for (int facing = 0; facing < 6; facing++) { + if ((mask & (1 << facing)) != 0) { + add(indexed, position, Feature.ELEVATOR_DOOR); + doors.computeIfAbsent(position, ignored -> new ArrayList<>()) + .add(new DoorMarker(facing, Feature.ELEVATOR_DOOR)); + } + } + }); + + indexed.forEach((position, values) -> features.put(position, List.copyOf(values))); + doors.replaceAll((position, values) -> List.copyOf(values)); + } + + private static void add(Map> indexed, + BuildingDesign.Position position, Feature feature) { + indexed.computeIfAbsent(position, ignored -> EnumSet.noneOf(Feature.class)).add(feature); + } + + private static int firstMapLevel(AbstractBuildingEntity building, CubeCoords hex) { + return building.getBldgClass() == IBuilding.BRIDGE ? building.getDesign().bridgeDeck(hex) : 0; + } + + private static long endMapLevel(AbstractBuildingEntity building, CubeCoords hex) { + return building.getBldgClass() == IBuilding.BRIDGE ? (long) building.getDesign().bridgeDeck(hex) + 1 + : building.getInternalBuilding().getHeight(hex); + } + + public List features(CubeCoords hex, int level) { + return features.getOrDefault(new BuildingDesign.Position(hex, level), List.of()); + } + + public List doors(CubeCoords hex, int level) { + return doors.getOrDefault(new BuildingDesign.Position(hex, level), List.of()); + } + } + + public static FeatureIndex featureIndex(AbstractBuildingEntity building, List mapDoors) { + return new FeatureIndex(building, mapDoors); + } + + public static Feature fill(List features) { + for (Feature feature : FILL_PRIORITY) { + if (features.contains(feature)) { + return feature; + } + } + return null; + } + + /** Center the triangle on its hexside; affine projection preserves that alignment. */ + public static double[][] doorPoints(double[] a, double[] b) { + double dx = (a[0] + b[0]) / 2, dy = (a[1] + b[1]) / 2; + return new double[][] { { dx * 1.3, dy * 1.3 }, + { dx * .85 - (b[0] - a[0]) * .18, dy * .85 - (b[1] - a[1]) * .18 }, + { dx * .85 + (b[0] - a[0]) * .18, dy * .85 + (b[1] - a[1]) * .18 } }; + } +} diff --git a/megameklab/src/megameklab/util/BuildingUtil.java b/megameklab/src/megameklab/util/BuildingUtil.java new file mode 100644 index 00000000000..9ebf5023d58 --- /dev/null +++ b/megameklab/src/megameklab/util/BuildingUtil.java @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.util; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.IntStream; + +import megamek.common.CriticalSlot; +import megamek.common.TechConstants; +import megamek.common.board.Coords; +import megamek.common.board.CubeCoords; +import megamek.common.enums.BuildingType; +import megamek.common.equipment.Engine; +import megamek.common.equipment.EquipmentType; +import megamek.common.equipment.Mounted; +import megamek.common.equipment.PowerGeneratorType; +import megamek.common.units.AbstractBuildingEntity; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.ConstructionUtil; +import megamek.common.units.Entity; +import megamek.common.units.IBuilding; + +/** Building construction operations and the shared editor/record-sheet coordinate system. */ +public final class BuildingUtil { + public static final List FACINGS = List.of("N", "NE", "SE", "S", "SW", "NW"); + private BuildingUtil() { + } + + public static megamek.common.units.BuildingEntity newBuilding() { + megamek.common.units.BuildingEntity entity = new megamek.common.units.BuildingEntity(BuildingType.MEDIUM, IBuilding.STANDARD); + entity.setEngine(new Engine(0, Engine.NONE, 0)); + entity.setChassis("New"); + entity.setModel("Building"); + entity.setYear(3145); + entity.setTechLevel(TechConstants.T_IS_ADVANCED); + entity.configureConstruction(BuildingType.MEDIUM, IBuilding.STANDARD, 1, 40, 0, List.of(CubeCoords.ZERO)); + entity.setArmorType(EquipmentType.T_ARMOR_STANDARD); + entity.setArmorTechLevel(entity.getTechLevel()); + return entity; + } + + public static String roofLevelLabel(AbstractBuildingEntity entity, int level) { + return level == entity.getInternalBuilding().getBuildingHeight() + ? "Roof (" + entity.getLevelLabel(level) + ")" : entity.getLevelLabel(level); + } + + public static megamek.common.units.MobileStructure newMobileStructure() { + var entity = new megamek.common.units.MobileStructure(BuildingType.MEDIUM, IBuilding.STANDARD); + entity.setChassis("New"); + entity.setModel("Mobile Structure"); + entity.setYear(3145); + entity.setTechLevel(TechConstants.T_IS_ADVANCED); + entity.configureConstruction(BuildingType.MEDIUM, IBuilding.STANDARD, 1, 40, 0, + List.of(CubeCoords.ZERO, new CubeCoords(1, 0, -1))); + entity.setArmorType(EquipmentType.T_ARMOR_STANDARD); + entity.setArmorTechLevel(entity.getTechLevel()); + return entity; + } + + public static String absoluteHexLabel(CubeCoords hex) { + return "%d,%d".formatted((int) hex.q(), (int) hex.r()); + } + + public static String facingLabel(int facing) { + return facing < 0 || facing >= FACINGS.size() ? "?" : FACINGS.get(facing); + } + + public static int exteriorFacing(AbstractBuildingEntity entity, CubeCoords hex) { + for (int side = 0; side < 6; side++) { + if (!entity.getInternalBuilding().getOriginalCoordsList().contains(hex.toOffset().translated(side).toCube())) { + return side; + } + } + return 0; + } + + /** A cube translation, rather than an offset translation, keeps odd/even column adjacency intact. */ + public record SheetGrid(int columns, int rows, int shiftQ, int shiftRow) { + public Coords position(CubeCoords hex) { + int column = (int) hex.q() + shiftQ; + return new Coords(column, (int) hex.r() + Math.floorDiv(column, 2) + shiftRow); + } + + public String label(CubeCoords hex) { + Coords position = position(hex); + return "%02d%02d".formatted(position.getX() + 1, position.getY() + 1); + } + } + + public static SheetGrid sheetGrid(List hexes) { + int minQ = hexes.stream().mapToInt(c -> (int) c.q()).min().orElse(0); + int maxQ = hexes.stream().mapToInt(c -> (int) c.q()).max().orElse(0); + int columns = Math.max(9, maxQ - minQ + 1); + int centeredQ = Math.floorDiv(columns - 1 - minQ - maxQ, 2); + // Try the nearest translations of both column parities before making the grid denser. + // The centered placement wins ties; all floors and location labels use this same translation. + return IntStream.of(centeredQ, centeredQ + 1, centeredQ - 1) + .filter(shiftQ -> minQ + shiftQ >= 0 && maxQ + shiftQ < columns) + .mapToObj(shiftQ -> { + int minRow = hexes.stream().mapToInt(c -> (int) c.r() + Math.floorDiv((int) c.q() + shiftQ, 2)) + .min().orElse(0); + int maxRow = hexes.stream().mapToInt(c -> (int) c.r() + Math.floorDiv((int) c.q() + shiftQ, 2)) + .max().orElse(0); + int rows = Math.max(7, maxRow - minRow + 1); + return new SheetGrid(columns, rows, shiftQ, Math.floorDiv(rows - 1 - minRow - maxRow, 2)); + }).min(Comparator.comparingInt(SheetGrid::rows)).orElseThrow(); + } + + public static String locationLabel(AbstractBuildingEntity entity, int location) { + if (location < 0 || location >= entity.locations()) { + return "Unallocated"; + } + int height = entity.getInternalBuilding().getBuildingHeight(); + List hexes = entity.getInternalBuilding().getOriginalCoordsList(); + CubeCoords hex = hexes.get(location / height); + int level = entity.getBldgClass() == IBuilding.BRIDGE ? entity.getDesign().bridgeDeck(hex) : location % height; + return sheetGrid(hexes).label(hex) + "/" + entity.getLevelLabel(level, true); + } + + public static void assignEquipment(AbstractBuildingEntity entity, Mounted mount, int location) { + if (location != mount.getLocation()) { + entity.getDesign().getEquipmentSpace().remove(mount); + } + ConstructionUtil.removeCriticalSlots(entity, mount); + ConstructionUtil.changeMountStatus(entity, mount, location, Entity.LOC_NONE, false); + if (location != Entity.LOC_NONE) { + entity.addCritical(location, new CriticalSlot(mount)); + } + } + + public static void configure(AbstractBuildingEntity entity, BuildingType type, int buildingClass, int levels, int cf, + int armor, List hexes) { + entity.configureConstruction(type, buildingClass, levels, cf, armor, hexes); + entity.getEquipment().stream().filter(m -> m.getLocation() == Entity.LOC_NONE && !m.isOneShotAmmo()).toList() + .forEach(m -> ConstructionUtil.removeMounted(entity, m)); + entity.getDesign().removeDeletedComponents(entity); + } + + public static void setHexHeight(AbstractBuildingEntity entity, CubeCoords hex, int height) { + if (!(entity instanceof megamek.common.units.MobileStructure) || height < 1 + || height > entity.getInternalBuilding().getBuildingHeight()) { + throw new IllegalArgumentException("Only a Mobile Structure can have individual hex heights"); + } + entity.getInternalBuilding().setHeight(height, hex); + int roof = entity.getInternalBuilding().getBuildingHeight(); + java.util.function.Predicate removed = position -> + position.hex().equals(hex) && position.level() >= height && position.level() < roof; + var hexEquipment = java.util.Set.copyOf(entity.getEquipmentInHex(hex)); + entity.getEquipment().stream().filter(mount -> { + return hexEquipment.contains(mount) && entity.getLocationLevel(mount.getLocation()) >= height + && entity.getLocationLevel(mount.getLocation()) < roof + || entity.getDesign().getEquipmentSpace().getOrDefault(mount, List.of()).stream().anyMatch(removed); + }).toList().forEach(mount -> ConstructionUtil.removeMounted(entity, mount)); + entity.getDesign().getBaySpace().entrySet().stream() + .filter(entry -> entry.getValue().stream().anyMatch(space -> removed.test(space.position()))) + .map(java.util.Map.Entry::getKey).toList().forEach(entity::removeTransporter); + entity.getDesign().getDoors().removeIf(door -> door.position().hex().equals(hex) + && door.position().level() + door.height() > height); + entity.getDesign().remap(position -> removed.test(position) ? null : position, facing -> facing); + entity.getDesign().removeDeletedComponents(entity); + } + + /** Rotate the footprint and weapon facings together, keeping the equipment on the same physical floor. */ + public static void rotate(AbstractBuildingEntity entity) { + transform(entity, c -> new CubeCoords(-(int) c.r(), -(int) c.s(), -(int) c.q()), facing -> (facing + 1) % 6); + } + + public static void transform(AbstractBuildingEntity entity, java.util.function.UnaryOperator transform, + java.util.function.IntUnaryOperator facingTransform) { + var building = entity.getInternalBuilding(); + entity.configureConstruction(entity.getBuildingType(), entity.getBldgClass(), building.getBuildingHeight(), + entity.getOInternal(0), entity.getOArmor(0), building.getCoordsList().stream().map(transform).toList(), + transform, facingTransform); + } + + public static double equipmentWeight(AbstractBuildingEntity entity) { + return UnitUtil.getEntityVerifier(entity).calculateWeight(); + } + + public static String powerDescription(AbstractBuildingEntity entity) { + if (entity instanceof megamek.common.units.MobileStructure mobile) { + return mobile.getPowerSystem().toString(); + } + if (BuildingConstruction.hasNoInterior(entity) || BuildingConstruction.usesHexsides(entity)) { + return "NA"; + } + if (BuildingConstruction.isLiquidStorageOnly(entity)) { + return "Not required (liquid storage)"; + } + if (entity.getEquipment().stream().noneMatch(m -> m.getType() instanceof PowerGeneratorType)) { + return "External supply"; + } + return entity.hasPower() ? "Available" : "Insufficient"; + } + + public static List constructionIssues(AbstractBuildingEntity entity) { + StringBuffer issues = new StringBuffer(); + UnitUtil.getEntityVerifier(entity).correctEntity(issues, entity.getTechLevel()); + return issues.toString().lines().filter(line -> !line.isBlank()).toList(); + } +} diff --git a/megameklab/src/megameklab/util/CConfig.java b/megameklab/src/megameklab/util/CConfig.java index 9587ef8b9de..6642488c4c8 100644 --- a/megameklab/src/megameklab/util/CConfig.java +++ b/megameklab/src/megameklab/util/CConfig.java @@ -67,6 +67,7 @@ import megameklab.ui.MenuBarOwner; import megameklab.ui.PopupMessages; import megameklab.ui.battleArmor.BAMainUI; +import megameklab.ui.building.BuildingMainUI; import megameklab.ui.combatVehicle.CVMainUI; import megameklab.ui.fighterAero.ASMainUI; import megameklab.ui.handheldWeapon.HHWMainUI; @@ -117,6 +118,7 @@ public final class CConfig { public static final String GUI_DS_MAIN_UI_WINDOW = "DSWindow"; public static final String GUI_WS_MAIN_UI_WINDOW = "WSWindow"; public static final String GUI_HHW_MAIN_UI_WINDOW = "HHWWindow"; + public static final String GUI_BUILDING_MAIN_UI_WINDOW = "BuildingWindow"; public static final String GUI_TABBED_WINDOW = "TabbedWindow"; public static final int RECENT_FILE_COUNT = 10; @@ -640,6 +642,7 @@ public static void resetWindowPositions() { setParam(GUI_DS_MAIN_UI_WINDOW, ""); setParam(GUI_WS_MAIN_UI_WINDOW, ""); setParam(GUI_HHW_MAIN_UI_WINDOW, ""); + setParam(GUI_BUILDING_MAIN_UI_WINDOW, ""); setParam(GUI_TABBED_WINDOW, ""); saveConfig(); } @@ -709,7 +712,9 @@ private static String settingForMainUi(MenuBarOwner ui) { } else if (ui instanceof WSMainUI) { return GUI_WS_MAIN_UI_WINDOW; } else if (ui instanceof HHWMainUI) { - return GUI_WS_MAIN_UI_WINDOW; + return GUI_HHW_MAIN_UI_WINDOW; + } else if (ui instanceof BuildingMainUI) { + return GUI_BUILDING_MAIN_UI_WINDOW; } else if (ui instanceof MegaMekLabTabbedUI) { return GUI_TABBED_WINDOW; } diff --git a/megameklab/src/megameklab/util/UnitPrintManager.java b/megameklab/src/megameklab/util/UnitPrintManager.java index 773c8634e30..2e7b61e1f8e 100644 --- a/megameklab/src/megameklab/util/UnitPrintManager.java +++ b/megameklab/src/megameklab/util/UnitPrintManager.java @@ -60,6 +60,7 @@ import megamek.common.loaders.MULParser; import megamek.common.loaders.MekFileParser; import megamek.common.options.GameOptions; +import megamek.common.units.AbstractBuildingEntity; import megamek.common.units.Aero; import megamek.common.units.BTObject; import megamek.common.units.Dropship; @@ -245,6 +246,10 @@ public static List createSheets(List entit sheets.add(prs); protoList = new ArrayList<>(); } + } else if (unit instanceof AbstractBuildingEntity building) { + var sheet = new PrintBuilding(building, pageCount, options); + sheets.add(sheet); + pageCount += sheet.getPageCount(); } else if (unit instanceof HandheldWeapon) { if (!singlePrint) { final PrintHandheldWeapon phw = new PrintHandheldWeapon((HandheldWeapon) unit, diff --git a/megameklab/src/megameklab/util/UnitUtil.java b/megameklab/src/megameklab/util/UnitUtil.java index d23a6f62908..dd561bb4a7d 100644 --- a/megameklab/src/megameklab/util/UnitUtil.java +++ b/megameklab/src/megameklab/util/UnitUtil.java @@ -790,6 +790,10 @@ public static int getMaximumArmorPoints(Entity unit) { points = (int) Math.floor(unit.getWeight()); } else if (unit.hasETypeFlag(Entity.ETYPE_AERO)) { points = (int) Math.floor(unit.getWeight() * 8); + } else if (unit instanceof AbstractBuildingEntity building) { + for (int location = 0; location < building.locations(); location++) { + points += TestBuilding.maxArmorPoints(building, location); + } } return points; } @@ -1539,6 +1543,8 @@ public static TestEntity getEntityVerifier(Entity unit) { testEntity = new TestInfantry((ConvInfantry) unit, entityVerifier.infOption, null); } else if (unit.hasETypeFlag(Entity.ETYPE_HANDHELD_WEAPON)) { testEntity = new TestHandheldWeapon((HandheldWeapon) unit, entityVerifier.infOption, null); + } else if (unit instanceof AbstractBuildingEntity building) { + testEntity = new TestBuilding(building, entityVerifier.tankOption, null); } return testEntity; } @@ -1968,6 +1974,10 @@ public static long getEditorTypeForEntity(Entity newUnit) { return Entity.ETYPE_TANK; } else if (newUnit instanceof HandheldWeapon) { return Entity.ETYPE_HANDHELD_WEAPON; + } else if (newUnit instanceof MobileStructure) { + return Entity.ETYPE_MOBILE_STRUCTURE; + } else if (newUnit instanceof megamek.common.units.BuildingEntity) { + return Entity.ETYPE_BUILDING_ENTITY; } else if (newUnit instanceof GunEmplacement) { return Entity.ETYPE_GUN_EMPLACEMENT; } else { diff --git a/megameklab/testresources/Dragonstar metadata lifecycle.blk b/megameklab/testresources/Dragonstar metadata lifecycle.blk new file mode 100644 index 00000000000..1dbad6b2ded --- /dev/null +++ b/megameklab/testresources/Dragonstar metadata lifecycle.blk @@ -0,0 +1,219 @@ +# MegaMek Data (C) 2025-2026 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0/ +# +# NOTICE: The MegaMek organization is a non-profit group of volunteers +# creating free software for the BattleTech community. +# +# MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks +# of The Topps Company, Inc. All Rights Reserved. +# +# Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of +# InMediaRes Productions, LLC. +# +# MechWarrior Copyright Microsoft Corporation. MegaMek Data was created under +# Microsoft's "Game Content Usage Rules" +# and it is not endorsed by or +# affiliated with Microsoft. + +#Saved from version 0.51.01 on 2026-07-12 + +019f583e-e138-7229-b9dc-5dd0bc1e0e8e + + + +SmallCraft + + + +Dragonstar Passenger Transport + + + + + + + +7767 + + + +2610 + + + +IS Level 2 + + + +None + + + +Spheroid + + + +cargobay:16.5:0:1::-1:0 +infantrybay:2.0:1:2:Jump:-1:0 +1stclassquarters:0.0:0:-1::-1:0 +crewquarters:0.0:0:-1::-1:0 +2ndclassquarters:0.0:0:-1::-1:0 +steeragequarters:20.0:0:-1::-1:0 + + + +5 + + + +20 + + + +0 + + + +480 + + + +1 + + + +41 + + + +0 + + + +1 + + + +86 +73 +73 +56 + + + +Large Laser +Medium Laser + + + +Medium Laser + + + +Medium Laser + + + +Large Laser +Medium Laser + + + + + + +6 + + + +

The Dragonstar PT is a 150-ton civilian spheroid built to basic construction standards, with a fusion engine providing safe thrust of 2.5g and a maximum of 4g. This performance is adequate for intersystem runs but leaves little margin against faster military vessels. The hull carries standard armor, offering modest protection sufficient to deter casual piracy or discourage a boarding attempt, though the vessel is clearly not designed for sustained combat. Crew quarters consist of Steerage Compartments throughout, reflecting the design's budget construction philosophy.

The PT's armament is light but practical for its role. Two Large Lasers cover the nose and aft arcs, while four Medium Lasers are distributed to cover all remaining firing directions. This arrangement can discourage opportunistic attack without transforming the vessel into a true combat platform. The Jump Infantry Bay accommodates forty troopers or an equivalent load of civilian passengers. A small secondary cargo bay handles additional supplies. The overall balance favors capacity and cost over combat effectiveness, and the Dragonstar offers little resistance to a determined military opponent.

+
+ + +

The Dragonstar Passenger Transport is the backbone of the Draconis Combine's intersystem transit network, introduced in 2610 by Yakima Enterprises, a division of the Stellar Trek conglomerate. Designed as a low-cost, easy-to-produce spheroid shuttle, the Dragonstar has ferried government officials, DCMS troops, and civilian passengers between planetary surfaces and vessels waiting in orbit or at jump points for centuries. Despite a reputation for mechanical fragility and poor cost-effectiveness for repairs, the design has embedded itself so thoroughly in Combine logistics that most operators display near-fanatical loyalty to the product line. Its enduring commercial success rests not on technical excellence but on availability, affordability, and the cultural weight of its ancestry.

+
+ + +

The Dragonstar Passenger Transport operates throughout the Draconis Combine's internal transit network, most commonly assigned to major spaceports, DCMS orbital stations, and jump point transfer facilities. It fulfills a routine but essential role, moving personnel between planetary surfaces and the larger vessels that cannot make planetfall themselves. DCMS units rely on it for troop transfers during internal redeployments, and the Jump Infantry Bay makes it a natural fit for light infantry movements within secured systems. Civilian charter use is widespread on core Combine worlds, with the PT regularly pressed into service for government officials, contract workers, and administrative personnel. Its availability across every corner of Combine territory means it is often the first vessel a traveler encounters when entering Combine space.

+
+ + +

The Dragonstar's origins lie in the Dragon's Stars vessels of the early Draconis Combine expansion era. Those older craft served as transports for Combine diplomats and dignitaries, shuttling officials to worlds being drawn into the growing realm. Over time they became symbols of the Dragon's diplomatic reach. When Yakima Enterprises developed the modern Dragonstar design in 2610, the company deliberately preserved the aesthetic lineage of its predecessor. Updated avionics and modernized systems gave the new vessel genuine utility, while the visual connection to the original helped position it as the natural continuation of Combine interstellar culture.

The vessel's commercial model is unusual. Dragonstars are cheap to build and notoriously prone to breakdown, with most transportation firms retiring examples after roughly 1,000 AU of service rather than paying escalating repair costs. Yakima is fully aware of this pattern and has built its business around it, combining heavy government subsidies with an active secondary market in salvaged parts stripped from retired hulls. Those recovered components frequently reappear in newly manufactured vessels, raising questions about quality but doing little to diminish the near-fanatical operator loyalty the design commands.

The vessel's military evolution came in 3059, when the Draconis Combine Admiralty approached Yakima to develop an assault transport variant. The proliferation of battle armor following the Clan Invasion and a renewed emphasis on naval operations revealed a gap in the Combine's assault transport inventory. With minimal changes to the existing platform, including updated weapon systems but retained armor protection and hull layout, Yakima delivered the Dragonstar Assault Transport to DCA installations barely a year later. The AT's rapid development cycle reflected both Yakima's familiarity with the base design and the Combine's characteristic tendency to militarize commercial platforms rather than develop purpose-built military craft from scratch.

+
+ + +Yakima Enterprises + + + +Chatham + + + +CHASSIS:Unknown +ENGINE:Unknown +ARMOR:Unknown +COMMUNICATIONS:Unknown +TARGETING:Unknown + + + +CHASSIS:Unknown +ENGINE:Unknown +ARMOR:Unknown +COMMUNICATIONS:Unknown +TARGETING:Unknown + + + +HB:HK + + + +HB:HK + + + +150.0 + + + +1 + + + +46 + + + +1 + + + +1 + + + +0 + + + +0 + + + +0 + + + +0 + + + +0 + + + +0 + + diff --git a/megameklab/testresources/Longinus metadata lifecycle.blk b/megameklab/testresources/Longinus metadata lifecycle.blk new file mode 100644 index 00000000000..fe7c8d2d585 --- /dev/null +++ b/megameklab/testresources/Longinus metadata lifecycle.blk @@ -0,0 +1,165 @@ +# MegaMek Data (C) 2025-2026 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0/ +# +# NOTICE: The MegaMek organization is a non-profit group of volunteers +# creating free software for the BattleTech community. +# +# MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks +# of The Topps Company, Inc. All Rights Reserved. +# +# Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of +# InMediaRes Productions, LLC. +# +# MechWarrior Copyright Microsoft Corporation. MegaMek Data was created under +# Microsoft's "Game Content Usage Rules" +# and it is not endorsed by or +# affiliated with Microsoft. + +#Saved from version 0.51.01 on 2026-07-12 + +019f583e-a2e3-75eb-b07c-811865ae78e1 + + + +BattleArmor + + + +Longinus Battle Armor + + + +[David](Sqd4) + + + +1942 + + + +3063 + + + +IS Level 2 + + + +Ambusher + + + +Jump + + + +1 + + + +30 + + + +3 + + + +BADavidLightGaussRifle:RA +ISBASRM2OS:Body +BAAPMount:LA +InfantryAssaultRifle:APM:LA +IS BA Advanced:RA +IS BA Advanced:RA +IS BA Advanced:LA +IS BA Advanced:LA +IS BA Advanced:Body +BABattleClaw:RA + + + + + + + + + + + + + + + +BAJumpJet + + + +

The Longinus carries a detachable one-shot SRM-2 on a dorsal rack that must be jettisoned before the suit's jump jets can engage, allowing leaps of up to ninety meters. The David configuration, fielded beginning in 3063, equips a "David" light Gauss rifle in the right-arm modular mount. This weapon gives the suit meaningful engagement range beyond what the flamer, laser, or machine gun options provide, allowing troopers to engage targets accurately at distances where return fire from infantry weapons is less effective. The left arm retains the standard battle claw for swarming attacks and an anti-personnel mount for close-range threats. Advanced armor composites give the Longinus protection comparable to Clan battle armor, absorbing a direct large laser hit. The combination of ranged firepower and heavy protection makes this configuration effective in open terrain where other loadouts would need to close the distance.

+
+ + +

The Longinus was the Free Worlds League's answer to the Clan Elemental battle armor, developed jointly with the Word of Blake beginning in 3054. Classified as a medium battle armor, the suit came closer to matching its Clan-tech inspiration than any other Inner Sphere design of the era. The FWL's alliance with the Blakists provided access to technologies that rival programs in other Great Houses lacked. First fielded in 3057, the Longinus earned a reputation as both capable and expensive, with production costs well above comparable Inner Sphere designs.

+
+ + +

When the "David" light Gauss rifle became available in 3063, FWLM commanders quickly adopted the configuration for squads tasked with open-terrain operations. Four-trooper squads equipped with the ranged weapon could engage targets at distances where other Longinus loadouts would need to close, giving FWLM battle armor a standoff capability it previously lacked. David-armed squads saw particular use in the rolling plains and open farmland common to many League border worlds.

+
+ + +

Development of the Longinus began in October 3054 as a joint venture between the Free Worlds League and the Word of Blake. The FWLM sought to replicate the Clan Elemental battle armor, and access to Blakist technologies gave the League an advantage over rival programs in the Federated Commonwealth and Draconis Combine. The advanced nature of the design and bureaucratic friction between the League and its allies slowed progress well beyond comparable efforts elsewhere.

The first prototypes reached testing in April 3056, and the results were catastrophic. Armor composites shattered during live-fire exercises, and power systems failed at random. Investigation traced many of these failures to a hidden ComStar sympathizer embedded in the Blakist development staff. Following the operative's removal, the team produced a corrected series of prototypes in early 3057. These early suits lacked the backpack missile launcher that made the Elemental so lethal, and the FWLM demanded one be incorporated. Other Inner Sphere teams had tried and failed to combine jump capability with a shoulder-mounted launcher, producing suits that were either too heavy on the ground or too ungainly in mid-jump. The Longinus team spent eight months on the problem before devising a detachable mounting that required jettisoning the launcher before the jump jets could engage.

By this point the project had far exceeded its budget, and the League Central Coordination Commission refused further funding. Production resumed only when Captain-General Thomas Marik personally authorized additional resources and IBMU agreed to absorb some costs in exchange for access to Blakist technology. The first production suits rolled off the Irian assembly line in December 3057. They arrived too late for Operation Guerrero but saw action in the Sirian Campaign and proved their worth during Operation Bulldog, where Longinus troopers held their own against Clan Elementals.

Corean Enterprises on Stewart and Kali-Yama Weapons Industries on Kalidasa soon picked up production, making the suit a common sight throughout the FWLM. Rear-echelon formations received small allotments for security, while elite frontline units fielded company or even battalion-strength deployments. The Longinus played a considerable role in the FWLM's success in the Isle of Skye. Thomas Marik initially refused to supply the suit to the Word of Blake for their planned assault on Terra, but his stance later softened, and Longinus suits began appearing in the Word of Blake Militia in significant numbers. During the Jihad, modified variants with magnetic clamps and experimental C³i electronics expanded the suit's operational profile, though the latter project was shelved after trials revealed excessive capability trade-offs.

+
+ + +Irian BattleMechs Unlimited|Corean Enterprises|Kali Yama Weapons Industries|Etna Foundries|Gibson Federated BattleMechs + + + +Irian|Stewart|Kalidasa|Oriente|Gibson + + + +CHASSIS:Unknown +ENGINE:Unknown +ARMOR:Unknown +JUMP_JET:Unknown +COMMUNICATIONS:Unknown +TARGETING:Unknown + + + +CHASSIS:Unknown +ENGINE:Unknown +ARMOR:Unknown +JUMP_JET:Unknown +COMMUNICATIONS:Unknown +TARGETING:Unknown + + + +TR:3058U + + + +RS:3058Uu-C + + + +biped + + + +3 + + + +9 + + + +4 + + + +2 + + diff --git a/megameklab/unittests/megameklab/printing/InventoryWriterBayAmmoTest.java b/megameklab/unittests/megameklab/printing/InventoryWriterBayAmmoTest.java new file mode 100644 index 00000000000..271f8198dda --- /dev/null +++ b/megameklab/unittests/megameklab/printing/InventoryWriterBayAmmoTest.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.printing; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import megamek.common.equipment.AmmoMounted; +import megamek.common.equipment.EquipmentType; +import megamek.common.equipment.WeaponMounted; +import megamek.common.units.Aero; +import megamek.common.units.Warship; +import megameklab.testing.util.InitializeTypes; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(InitializeTypes.class) +class InventoryWriterBayAmmoTest { + @Test + void usesCompatibleAmmoFromTheActualBay() throws Exception { + Warship ship = new Warship(); + WeaponMounted bay = (WeaponMounted) ship.addEquipment( + EquipmentType.get("Capital AC Bay"), Aero.LOC_NOSE); + WeaponMounted weapon = (WeaponMounted) ship.addEquipment( + EquipmentType.get("NAC20"), Aero.LOC_NOSE); + bay.addWeaponToBay(weapon); + AmmoMounted differentRack = (AmmoMounted) ship.addEquipment( + EquipmentType.get("Ammo NAC/35"), Aero.LOC_NOSE); + differentRack.setShotsLeft(20); + bay.addAmmoToBay(differentRack); + AmmoMounted differentType = (AmmoMounted) ship.addEquipment( + EquipmentType.get("IS Ammo AC/20"), Aero.LOC_NOSE); + differentType.setShotsLeft(20); + bay.addAmmoToBay(differentType); + AmmoMounted unrelatedBay = (AmmoMounted) ship.addEquipment( + EquipmentType.get("Ammo NAC/20"), Aero.LOC_NOSE); + unrelatedBay.setShotsLeft(10); + AmmoMounted correct = (AmmoMounted) ship.addEquipment( + EquipmentType.get("Ammo NAC/20"), Aero.LOC_NOSE); + correct.setShotsLeft(30); + bay.addAmmoToBay(correct); + + List rows = InventoryWriter.computeWeaponBayTexts(List.of(bay)); + assertEquals(1, rows.size()); + assertEquals(List.of(correct), rows.getFirst().weaponAmmo.get(weapon.getType())); + assertEquals(30, rows.getFirst().weaponAmmo.get(weapon.getType()).getFirst().getBaseShotsLeft()); + } + + @Test + void ammoMatchingIgnoresOrderButPreservesDuplicateBinCountsAndIndividualShots() throws Exception { + Warship ship = new Warship(); + var left = bay(ship, Warship.LOC_LBS, false, 1, 10, 10, 20); + var reordered = bay(ship, Warship.LOC_RBS, false, 1, 20, 10, 10); + var differentCounts = bay(ship, Warship.LOC_RBS, false, 1, 20, 20, 10); + var sameTotal = bay(ship, Warship.LOC_RBS, false, 1, 15, 15, 10); + + assertEquals(1, InventoryWriter.computeWeaponBayTexts(List.of(left, reordered)).size()); + assertEquals(2, InventoryWriter.computeWeaponBayTexts(List.of(left, differentCounts)).size()); + assertEquals(2, InventoryWriter.computeWeaponBayTexts(List.of(left, sameTotal)).size()); + } + + @Test + void repeatedWeaponsDoNotDuplicateAmmoAndEmptyBinsAreOmitted() throws Exception { + Warship ship = new Warship(); + var bay = bay(ship, Warship.LOC_LBS, false, 3, 0, 10, 20); + var row = InventoryWriter.computeWeaponBayTexts(List.of(bay)).getFirst(); + var weapon = bay.getBayWeapons().getFirst().getType(); + + assertEquals(3, row.weapons.get(weapon)); + assertEquals(bay.getBayAmmo().subList(1, 3), row.weaponAmmo.get(weapon)); + } + + @Test + void weaponAndAugmentationCountsArePartOfTheCombination() throws Exception { + Warship ship = new Warship(); + var left = bay(ship, Warship.LOC_LBS, false, 2, 10); + var right = bay(ship, Warship.LOC_RBS, false, 2, 10); + var single = bay(ship, Warship.LOC_RBS, false, 1, 10); + var leftLink = ship.addEquipment(EquipmentType.get("ISPPCCapacitor"), Warship.LOC_LBS); + leftLink.setLinked(left.getBayWeapons().getFirst()); + assertEquals(2, InventoryWriter.computeWeaponBayTexts(List.of(left, right)).size()); + var rightLink = ship.addEquipment(EquipmentType.get("ISPPCCapacitor"), Warship.LOC_RBS); + rightLink.setLinked(right.getBayWeapons().getFirst()); + assertEquals(1, InventoryWriter.computeWeaponBayTexts(List.of(left, right)).size()); + var singleLink = ship.addEquipment(EquipmentType.get("ISPPCCapacitor"), Warship.LOC_RBS); + singleLink.setLinked(single.getBayWeapons().getFirst()); + assertEquals(2, InventoryWriter.computeWeaponBayTexts(List.of(left, single)).size()); + var extraLink = ship.addEquipment(EquipmentType.get("ISPPCCapacitor"), Warship.LOC_RBS); + extraLink.setLinked(right.getBayWeapons().getLast()); + assertEquals(2, InventoryWriter.computeWeaponBayTexts(List.of(left, right)).size()); + } + + @Test + void frontSidesRequireMatchingRearFlagsAndRowsCombineOnlyOnce() throws Exception { + Warship ship = new Warship(); + var left = bay(ship, Warship.LOC_FLS, false, 1); + var rightRear = bay(ship, Warship.LOC_FRS, true, 1); + var right = bay(ship, Warship.LOC_FRS, false, 1); + var secondRight = bay(ship, Warship.LOC_FRS, false, 1); + var rows = InventoryWriter.computeWeaponBayTexts(List.of(left, rightRear, right, secondRight)); + + assertEquals(List.of(List.of(Warship.LOC_FLS, Warship.LOC_FRS), List.of(Warship.LOC_FRS), + List.of(Warship.LOC_FRS)), rows.stream().map(row -> row.loc).toList()); + assertEquals(1, rows.stream().filter(row -> row.rear).count()); + } + + @Test + void broadsidePairingRetainsTheFirstBayRegardlessOfRearFlag() throws Exception { + Warship ship = new Warship(); + var leftRear = bay(ship, Warship.LOC_LBS, true, 1, 10, 20); + var left = bay(ship, Warship.LOC_LBS, false, 1, 20, 10); + var rightRear = bay(ship, Warship.LOC_RBS, true, 1, 20, 10); + var right = bay(ship, Warship.LOC_RBS, false, 1, 10, 20); + for (var bays : List.of(List.of(leftRear, left, right, rightRear), List.of(rightRear, right, left, leftRear))) { + var rows = InventoryWriter.computeWeaponBayTexts(bays); + assertEquals(2, rows.size()); + for (int i = 0; i < rows.size(); i++) { + assertEquals(List.of(Warship.LOC_LBS, Warship.LOC_RBS), rows.get(i).loc); + assertEquals(bays.get(i).isRearMounted(), rows.get(i).rear); + assertEquals(bays.get(i).getBayAmmo(), rows.get(i).weaponAmmo.get(bays.get(i).getBayWeapons().getFirst().getType())); + } + } + } + + private static WeaponMounted bay(Warship ship, int location, boolean rear, int weapons, int... shots) + throws Exception { + var bay = (WeaponMounted) ship.addEquipment(EquipmentType.get("Capital AC Bay"), location, rear); + for (int i = 0; i < weapons; i++) { + bay.addWeaponToBay((WeaponMounted) ship.addEquipment(EquipmentType.get("NAC20"), location)); + } + for (int count : shots) { + var ammo = (AmmoMounted) ship.addEquipment(EquipmentType.get("Ammo NAC/20"), location); + ammo.setShotsLeft(count); + bay.addAmmoToBay(ammo); + } + return bay; + } + +} diff --git a/megameklab/unittests/megameklab/printing/PrintBuildingTest.java b/megameklab/unittests/megameklab/printing/PrintBuildingTest.java new file mode 100644 index 00000000000..203a8caea46 --- /dev/null +++ b/megameklab/unittests/megameklab/printing/PrintBuildingTest.java @@ -0,0 +1,627 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.printing; + +import static org.junit.jupiter.api.Assertions.*; + +import java.awt.Color; +import java.awt.print.PageFormat; +import java.awt.print.Paper; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import megamek.common.bays.FirstClassQuartersCargoBay; +import megamek.common.board.CubeCoords; +import megamek.common.enums.BuildingType; +import megamek.common.equipment.EquipmentType; +import megamek.common.equipment.MiscType; +import megamek.common.loaders.BLKFile; +import megamek.common.loaders.BLKStructureFile; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.BuildingDesign; +import megamek.common.units.BuildingEntity; +import megamek.common.units.IBuilding; +import megamek.common.util.BuildingBlock; +import megameklab.testing.util.InitializeTypes; +import megameklab.util.BuildingUtil; +import megameklab.util.UnitPrintManager; +import org.apache.batik.transcoder.TranscoderInput; +import org.apache.batik.transcoder.TranscoderOutput; +import org.apache.batik.transcoder.image.PNGTranscoder; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.config.Configurator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.w3c.dom.Element; +import org.w3c.dom.svg.SVGGElement; +import org.w3c.dom.svg.SVGPolygonElement; +import org.w3c.dom.svg.SVGRectElement; + +@ExtendWith(InitializeTypes.class) +class PrintBuildingTest { + @Test + void fitsTheStandardGridBeforeMakingWideOrTallFootprintsDenserWithinTheMapArea() throws Exception { + var tight = java.util.stream.IntStream.range(0, 14) + .mapToObj(i -> new CubeCoords(i / 7, i % 7, -i / 7 - i % 7)).toList(); + var wide = java.util.stream.IntStream.range(0, 13) + .mapToObj(q -> new CubeCoords(q, -q / 2, -q + q / 2)).toList(); + var tall = java.util.stream.IntStream.range(0, 12).mapToObj(r -> new CubeCoords(0, r, -r)).toList(); + var both = new ArrayList<>(wide.subList(0, 11)); + both.addAll(tall.subList(1, 9)); + var footprints = List.of(tight, wide, tall, both); + int[] cells = { 9 * 7, 13 * 7, 9 * 12, 11 * 9 }; + for (var paper : List.of(PaperSize.US_LETTER, PaperSize.ISO_A4)) { + double standardHexWidth = 0; + for (int index = 0; index < footprints.size(); index++) { + var hexes = footprints.get(index); + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.CASTLE_BRIAN, 2, 40, 0, hexes); + var mount = building.addEquipment(EquipmentType.get("ISMediumLaser"), (hexes.size() - 1) * 2); + var sheet = sheet(building, paper); + assertTrue(sheet.createDocument(0, pageFormat(paper), true)); + assertEquals(cells[index] * 2, elements(sheet, "polygon", "building-hex").size()); + assertEquals(hexes.size() * 2, elements(sheet, "polygon", "occupied").size()); + var region = (SVGRectElement) sheet.getSVGDocument().getElementById("structureMap"); + for (var element : elements(sheet, "g", "building-map-layer")) { + var layer = (SVGGElement) element; + var translation = layer.getTransform().getBaseVal().consolidate().getMatrix(); + var polygons = layer.getElementsByTagName("polygon"); + for (int i = 0; i < polygons.getLength(); i++) { + var points = ((SVGPolygonElement) polygons.item(i)).getPoints(); + for (int j = 0; j < points.getNumberOfItems(); j++) { + double x = translation.getE() + points.getItem(j).getX(); + double y = translation.getF() + points.getItem(j).getY(); + assertTrue(x >= region.getX().getBaseVal().getValue() - .01); + assertTrue(x <= region.getX().getBaseVal().getValue() + region.getWidth().getBaseVal().getValue() + .01); + assertTrue(y >= region.getY().getBaseVal().getValue() - .01); + assertTrue(y <= region.getY().getBaseVal().getValue() + region.getHeight().getBaseVal().getValue() + .01); + } + } + } + var points = ((SVGPolygonElement) elements(sheet, "polygon", "building-hex").getFirst()).getPoints(); + double hexWidth = points.getItem(3).getX() - points.getItem(0).getX(); + if (index == 0) { + standardHexWidth = hexWidth; + assertTrue(elements(sheet, "g", "building-inventory-entry").getFirst().getTextContent().contains("0607/G")); + } else { + assertTrue(hexWidth < standardHexWidth, "A larger grid uses smaller hexes in the same map area"); + } + assertEquals(hexes, building.getInternalBuilding().getOriginalCoordsList()); + assertEquals((hexes.size() - 1) * 2, mount.getLocation()); + if (paper == PaperSize.US_LETTER && (index == 0 || index == 3)) { + render(sheet, "building-fit-grid-" + index); + } + } + } + } + + @Test + void printsWallSidesAndOnlyTheActualBridgeDeckElevations() throws Exception { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.MEDIUM, IBuilding.WALL, 2, 40, 32, List.of(CubeCoords.ZERO)); + building.getDesign().getWallSides().put(CubeCoords.ZERO, 3); + var wall = sheet(building, PaperSize.US_LETTER); + assertTrue(wall.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + assertEquals(2, elements(wall, "g", "building-map-layer").size()); + assertTrue(wall.getSVGDocument().getDocumentElement().getTextContent().contains("0504/N")); + assertTrue(wall.getSVGDocument().getDocumentElement().getTextContent().contains("0504/NE")); + int sides = 0; + var lines = wall.getSVGDocument().getElementsByTagName("line"); + for (int i = 0; i < lines.getLength(); i++) { + if (((Element) lines.item(i)).hasAttribute("data-building-side")) { + sides++; + } + } + assertEquals(4, sides); + render(wall, "wall-hexside-classification"); + var hexes = java.util.stream.IntStream.rangeClosed(0, 8).mapToObj(q -> new CubeCoords(q, 0, -q)).toList(); + BuildingUtil.configure(building, BuildingType.RAIL, IBuilding.BRIDGE, 1, 650, 0, hexes); + BuildingConstruction.setBridgeSlope(building, 5, 7); + var bridge = sheet(building, PaperSize.US_LETTER); + assertTrue(bridge.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + assertEquals(List.of("7", "6", "5"), elements(bridge, "g", "building-map-layer").stream() + .map(layer -> layer.getAttribute("data-building-floor")).toList()); + assertEquals(9, elements(bridge, "polygon", "occupied").size()); + render(bridge, "bridge-deck-classification"); + } + + @Test + void largeCastleBrianKeepsEveryLevelAndProtectionRowReadableAcrossPages() throws Exception { + var building = BuildingUtil.newBuilding(); + var hexes = java.util.stream.IntStream.range(0, 70).mapToObj(index -> + new CubeCoords(index % 10, index / 10, -(index % 10) - index / 10)).toList(); + BuildingUtil.configure(building, BuildingType.HARDENED, IBuilding.CASTLE_BRIAN, 15, 150, 100, hexes); + var sheet = sheet(building, PaperSize.US_LETTER); + assertEquals(3, sheet.getPageCount()); + var levels = new ArrayList(); + for (int page = 0; page < sheet.getPageCount(); page++) { + assertTrue(sheet.createDocument(page, pageFormat(PaperSize.US_LETTER), true)); + var layers = elements(sheet, "g", "building-map-layer"); + assertTrue(layers.size() <= 6); + levels.addAll(layers.stream().map(layer -> layer.getAttribute("data-building-floor")).toList()); + render(sheet, "castle-brian-classification-" + page); + } + assertEquals(java.util.stream.IntStream.range(0, 15).mapToObj(index -> Integer.toString(14 - index)).toList(), levels); + } + + private PrintBuilding sheet(megamek.common.units.AbstractBuildingEntity building, PaperSize size) { + var options = new RecordSheetOptions(); + options.setPaperSize(size); + options.setReferenceCharts(false); + return new PrintBuilding(building, 0, options) { + @Override + String getSVGDirectoryName(boolean testDirectory) { + // The actual mm-data assets are the source of truth for both test and release rendering. + return "../../mm-data/data/images/recordsheets/" + size.dirName; + } + }; + } + + @Test + void mobileSheetPreservesSpeedAndIndividualHexHeights() throws Exception { + var mobile = BuildingUtil.newMobileStructure(); + var hexes = List.copyOf(mobile.getInternalBuilding().getOriginalCoordsList()); + BuildingUtil.configure(mobile, BuildingType.MEDIUM, IBuilding.FORTRESS, 3, 40, 0, hexes); + BuildingUtil.setHexHeight(mobile, hexes.get(1), 1); + mobile.setMaximumMP(1.25); + var sheet = sheet(mobile, PaperSize.ISO_A4); + assertTrue(sheet.createDocument(0, pageFormat(PaperSize.ISO_A4), true)); + assertEquals(3, elements(sheet, "g", "building-map-layer").size()); + assertEquals(4, elements(sheet, "polygon", "occupied").size()); + assertTrue(sheet.getSVGDocument().getDocumentElement().getTextContent().contains("1.25")); + render(sheet, "mobile-variable-height"); + } + + @Test + void compressesBeforeOverflowAndKeepsAllEquipmentOnContinuationPages() throws Exception { + var building = BuildingUtil.newBuilding(); + for (int index = 0; index < 30; index++) { + building.addEquipment(printableItem(index), 0); + } + var compact = sheet(building, PaperSize.US_LETTER); + assertEquals(1, compact.getPageCount()); + assertTrue(compact.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + render(compact, "building-dense-inventory"); + for (int index = 30; index < 110; index++) { + building.addEquipment(printableItem(index), 0); + } + var overflow = sheet(building, PaperSize.US_LETTER); + assertTrue(overflow.getPageCount() > 1); + var seen = new ArrayList(); + for (int page = 0; page < overflow.getPageCount(); page++) { + assertTrue(overflow.createDocument(page, pageFormat(PaperSize.US_LETTER), true)); + for (var entry : elements(overflow, "g", "building-inventory-entry")) { + assertEquals(0, entry.getElementsByTagName("line").getLength(), "No ruled inventory placeholders"); + if (Integer.parseInt(entry.getAttribute("data-location")) >= 0) { + seen.add(entry.getAttribute("data-equipment-id")); + } + } + render(overflow, "building-inventory-overflow-" + page); + } + assertEquals(110, seen.size()); + assertEquals(110, new java.util.HashSet<>(seen).size()); + } + + private MiscType printableItem(int index) { + return new MiscType() { + { + name = "Equipment " + index; + setInternalName(name); + tonnage = 1; + criticalSlots = 1; + } + }; + } + + @Test + void projectedDoorsStayCenteredAndFeatureColorsRemainAlongsideSymbols() throws Exception { + var building = BuildingUtil.newBuilding(); + for (int side = 0; side < 6; side++) { + building.getDesign().getDoors().add(new BuildingDesign.Door(new BuildingDesign.Position(CubeCoords.ZERO, 0), side, 1)); + } + var sheet = sheet(building, PaperSize.US_LETTER); + assertTrue(sheet.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + var hex = (SVGPolygonElement) elements(sheet, "polygon", "occupied").getFirst(); + var layer = elements(sheet, "g", "building-map-layer").getFirst(); + var polygons = layer.getElementsByTagName("polygon"); + int doors = 0; + for (int index = 0; index < polygons.getLength(); index++) { + var door = (SVGPolygonElement) polygons.item(index); + if (!door.getAttribute("data-building-symbol").equals("door")) { + continue; + } + doors++; + int side = Integer.parseInt(door.getAttribute("data-building-facing")); + var a = hex.getPoints().getItem((side + 1) % 6); + var b = hex.getPoints().getItem((side + 2) % 6); + var left = door.getPoints().getItem(1); + var right = door.getPoints().getItem(2); + var tip = door.getPoints().getItem(0); + assertEquals((a.getX() + b.getX()) / 2, (tip.getX() + left.getX() + right.getX()) / 3, .01); + assertEquals((a.getY() + b.getY()) / 2, (tip.getY() + left.getY() + right.getY()) / 3, .01); + assertEquals(0, (right.getX() - left.getX()) * (b.getY() - a.getY()) + - (right.getY() - left.getY()) * (b.getX() - a.getX()), .01); + } + assertEquals(6, doors); + var keyPolygons = elements(sheet, "g", "building-map-key").getFirst().getElementsByTagName("polygon"); + assertEquals(1, keyPolygons.getLength(), "The Door legend has no hex swatch"); + assertEquals(3, ((SVGPolygonElement) keyPolygons.item(0)).getPoints().getNumberOfItems()); + render(sheet, "building-six-door-directions"); + building.getDesign().getDoors().clear(); + building.getDesign().getElevators().add(new BuildingDesign.Elevator(CubeCoords.ZERO, 20, Map.of(0, 4, 1, 4))); + for (var mode : RecordSheetOptions.ColorMode.values()) { + var colored = sheet(building, PaperSize.US_LETTER); + colored.options.setColor(mode); + assertTrue(colored.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + assertTrue(elements(colored, "g", "building-map-layer").getFirst().getTextContent().contains("E0504")); + var key = elements(colored, "g", "building-map-key").getFirst(); + assertTrue(key.getTextContent().contains("EElevator")); + assertEquals("#efcb8d", elements(colored, "polygon", "occupied").getFirst().getAttribute("fill")); + assertEquals("#efcb8d", ((Element) key.getElementsByTagName("polygon").item(0)).getAttribute("fill")); + if (mode == RecordSheetOptions.ColorMode.LOGO_ONLY) { + render(colored, "building-feature-colors"); + } + } + } + + @Test + void elevatorDoorsPrintOnTheirConfiguredSidesAndLevelsIncludingContinuationPages() throws Exception { + var building = BuildingUtil.newBuilding(); + var hexes = new ArrayList<>(CubeCoords.ZERO.neighbors()); + hexes.add(CubeCoords.ZERO); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 7, 80, 0, hexes); + building.getDesign().getElevators().add(new BuildingDesign.Elevator(CubeCoords.ZERO, 20, + Map.of(0, 5, 2, 63, 6, 32, 7, 8))); + var expectedSides = Map.of(0, List.of(0, 2), 2, List.of(0, 1, 2, 3, 4, 5), 6, List.of(5)); + String label = BuildingUtil.sheetGrid(hexes).label(CubeCoords.ZERO); + for (var paper : List.of(PaperSize.US_LETTER, PaperSize.ISO_A4)) { + var sheet = sheet(building, paper); + for (int page = 0; page < 2; page++) { + assertTrue(sheet.createDocument(page, pageFormat(paper), true)); + var hex = (SVGPolygonElement) elements(sheet, "polygon", "occupied").stream() + .filter(cell -> cell.getAttribute("data-building-hex").equals(label)).findFirst().orElseThrow(); + for (var layer : elements(sheet, "g", "building-map-layer")) { + int floor = Integer.parseInt(layer.getAttribute("data-building-floor")); + var sides = new ArrayList(); + var polygons = layer.getElementsByTagName("polygon"); + for (int index = 0; index < polygons.getLength(); index++) { + var marker = (SVGPolygonElement) polygons.item(index); + if (!marker.getAttribute("data-building-symbol").equals("elevator-door")) { + continue; + } + int side = Integer.parseInt(marker.getAttribute("data-building-facing")); + sides.add(side); + assertEquals("#efcb8d", marker.getAttribute("fill")); + assertEquals(3, marker.getPoints().getNumberOfItems()); + var a = hex.getPoints().getItem((side + 1) % 6); + var b = hex.getPoints().getItem((side + 2) % 6); + var tip = marker.getPoints().getItem(0); + var left = marker.getPoints().getItem(1); + var right = marker.getPoints().getItem(2); + assertEquals((a.getX() + b.getX()) / 2, (tip.getX() + left.getX() + right.getX()) / 3, .01); + assertEquals((a.getY() + b.getY()) / 2, (tip.getY() + left.getY() + right.getY()) / 3, .01); + } + assertEquals(expectedSides.getOrDefault(floor, List.of()), sides, "Access sides on level " + floor); + } + var key = elements(sheet, "g", "building-map-key").getFirst(); + assertTrue(key.getTextContent().contains("Elevator door")); + var groups = key.getElementsByTagName("g"); + boolean found = false; + for (int index = 0; index < groups.getLength(); index++) { + var group = (Element) groups.item(index); + if (group.getAttribute("data-building-symbol").equals("elevator-door")) { + var marker = (SVGPolygonElement) group.getElementsByTagName("polygon").item(0); + assertEquals(3, marker.getPoints().getNumberOfItems()); + assertEquals("#efcb8d", marker.getAttribute("fill")); + found = true; + } + } + assertTrue(found, "The legend must include the amber elevator-door triangle"); + render(sheet, "building-elevator-doors-" + paper.name() + "-" + page); + } + } + } + + private PageFormat pageFormat(PaperSize size) { + Paper paper = new Paper(); + paper.setSize(size.pxWidth, size.pxHeight); + paper.setImageableArea(18, 18, size.pxWidth - 36, size.pxHeight - 36); + PageFormat format = new PageFormat(); + format.setPaper(paper); + return format; + } + + private List elements(PrintBuilding sheet, String tag, String className) { + List result = new ArrayList<>(); + var nodes = sheet.getSVGDocument().getElementsByTagName(tag); + for (int i = 0; i < nodes.getLength(); i++) { + var element = (Element) nodes.item(i); + if (List.of(element.getAttribute("class").split(" ")).contains(className)) { + result.add(element); + } + } + return result; + } + + @Test + void singleFloorShowsWholeGridButOnlyOccupiedHexIsLabeled() throws Exception { + var building = BuildingUtil.newBuilding(); + building.setChassis("Control Tower"); + var laser = EquipmentType.get("ISMediumLaser"); + building.addEquipment(laser, 0); + building.addEquipment(laser, 0); + var sheet = sheet(building, PaperSize.US_LETTER); + assertTrue(sheet.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + assertEquals(1, elements(sheet, "g", "building-map-layer").size()); + assertEquals(63, elements(sheet, "polygon", "building-hex").size()); + assertEquals(1, elements(sheet, "polygon", "occupied").size()); + assertEquals("0504", elements(sheet, "polygon", "occupied").getFirst().getAttribute("data-building-hex")); + var layer = elements(sheet, "g", "building-map-layer").getFirst(); + assertEquals("0504Level: G", layer.getTextContent()); + assertTrue(layer.getAttribute("transform").contains("127.0"), "Top aligned with 24 points of spare header space"); + assertEquals(1, sheet.inventoryGroups().size()); + assertEquals(2, sheet.inventoryGroups().getFirst().size()); + assertTrue(elements(sheet, "g", "building-inventory-entry").getFirst().getTextContent().contains("0504/G")); + render(sheet, "building-letter-single"); + } + + @Test + void sheetsPrintGroundRelativeFloorsInDescendingOrderAndKeepNativeLocations() throws Exception { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 4, 80, 0, List.of(CubeCoords.ZERO)); + building.getDesign().setBaseLevel(-2); + building.addEquipment(EquipmentType.get("ISMediumLaser"), 1); + var sheet = sheet(building, PaperSize.ISO_A4); + assertTrue(sheet.createDocument(0, pageFormat(PaperSize.ISO_A4), true)); + var layers = elements(sheet, "g", "building-map-layer"); + assertEquals(List.of("3", "2", "1", "0"), layers.stream().map(e -> e.getAttribute("data-building-floor")).toList()); + assertEquals(List.of("1", "G", "-1", "-2"), layers.stream() + .map(e -> e.getTextContent().substring(e.getTextContent().lastIndexOf("Level: ") + 7)).toList()); + assertTrue(elements(sheet, "g", "building-inventory-entry").stream() + .anyMatch(row -> row.getTextContent().contains("0504/-1") && row.getAttribute("data-location").equals("1"))); + render(sheet, "building-ground-reference"); + building.getDesign().setBaseLevel(null); + building.getDesign().setSite(BuildingDesign.Site.UNDERGROUND); + building.getDesign().setDepth(1); + sheet = sheet(building, PaperSize.ISO_A4); + assertTrue(sheet.createDocument(0, pageFormat(PaperSize.ISO_A4), true)); + assertTrue(elements(sheet, "g", "building-map-layer").getLast().getTextContent().contains("Level: -5")); + } + + @Test + void rulesAtriumExampleRetainsItsEmptyCenterAndUniformHeightThroughBlkAndPrinting() throws Exception { + // TO:AR p. 128: a Medium Standard mall, CF 40, six hexes around an open atrium, three levels tall. + var building = BuildingUtil.newBuilding(); + building.setChassis("Atrium Mall"); + BuildingUtil.configure(building, BuildingType.MEDIUM, IBuilding.STANDARD, 3, 40, 0, + List.of(new CubeCoords(0, -1, 1), new CubeCoords(1, -1, 0), new CubeCoords(1, 0, -1), + new CubeCoords(0, 1, -1), new CubeCoords(-1, 1, 0), new CubeCoords(-1, 0, 1))); + var loaded = (BuildingEntity) new BLKStructureFile(BLKFile.getBlock(building)).getEntity(); + assertEquals(6, loaded.getInternalBuilding().getCoordsList().size()); + assertEquals(18, loaded.locations()); + assertEquals(720, loaded.getWeight(), "Six hexes at 120 tons per hex, without adding an atrium hex"); + assertTrue(loaded.getEquipment().isEmpty()); + for (var hex : loaded.getInternalBuilding().getCoordsList()) { + assertEquals(3, loaded.getInternalBuilding().getHeight(hex)); + } + var sheet = sheet(loaded, PaperSize.US_LETTER); + assertTrue(sheet.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + var layers = elements(sheet, "g", "building-map-layer"); + assertEquals(List.of("2", "1", "0"), layers.stream() + .map(layer -> layer.getAttribute("data-building-floor")).toList()); + for (var layer : layers) { + var polygons = layer.getElementsByTagName("polygon"); + assertEquals(63, polygons.getLength()); + List occupied = new ArrayList<>(); + for (int i = 0; i < polygons.getLength(); i++) { + String label = ((Element) polygons.item(i)).getAttribute("data-building-hex"); + if (!label.isEmpty()) { + occupied.add(label); + } + } + assertEquals(List.of("0403", "0404", "0503", "0505", "0603", "0604"), occupied); + assertFalse(layer.getTextContent().contains("0504"), "The central atrium is empty on every floor"); + } + render(sheet, "building-atrium-rules-example"); + } + + @Test + void layersAndInventoryUseExactLevelsAndContinueOnNextPage() throws Exception { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 8, 80, 32, + List.of(CubeCoords.ZERO, new CubeCoords(1, 0, -1), new CubeCoords(0, 1, -1))); + for (int loc = 0; loc < building.locations(); loc++) { + building.addEquipment(EquipmentType.get("ISMediumLaser"), loc); + } + var sheet = sheet(building, PaperSize.ISO_A4); + assertEquals(2, sheet.getPageCount()); + assertEquals(24, sheet.inventoryGroups().size()); + assertTrue(sheet.createDocument(0, pageFormat(PaperSize.ISO_A4), true)); + assertEquals(6, elements(sheet, "g", "building-map-layer").size()); + assertEquals(List.of("7", "6", "5", "4", "3", "2"), elements(sheet, "g", "building-map-layer").stream() + .map(e -> e.getAttribute("data-building-floor")).toList()); + assertEquals(24, elements(sheet, "g", "building-inventory-entry").stream() + .filter(row -> Integer.parseInt(row.getAttribute("data-location")) >= 0).count()); + render(sheet, "building-a4-six-layers"); + assertTrue(sheet.createDocument(1, pageFormat(PaperSize.ISO_A4), true)); + assertEquals(List.of("1", "0"), elements(sheet, "g", "building-map-layer").stream() + .map(e -> e.getAttribute("data-building-floor")).toList()); + assertEquals(0, elements(sheet, "g", "building-inventory-entry").size()); + render(sheet, "building-a4-continuation"); + } + + @Test + void printQueueAccountsForAllBuildingPages() { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 8, 80, 0, List.of(CubeCoords.ZERO)); + var sheets = UnitPrintManager.createSheets(List.of(building, BuildingUtil.newBuilding()), true, new RecordSheetOptions()); + assertEquals(2, sheets.size()); + assertInstanceOf(PrintBuilding.class, sheets.getFirst()); + assertEquals(2, sheets.getFirst().getPageCount()); + assertEquals(1, sheets.getLast().getPageCount()); + } + + @Test + void capitalWeaponsPrintTheirUpwardArcWithoutAnInventedWallFacing() throws Exception { + var building = BuildingUtil.newBuilding(); + building.addEquipment(EquipmentType.get("Naval Autocannon (NAC/10)"), 0).setFacing(-1); + var sheet = sheet(building, PaperSize.US_LETTER); + assertTrue(sheet.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + assertTrue(elements(sheet, "g", "building-inventory-entry").stream() + .anyMatch(row -> row.getTextContent().contains("Upward (capital)"))); + } + + @Test + void ammoQuantitiesQuartersAndPdfUseTheNativePrintPipeline() throws Exception { + var building = BuildingUtil.newBuilding(); + building.addTransporter(new FirstClassQuartersCargoBay(2)); + var ammo = EquipmentType.get("IS Ammo AC/5"); + building.addEquipment(ammo, 0).setOriginalShots(17); + building.addEquipment(ammo, 0).setOriginalShots(10); + var sheet = sheet(building, PaperSize.US_LETTER); + assertTrue(sheet.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + var rows = elements(sheet, "g", "building-inventory-entry"); + assertEquals(3, rows.size()); + assertTrue(rows.getFirst().getTextContent().contains("(27)")); + assertTrue(rows.get(1).getTextContent().contains("Quarters")); + // The name may wrap around a separate location cell in SVG document order. + assertTrue(rows.get(1).getTextContent().contains("(20")); + assertTrue(rows.get(1).getTextContent().contains("t)")); + Path output = Path.of("build", "building-review", "building-letter.pdf"); + Files.createDirectories(output.getParent()); + Level fontLogLevel = LogManager.getLogger("org.apache.fop").getLevel(); + Configurator.setLevel("org.apache.fop", Level.WARN); + try { + try (var pdf = sheet.exportPDF(0, pageFormat(PaperSize.US_LETTER))) { + assertNotNull(pdf); + Files.copy(pdf, output, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Configurator.setLevel("org.apache.fop", fontLogLevel); + } + assertTrue(Files.size(output) > 1000); + } + + @Test + void defaultBuildingAmmoPrintsStartingLoadAndCurrentRoundsSeparately() throws Exception { + var building = BuildingUtil.newBuilding(); + building.addEquipment(EquipmentType.get("IS Ammo AC/5"), 0); + String[] nativeLines = java.util.Arrays.stream(BLKFile.getBlock(building).getAllDataAsString()) + .map(line -> line.replace(":Shots20#", "")).toArray(String[]::new); + var loaded = (BuildingEntity) new BLKStructureFile(new BuildingBlock(nativeLines)).getEntity(); + loaded.getAmmo().getFirst().setShotsLeft(7); + + var clean = sheet(loaded, PaperSize.US_LETTER); + clean.options.setDamage(false); + assertTrue(clean.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + assertTrue(elements(clean, "g", "building-inventory-entry").getFirst().getTextContent().contains("(20)")); + + var current = sheet(loaded, PaperSize.US_LETTER); + current.options.setDamage(true); + assertTrue(current.createDocument(0, pageFormat(PaperSize.US_LETTER), true)); + assertTrue(elements(current, "g", "building-inventory-entry").getFirst().getTextContent().contains("(7)")); + } + + @Test + void constructionServicesAndWeaponPlacementRemainReadableAcrossInventoryPages() throws Exception { + var building = BuildingUtil.newBuilding(); + var east = new CubeCoords(1, 0, -1); + var west = new CubeCoords(-1, 0, 1); + building.setChassis("Underground Control Center"); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 32, + List.of(CubeCoords.ZERO, east, west)); + var design = building.getDesign(); + design.setEnvironmentalSealing(true); + design.setHeavyMetal(true); + design.setCeiling(BuildingDesign.Ceiling.LOW); + design.setSite(BuildingDesign.Site.UNDERGROUND); + design.setRoofClearance(true); + design.setDepth(2); + var generator = building.addEquipment(EquipmentType.get("FUSION PowerGenerator"), 4); + generator.setSize(10); + design.getEquipmentSpace().put(generator, + List.of(new BuildingDesign.Position(east, 1), new BuildingDesign.Position(west, 1))); + for (int i = 0; i < 3; i++) { + var laser = building.addEquipment(EquipmentType.get("ISMediumLaser"), 3); + laser.setFacing(2); + if (i < 2) { + design.getAutomatedWeapons().add(laser); + } + } + var quarters = new FirstClassQuartersCargoBay(2); + building.addTransporter(quarters); + design.getBaySpace().put(quarters, List.of(new BuildingDesign.Space(new BuildingDesign.Position(west, 0), 20))); + design.getDoors().add(new BuildingDesign.Door(new BuildingDesign.Position(east, 0), 2, 2)); + design.getElevators().add(new BuildingDesign.Elevator(CubeCoords.ZERO, 20, Map.of(0, 36, 1, 36, 2, 36, 3, 36))); + assertTrue(BuildingUtil.constructionIssues(building).isEmpty(), BuildingUtil.constructionIssues(building).toString()); + var sheet = sheet(building, PaperSize.ISO_A4); + assertEquals(1, sheet.getPageCount(), "Fit the complete service inventory before adding another page"); + assertEquals(3, sheet.inventoryGroups().get(1).size(), "Same equipment/hex/floor retains one quantity group"); + var text = new StringBuilder(); + for (int page = 0; page < sheet.getPageCount(); page++) { + assertTrue(sheet.createDocument(page, pageFormat(PaperSize.ISO_A4), true)); + var rows = elements(sheet, "g", "building-inventory-entry"); + assertTrue(rows.size() > 18, "A full inventory is no longer limited to 18 rows"); + assertFalse(elements(sheet, "g", "building-map-key").isEmpty()); + rows.forEach(row -> text.append(row.getTextContent()).append('\n')); + render(sheet, "building-construction-details-" + (page + 1)); + } + assertTrue(text.toString().contains("2 × SE fixed; auto, Gunnery 5")); + assertTrue(text.toString().contains("Mass share: 5.00 t")); + assertTrue(text.toString().contains("Environmental sealing")); + assertTrue(text.toString().contains("Door SE; 2 levels high")); + assertTrue(text.toString().contains("Lift access: SE, NW")); + assertTrue(text.toString().contains("/Roof")); + assertTrue(text.toString().contains("Current elevator level:")); + assertFalse(text.toString().contains("Unallocated")); + } + + /** Keep review images in build/ rather than committing snapshots of generated artwork. */ + private void render(PrintBuilding sheet, String name) throws Exception { + Path directory = Path.of("build", "building-review"); + Files.createDirectories(directory); + PNGTranscoder renderer = new PNGTranscoder(); + renderer.addTranscodingHint(PNGTranscoder.KEY_WIDTH, 1224f); + renderer.addTranscodingHint(PNGTranscoder.KEY_BACKGROUND_COLOR, Color.WHITE); + try (OutputStream output = Files.newOutputStream(directory.resolve(name + ".png"))) { + renderer.transcode(new TranscoderInput(sheet.getSVGDocument()), new TranscoderOutput(output)); + } + } +} diff --git a/megameklab/unittests/megameklab/printing/PrintInfantryRangeTest.java b/megameklab/unittests/megameklab/printing/PrintInfantryRangeTest.java new file mode 100644 index 00000000000..484f13aa89e --- /dev/null +++ b/megameklab/unittests/megameklab/printing/PrintInfantryRangeTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.printing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.print.PageFormat; + +import megamek.common.equipment.EquipmentType; +import megamek.common.units.ConvInfantry; +import megamek.common.units.EntityMovementMode; +import megamek.common.weapons.infantry.InfantryWeapon; +import megameklab.testing.util.InitializeTypes; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +@ExtendWith(InitializeTypes.class) +class PrintInfantryRangeTest { + @ParameterizedTest + @CsvSource({ "0, -2", "1, -1", "2, -1" }) + void crewOperatedWeaponsAddOneAtRangeZeroWithoutChangingOtherRanges(int secondaryCount, + String expectedPointBlank) throws Exception { + PrintInfantry sheet = sheet(secondaryCount, false); + assertTrue(sheet.createDocument(0, new PageFormat(), true)); + assertEquals(expectedPointBlank, text(sheet, "range_mod_0")); + assertEquals("0", text(sheet, "range_mod_1")); + assertEquals(secondaryCount > 1 ? "+2" : InventoryEntry.DASH, text(sheet, "range_mod_4")); + assertEquals(InventoryEntry.DASH, text(sheet, "range_mod_7")); + } + + @Test + void underwaterRangeHalvingRetainsTheCrewPenalty() throws Exception { + PrintInfantry sheet = sheet(2, true); + assertTrue(sheet.createDocument(0, new PageFormat(), true)); + assertEquals("-1", text(sheet, "uw_range_mod_0")); + assertEquals("+2", text(sheet, "uw_range_mod_2")); + assertEquals(InventoryEntry.DASH, text(sheet, "uw_range_mod_4")); + assertEquals("0", text(sheet, "range_mod_2")); + } + + private PrintInfantry sheet(int secondaryCount, boolean underwater) { + ConvInfantry infantry = new ConvInfantry(); + infantry.setChassis("Infantry range test"); + infantry.setSquadSize(5); + infantry.setSquadCount(4); + infantry.setPrimaryWeapon((InfantryWeapon) EquipmentType.get("InfantryAssaultRifle")); + if (secondaryCount > 0) { + infantry.setSecondaryWeapon((InfantryWeapon) EquipmentType.get("InfantryMk2PortableAA")); + infantry.setSecondaryWeaponsPerSquad(secondaryCount); + } + if (underwater) { + infantry.setMovementMode(EntityMovementMode.INF_UMU); + } + infantry.autoSetInternal(); + RecordSheetOptions options = new RecordSheetOptions(); + options.setReferenceCharts(false); + return new PrintInfantry(infantry, 0, options) { + @Override + String getSVGDirectoryName(boolean testDirectory) { + return "../../mm-data/data/images/recordsheets/" + PaperSize.US_LETTER.dirName; + } + }; + } + + private String text(PrintInfantry sheet, String id) { + return sheet.getSVGDocument().getElementById(id).getTextContent(); + } +} diff --git a/megameklab/unittests/megameklab/printing/WeaponBayDamageTest.java b/megameklab/unittests/megameklab/printing/WeaponBayDamageTest.java new file mode 100644 index 00000000000..9b78f85e144 --- /dev/null +++ b/megameklab/unittests/megameklab/printing/WeaponBayDamageTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.printing; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import megamek.common.equipment.EquipmentType; +import megamek.common.equipment.WeaponMounted; +import megamek.common.units.Aero; +import megamek.common.units.Warship; +import megameklab.testing.util.InitializeTypes; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(InitializeTypes.class) +class WeaponBayDamageTest { + @Test + void roundsTheCapitalBayTotalAfterAddingFractionalWeaponDamage() throws Exception { + assertDamage("Naval Laser 55", 3, "17"); // SO:AA p. 88 example: 3 x 5.5 = 16.5, rounds to 17. + assertDamage("Naval Laser 45", 4, "18"); + } + + private static void assertDamage(String name, int count, String expected) throws Exception { + Warship ship = new Warship(); + WeaponBayText bay = new WeaponBayText(Aero.LOC_NOSE, false); + for (int i = 0; i < count; i++) { + bay.addBayWeapon((WeaponMounted) ship.addEquipment(EquipmentType.get(name), Aero.LOC_NOSE)); + } + WeaponBayInventoryEntry entry = new WeaponBayInventoryEntry(ship, 1, bay, true); + assertEquals(expected, entry.getShortField(0)); + assertEquals(expected, entry.getMediumField(0)); + assertEquals(expected, entry.getLongField(0)); + assertEquals(expected, entry.getExtremeField(0)); + } +} diff --git a/megameklab/unittests/megameklab/ui/building/BuildingMainUITest.java b/megameklab/unittests/megameklab/ui/building/BuildingMainUITest.java new file mode 100644 index 00000000000..b11a64f18a9 --- /dev/null +++ b/megameklab/unittests/megameklab/ui/building/BuildingMainUITest.java @@ -0,0 +1,407 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.ui.building; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import java.awt.Color; +import java.awt.Component; +import java.awt.Container; +import java.awt.Graphics2D; +import java.awt.GraphicsEnvironment; +import java.awt.Point; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTable; +import javax.swing.SwingUtilities; + +import megamek.common.board.CubeCoords; +import megamek.common.enums.BuildingType; +import megamek.common.equipment.EquipmentType; +import megamek.common.units.AbstractBuildingEntity; +import megamek.common.units.BuildingDesign; +import megamek.common.units.IBuilding; +import megameklab.testing.util.InitializeTypes; +import megameklab.util.BuildingMap; +import megameklab.util.BuildingUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(InitializeTypes.class) +class BuildingMainUITest { + private static final CubeCoords EAST = new CubeCoords(1, 0, -1); + + @BeforeEach + void requireGraphicsEnvironment() { + assumeFalse(GraphicsEnvironment.isHeadless(), "The editor's drag-and-drop tables require a display"); + } + + @Test + void pancakeSelectsHexAndFloorWithoutLeavingTheCurrentTab() throws Exception { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 0, + List.of(CubeCoords.ZERO, EAST)); + building.addEquipment(EquipmentType.get("ISMediumLaser"), 5); + SwingUtilities.invokeAndWait(() -> { + BuildingMainUI editor = editor(building); + var tabs = editor.getConfigPane(); + editor.showEquipment(); + Component equipment = tabs.getSelectedComponent(); + JComponent pancake = find(editor, "Building pancake", JComponent.class); + assertFalse(SwingUtilities.isDescendingFrom(pancake, tabs)); + JTable loadout = find(editor, "Building equipment", JTable.class); + assertEquals(0, loadout.getRowCount()); + paint(pancake); + click(pancake, pointFor(pancake, editor.hexLabel(EAST) + "/2")); + + assertSame(equipment, tabs.getSelectedComponent()); + assertEquals(EAST, editor.selectedHex()); + assertEquals(2, editor.selectedFloor()); + assertEquals(5, editor.selectedLocation()); + assertEquals(1, loadout.getRowCount()); + JComponent top = find(editor, "Building footprint", JComponent.class); + paint(top); + assertTrue(top.getToolTipText(mouse(top, pointFor(top, editor.hexLabel(EAST) + "/2"))).endsWith("/2")); + assertTrue(find(editor, "Editing location", JLabel.class).getText().endsWith("Level 2")); + + click(pancake, new Point(1, 1)); + assertEquals(EAST, editor.selectedHex(), "Empty space must not select an arbitrary hex"); + assertEquals(2, editor.selectedFloor()); + }); + } + + @Test + void navigatorKeepsItsWidthAndScrollPositionAcrossEveryTab() throws Exception { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 20, 80, 0, + List.of(CubeCoords.ZERO, EAST)); + SwingUtilities.invokeAndWait(() -> { + BuildingMainUI editor = editor(building); + JSplitPane split = find(editor, "Building editor split", JSplitPane.class); + JComponent pancake = find(editor, "Building pancake", JComponent.class); + JScrollPane scroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, pancake); + int originalWidth = split.getRightComponent().getWidth(); + split.setDividerLocation(split.getLeftComponent().getWidth() - 100); + layout(editor); + assertTrue(split.getRightComponent().getWidth() > originalWidth); + scroll.getVerticalScrollBar().setValue(150); + int divider = split.getDividerLocation(); + Point position = scroll.getViewport().getViewPosition(); + assertTrue(position.y > 0, "Tall buildings must scroll"); + + for (int index = 0; index < editor.getConfigPane().getTabCount(); index++) { + editor.getConfigPane().setSelectedIndex(index); + layout(editor); + assertSame(pancake, find(editor, "Building pancake", JComponent.class)); + assertTrue(split.getRightComponent().isVisible()); + assertEquals(divider, split.getDividerLocation()); + assertEquals(position, scroll.getViewport().getViewPosition()); + } + int width = split.getRightComponent().getWidth(); + editor.setSize(1500, 900); + layout(editor); + assertEquals(width, split.getRightComponent().getWidth(), "Window resizing should grow the editor area"); + }); + } + + @Test + void changingLocationRevealsTheSelectedHexInTallBuildings() throws Exception { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 20, 80, 0, + List.of(CubeCoords.ZERO, EAST)); + AtomicReference editorReference = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> editorReference.set(editor(building))); + SwingUtilities.invokeAndWait(() -> { + BuildingMainUI editor = editorReference.get(); + JComponent pancake = find(editor, "Building pancake", JComponent.class); + assertTrue(pancake.getVisibleRect().contains(selectionPixel(paint(pancake)))); + editor.selectLocation(EAST, 19); + paint(pancake); + }); + SwingUtilities.invokeAndWait(() -> { + JComponent pancake = find(editorReference.get(), "Building pancake", JComponent.class); + assertTrue(pancake.getVisibleRect().contains(selectionPixel(paint(pancake)))); + JScrollPane scroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, pancake); + scroll.getVerticalScrollBar().setValue(150); + paint(pancake); + }); + SwingUtilities.invokeAndWait(() -> { + JComponent pancake = find(editorReference.get(), "Building pancake", JComponent.class); + assertEquals(150, pancake.getVisibleRect().y, "Repainting must preserve manual scrolling"); + }); + } + + @Test + void bridgeDeckClicksUseTheDeckElevationAndNativeFloorZero() throws Exception { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.BRIDGE, 1, 80, 0, + List.of(CubeCoords.ZERO, EAST)); + building.getDesign().getBridgeDecks().put(CubeCoords.ZERO, 2); + building.getDesign().getBridgeDecks().put(EAST, 4); + SwingUtilities.invokeAndWait(() -> { + BuildingMainUI editor = editor(building); + JComponent pancake = find(editor, "Building pancake", JComponent.class); + paint(pancake); + click(pancake, pointFor(pancake, editor.hexLabel(EAST) + "/4")); + assertEquals(EAST, editor.selectedHex()); + assertEquals(0, editor.selectedFloor()); + assertTrue(find(editor, "Editing location", JLabel.class).getText().endsWith("Deck 4")); + }); + } + + @Test + void selectionRemainsValidWhenMobileFloorsAndHexesAreRemoved() throws Exception { + var building = BuildingUtil.newMobileStructure(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 0, + List.of(CubeCoords.ZERO, EAST)); + building.getDesign().setBaseLevel(-2); + SwingUtilities.invokeAndWait(() -> { + BuildingMainUI editor = editor(building); + JComponent pancake = find(editor, "Building pancake", JComponent.class); + paint(pancake); + click(pancake, pointFor(pancake, editor.hexLabel(EAST) + "/Ground")); + assertEquals(2, editor.selectedFloor()); + BuildingUtil.setHexHeight(building, EAST, 1); + editor.refreshAll(); + assertEquals(0, editor.selectedFloor()); + assertEquals(EAST, editor.selectedHex()); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 0, + List.of(CubeCoords.ZERO)); + editor.refreshAll(); + assertEquals(CubeCoords.ZERO, editor.selectedHex()); + assertEquals(0, editor.selectedFloor()); + }); + } + + @Test + void selectedHexHasAStrokeOnPlainAndColoredFloors() throws Exception { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 2, 80, 0, + List.of(CubeCoords.ZERO, EAST)); + building.getDesign().getElevators().add(new BuildingDesign.Elevator(EAST, 20, Map.of(0, 1, 1, 1))); + SwingUtilities.invokeAndWait(() -> { + BuildingMainUI editor = editor(building); + JComponent pancake = find(editor, "Building pancake", JComponent.class); + BufferedImage plain = paint(pancake); + Point plainStroke = selectionPixel(plain); + click(pancake, pointFor(pancake, editor.hexLabel(EAST) + "/1")); + BufferedImage colored = paint(pancake); + Point coloredStroke = selectionPixel(colored); + assertNotEquals(plainStroke, coloredStroke); + assertNotEquals(plain.getRGB(plainStroke.x, plainStroke.y), colored.getRGB(plainStroke.x, plainStroke.y)); + }); + } + + @Test + void selectionPreservesDoorAndElevatorMarksInBothViews() throws Exception { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 0, + List.of(CubeCoords.ZERO, EAST)); + for (int facing = 0; facing < 6; facing++) { + building.getDesign().getDoors().add(new BuildingDesign.Door(new BuildingDesign.Position(EAST, 1), facing, 1)); + } + building.getDesign().getElevators().add(new BuildingDesign.Elevator(EAST, 20, Map.of(0, 1, 2, 1))); + var doors = List.copyOf(building.getDesign().getDoors()); + var elevators = List.copyOf(building.getDesign().getElevators()); + SwingUtilities.invokeAndWait(() -> { + BuildingMainUI editor = editor(building); + for (String name : List.of("Building footprint", "Building pancake")) { + editor.selectLocation(CubeCoords.ZERO, 1); + JComponent view = find(editor, name, JComponent.class); + BufferedImage features = paint(view); + building.getDesign().getDoors().clear(); + building.getDesign().getElevators().clear(); + BufferedImage plain = paint(view); + building.getDesign().getDoors().addAll(doors); + building.getDesign().getElevators().addAll(elevators); + editor.selectLocation(EAST, 1); + BufferedImage selected = paint(view); + int symbolPixels = 0; + int shaftPixels = 0; + for (int y = 0; y < features.getHeight(); y++) { + for (int x = 0; x < features.getWidth(); x++) { + int color = features.getRGB(x, y); + if (color == plain.getRGB(x, y)) { + continue; + } + if (color == Color.BLACK.getRGB()) { + symbolPixels++; + assertEquals(color, selected.getRGB(x, y), name + " obscured a feature symbol at " + x + "," + y); + } else if (name.equals("Building pancake") + && color == Color.decode(BuildingMap.Feature.ELEVATOR.color).getRGB() + && view.getToolTipText(mouse(view, new Point(x, y))) == null) { + shaftPixels++; + assertEquals(color, selected.getRGB(x, y), "Selection obscured an elevator shaft at " + x + "," + y); + } + } + } + assertTrue(symbolPixels > 20, "The fixture must have visible door and elevator symbols"); + if (name.equals("Building pancake")) { + assertTrue(shaftPixels > 20, "The fixture must have visible elevator shafts"); + } + selectionPixel(selected); + } + }); + } + + @Test + void elevatorAccessSidesAppearAsAmberDoorTrianglesInBothViews() throws Exception { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 0, + List.of(CubeCoords.ZERO, EAST)); + var throughFloor = new BuildingDesign.Elevator(EAST, 20, Map.of(0, 1, 2, 1)); + var withAccess = new BuildingDesign.Elevator(EAST, 20, Map.of(0, 1, 1, 63, 2, 1)); + building.getDesign().getElevators().add(throughFloor); + SwingUtilities.invokeAndWait(() -> { + BuildingMainUI editor = editor(building); + editor.selectLocation(EAST, 1); + for (String name : List.of("Building footprint", "Building pancake")) { + JComponent view = find(editor, name, JComponent.class); + building.getDesign().getElevators().set(0, throughFloor); + BufferedImage withoutDoors = paint(view); + building.getDesign().getElevators().set(0, withAccess); + BufferedImage withDoors = paint(view); + int amber = Color.decode(BuildingMap.Feature.ELEVATOR_DOOR.color).getRGB(); + int doorPixels = 0; + for (int y = 0; y < withDoors.getHeight(); y++) { + for (int x = 0; x < withDoors.getWidth(); x++) { + if (withDoors.getRGB(x, y) == amber && withoutDoors.getRGB(x, y) != amber) { + String tooltip = view.getToolTipText(mouse(view, new Point(x, y))); + if (tooltip == null || !tooltip.startsWith(editor.hexLabel(EAST) + "/")) { + doorPixels++; + } + } + } + } + assertTrue(doorPixels > 20, name + " must draw amber doors projecting beyond the selected hex"); + selectionPixel(withDoors); + } + editor.refreshAll(); + for (String name : List.of("Building footprint legend", "Building pancake legend")) { + Container legend = find(editor, name, Container.class); + assertTrue(java.util.Arrays.stream(legend.getComponents()) + .anyMatch(entry -> entry instanceof JLabel label && label.getText().equals("Elevator door") + && label.isVisible() && label.getIcon() != null)); + } + }); + } + + private static BuildingMainUI editor(AbstractBuildingEntity building) { + BuildingMainUI editor = new BuildingMainUI(building, ""); + editor.onActivated(); + editor.setSize(1400, 900); + layout(editor); + paint(editor); + return editor; + } + + private static void layout(Container container) { + container.doLayout(); + for (Component child : container.getComponents()) { + if (child instanceof Container nested) { + layout(nested); + } + } + } + + private static T find(Container parent, String name, Class type) { + for (Component child : parent.getComponents()) { + if (name.equals(child.getName())) { + return type.cast(child); + } + if (child instanceof Container nested) { + T result = find(nested, name, type); + if (result != null) { + return result; + } + } + } + return null; + } + + private static BufferedImage paint(JComponent component) { + BufferedImage image = new BufferedImage(component.getWidth(), component.getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + component.paint(graphics); + graphics.dispose(); + return image; + } + + private static MouseEvent mouse(JComponent component, Point point) { + return new MouseEvent(component, MouseEvent.MOUSE_CLICKED, 0, 0, point.x, point.y, 1, false, MouseEvent.BUTTON1); + } + + private static void click(JComponent component, Point point) { + component.dispatchEvent(mouse(component, point)); + } + + private static Point pointFor(JComponent component, String label) { + for (int y = 4; y < component.getHeight(); y += 4) { + for (int x = 4; x < component.getWidth(); x += 4) { + Point point = new Point(x, y); + String tooltip = component.getToolTipText(mouse(component, point)); + if (tooltip != null && (tooltip.equals(label) || tooltip.startsWith(label + " —"))) { + return point; + } + } + } + return fail("No clickable hex found for " + label); + } + + private static Point selectionPixel(BufferedImage image) { + int stroke = new Color(30, 105, 210).getRGB(); + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + if (image.getRGB(x, y) == stroke) { + return new Point(x, y); + } + } + } + return fail("The selected hex must have a visible blue stroke"); + } +} diff --git a/megameklab/unittests/megameklab/util/BuildingUtilTest.java b/megameklab/unittests/megameklab/util/BuildingUtilTest.java new file mode 100644 index 00000000000..5f2087d0337 --- /dev/null +++ b/megameklab/unittests/megameklab/util/BuildingUtilTest.java @@ -0,0 +1,383 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMekLab is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMekLab is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megameklab.util; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; + +import megamek.common.bays.FirstClassQuartersCargoBay; +import megamek.common.board.CubeCoords; +import megamek.common.enums.BuildingType; +import megamek.common.equipment.EquipmentType; +import megamek.common.loaders.BLKFile; +import megamek.common.loaders.BLKStructureFile; +import megamek.common.units.BuildingConstruction; +import megamek.common.units.BuildingDesign; +import megamek.common.units.BuildingEntity; +import megamek.common.units.IBuilding; +import megameklab.testing.util.InitializeTypes; +import megameklab.util.BuildingMap.DoorMarker; +import megameklab.util.BuildingMap.Feature; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(InitializeTypes.class) +class BuildingUtilTest { + @Test + void featureIndexBoundsMalformedLevelRangesToTheBuildingHeight() { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 2, 80, 0, List.of(CubeCoords.ZERO)); + var door = new BuildingDesign.Door(new BuildingDesign.Position(CubeCoords.ZERO, 1), 0, Integer.MAX_VALUE); + building.getDesign().getElevators().add(new BuildingDesign.Elevator(CubeCoords.ZERO, 20, + Map.of(1, 1, Integer.MAX_VALUE, 1))); + + var index = BuildingMap.featureIndex(building, List.of(door)); + + assertEquals(List.of(BuildingMap.Feature.ELEVATOR, BuildingMap.Feature.DOOR, BuildingMap.Feature.ELEVATOR_DOOR), + index.features(CubeCoords.ZERO, 1)); + assertEquals(List.of(new DoorMarker(0, Feature.DOOR), new DoorMarker(0, Feature.ELEVATOR_DOOR)), + index.doors(CubeCoords.ZERO, 1)); + assertTrue(index.features(CubeCoords.ZERO, 2).isEmpty()); + assertTrue(index.doors(CubeCoords.ZERO, 2).isEmpty()); + } + + @Test + void featureIndexUsesBridgeDeckElevationInsteadOfStructuralHeight() { + var building = BuildingUtil.newBuilding(); + BuildingUtil.configure(building, BuildingType.RAIL, IBuilding.BRIDGE, 1, 80, 0, List.of(CubeCoords.ZERO)); + building.getDesign().getBridgeDecks().put(CubeCoords.ZERO, 5); + var door = new BuildingDesign.Door(new BuildingDesign.Position(CubeCoords.ZERO, 5), 2, 1); + building.getDesign().getElevators().add(new BuildingDesign.Elevator(CubeCoords.ZERO, 20, Map.of(0, 1, 5, 1))); + + var index = BuildingMap.featureIndex(building, List.of(door)); + + assertEquals(List.of(BuildingMap.Feature.ELEVATOR, BuildingMap.Feature.DOOR, BuildingMap.Feature.ELEVATOR_DOOR), + index.features(CubeCoords.ZERO, 5)); + assertEquals(List.of(new DoorMarker(2, Feature.DOOR), new DoorMarker(0, Feature.ELEVATOR_DOOR)), + index.doors(CubeCoords.ZERO, 5)); + } + + @Test + void featureIndexPreservesFeatureOrderRoofProjectionAndDoorOrder() throws Exception { + var building = BuildingUtil.newMobileStructure(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 0, List.of(CubeCoords.ZERO, EAST)); + BuildingUtil.setHexHeight(building, EAST, 2); + var quarters = new FirstClassQuartersCargoBay(2); + building.addTransporter(quarters); + building.getDesign().getBaySpace().put(quarters, List.of( + new BuildingDesign.Space(new BuildingDesign.Position(CubeCoords.ZERO, 2), 10), + new BuildingDesign.Space(new BuildingDesign.Position(EAST, 0), 0), + new BuildingDesign.Space(new BuildingDesign.Position(EAST, 1), 10))); + var deck = building.addEquipment(EquipmentType.get("Building Flight Deck"), 0); + deck.setSponsonTurretMounted(true); + var outside = new CubeCoords(-1, 0, 1); + building.getDesign().getEquipmentSpace().put(deck, List.of( + new BuildingDesign.Position(CubeCoords.ZERO, 0), new BuildingDesign.Position(EAST, 0), + new BuildingDesign.Position(outside, 0))); + building.getDesign().getElevators().add(new BuildingDesign.Elevator(CubeCoords.ZERO, 20, Map.of(0, 1, 3, 1))); + var door = new BuildingDesign.Door(new BuildingDesign.Position(CubeCoords.ZERO, 1), 2, 2); + var secondDoor = new BuildingDesign.Door(new BuildingDesign.Position(CubeCoords.ZERO, 2), 4, 1); + var doors = List.of(door, secondDoor); + + var index = BuildingMap.featureIndex(building, doors); + + assertEquals(List.of(BuildingMap.Feature.BAY, BuildingMap.Feature.ELEVATOR, BuildingMap.Feature.DECK, + BuildingMap.Feature.TURRET, BuildingMap.Feature.DOOR), index.features(CubeCoords.ZERO, 2)); + assertEquals(List.of(BuildingMap.Feature.BAY, BuildingMap.Feature.DECK, BuildingMap.Feature.TURRET), + index.features(EAST, 1)); + assertTrue(index.features(EAST, 0).isEmpty()); + assertTrue(index.features(outside, -1).isEmpty()); + assertEquals(List.of(new DoorMarker(2, Feature.DOOR), new DoorMarker(4, Feature.DOOR)), index.doors(CubeCoords.ZERO, 2)); + assertEquals(List.of(new DoorMarker(2, Feature.DOOR)), index.doors(CubeCoords.ZERO, 1)); + assertEquals(BuildingMap.Feature.ELEVATOR, BuildingMap.fill(index.features(CubeCoords.ZERO, 2))); + + building.getDesign().getElevators().clear(); + deck.setSponsonTurretMounted(false); + var refreshed = BuildingMap.featureIndex(building, List.of()); + assertEquals(List.of(BuildingMap.Feature.BAY, BuildingMap.Feature.DECK), refreshed.features(CubeCoords.ZERO, 2)); + assertEquals(5, index.features(CubeCoords.ZERO, 2).size(), "A render's snapshot must remain stable"); + } + + @Test + void shorterMobileHexRemovesEquipmentAndDoorsOnDeletedFloors() throws Exception { + var mobile = BuildingUtil.newMobileStructure(); + var hexes = List.copyOf(mobile.getInternalBuilding().getOriginalCoordsList()); + BuildingUtil.configure(mobile, BuildingType.MEDIUM, IBuilding.FORTRESS, 3, 40, 0, hexes); + var retained = mobile.addEquipment(EquipmentType.get("ISMediumLaser"), 0); + var removed = mobile.addEquipment(EquipmentType.get("ISMediumLaser"), 2); + mobile.getDesign().getDoors().add(new BuildingDesign.Door(new BuildingDesign.Position(CubeCoords.ZERO, 2), 0, 1)); + BuildingUtil.setHexHeight(mobile, CubeCoords.ZERO, 1); + assertTrue(mobile.getEquipment().contains(retained)); + assertFalse(mobile.getEquipment().contains(removed)); + assertTrue(mobile.getDesign().getDoors().isEmpty()); + assertEquals(1, mobile.getInternalBuilding().getHeight(CubeCoords.ZERO)); + assertEquals(3, mobile.getInternalBuilding().getHeight(hexes.get(1))); + var loaded = new BLKStructureFile(BLKFile.getBlock(mobile)).getEntity(); + assertInstanceOf(megamek.common.units.MobileStructure.class, loaded); + assertEquals(1, ((megamek.common.units.MobileStructure) loaded).getInternalBuilding().getHeight(CubeCoords.ZERO)); + assertEquals(1, loaded.getEquipment().size()); + } + + private static final CubeCoords EAST = new CubeCoords(1, 0, -1); + + @Test + void elevatorDoorMarkersUseExactStopsAndMergeSharedAccessSides() { + var building = BuildingUtil.newMobileStructure(); + BuildingUtil.configure(building, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 0, + List.of(CubeCoords.ZERO, EAST)); + BuildingUtil.setHexHeight(building, EAST, 1); + var design = building.getDesign(); + design.getElevators().add(new BuildingDesign.Elevator(CubeCoords.ZERO, 20, Map.of(0, 5, 2, 32, 3, 8))); + design.getElevators().add(new BuildingDesign.Elevator(CubeCoords.ZERO, 20, Map.of(0, 1, 2, 32))); + design.getElevators().add(new BuildingDesign.Elevator(EAST, 20, Map.of(0, 8, 1, 16))); + var structuralDoor = new BuildingDesign.Door(new BuildingDesign.Position(CubeCoords.ZERO, 0), 4, 2); + design.getDoors().add(structuralDoor); + var index = BuildingMap.featureIndex(building, design.getMapDoors()); + + assertEquals(List.of(new DoorMarker(4, Feature.DOOR), new DoorMarker(0, Feature.ELEVATOR_DOOR), + new DoorMarker(2, Feature.ELEVATOR_DOOR)), index.doors(CubeCoords.ZERO, 0)); + assertEquals(List.of(new DoorMarker(4, Feature.DOOR)), index.doors(CubeCoords.ZERO, 1)); + assertEquals(List.of(new DoorMarker(5, Feature.ELEVATOR_DOOR)), index.doors(CubeCoords.ZERO, 2)); + assertTrue(index.doors(CubeCoords.ZERO, 3).isEmpty(), "Roof access must not appear on an interior floor"); + assertEquals(List.of(new DoorMarker(3, Feature.ELEVATOR_DOOR)), index.doors(EAST, 0)); + assertTrue(index.doors(EAST, 1).isEmpty(), "A shorter hex has no interior at its roof level"); + assertFalse(index.features(CubeCoords.ZERO, 1).contains(Feature.ELEVATOR_DOOR)); + assertTrue(index.features(CubeCoords.ZERO, 2).contains(Feature.ELEVATOR_DOOR)); + assertEquals(List.of(structuralDoor), design.getMapDoors(), "Rendering must not add structural doors"); + design.getElevators().clear(); + assertEquals(List.of(new DoorMarker(5, Feature.ELEVATOR_DOOR)), index.doors(CubeCoords.ZERO, 2)); + assertTrue(BuildingMap.featureIndex(building, design.getMapDoors()).doors(CubeCoords.ZERO, 2).isEmpty()); + } + + @Test + void groundReferenceRoundTripsWithoutMovingEquipmentDoorsOrElevators() throws Exception { + var entity = BuildingUtil.newBuilding(); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 4, 80, 0, List.of(CubeCoords.ZERO)); + entity.addEquipment(EquipmentType.get("ISMediumLaser"), 1); + entity.getDesign().getDoors().add(new BuildingDesign.Door(new BuildingDesign.Position(CubeCoords.ZERO, 2), 0, 1)); + entity.getDesign().getElevators().add(new BuildingDesign.Elevator(CubeCoords.ZERO, 20, Map.of(0, 0, 1, 0, 2, 0, 3, 0, 4, 0))); + entity.getDesign().setBaseLevel(-2); + var block = BLKFile.getBlock(entity); + assertTrue(List.of(block.getDataAsString("building_options")).contains("base_level=-2")); + var loaded = (BuildingEntity) new BLKStructureFile(block).getEntity(); + assertEquals(-2, loaded.getDesign().getBaseLevel()); + assertEquals(List.of("0504/-2", "0504/-1", "0504/G", "0504/1"), java.util.stream.IntStream.range(0, 4) + .mapToObj(loc -> BuildingUtil.locationLabel(loaded, loc)).toList()); + assertEquals(1, loaded.getEquipment().getFirst().getLocation()); + assertEquals(entity.getDesign().getDoors(), loaded.getDesign().getDoors()); + assertEquals(entity.getDesign().getElevators(), loaded.getDesign().getElevators()); + assertEquals("Roof (2)", BuildingUtil.roofLevelLabel(loaded, 4)); + assertEquals(entity.getWeight(), loaded.getWeight()); + } + + @Test + void automaticSubsurfaceNumberingUsesRoofCoverAndAnExplicitZeroOverridesIt() throws Exception { + var entity = BuildingUtil.newBuilding(); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 0, List.of(CubeCoords.ZERO)); + assertEquals(0, BuildingConstruction.baseLevel(entity)); + for (var site : List.of(BuildingDesign.Site.UNDERGROUND, BuildingDesign.Site.UNDERWATER)) { + entity.getDesign().setSite(site); + entity.getDesign().setDepth(2); + var loaded = (BuildingEntity) new BLKStructureFile(BLKFile.getBlock(entity)).getEntity(); + assertNull(loaded.getDesign().getBaseLevel()); + assertEquals(-5, BuildingConstruction.baseLevel(loaded)); + assertEquals("0504/-5", BuildingUtil.locationLabel(loaded, 0)); + assertEquals("Roof (-2)", BuildingUtil.roofLevelLabel(loaded, 3)); + } + entity.getDesign().setBaseLevel(0); + var loaded = (BuildingEntity) new BLKStructureFile(BLKFile.getBlock(entity)).getEntity(); + assertEquals(0, loaded.getDesign().getBaseLevel()); + assertEquals("0504/G", BuildingUtil.locationLabel(loaded, 0)); + loaded.getDesign().setBaseLevel(null); + assertEquals(-5, BuildingConstruction.baseLevel(loaded)); + loaded.getDesign().setSite(BuildingDesign.Site.SURFACE); + assertEquals(0, BuildingConstruction.baseLevel(loaded)); + } + + @Test + void centersSingleHexAndPreservesAdjacencyAcrossColumnParity() { + assertEquals("0504", BuildingUtil.sheetGrid(List.of(CubeCoords.ZERO)).label(CubeCoords.ZERO)); + List hexes = List.of(CubeCoords.ZERO, EAST, new CubeCoords(-1, 0, 1)); + var grid = BuildingUtil.sheetGrid(hexes); + for (CubeCoords a : hexes) { + for (CubeCoords b : hexes) { + assertEquals(a.toOffset().distance(b.toOffset()), grid.position(a).distance(grid.position(b))); + } + } + assertEquals("0504/G", BuildingUtil.locationLabel(BuildingUtil.newBuilding(), 0)); + } + + @Test + void fitsEitherColumnParityBeforeExpandingRegardlessOfTheAuthoredOrigin() { + var footprint = java.util.stream.IntStream.range(0, 14) + .mapToObj(i -> new CubeCoords(i / 7, i % 7, -i / 7 - i % 7)).toList(); + for (var origin : List.of(CubeCoords.ZERO, new CubeCoords(-10, -10, 20), + new CubeCoords(37, -51, 14), new CubeCoords(-94, 63, 31))) { + var hexes = footprint.stream().map(hex -> hex.add(origin)).toList(); + var grid = BuildingUtil.sheetGrid(hexes); + assertEquals(9, grid.columns()); + assertEquals(7, grid.rows(), "Shifting one column fits this footprint without a denser grid"); + assertEquals("0501", grid.label(hexes.getFirst())); + assertEquals("0607", grid.label(hexes.getLast())); + assertEquals("0504", BuildingUtil.sheetGrid(List.of(origin)).label(origin)); + assertEquals(grid, BuildingUtil.sheetGrid(hexes.reversed()), "Input order must not affect placement"); + for (var a : hexes) { + var position = grid.position(a); + assertTrue(position.getX() >= 0 && position.getX() < grid.columns()); + assertTrue(position.getY() >= 0 && position.getY() < grid.rows()); + for (var b : hexes) { + assertEquals(a.toOffset().distance(b.toOffset()), position.distance(grid.position(b))); + } + } + } + } + + @Test + void resizingRemapsSurvivingEquipmentAndRemovesDeletedFloorsAndHexes() throws Exception { + var entity = BuildingUtil.newBuilding(); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 32, + List.of(CubeCoords.ZERO, EAST)); + var ground = entity.addEquipment(EquipmentType.get("ISMediumLaser"), 0); + var upper = entity.addEquipment(EquipmentType.get("ISMediumLaser"), 2); + var eastern = entity.addEquipment(EquipmentType.get("ISMediumLaser"), 4); + assertEquals(100, entity.getNumberOfCriticalSlots(4)); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 2, 80, 32, + List.of(CubeCoords.ZERO, EAST)); + assertEquals(4, entity.locations()); + assertFalse(entity.getEquipment().contains(upper)); + assertFalse(entity.getWeaponList().contains(upper)); + assertEquals(3, eastern.getLocation()); + assertEquals(0, ground.getLocation()); + assertEquals(eastern, entity.getCritical(3, 0).getMount()); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 2, 80, 32, List.of(CubeCoords.ZERO)); + assertEquals(2, entity.locations()); + assertEquals(List.of(ground), entity.getEquipment()); + assertEquals(32, entity.getArmor(0)); + assertEquals(80, entity.getOInternal(1)); + } + + @Test + void nativeBlkRoundTripRetainsGeometryArmorFacingTurretSizeAndAmmo() throws Exception { + var entity = BuildingUtil.newBuilding(); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 32, + List.of(CubeCoords.ZERO, EAST)); + var weapon = entity.addEquipment(EquipmentType.get("ISMediumLaser"), 4); + weapon.setFacing(5); + weapon.setSponsonTurretMounted(true); + var generator = entity.addEquipment(EquipmentType.get("FUSION PowerGenerator"), 0); + generator.setSize(12.5); + var ammo = entity.addEquipment(EquipmentType.get("IS Ammo AC/5"), 2); + ammo.setOriginalShots(17); + ammo.setShotsLeft(17); + entity.addTransporter(new FirstClassQuartersCargoBay(2)); + var loaded = (BuildingEntity) new BLKStructureFile(BLKFile.getBlock(entity)).getEntity(); + assertEquals(6, loaded.locations()); + assertEquals(List.of(CubeCoords.ZERO, EAST), loaded.getInternalBuilding().getCoordsList()); + var loadedWeapon = loaded.getWeaponList().getFirst(); + assertEquals(4, loadedWeapon.getLocation()); + assertEquals(5, loadedWeapon.getFacing()); + assertTrue(loadedWeapon.isSponsonTurretMounted()); + assertEquals(12.5, loaded.getMisc().getFirst().getSize()); + assertEquals(17, loaded.getAmmo().getFirst().getBaseShotsLeft()); + assertEquals(ammo.getTonnage(), loaded.getAmmo().getFirst().getTonnage()); + assertEquals(20, loaded.getTransportBays().getFirst().getWeight()); + assertEquals(80, loaded.getOInternal(5)); + assertEquals(32, loaded.getArmor(5)); + assertFalse(UnitUtil.saveUnitToString(loaded, false).contains("Unallocated Equipment")); + loaded.getAmmo().getFirst().setShotsLeft(0); + var empty = (BuildingEntity) new BLKStructureFile(BLKFile.getBlock(loaded)).getEntity(); + assertEquals(0, empty.getAmmo().getFirst().getBaseShotsLeft()); + } + + @Test + void rotationKeepsEquipmentAndFacingWithItsPhysicalHex() throws Exception { + var entity = BuildingUtil.newBuilding(); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 2, 80, 0, + List.of(CubeCoords.ZERO, EAST)); + var weapon = entity.addEquipment(EquipmentType.get("ISMediumLaser"), 3); + weapon.setFacing(5); + for (int i = 0; i < 6; i++) { + BuildingUtil.rotate(entity); + assertTrue(entity.getEquipment().contains(weapon)); + assertEquals(3, weapon.getLocation()); + } + assertEquals(List.of(CubeCoords.ZERO, EAST), entity.getInternalBuilding().getCoordsList()); + assertEquals(5, weapon.getFacing()); + assertEquals(weapon, entity.getCritical(3, 0).getMount()); + } + + @Test + void removingTheOriginRebasesSurvivorsWithoutLosingTheirEquipment() throws Exception { + var entity = BuildingUtil.newBuilding(); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 2, 80, 0, List.of(CubeCoords.ZERO, EAST)); + var removed = entity.addEquipment(EquipmentType.get("ISMediumLaser"), 0); + var retained = entity.addEquipment(EquipmentType.get("ISMediumLaser"), 3); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 2, 80, 0, List.of(EAST)); + assertEquals(List.of(CubeCoords.ZERO), entity.getInternalBuilding().getCoordsList()); + assertFalse(entity.getEquipment().contains(removed)); + assertEquals(List.of(retained), entity.getEquipment()); + assertEquals(1, retained.getLocation()); + assertEquals("0504/1", BuildingUtil.locationLabel(entity, retained.getLocation())); + } + + @Test + void geometryEditsKeepServicesAndSharedMassWithTheirPhysicalLocations() throws Exception { + var entity = BuildingUtil.newBuilding(); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 3, 80, 0, List.of(CubeCoords.ZERO, EAST)); + var generator = entity.addEquipment(EquipmentType.get("FUSION PowerGenerator"), 4); + generator.setSize(6); + var quarters = new FirstClassQuartersCargoBay(2); + entity.addTransporter(quarters); + var design = entity.getDesign(); + design.getEquipmentSpace().put(generator, List.of(new BuildingDesign.Position(EAST, 1), + new BuildingDesign.Position(CubeCoords.ZERO, 1))); + design.getBaySpace().put(quarters, List.of(new BuildingDesign.Space(new BuildingDesign.Position(EAST, 0), 20))); + design.getDoors().add(new BuildingDesign.Door(new BuildingDesign.Position(EAST, 0), 2, 2)); + design.getElevators().add(new BuildingDesign.Elevator(EAST, 20, Map.of(0, 32, 1, 32, 2, 32, 3, 32))); + for (int i = 0; i < 6; i++) { + BuildingUtil.rotate(entity); + } + assertEquals(new BuildingDesign.Door(new BuildingDesign.Position(EAST, 0), 2, 2), design.getDoors().getFirst()); + assertEquals(Map.of(0, 32, 1, 32, 2, 32, 3, 32), design.getElevators().getFirst().exits()); + BuildingUtil.configure(entity, BuildingType.HEAVY, IBuilding.FORTRESS, 2, 80, 0, List.of(EAST)); + assertEquals(List.of(new BuildingDesign.Position(CubeCoords.ZERO, 1)), design.getEquipmentSpace().get(generator)); + assertEquals(1, generator.getLocation()); + assertEquals(20, BuildingConstruction.bayWeightInHex(entity, CubeCoords.ZERO)); + assertEquals(CubeCoords.ZERO, design.getDoors().getFirst().position().hex()); + assertEquals(CubeCoords.ZERO, design.getElevators().getFirst().hex()); + assertEquals(Map.of(0, 32, 1, 32, 2, 32), design.getElevators().getFirst().exits(), "The old roof follows the new roof"); + } +} diff --git a/megameklab/unittests/megameklab/util/UnitUtilTest.java b/megameklab/unittests/megameklab/util/UnitUtilTest.java index a223aadab2d..2917d3a7177 100644 --- a/megameklab/unittests/megameklab/util/UnitUtilTest.java +++ b/megameklab/unittests/megameklab/util/UnitUtilTest.java @@ -38,15 +38,20 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; +import java.util.List; import megamek.common.TechAdvancement; import megamek.common.TechConstants; import megamek.common.bays.CargoBay; +import megamek.common.board.CubeCoords; +import megamek.common.enums.BuildingType; import megamek.common.enums.TechBase; import megamek.common.interfaces.ITechnology; import megamek.common.loaders.MekFileParser; import megamek.common.units.BipedMek; +import megamek.common.units.BuildingEntity; import megamek.common.units.Entity; +import megamek.common.units.IBuilding; import megamek.common.units.Mek; import megamek.common.units.SmallCraft; import megamek.common.verifier.TestAero; @@ -57,6 +62,23 @@ @ExtendWith(value = InitializeTypes.class) class UnitUtilTest { + @Test + void buildingArmorCapacityUsesConstructionClassAndAllLocations() { + BuildingEntity building = new BuildingEntity(BuildingType.HEAVY, IBuilding.FORTRESS); + building.configureConstruction(BuildingType.HEAVY, IBuilding.FORTRESS, 2, 100, 40, + List.of(CubeCoords.ZERO)); + + assertEquals(200, UnitUtil.getMaximumArmorPoints(building)); + + building.configureConstruction(BuildingType.HARDENED, IBuilding.CASTLE_BRIAN, 2, 100, 40, + List.of(CubeCoords.ZERO)); + assertEquals(400, UnitUtil.getMaximumArmorPoints(building)); + + building.configureConstruction(BuildingType.HEAVY, IBuilding.STANDARD, 2, 100, 0, + List.of(CubeCoords.ZERO)); + assertEquals(0, UnitUtil.getMaximumArmorPoints(building)); + } + @Test void isLegalUsesOriginalBuildYear() { ITechnology lostech = new TechAdvancement(TechBase.IS).setISAdvancement(2500, @@ -176,6 +198,27 @@ void updateLoadedUnitMaterializesAndDematerializesAutoFilledCrewAcrossThreshold( assertFalse(craft.getTransportBays().get(0).isQuarters()); } + @Test + void rememberedCrewUsesObjectIdentityAcrossDuplicateAndChangingGameIds() { + SmallCraft first = cargoOnlySmallCraft(30); + first.setNCrew(2); + SmallCraft second = cargoOnlySmallCraft(30); + second.setNCrew(7); + assertEquals(-1, first.getId()); + assertEquals(first.getId(), second.getId()); + + UnitUtil.updateLoadedUnit(first); + UnitUtil.updateLoadedUnit(second); + first.setId(42); + first.setWeight(5); + second.setWeight(5); + UnitUtil.updateLoadedUnit(first); + UnitUtil.updateLoadedUnit(second); + + assertEquals(2, first.getNCrew()); + assertEquals(7, second.getNCrew()); + } + private SmallCraft cargoOnlySmallCraft(double tonnage) { SmallCraft craft = new SmallCraft(); craft.setWeight(tonnage);