diff --git a/docs/manual/07-meshes.md b/docs/manual/07-meshes.md index d8d56d8..2a8b823 100644 --- a/docs/manual/07-meshes.md +++ b/docs/manual/07-meshes.md @@ -40,7 +40,7 @@ cells. |---|---| | **Up axis** | **Y up (glTF, most OBJ)** or **Z up (some CAD exports)** | | **Unit scale (file units → cells)** | how many cells one unit of the file is | -| **Largest side** … **cells** + **Fit** | sets the unit scale so the largest side becomes this many cells (default 4) | +| **Largest side** … **cells** + **Fit** | sets the unit scale so the largest side of the main part becomes this many cells (default 4); far-off stray pieces are left out, and a hint below says so | | **Turn** | 0°, 90°, 180° or 270° about the vertical axis | | **Mirror (left ↔ right)** | mirrors the model | diff --git a/notes/editor-format.md b/notes/editor-format.md index f67d80d..6b8cb5f 100644 --- a/notes/editor-format.md +++ b/notes/editor-format.md @@ -55,10 +55,14 @@ deterministic; brushes use the stored `seed`). Node kinds, ids and properties: `notes/editor-api.md` (every persistent, settable `Prop`). Values by `PropType`: numbers (INT/FIXED/ANGLE/FLAGS/COLOR as integers, DOUBLE), booleans, strings, REF = node id (or `null`). Arrays: up to 64 elements as JSON lists, longer ones as -`{"i32": base64}` (int32 LE), `{"f64": base64}` (float64 LE) or `{"bytes": base64}`. A *changed* +`{"i32": base64}` (int32 LE), `{"f64": base64}` (float64 LE) or `{"bytes": base64}`; a short double +array containing -0.0 is written as `f64` too (JSON numbers drop the sign; since 2026-11-07). A *changed* array of unchanged length is stored as runs of the changed elements only: `{"runs": [[start, values], …]}` (values encoded as above) – e.g. editing a few vertices or -pixels of an original page stores just those values. +pixels of an original page stores just those values. Runs apply to the reference value read before any +property of the node is set (setting a model's vertices clears its header; until 2026-11-07 header runs +after a vertex change failed with "project file is damaged: NullPointerException", e.g. on +`llamamod --editor-import` of a levelbuild WAD). Loading: reference scene → removals → create all added nodes (all kinds, so references across kinds resolve) → set values of added/changed nodes and settings → apply `order`. Problems diff --git a/notes/editor-tools-geometry.md b/notes/editor-tools-geometry.md index 2e9d6ab..a8c1a13 100644 --- a/notes/editor-tools-geometry.md +++ b/notes/editor-tools-geometry.md @@ -91,7 +91,10 @@ Invisible collision volumes (Invisible wall/block, colliders) are drawn transluc ### Mesh import (File → Import mesh) File chooser, then a dialog: rotatable preview (mouse wheel zooms; framed on the main mass – `Preview.focusBounds`: per axis the vertices within 2.5 IQR of the middle half (Tukey fences), the full -bounds unless they are over 1.5× larger; `geo_import_focus.jpg`: house with a triangle 400 cells away, `geo_import_zoomed_out.jpg`) (shown as in game; flat shading with a small z-buffer at +bounds unless they are over 1.5× larger; since 2026-11-07 also minus the smaller vertex group beyond a gap +wider than half the extent and twice the kept side, holding under half the other side's vertices – a stray triangle on a +low-poly box is 3 of 11 vertices and passes the fences; **Fit** uses the same bounds, and a hint under +"Largest side" names the main part's size when pieces are ignored, `geo_import_fit_stray.jpg`; `geo_import_focus.jpg`: house with a triangle 400 cells away, `geo_import_zoomed_out.jpg`) (shown as in game; flat shading with a small z-buffer at twice the panel size, `PreviewRaster` – before 2026-11-07 the triangles were sorted by centroid and hidden faces showed as slivers) with a 1-cell grid and a llama-sized marker, source info, up axis, unit scale and "fit largest side to n cells", turn, mirror, stand on the ground, diff --git a/notes/img/editor/bootstart_testworld_camera.jpg b/notes/img/editor/bootstart_testworld_camera.jpg new file mode 100644 index 0000000..b400756 Binary files /dev/null and b/notes/img/editor/bootstart_testworld_camera.jpg differ diff --git a/notes/img/editor/geo_import_fit_stray.jpg b/notes/img/editor/geo_import_fit_stray.jpg new file mode 100644 index 0000000..9f43ae1 Binary files /dev/null and b/notes/img/editor/geo_import_fit_stray.jpg differ diff --git a/notes/img/editor/village2_levelbuild_start_camera.jpg b/notes/img/editor/village2_levelbuild_start_camera.jpg new file mode 100644 index 0000000..e28a810 Binary files /dev/null and b/notes/img/editor/village2_levelbuild_start_camera.jpg differ diff --git a/notes/levelbau.md b/notes/levelbau.md index 8d29ebe..2045b5c 100644 --- a/notes/levelbau.md +++ b/notes/levelbau.md @@ -138,7 +138,9 @@ stairs, platforms register as shadow casters (`solids`). 1.0 = texture colour rest on the llama): on village 2 bases (node 2 has unk2 ≠ 0) that put the camera into the llama's head. Waypoint lists take an optional 5th value unk2: `[(x,z,y,deg,unk2)]`. In game (test world, apptest 2026-11-07): the start camera is behind the llama at the default - distance (`notes/img/editor/testworld_start_camera.jpg`). + distance (`notes/img/editor/testworld_start_camera.jpg`). Village 2 base (levelbuild floor + player on + t1l1m002, `--editor-import` as an edit project, boot-to-level 1/2): camera behind the llama, not in its + head (`notes/img/editor/village2_levelbuild_start_camera.jpg`). * **LevelDoor** (exit) – solved, see section "Exit (LevelDoor)" below. * Sizes that work: TRAK 0.6–1.0 MB, up to 1100 models, 5200 tiles, WAD up to 6.9 MB (originals: TRAK ≤ 1 MB, ≤ 5800 tiles, WAD ≤ 6.85 MB). Test world: 628 models, diff --git a/notes/mod-boottolevel.md b/notes/mod-boottolevel.md index e09dfaf..ef5a926 100644 --- a/notes/mod-boottolevel.md +++ b/notes/mod-boottolevel.md @@ -24,7 +24,9 @@ The editor's play-test (`PlaytestRunner.install`) should write the bound form **Start camera along** (`StartOverride.apply`): the llama's chain moves and turns with the start when it is a levelbuild/editor start camera – one node within 2.5 cells (`PlayerStart`, levelbuild since 2026-11-07) or the older "node 8 behind + nodes on the llama" chain; original chains stay. Test: -`BootToLevelModTest.singleCameraNodeMovesWithTheStart`. +`BootToLevelModTest.singleCameraNodeMovesWithTheStart`. In game (apptest 2026-11-07, test world, +`start=21, 0.1, 15, 90`, i.e. moved and turned from the plaza start 18/14.6 yaw 0): camera behind the llama, +facing the stairs (`img/editor/bootstart_testworld_camera.jpg`). Patches: `jmp` at 0x549C50 (main-menu frame handler) → once: read slots (FUN_0041c0e0), load slot (FUN_0041cc70), set tribe/world/chapter/map 0x584EB4…, music shutdown, state 0x5846E4 = 3, diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/NodeCodec.java b/src/main/java/com/eliasmueller/llamamod/editor/io/NodeCodec.java index 5db539c..1ba87c6 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/io/NodeCodec.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/NodeCodec.java @@ -20,6 +20,7 @@ import java.util.Map; */ final class NodeCodec { static final int INLINE = 64; + private static final long NEG_ZERO = Double.doubleToRawLongBits(-0.0); private NodeCodec() {} @@ -54,7 +55,9 @@ final class NodeCodec { } private static Object doubles(double[] a) { - if (a.length <= INLINE) { + // JSON numbers lose the sign of -0.0 (imported models have it; it changes the built bytes) + if (a.length <= INLINE + && java.util.Arrays.stream(a).noneMatch(x -> Double.doubleToRawLongBits(x) == NEG_ZERO)) { List l = new ArrayList<>(a.length); for (double x : a) { l.add(x); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/SceneDiff.java b/src/main/java/com/eliasmueller/llamamod/editor/io/SceneDiff.java index 09cb41a..e6133d5 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/io/SceneDiff.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/SceneDiff.java @@ -7,6 +7,7 @@ import com.eliasmueller.llamamod.editor.model.NodeKind; import com.eliasmueller.llamamod.editor.model.NodeList; import com.eliasmueller.llamamod.editor.model.Prop; import com.eliasmueller.llamamod.editor.model.PropType; +import com.eliasmueller.llamamod.editor.model.ReadableNames; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.TexturePage; import com.eliasmueller.llamamod.editor.model.cmd.Values; @@ -15,6 +16,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -154,6 +156,8 @@ final class SceneDiff { Map nodes = (Map) json.getOrDefault("nodes", Map.of()); // 1. removals, 2. create added nodes (all kinds, so references resolve), 3. set values List>> fill = new ArrayList<>(); + // per-node problems, named once the level order is final (readable names are numbered by it) + List> nodeWarn = new ArrayList<>(); for (NodeKind k : Scene.LIST_KINDS) { Map d = (Map) nodes.get(k.prefix()); if (d == null) { @@ -162,7 +166,7 @@ final class SceneDiff { for (Object id : (List) d.getOrDefault("removed", List.of())) { Node n = s.node((String) id); if (n == null) { - warn.add("removed " + id + " does not exist in the base level"); + warn.add("The project removes " + aKind(k) + " that is not in the base level – ignored"); } else { s.remove(n); } @@ -171,7 +175,7 @@ final class SceneDiff { Map m = (Map) o; String id = (String) m.get("id"); if (s.node(id) != null) { - warn.add("added node " + id + " already exists – skipped"); + nodeWarn.add(Map.entry(s.node(id), "the project adds " + aKind(k) + " in its place – skipped")); continue; } BrushKind bk = k == NodeKind.BRUSH ? BrushKind.valueOf((String) m.get("brushKind")) : null; @@ -183,7 +187,7 @@ final class SceneDiff { for (Map.Entry e : ch.entrySet()) { Node n = s.node(e.getKey()); if (n == null) { - warn.add("changed " + e.getKey() + " does not exist in the base level"); + warn.add("The project changes " + aKind(k) + " that is not in the base level – skipped"); continue; } fill.add(Map.entry(n, (Map) e.getValue())); @@ -198,6 +202,12 @@ final class SceneDiff { for (Map.Entry> e : fill) { Node n = e.getKey(); Set known = new HashSet<>(List.of("id", "brushKind")); + // array runs are relative to the base value: read them all before a setter clears one + // (setting a model's vertices clears its header) + Map before = new HashMap<>(); + for (Prop p : n.props()) { + before.put(p.name(), p.get(n)); + } for (Prop p : n.props()) { known.add(p.name()); if (!e.getValue().containsKey(p.name()) || !editable(p)) { @@ -211,17 +221,17 @@ final class SceneDiff { && pm.containsKey("png")) { byte[] png = assets.get((String) pm.get("png")); if (png == null) { - warn.add(n.id() + ": page image " + pm.get("png") + " missing"); + nodeWarn.add(Map.entry(n, "page image " + pm.get("png") + " missing")); continue; } v = Images.pagePixels(png, pg.width(), pg.height()); } else { - v = NodeCodec.decode(p, j, s, p.get(n)); + v = NodeCodec.decode(p, j, s, before.get(p.name())); } try { p.setRaw(n, v); } catch (IllegalArgumentException ex) { - warn.add(n.id() + "." + p.name() + ": " + ex.getMessage()); + nodeWarn.add(Map.entry(n, propLabel(p) + ": " + ex.getMessage())); } } for (Map.Entry f : e.getValue().entrySet()) { @@ -254,5 +264,20 @@ final class SceneDiff { for (var st : s.strats()) { st.setTypeName(s.catalog().nameOf(st.code())); } + ReadableNames names = ReadableNames.of(s); + for (Map.Entry w : nodeWarn) { + Node n = w.getKey(); + warn.add((n.scene() == s ? names.nameOf(n) : ReadableNames.name(n)) + ": " + w.getValue()); + } + } + + /** "an object", "a tile model". */ + private static String aKind(NodeKind k) { + String l = k.label().toLowerCase(Locale.ROOT); + return ("aeiou".indexOf(l.charAt(0)) >= 0 ? "an " : "a ") + l; + } + + private static String propLabel(Prop p) { + return p.label() == null || p.label().isEmpty() ? p.name() : p.label(); } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java index 75c0886..4b579a2 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java @@ -52,6 +52,7 @@ final class MeshImportDialog extends JDialog { private final Preview preview = new Preview(); private final JLabel info = new JLabel(); private final JLabel warn = new JLabel(); + private final JLabel fitHint = GeoWidgets.hint(" "); private final JComboBox up = new JComboBox<>(new String[] {"Y up (glTF, most OBJ)", "Z up (some CAD exports)"}); private final JSpinner unit; @@ -100,6 +101,7 @@ final class MeshImportDialog extends JDialog { GeoWidgets.row(new JLabel("Up axis"), up), GeoWidgets.row(new JLabel("Unit scale (file units → cells)"), unit), GeoWidgets.row(new JLabel("Largest side"), fit, new JLabel("cells"), fitB), + fitHint, GeoWidgets.row(new JLabel("Turn"), turn, mirror), GeoWidgets.row(ground))); form.add(GeoWidgets.section( @@ -196,22 +198,50 @@ final class MeshImportDialog extends JDialog { if (m == null) { return; } - double[] b = m.bounds(); - double size = Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2])); - if (size <= 0) { - return; + double us = fitScale(((Number) unit.getValue()).doubleValue(), m.positions(), cells); + if (us > 0) { + unit.setValue(us); } - double us = ((Number) unit.getValue()).doubleValue() * cells / size; - unit.setValue(us); + } + + /** + * Unit scale that makes the largest side of the mesh's main mass ({@link Preview#focusBounds}) {@code + * cells} long; 0 for an empty or flat-point mesh. {@code pos} is in cells at the current {@code unitScale}. + */ + static double fitScale(double unitScale, double[] pos, double cells) { + double size = largestSide(Preview.focusBounds(pos)); + return size > 0 ? unitScale * cells / size : 0; + } + + private static double largestSide(double[] b) { + return Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2])); + } + + /** Hint shown under "Largest side" when far-off pieces are left out of Fit; empty otherwise. */ + static String fitHintText(MeshData m) { + double[] f = Preview.focusBounds(m.positions()); + double[] b = m.bounds(); + if (largestSide(f) >= largestSide(b) - 1e-9) { + return ""; + } + return String.format( + Locale.ROOT, + "Fit ignores far-off stray pieces: main part %.2f × %.2f × %.2f cells.", + f[3] - f[0], + f[4] - f[1], + f[5] - f[2]); } private void update() { MeshData m = asset.mesh(); if (m == null) { info.setText("(no mesh)"); + fitHint.setText(" "); return; } double[] b = m.bounds(); + String fh = fitHintText(m); + fitHint.setText(fh.isEmpty() ? " " : fh); int textured = 0; for (MeshData.Material mt : m.materialList()) { if (mt.image() != null) { @@ -444,7 +474,8 @@ final class MeshImportDialog extends JDialog { /** * Bounds {x0, y0, z0, x1, y1, z1} of the mesh's main mass: per axis the vertices within 2.5 - * interquartile ranges of the middle half (Tukey fences); the full bounds when they are not much + * interquartile ranges of the middle half (Tukey fences), minus a smaller vertex group beyond a wide + * gap (a stray triangle on a low-poly model); the full bounds when they are not much * larger (far-off pieces such as a stray triangle or a huge ground plane would otherwise shrink the * model to a dot). */ @@ -473,6 +504,30 @@ final class MeshImportDialog extends JDialog { while (v[hi] > q3 + fence) { hi--; } + // low-poly models: a stray piece can hold a quarter of the vertices and pass the fences – + // cut off the smaller side of a wide gap (fewer than half the vertices of the other side) + for (int pass = 0; pass < 8 && hi > lo; pass++) { + int at = lo; + for (int i = lo; i < hi; i++) { + if (v[i + 1] - v[i] > v[at + 1] - v[at]) { + at = i; + } + } + double gap = v[at + 1] - v[at]; + int left = at - lo + 1, right = hi - at; + double keep = left >= right ? v[at] - v[lo] : v[hi] - v[at + 1]; + if (gap <= 0.5 * (v[hi] - v[lo]) + || keep <= 0 + || gap <= 2 * keep + || 2 * Math.min(left, right) > Math.max(left, right)) { + break; + } + if (left >= right) { + hi = at; + } else { + lo = at + 1; + } + } robust[axis] = v[lo]; robust[axis + 3] = v[hi]; far |= v[n - 1] - v[0] > 1.5 * (robust[axis + 3] - robust[axis]) + 1e-9; diff --git a/src/test/java/com/eliasmueller/llamamod/editor/io/ProjectRoundTripTest.java b/src/test/java/com/eliasmueller/llamamod/editor/io/ProjectRoundTripTest.java index b0fc060..caae38c 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/io/ProjectRoundTripTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/io/ProjectRoundTripTest.java @@ -150,4 +150,23 @@ class ProjectRoundTripTest { assertEquals(0, q.scene().tiles().size()); assertTrue(q.scene().listOf(NodeKind.STRAT).isEmpty()); } + + /** + * An imported levelbuild WAD as an edit project (llamamod --editor-import): model headers change by + * runs while the vertices change too – the vertex setter clears the header, the runs still apply. + */ + @Test + void importedLevelbuildWadLoadsBack() throws IOException { + Bases cd = cd(); + com.eliasmueller.llamamod.levelbuild.LevelBuilder lb = new com.eliasmueller.llamamod.levelbuild.LevelBuilder( + cd.wad("t1l1m002"), rel -> null, "Start", 1, Map.of()); + lb.floor(6, 6, 30, 30, 1.0, "grass"); + lb.player(16, 12, 0); + Project p = new Project( + Templates.original(cd, "t1l1m002"), WadImporter.importWad(lb.build(), "t1l1m002", cd::fileOrNull)); + byte[] wad = WadBuilder.build(p.scene()); + Project q = ProjectIO.read(ProjectIO.write(p, cd), cd); + assertTrue(q.warnings().isEmpty(), q.warnings().toString()); + assertArrayEquals(wad, WadBuilder.build(q.scene())); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/io/SceneDiffWarningsTest.java b/src/test/java/com/eliasmueller/llamamod/editor/io/SceneDiffWarningsTest.java new file mode 100644 index 0000000..165322c --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/io/SceneDiffWarningsTest.java @@ -0,0 +1,39 @@ +package com.eliasmueller.llamamod.editor.io; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.Scene; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SceneDiffWarningsTest { + @Test + void loadWarningsUseReadableNamesNotIds() { + Scene s = new Scene(); + s.add(Scene.createWithId(NodeKind.LIGHT, "light:1", null), -1); + s.add(Scene.createWithId(NodeKind.LIGHT, "light:2", null), -1); + Map json = Map.of( + "nodes", + Map.of( + "light", + Map.of( + "removed", List.of("light:99"), + "added", List.of(Map.of("id", "light:2")), + "changed", Map.of("light:77", Map.of())))); + List warn = new ArrayList<>(); + SceneDiff.apply(s, json, Map.of(), new SceneDiff.Unknown(), warn); + assertEquals( + List.of( + "The project removes a light that is not in the base level – ignored", + "The project changes a light that is not in the base level – skipped", + "Light 2: the project adds a light in its place – skipped"), + warn); + for (String w : warn) { + assertFalse(w.contains("light:"), w); + } + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PaletteAndPreviewTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PaletteAndPreviewTest.java index 2263f6f..387a21f 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PaletteAndPreviewTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PaletteAndPreviewTest.java @@ -53,4 +53,47 @@ class PaletteAndPreviewTest { double[] g = MeshImportDialog.Preview.focusBounds(small); assertArrayEquals(new double[] {0, 0, 0, 2, 1, 2}, g, 1e-9); } + + @Test + void fitUsesTheMainMassAndHintsAtStrayPieces() { + // a 1-cell cube of 200 vertices plus one stray vertex 500 cells away + double[] pos = new double[3 * 201]; + for (int i = 0; i < 200; i++) { + pos[3 * i] = (i % 2); + pos[3 * i + 1] = (i / 2 % 2); + pos[3 * i + 2] = (i / 4 % 2); + } + pos[600] = 500; + // largest side of the main part is 1 cell: Fit to 4 cells scales by 4, not by 4/500 + assertEquals(4 * 0.5, MeshImportDialog.fitScale(0.5, pos, 4), 1e-9); + int[] tris = new int[] {0, 1, 2}; + var stray = new com.eliasmueller.llamamod.editor.model.MeshData( + pos, tris, new double[0], new int[1], new int[0], java.util.List.of()); + assertTrue(MeshImportDialog.fitHintText(stray).startsWith("Fit ignores far-off stray pieces")); + double[] cube = java.util.Arrays.copyOf(pos, 600); + assertEquals(2, MeshImportDialog.fitScale(1, cube, 2), 1e-9); + var clean = new com.eliasmueller.llamamod.editor.model.MeshData( + cube, tris, new double[0], new int[1], new int[0], java.util.List.of()); + assertEquals("", MeshImportDialog.fitHintText(clean)); + assertEquals(0, MeshImportDialog.fitScale(1, new double[0], 2)); + // low-poly: a 2×1×3 box (8 vertices) and a stray triangle 400 cells away (3 of 11 vertices) + double[] low = new double[3 * 11]; + for (int i = 0; i < 8; i++) { + low[3 * i] = i % 2 * 2; + low[3 * i + 1] = i / 2 % 2; + low[3 * i + 2] = i / 4 * 3; + } + low[24] = 400; + low[27] = 401; + low[30] = 400; + low[31] = 1; + assertArrayEquals(new double[] {0, 0, 0, 2, 1, 3}, MeshImportDialog.Preview.focusBounds(low), 1e-9); + assertEquals(4.0 / 3, MeshImportDialog.fitScale(1, low, 4), 1e-9); + // a wedge (6 vertices on x = 0, 2 on x = 1) is not cut + double[] wedge = {0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 2, 0, 0, 2, 1, 1, 0, 0, 1, 0, 1}; + assertArrayEquals(new double[] {0, 0, 0, 1, 2, 1}, MeshImportDialog.Preview.focusBounds(wedge), 1e-9); + // two equal towers far apart stay together + double[] towers = {0, 0, 0, 1, 5, 1, 0, 5, 0, 1, 0, 1, 20, 0, 0, 21, 5, 1, 20, 5, 0, 21, 0, 1}; + assertArrayEquals(new double[] {0, 0, 0, 21, 5, 1}, MeshImportDialog.Preview.focusBounds(towers), 1e-9); + } }