diff --git a/src/main/java/math/Color.java b/src/main/java/math/Color.java index c6b0ffab..cc1613bf 100644 --- a/src/main/java/math/Color.java +++ b/src/main/java/math/Color.java @@ -171,9 +171,9 @@ public Color add(Color color) { /** * Adds the components of a given color to those of this color storing the - * result in the given result color. Each component is added separately. If the - * provided color c is null, an exception is thrown. If the provided result - * color is null, a new color is created. + * result in the given result color. Each component is added separately. If + * the provided color c is null, an exception is thrown. If the provided + * result color is null, a new color is created. * * @param color the color to add to this color * @param result the color to store the result in @@ -190,8 +190,8 @@ public Color add(Color color, Color result) { } /** - * Adds the given r,g,b,a components to those of this color creating a new color - * object. Each component is added separately. + * Adds the given r,g,b,a components to those of this color creating a new + * color object. Each component is added separately. * * @param r the red component to add * @param g the green component to add @@ -204,8 +204,8 @@ public Color add(float r, float g, float b, float a) { } /** - * Adds the color c to this color internally, and returns a handle to this color - * for easy chaining of calls. Each component is added separately. + * Adds the color c to this color internally, and returns a handle to this + * color for easy chaining of calls. Each component is added separately. * * @param color the color to add to this color * @return this @@ -238,9 +238,9 @@ public Color addLocal(float r, float g, float b, float a) { } /** - * Subtracts the components of a given color from those of this color creating a - * new color object. Each component is subtracted separately. If the provided - * color is null, an exception is thrown. + * Subtracts the components of a given color from those of this color creating + * a new color object. Each component is subtracted separately. If the + * provided color is null, an exception is thrown. * * @param color the color to subtract from this color * @return the resultant color @@ -270,8 +270,8 @@ public Color subtract(Color color, Color result) { } /** - * * Subtracts the given r,g,b,a components from those of this color creating a - * new color object. Each component is subtracted separately. + * * Subtracts the given r,g,b,a components from those of this color creating + * a new color object. Each component is subtracted separately. * * @param r the red component to subtract * @param g the green component to subtract @@ -347,8 +347,8 @@ public Color clampLocal() { } /** - * Sets all components of this color to 0.0f internally, and returns a handle to - * this color for easy chaining of calls. + * Sets all components of this color to 0.0f internally, and returns a handle + * to this color for easy chaining of calls. * * @return this */ @@ -368,8 +368,8 @@ public float maxComponent() { } /** - * Returns a new float array containing the r,g,b,a components of this color in - * that order. + * Returns a new float array containing the r,g,b,a components of this color + * in that order. * * @return the components of this color as array */ @@ -378,8 +378,8 @@ public float[] toArray() { } /** - * Stores the r,g,b,a components in the given array. If the provided store array - * is null a new array is created to store the components in. + * Stores the r,g,b,a components in the given array. If the provided store + * array is null a new array is created to store the components in. * * @param store the array to store the components into * @return store @@ -508,12 +508,14 @@ public int getRGBA() { int g = getGreenInt(); int b = getBlueInt(); int a = getAlphaInt(); - return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | ((b & 0xFF) << 0); + return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) + | ((b & 0xFF) << 0); } /** - * Returns a unique hash code for this color object based on it's values. If two - * colors are logically equivalent, they will return the same hash code value. + * Returns a unique hash code for this color object based on it's values. If + * two colors are logically equivalent, they will return the same hash code + * value. * * @return the hash code value of this color */ @@ -544,9 +546,9 @@ public boolean equals(Object obj) { return false; Color other = (Color) obj; return Float.floatToIntBits(r) == Float.floatToIntBits(other.r) - && Float.floatToIntBits(g) == Float.floatToIntBits(other.g) - && Float.floatToIntBits(b) == Float.floatToIntBits(other.b) - && Float.floatToIntBits(a) == Float.floatToIntBits(other.a); + && Float.floatToIntBits(g) == Float.floatToIntBits(other.g) + && Float.floatToIntBits(b) == Float.floatToIntBits(other.b) + && Float.floatToIntBits(a) == Float.floatToIntBits(other.a); } /** diff --git a/src/main/java/math/Mathf.java b/src/main/java/math/Mathf.java index 6f815973..70c06d16 100644 --- a/src/main/java/math/Mathf.java +++ b/src/main/java/math/Mathf.java @@ -113,7 +113,8 @@ public class Mathf { /** * The Tribonacci constant, often denoted as t, is the real root of the cubic - * equation x³ - x² - x - 1 = 0. It is approximately equal to 1.83928675521416. + * equation x³ - x² - x - 1 = 0. It is approximately equal to + * 1.83928675521416. */ public static final float TRIBONACCI_CONSTANT = 1.83928675521416f; @@ -121,20 +122,21 @@ public class Mathf { * Converts a 2D index (row, column) into a 1D index for a matrix or array. * *

- * This method is useful when working with matrices or arrays that are stored in - * a 1D array. It calculates the 1D index corresponding to the specified row and - * column in a matrix with the given number of columns. + * This method is useful when working with matrices or arrays that are stored + * in a 1D array. It calculates the 1D index corresponding to the specified + * row and column in a matrix with the given number of columns. * * @param rowIndex The zero-based index of the row. * @param colIndex The zero-based index of the column. * @param numberOfColumns The total number of columns in the matrix. * @return The 1D index corresponding to the given row and column. * - * @throws IllegalArgumentException if `rowIndex` or `colIndex` is negative, or - * if `numberOfColumns` is less than or equal - * to zero. + * @throws IllegalArgumentException if `rowIndex` or `colIndex` is negative, + * or if `numberOfColumns` is less than or + * equal to zero. */ - public static int toOneDimensionalIndex(int rowIndex, int colIndex, int numberOfColumns) { + public static int toOneDimensionalIndex(int rowIndex, int colIndex, + int numberOfColumns) { if (rowIndex < 0 || colIndex < 0) throw new IllegalArgumentException(); @@ -228,8 +230,8 @@ public static float min(float a, float b) { * Returns the maximum float value in the given array. * * @param values The array of float values. - * @return The maximum value in the array, or {@link Float#NaN} if the array is - * empty. + * @return The maximum value in the array, or {@link Float#NaN} if the array + * is empty. */ public static float max(float... values) { if (values.length == 0) @@ -245,8 +247,8 @@ public static float max(float... values) { * Returns the minimum float value in the given array. * * @param values The array of float values. - * @return The minimum value in the array, or {@link Float#NaN} if the array is - * empty. + * @return The minimum value in the array, or {@link Float#NaN} if the array + * is empty. */ public static float min(float... values) { if (values.length == 0) @@ -274,8 +276,8 @@ public static int roundToInt(float a) { * *

* This method rounds the given float value to the nearest integer. If the - * fractional part is 0.5 or greater, the value is rounded up. Otherwise, it is - * rounded down. + * fractional part is 0.5 or greater, the value is rounded up. Otherwise, it + * is rounded down. * * @param a The float value to be rounded. * @return The rounded float value. @@ -305,13 +307,12 @@ public static float clamp(float a, float min, float max) { * @return The clamped value. */ public static int clampInt(int a, int min, int max) { - a = a < min ? min : (a > max ? max : a); - return a; + return a < min ? min : (a > max ? max : a); } /** - * Clamps the given float value to be between 0 and 1. This method is equivalent - * to {@link #saturate(float)}. + * Clamps the given float value to be between 0 and 1. This method is + * equivalent to {@link #saturate(float)}. * * @param a The value to clamp. * @return A clamped value between 0 and 1- @@ -369,8 +370,8 @@ public static float abs(float a) { * Returns the trigonometric tangent of an angle. Special cases: *

* * @param a A value. @@ -633,8 +635,8 @@ public static float log10(float a) { *
  • If the argument is positive zero or negative zero, then the result is * negative infinity.
  • * - * The computed result must be within 1 ulp of the exact result. Results must be - * semi-monotonic. + * The computed result must be within 1 ulp of the exact result. Results must + * be semi-monotonic. * * @param a A value. * @return The value ln a, the natural logarithm of a. @@ -644,18 +646,18 @@ public static float log(float a) { } /** - * Returns the largest (closest to positive infinity) integer value that is less - * than or equal to the argument. + * Returns the largest (closest to positive infinity) integer value that is + * less than or equal to the argument. * * * @param a A value. - * @return The largest (closest to positive infinity) integer value that is less - * than or equal to the argument. + * @return The largest (closest to positive infinity) integer value that is + * less than or equal to the argument. */ public static int floorToInt(float a) { return (int) Math.floor((double) a); @@ -666,8 +668,8 @@ public static int floorToInt(float a) { * * @param a The value to ceil. * @return The smallest (closest to negative infinity) integer value that is - * greater than or equal to the argument and is equal to a mathematical - * integer. + * greater than or equal to the argument and is equal to a + * mathematical integer. */ public static int ceilToInt(float a) { return (int) Math.ceil((double) a); @@ -675,8 +677,8 @@ public static int ceilToInt(float a) { /** * Linearly interpolates between a and b by t. The parameter t is not clamped - * and values outside the range [0, 1] will result in a return value outside the - * range [a, /b/]. + * and values outside the range [0, 1] will result in a return value outside + * the range [a, /b/]. * *
     	 * When t = 0 returns a.
    @@ -694,8 +696,8 @@ public static float lerpUnclamped(float a, float b, float t) {
     	}
     
     	/**
    -	 * Linearly interpolates between from and to by t. The parameter t is clamped to
    -	 * the range [0, 1].
    +	 * Linearly interpolates between from and to by t. The parameter t is clamped
    +	 * to the range [0, 1].
     	 * 
     	 * 
     	 * When t = 0 returns a. 
    @@ -734,8 +736,8 @@ public static int nextPowerOfTwo(int value) {
     
     	/**
     	 * Smoothly interpolates between two values. This function provides a smoother
    -	 * transition between the two values compared to linear interpolation. It uses a
    -	 * cubic Hermite spline to achieve a smooth curve.
    +	 * transition between the two values compared to linear interpolation. It uses
    +	 * a cubic Hermite spline to achieve a smooth curve.
     	 * 
     	 * @param from The starting value.
     	 * @param to   The ending value.
    @@ -795,16 +797,17 @@ public static float randomFloat() {
     	}
     
     	/**
    -	 * Calculates a smooth, oscillating value between 0 and `length` over time `t`.
    +	 * Calculates a smooth, oscillating value between 0 and `length` over time
    +	 * `t`.
     	 *
    -	 * This function is commonly used in game development to create various effects,
    -	 * such as character movement, object animations, camera effects, and particle
    -	 * systems.
    +	 * This function is commonly used in game development to create various
    +	 * effects, such as character movement, object animations, camera effects, and
    +	 * particle systems.
     	 *
     	 * The function works by repeating the input time `t` over an interval of
    -	 * `length * 2`, and then calculating the distance between the repeated time and
    -	 * the midpoint `length`. This distance is then subtracted from `length` to
    -	 * produce the final oscillating value.
    +	 * `length * 2`, and then calculating the distance between the repeated time
    +	 * and the midpoint `length`. This distance is then subtracted from `length`
    +	 * to produce the final oscillating value.
     	 *
     	 * @param t      The input time.
     	 * @param length The desired range of oscillation.
    @@ -816,7 +819,8 @@ public static float pingPong(float t, float length) {
     	}
     
     	/**
    -	 * Normalizes an angle to a specific range centered around a given center angle.
    +	 * Normalizes an angle to a specific range centered around a given center
    +	 * angle.
     	 *
     	 * This method ensures that the returned angle is within a specific range,
     	 * typically between -π and π or 0 and 2π.
    @@ -832,14 +836,14 @@ public static float normalizeAngle(float a, float center) {
     	/**
     	 * Wraps a value cyclically within a specified range.
     	 *
    -	 * This method takes a value `t` and maps it to a value within the interval [0,
    -	 * length). The value is repeatedly decreased by `length` until it becomes less
    -	 * than `length`. This creates a cyclic effect, where the value continuously
    -	 * cycles from 0 to `length` and then back to 0.
    +	 * This method takes a value `t` and maps it to a value within the interval
    +	 * [0, length). The value is repeatedly decreased by `length` until it becomes
    +	 * less than `length`. This creates a cyclic effect, where the value
    +	 * continuously cycles from 0 to `length` and then back to 0.
     	 *
    -	 * **Example:** For `t = 12` and `length = 5`, the result is: - `floor(12 / 5) =
    -	 * 2` (number of full cycles) - `2 * 5 = 10` (value exceeding the range) - `12 -
    -	 * 10 = 2` (the returned value)
    +	 * **Example:** For `t = 12` and `length = 5`, the result is: - `floor(12 / 5)
    +	 * = 2` (number of full cycles) - `2 * 5 = 10` (value exceeding the range) -
    +	 * `12 - 10 = 2` (the returned value)
     	 *
     	 * @param t      The value to be wrapped.
     	 * @param length The length of the interval within which the value is wrapped.
    @@ -852,18 +856,18 @@ public static float repeat(float t, float length) {
     	/**
     	 * Determines if two floating-point numbers are approximately equal.
     	 *
    -	 * This method compares two floating-point numbers, `a` and `b`, considering the
    -	 * limited precision of floating-point numbers. It accounts for both relative
    -	 * and absolute tolerances to provide a robust comparison method.
    +	 * This method compares two floating-point numbers, `a` and `b`, considering
    +	 * the limited precision of floating-point numbers. It accounts for both
    +	 * relative and absolute tolerances to provide a robust comparison method.
     	 * 
     	 * **How it works:** 1. **Calculates absolute difference:** The absolute
     	 * difference between `a` and `b` is calculated. 2. **Determines relative
     	 * tolerance:** The larger of the two absolute values of `a` and `b` is
    -	 * multiplied by a small factor (e.g., 1e-6) to obtain a relative tolerance. 3.
    -	 * **Determines absolute tolerance:** A small fixed value (e.g., `FLT_EPSILON *
    -	 * 8`) is set as the absolute tolerance. 4. **Comparison:** The absolute
    -	 * difference is compared with the larger of the two tolerances. If the
    -	 * difference is smaller, the numbers are considered approximately equal.
    +	 * multiplied by a small factor (e.g., 1e-6) to obtain a relative tolerance.
    +	 * 3. **Determines absolute tolerance:** A small fixed value (e.g.,
    +	 * `FLT_EPSILON * 8`) is set as the absolute tolerance. 4. **Comparison:** The
    +	 * absolute difference is compared with the larger of the two tolerances. If
    +	 * the difference is smaller, the numbers are considered approximately equal.
     	 * 
     	 * **Why such a method is necessary:** Due to the limited precision of
     	 * floating-point numbers, small rounding errors can occur, causing two
    @@ -879,8 +883,8 @@ public static boolean approximately(float a, float b) {
     	}
     
     	/**
    -	 * Clamps the given float value to be between 0 and 1. This method is equivalent
    -	 * to {@link #clamp01(float)}.
    +	 * Clamps the given float value to be between 0 and 1. This method is
    +	 * equivalent to {@link #clamp01(float)}.
     	 * 
     	 * @param a The value to clamp.
     	 * @return A clamped between 0 and 1.
    @@ -922,7 +926,8 @@ public static int closestPowerOfTwo(int value) {
     	 *
     	 * @throws IllegalArgumentException if `from0 == to0` or `from1 == to1`.
     	 */
    -	public static float map(float value, float from0, float to0, float from1, float to1) {
    +	public static float map(float value, float from0, float to0, float from1,
    +	    float to1) {
     		if (from0 == to0 || from1 == to1) {
     			throw new IllegalArgumentException("Invalid input ranges");
     		}
    diff --git a/src/main/java/mesh/Mesh3D.java b/src/main/java/mesh/Mesh3D.java
    index 6ce39e7c..adf62b36 100644
    --- a/src/main/java/mesh/Mesh3D.java
    +++ b/src/main/java/mesh/Mesh3D.java
    @@ -25,8 +25,8 @@ public Mesh3D() {
     	}
     
     	/**
    -	 * Applies the provided {@link IMeshModifier} to this mesh. This is congruent to
    -	 * {@link IMeshModifier#modify(Mesh3D)}.
    +	 * Applies the provided {@link IMeshModifier} to this mesh. This is congruent
    +	 * to {@link IMeshModifier#modify(Mesh3D)}.
     	 * 
     	 * @param modifier The modifier to apply to this mesh.
     	 * @return this
    @@ -82,52 +82,56 @@ public Mesh3D translateZ(float tz) {
     	}
     
     	/**
    -	 * Calculates the axis-aligned bounding box (AABB) for the 3D mesh based on its
    -	 * vertices.
    +	 * Calculates the axis-aligned bounding box (AABB) for the 3D mesh based on
    +	 * its vertices.
     	 * 

    * The bounding box is defined by the minimum and maximum extents of the - * vertices along the X, Y, and Z axes. If there are no vertices in the mesh, an - * empty `Bounds3` is returned. + * vertices along the X, Y, and Z axes. If there are no vertices in the mesh, + * an empty `Bounds3` is returned. *

    * - * @return A {@link Bounds3} object representing the calculated bounding box of - * the mesh. The bounding box extends from the minimum vertex coordinate - * to the maximum vertex coordinate. + * @return A {@link Bounds3} object representing the calculated bounding box + * of the mesh. The bounding box extends from the minimum vertex + * coordinate to the maximum vertex coordinate. */ - public Bounds3 calculateBounds() { - if (vertices.isEmpty()) - return new Bounds3(); - - Vector3f min = new Vector3f(getVertexAt(0)); - Vector3f max = new Vector3f(getVertexAt(0)); - Bounds3 bounds = new Bounds3(); - for (Vector3f v : vertices) { - float minX = v.getX() < min.getX() ? v.getX() : min.getX(); - float minY = v.getY() < min.getY() ? v.getY() : min.getY(); - float minZ = v.getZ() < min.getZ() ? v.getZ() : min.getZ(); - float maxX = v.getX() > max.getX() ? v.getX() : max.getX(); - float maxY = v.getY() > max.getY() ? v.getY() : max.getY(); - float maxZ = v.getZ() > max.getZ() ? v.getZ() : max.getZ(); - min.set(minX, minY, minZ); - max.set(maxX, maxY, maxZ); - } - bounds.setMinMax(min, max); - return bounds; - } + public Bounds3 calculateBounds() { + if (vertices.isEmpty()) + return new Bounds3(); + + Vector3f min = new Vector3f(getVertexAt(0)); + Vector3f max = new Vector3f(getVertexAt(0)); + Bounds3 bounds = new Bounds3(); + for (Vector3f v : vertices) { + float minX = v.getX() < min.getX() ? v.getX() : min.getX(); + float minY = v.getY() < min.getY() ? v.getY() : min.getY(); + float minZ = v.getZ() < min.getZ() ? v.getZ() : min.getZ(); + float maxX = v.getX() > max.getX() ? v.getX() : max.getX(); + float maxY = v.getY() > max.getY() ? v.getY() : max.getY(); + float maxZ = v.getZ() > max.getZ() ? v.getZ() : max.getZ(); + min.set(minX, minY, minZ); + max.set(maxX, maxY, maxZ); + } + bounds.setMinMax(min, max); + return bounds; + } public Vector3f calculateFaceNormal(Face3D face) { Vector3f faceNormal = new Vector3f(); for (int i = 0; i < face.indices.length; i++) { Vector3f currentVertex = vertices.get(face.indices[i]); - Vector3f nextVertex = vertices.get(face.indices[(i + 1) % face.indices.length]); - float x = (currentVertex.getY() - nextVertex.getY()) * (currentVertex.getZ() + nextVertex.getZ()); - float y = (currentVertex.getZ() - nextVertex.getZ()) * (currentVertex.getX() + nextVertex.getX()); - float z = (currentVertex.getX() - nextVertex.getX()) * (currentVertex.getY() + nextVertex.getY()); + Vector3f nextVertex = vertices + .get(face.indices[(i + 1) % face.indices.length]); + float x = (currentVertex.getY() - nextVertex.getY()) + * (currentVertex.getZ() + nextVertex.getZ()); + float y = (currentVertex.getZ() - nextVertex.getZ()) + * (currentVertex.getX() + nextVertex.getX()); + float z = (currentVertex.getX() - nextVertex.getX()) + * (currentVertex.getY() + nextVertex.getY()); faceNormal.addLocal(x, y, z); } return faceNormal.normalize(); } - + public void removeDoubles(int decimalPlaces) { for (Vector3f v : vertices) v.roundLocalDecimalPlaces(decimalPlaces); diff --git a/src/main/java/mesh/modifier/BevelEdgesModifier.java b/src/main/java/mesh/modifier/BevelEdgesModifier.java index 18bf63de..b1fbb0ca 100644 --- a/src/main/java/mesh/modifier/BevelEdgesModifier.java +++ b/src/main/java/mesh/modifier/BevelEdgesModifier.java @@ -17,11 +17,7 @@ public class BevelEdgesModifier implements IMeshModifier { public enum WidthType { - OFFSET, - - WIDTH, - - DEPTH, + OFFSET, WIDTH, DEPTH } @@ -37,14 +33,11 @@ public enum WidthType { private List verticesToAdd; - private HashMap oldEdgeToNewEdge; - - private HashSet processed; + private EdgeProcessor edgeProcessor; public BevelEdgesModifier(float amount) { setAmount(amount); - processed = new HashSet<>(); - oldEdgeToNewEdge = new HashMap<>(); + edgeProcessor = new EdgeProcessor(); verticesToAdd = new ArrayList(); facesToAdd = new ArrayList(); } @@ -55,81 +48,26 @@ public BevelEdgesModifier() { @Override public Mesh3D modify(Mesh3D mesh) { - if (amount == 0) + validateMesh(mesh); + if (canExitEarly(mesh)) { return mesh; + } + setMesh(mesh); clearAll(); + createInsetFaces(); + createFacesForOldEdges(); createFacesVertex(); + clearOriginalFaces(); clearOriginalVertices(); + addNewVertices(); addNewFaces(); - return mesh; - } - - private void createInsetFaces() { - for (Face3D face : mesh.faces) - insetFace(mesh, face); - } - - private void insetFace(Mesh3D mesh, Face3D face) { - int nextVertexIndex = verticesToAdd.size(); - int[] indices = createIndices(face.indices.length, nextVertexIndex); - List vertices = new ArrayList(); - extracted(face, vertices); - extracted(vertices); - mapOldEdgesToNewEdges(face, indices); - addNewFace(indices); - } - - private void extracted(Face3D face, List vertices) { - for (int i = 0; i < face.indices.length; i++) { - Vector3f from = getVertexAt(face, i); - Vector3f to = getVertexAt(face, i + 1); - - float distance = to.distance(from); - float a = 1 / distance * getAmountByWidthType(); - - Vector3f v4 = to.subtract(from).mult(a).add(from); - Vector3f v5 = to.add(to.subtract(from).mult(-a)); - vertices.add(v4); - vertices.add(v5); - } - } - - private float getAmountByWidthType() { - float a; - switch (widthType) { - case OFFSET: - // amount is offset of new edges from original - a = amount * 2; - break; - case WIDTH: - // amount is width of new faces - a = inset; - break; - case DEPTH: - a = inset * 2; - break; - default: - // default width type offset - a = amount * 2; - break; - } - return a; - } - - private void extracted(List vertices) { - for (int i = 1; i < vertices.size(); i += 2) { - int a = vertices.size() - 2 + i; - Vector3f v0 = vertices.get(a % vertices.size()); - Vector3f v1 = vertices.get((a + 1) % vertices.size()); - Vector3f v = v1.add(v0).mult(0.5f); - verticesToAdd.add(v); - } + return mesh; } private void createFacesVertex() { @@ -139,7 +77,7 @@ private void createFacesVertex() { Edge3D edge = outgoingEdge; List indices = new ArrayList(); do { - Edge3D newEdge = oldEdgeToNewEdge.get(edge); + Edge3D newEdge = edgeProcessor.getMappedEdge(edge); int index = newEdge.fromIndex; indices.add(index); edge = helper.getPairNext(edge.fromIndex, edge.toIndex); @@ -155,33 +93,46 @@ private void createFacesForOldEdges() { } private void createFaceForOldEdgeAt(Face3D face, int i) { - Edge3D edge = getMappedEdge(createEdgeAt(face.indices, i)); - Edge3D pair = getMappedEdge(createEdgeAt(face.indices, i).createPair()); + Edge3D edge = edgeProcessor + .getMappedEdge(edgeProcessor.createEdge(face.indices, i)); + Edge3D pair = edgeProcessor + .getMappedEdge(edgeProcessor.createEdge(face.indices, i).createPair()); - if (isProcessed(edge) || isProcessed(pair)) + if (edgeProcessor.isProcessed(edge) || edgeProcessor.isProcessed(pair)) return; - addNewFace(new int[] { edge.toIndex, edge.fromIndex, pair.toIndex, - pair.fromIndex }); + createFaceForEdge(edge, pair); - markAsProcessed(edge); - markAsProcessed(pair); + edgeProcessor.markProcessed(edge); + edgeProcessor.markProcessed(pair); } - private void mapOldEdgesToNewEdges(Face3D face, int[] indices) { - for (int i = 0; i < indices.length; i++) { - Edge3D oldEdge = createEdgeAt(face.indices, i); - Edge3D newEdge = createEdgeAt(indices, i); - oldEdgeToNewEdge.put(oldEdge, newEdge); - } + private void createFaceForEdge(Edge3D edge, Edge3D pair) { + addNewFace(edge.toIndex, edge.fromIndex, pair.toIndex, pair.fromIndex); } - private Edge3D getMappedEdge(Edge3D edge) { - return oldEdgeToNewEdge.get(edge); + private int[] toReverseArray(List values) { + Collections.reverse(values); + return values.stream().mapToInt(x -> x).toArray(); } - private Edge3D createEdgeAt(int[] indices, int i) { - return new Edge3D(indices[i], indices[(i + 1) % indices.length]); + private void clearAll() { + edgeProcessor.clearAll(); + verticesToAdd.clear(); + facesToAdd.clear(); + } + + private void createInsetFaces() { + for (Face3D face : mesh.faces) + insetFace(mesh, face); + } + + private void insetFace(Mesh3D mesh, Face3D face) { + int nextVertexIndex = verticesToAdd.size(); + int[] indices = createIndices(face.indices.length, nextVertexIndex); + createInsetVertices(processFaceEdges(face)); + edgeProcessor.mapOldEdgesToNewEdges(face, indices); + addNewFace(indices); } private int[] createIndices(int size, int nextVertexIndex) { @@ -191,24 +142,75 @@ private int[] createIndices(int size, int nextVertexIndex) { return indices; } - private int[] toReverseArray(List values) { - return values.stream().sorted(Collections.reverseOrder()).mapToInt(x -> x) - .toArray(); + /** + * Processes the edges of a face to calculate the new inset vertices. + * + * @param face the face to process. + * @return a list of inset vertices. + */ + private List processFaceEdges(Face3D face) { + List vertices = new ArrayList<>(); + for (int i = 0; i < face.indices.length; i++) { + Vector3f from = getVertexAt(face, i); + Vector3f to = getVertexAt(face, i + 1); + + float edgeLength = to.distance(from); + float insetFactor = calculateInsetFactor(edgeLength); + + Vector3f v4 = to.subtract(from).mult(insetFactor).add(from); + Vector3f v5 = to.add(to.subtract(from).mult(-insetFactor)); + + vertices.add(v4); + vertices.add(v5); + } + return vertices; } - private void clearAll() { - processed.clear(); - oldEdgeToNewEdge.clear(); - verticesToAdd.clear(); - facesToAdd.clear(); + /** + * Creates the inset vertices from the processed edge vertices. + * + * @param vertices the processed edge vertices. + */ + private void createInsetVertices(List vertices) { + for (int i = 1; i < vertices.size(); i += 2) { + int a = vertices.size() - 2 + i; + Vector3f v0 = vertices.get(a % vertices.size()); + Vector3f v1 = vertices.get((a + 1) % vertices.size()); + Vector3f v = v1.add(v0).mult(0.5f); + verticesToAdd.add(v); + } } - private void markAsProcessed(Edge3D edge) { - processed.add(edge); + /** + * Calculates the inset factor based on the edge length and + * #{@link WidthType}. + * + * @param edgeLength the length of the edge. + * @return the inset factor. + */ + private float calculateInsetFactor(float edgeLength) { + return edgeLength > 0 ? (1f / edgeLength) * getAmountByWidthType() : 0f; } - private boolean isProcessed(Edge3D edge) { - return processed.contains(edge); + private float getAmountByWidthType() { + float amount; + switch (widthType) { + case OFFSET -> amount = this.amount * 2; + case WIDTH -> amount = inset; + case DEPTH -> amount = inset * 2; + default -> amount = this.amount * 2; + } + return amount; + } + + private boolean canExitEarly(Mesh3D mesh) { + return amount == 0 || mesh.faces.isEmpty(); + } + + private void validateMesh(Mesh3D mesh) { + if (mesh == null) { + throw new IllegalArgumentException("Mesh cannot be null."); + } } private Vector3f getVertexAt(Face3D face, int index) { @@ -231,7 +233,7 @@ private void clearOriginalFaces() { mesh.faces.clear(); } - private void addNewFace(int[] indices) { + private void addNewFace(int... indices) { facesToAdd.add(new Face3D(indices)); } @@ -256,4 +258,46 @@ public void setWidthType(WidthType widthType) { this.widthType = widthType; } + private class EdgeProcessor { + + private HashSet processed; + + private HashMap edgeMapping; + + public EdgeProcessor() { + processed = new HashSet(); + edgeMapping = new HashMap(); + } + + public void mapOldEdgesToNewEdges(Face3D face, int[] indices) { + for (int i = 0; i < indices.length; i++) { + Edge3D oldEdge = createEdge(face.indices, i); + Edge3D newEdge = createEdge(indices, i); + edgeMapping.put(oldEdge, newEdge); + } + } + + public Edge3D getMappedEdge(Edge3D edge) { + return edgeMapping.get(edge); + } + + public void markProcessed(Edge3D edge) { + processed.add(edge); + } + + public boolean isProcessed(Edge3D edge) { + return processed.contains(edge); + } + + public Edge3D createEdge(int[] indices, int i) { + return new Edge3D(indices[i], indices[(i + 1) % indices.length]); + } + + public void clearAll() { + processed.clear(); + edgeMapping.clear(); + } + + } + } diff --git a/src/main/java/mesh/modifier/ExtrudeModifier.java b/src/main/java/mesh/modifier/ExtrudeModifier.java index 7a3a7bee..d75cd463 100644 --- a/src/main/java/mesh/modifier/ExtrudeModifier.java +++ b/src/main/java/mesh/modifier/ExtrudeModifier.java @@ -26,14 +26,36 @@ */ public class ExtrudeModifier implements IMeshModifier, FaceModifier { + /** + * The default scaling factor applied during extrusion. Defaults to 1.0, which + * means no scaling. + */ private static final float DEFAULT_SCALE = 1.0f; + /** + * The default extrusion amount applied to faces. Defaults to 0.0, meaning + * extrusion with a dinstance of zero. + */ private static final float DEFAULT_AMOUNT = 0.0f; + /** + * Flag indicating whether the original faces should be removed after + * extrusion. If true, the original faces are removed; if false, they remain + * in the mesh. + */ private boolean removeFaces; + /** + * The scaling factor applied to the extruded geometry. A value of 1.0 + * maintains the original size of the faces, while values greater or less than + * 1.0 scale the extruded faces proportionally. + */ private float scale; + /** + * The distance to extrude faces by. Positive values extrude outward along the + * normal of the face, while negative values extrude inward. + */ private float amount; /** @@ -161,8 +183,12 @@ private void extrudeFace(Mesh3D mesh, Face3D face) { Vector3f center = mesh.calculateFaceCenter(face); for (int i = 0; i < n; i++) { - Vector3f vertex = mesh.vertices.get(face.indices[i]).subtract(center) - .mult(scale).add(center).add(normal.mult(amount)); + Vector3f vertex = mesh.vertices.get( + face.indices[i]) + .subtract(center) + .mult(scale) + .add(center) + .add(normal.mult(amount)); mesh.add(vertex); mesh.addFace(face.indices[i], face.indices[(i + 1) % n], nextIndex + ((i + 1) % n), nextIndex + i); diff --git a/src/main/java/mesh/modifier/HolesModifier.java b/src/main/java/mesh/modifier/HolesModifier.java index 3edc1b40..51d60431 100644 --- a/src/main/java/mesh/modifier/HolesModifier.java +++ b/src/main/java/mesh/modifier/HolesModifier.java @@ -1,27 +1,73 @@ package mesh.modifier; +import java.util.ArrayList; import java.util.Collection; import mesh.Face3D; import mesh.Mesh3D; +/** + * The {@code HolesModifier} class modifies a 3D mesh by creating holes in its + * faces. This is achieved by insetting specified faces to a percentage of their + * original size, effectively leaving holes where the faces used to be. + * + *
    + *  
    + * The behavior is defined as follows:
    + * - A `holePercentage` of 0.0 leaves the face unchanged (no hole).
    + * - A `holePercentage` of 1.0 removes the face entirely
    + *      (hole consumes the full face area).
    + * - Values between 0.0 and 1.0 create proportionally smaller holes,
    + *      with the face scaled to (1 - holePercentage).
    + *      
    + * Key features:
    + * - Adjusts faces to create holes based on a specified percentage.
    + * - Can work with the entire mesh, specific collections of faces, or a 
    + *   single face.
    + * 
    + */ public class HolesModifier implements IMeshModifier, FaceModifier { + /** + * The percentage of the face's size to retain after creating the hole. A + * value of 0.0 leaves the face unchanged, while hole consumes the full face + * area with a value of 1.0. + */ private float holePercentage; + /** + * Default constructor initializes the modifier with a hole percentage of 0.5 + * (50%). + */ public HolesModifier() { this.holePercentage = 0.5f; } + /** + * Constructs the {@code HolesModifier} with a specified hole percentage. + * + * @param holePercentage The percentage of the face's size to retain after + * creating the hole. Must be between 0.0 and 1.0, + * inclusive. + * @throws IllegalArgumentException if the percentage is outside the valid + * range. + */ public HolesModifier(float holePercentage) { - this.holePercentage = holePercentage; + setHolePercentage(holePercentage); } + /** + * Modifies the given mesh to create holes by insetting it's faces to a + * specified percentage of their original size. + * + * @see #HolesModifier() + * @param mesh The mesh to modify. + * @return The modified mesh. + * @throws IllegalArgumentException if the mesh is null. + */ @Override public Mesh3D modify(Mesh3D mesh) { - if (mesh == null) { - throw new IllegalArgumentException("Mesh cannot be null."); - } + validateMesh(mesh); return modify(mesh, mesh.getFaces()); } @@ -29,15 +75,7 @@ public Mesh3D modify(Mesh3D mesh) { * Modifies the given mesh to create holes by insetting the specified faces to * a specified percentage of their original size. * - *
    -	 * The behavior is defined as follows:
    -	 * - A `holePercentage` of 0.0 leaves the face unchanged (no hole).
    -	 * - A `holePercentage` of 1.0 removes the face entirely
    -	 *      (hole consumes the full face area).
    -	 * - Values between 0.0 and 1.0 create proportionally smaller holes,
    -	 *      with the face scaled to (1 - holePercentage).
    -	 * 
    - * + * @see #HolesModifier() * @param mesh The mesh to modify. * @param faces The faces to be inset. * @return The modified mesh. @@ -45,31 +83,47 @@ public Mesh3D modify(Mesh3D mesh) { */ @Override public Mesh3D modify(Mesh3D mesh, Collection faces) { - if (mesh == null) { - throw new IllegalArgumentException("Mesh cannot be null."); - } - if (faces == null) { - throw new IllegalArgumentException("Faces cannot be null."); - } - if (mesh.faces.isEmpty() || holePercentage == 0) { + validateMesh(mesh); + validateFaces(faces); + if (canExitEarly(mesh)) { return mesh; } - createExtrudeModifier().modify(mesh, faces); + Collection facesToModify = faces; + if (faces == mesh.faces) { + facesToModify = new ArrayList(mesh.faces); + } + createExtrudeModifier().modify(mesh, facesToModify); return mesh; } + /** + * Modifies the given mesh to create holes by insetting the specified face to + * a specified percentage of it's original size. + * + * @see #HolesModifier() + * @param mesh The mesh to modify. + * @param face The face to be inset. + * @return The modified mesh. + * @throws IllegalArgumentException if the mesh or face are null. + */ @Override public Mesh3D modify(Mesh3D mesh, Face3D face) { - if (mesh == null) { - throw new IllegalArgumentException("Mesh cannot be null."); - } - if (face == null) { - throw new IllegalArgumentException("Face cannot be null."); + validateMesh(mesh); + validateFace(face); + if (canExitEarly(mesh)) { + return mesh; } createExtrudeModifier().modify(mesh, face); return mesh; } + /** + * Creates an instance of {@code ExtrudeModifier} configured to create holes. + * The modifier will scale faces based on the specified hole percentage, with + * no extrusion applied, and will remove the original faces. + * + * @return A configured {@code ExtrudeModifier} instance. + */ private ExtrudeModifier createExtrudeModifier() { ExtrudeModifier modifier = new ExtrudeModifier(); modifier.setScale(holePercentage); @@ -78,14 +132,76 @@ private ExtrudeModifier createExtrudeModifier() { return modifier; } + /** + * Determines whether the modification can exit early based on the current + * mesh state and hole percentage. + * + * @param mesh The mesh to evaluate for early exit. + * @return {@code true} if the modification can exit early (e.g., the mesh has + * no faces or the hole percentage is zero), {@code false} otherwise. + */ + private boolean canExitEarly(Mesh3D mesh) { + return mesh.faces.isEmpty() || holePercentage == 0; + } + + /** + * Validates that the face is not null. + * + * @param face the face to validate. + * @throws IllegalArgumentException if the face is null. + */ + private void validateFace(Face3D face) { + if (face == null) { + throw new IllegalArgumentException("Face cannot be null."); + } + } + + /** + * Validates that the mesh is not null. + * + * @param mesh the mesh to validate. + * @throws IllegalArgumentException if the mesh is null. + */ + private void validateMesh(Mesh3D mesh) { + if (mesh == null) { + throw new IllegalArgumentException("Mesh cannot be null."); + } + } + + /** + * Validates that the faces collection is not null. + * + * @param faces the collection of faces to validate. + * @throws IllegalArgumentException if the collection is null. + */ + private void validateFaces(Collection faces) { + if (faces == null) { + throw new IllegalArgumentException("Faces cannot be null."); + } + } + + /** + * Gets the current percentage of the face's size retained after creating the + * hole. + * + * @return The hole percentage. + */ public float getHolePercentage() { return holePercentage; } + /** + * Sets the percentage of the face's size to retain after creating the hole. + * + * @param holePercentage The new hole percentage to set. Must be between 0.0 + * and 1.0, inclusive. + * @throws IllegalArgumentException if the percentage is outside the valid + * range. + */ public void setHolePercentage(float holePercentage) { if (holePercentage < 0 || holePercentage > 1) { throw new IllegalArgumentException( - "Hole percentage must be between 0 and 1."); + "Hole percentage must be between 0 and 1 inclusive."); } this.holePercentage = holePercentage; } diff --git a/src/main/java/mesh/modifier/InsetModifier.java b/src/main/java/mesh/modifier/InsetModifier.java index a77d205c..5e69bcd0 100644 --- a/src/main/java/mesh/modifier/InsetModifier.java +++ b/src/main/java/mesh/modifier/InsetModifier.java @@ -10,65 +10,143 @@ import mesh.Mesh3D; /** - * Renamed process face to inset face + * The {@code InsetModifier} modifies a mesh by applying an inset operation to + * its faces. Insetting creates smaller, inset faces inside the original faces, + * producing a beveled or framed effect. This modifier supports applying the + * inset operation to all faces in a mesh, a specific collection of faces, or a + * single face. + *

    + * The inset factor determines how far the vertices of the new face are moved + * inward, based on the edges' lengths of the original face. + *

    */ public class InsetModifier implements IMeshModifier, FaceModifier { + /** + * The default inset factor applied if no custom value is specified. This + * value determines the default distance vertices are moved inward when the + * inset operation is performed. + */ private static final float DEFAULT_INSET = 0.1f; + /** + * The index for the next available vertex in the mesh. This value is used to + * keep track of where to insert new vertices during the inset operation. + */ private int nextIndex; + /** + * The inset factor that controls the distance vertices are moved inward + * during the inset operation. A higher value results in a deeper inset, while + * a smaller value results in a shallower inset. + */ private float inset; + /** + * The mesh being modified by the {@code InsetModifier}. This field is set + * during the modification process and stores the reference to the mesh that + * is being operated upon. + */ private Mesh3D mesh; + /** + * Creates an {@code InsetModifier} with the default inset factor (0.1). + */ public InsetModifier() { this(DEFAULT_INSET); } + /** + * Creates an {@code InsetModifier} with the specified inset factor. + * + * @param inset the inset factor, controlling the distance vertices are moved + * inward. + */ public InsetModifier(float inset) { this.inset = inset; } + /** + * Modifies the entire mesh by applying the inset operation to all its faces. + * + * @param mesh the {@code Mesh3D} to modify. + * @return the modified mesh. + * @throws IllegalArgumentException if the mesh is null. + */ @Override public Mesh3D modify(Mesh3D mesh) { - if (mesh == null) { - throw new IllegalArgumentException("Mesh cannot be null."); + validateMesh(mesh); + if (mesh.faces.isEmpty()) { + return mesh; } modify(mesh, mesh.getFaces()); return mesh; } + /** + * Modifies the specified collection of faces in the mesh by applying the + * inset operation. + * + * @param mesh the {@code Mesh3D} to modify. + * @param faces the collection of {@code Face3D} instances to modify. + * @return the modified mesh. + * @throws IllegalArgumentException if the mesh or faces are null. + */ @Override public Mesh3D modify(Mesh3D mesh, Collection faces) { - if (mesh == null) { - throw new IllegalArgumentException("Mesh cannot be null."); - } - if (faces == null) { - throw new IllegalArgumentException("Faces cannot be null."); + validateMesh(mesh); + validateFaces(faces); + Collection facesToModify = faces; + if (faces == mesh.faces) { + facesToModify = new ArrayList(mesh.faces); } setMesh(mesh); - for (Face3D face : faces) { + for (Face3D face : facesToModify) { insetFace(face); } return mesh; } + /** + * Modifies a single face in the mesh by applying the inset operation. + * + * @param mesh the {@code Mesh3D} containing the face. + * @param face the {@code Face3D} to modify. + * @return the modified mesh. + * @throws IllegalArgumentException if the mesh or face is null. + */ @Override public Mesh3D modify(Mesh3D mesh, Face3D face) { - if (mesh == null) { - throw new IllegalArgumentException("Mesh cannot be null."); - } - if (face == null) { - throw new IllegalArgumentException("Face cannot be null."); - } + validateMesh(mesh); + validateFace(face); setMesh(mesh); insetFace(face); return mesh; } + /** + * Applies the inset operation to a single face, creating inset vertices and + * updating the face structure. + * + * @param face the face to modify. + */ + private void insetFace(Face3D face) { + updateNextIndex(); + createInsetVertices(processFaceEdges(face)); + for (int i = 0; i < face.getVertexCount(); i++) { + createFaceAt(face, i); + } + replaceOriginalFaceWithInsetFace(face); + } + + /** + * Processes the edges of a face to calculate the new inset vertices. + * + * @param face the face to process. + * @return a list of inset vertices. + */ private List processFaceEdges(Face3D face) { - List verts = new ArrayList<>(); + List vertices = new ArrayList<>(); for (int i = 0; i < face.indices.length; i++) { int index0 = face.indices[i]; int index1 = face.indices[(i + 1) % face.indices.length]; @@ -82,27 +160,17 @@ private List processFaceEdges(Face3D face) { Vector3f v4 = v1.subtract(v0).mult(insetFactor).add(v0); Vector3f v5 = v1.add(v1.subtract(v0).mult(-insetFactor)); - verts.add(v4); - verts.add(v5); - } - return verts; - } - - private void insetFace(Face3D face) { - updateNextIndex(); - createInsetVertices(processFaceEdges(face)); - for (int i = 0; i < face.getVertexCount(); i++) { - createFaceAt(face, i); - } - replaceOriginalFaceWithInsetFace(face); - } - - private void replaceOriginalFaceWithInsetFace(Face3D face) { - for (int i = 0; i < face.getVertexCount(); i++) { - face.indices[i] = nextIndex + i; + vertices.add(v4); + vertices.add(v5); } + return vertices; } + /** + * Creates the inset vertices from the processed edge vertices. + * + * @param vertices the processed edge vertices. + */ private void createInsetVertices(List vertices) { for (int i = 1; i < vertices.size(); i += 2) { int a = vertices.size() - 2 + i; @@ -113,6 +181,24 @@ private void createInsetVertices(List vertices) { } } + /** + * Replaces the original face with the inset face. + * + * @param face the face to replace. + */ + private void replaceOriginalFaceWithInsetFace(Face3D face) { + for (int i = 0; i < face.getVertexCount(); i++) { + face.indices[i] = nextIndex + i; + } + } + + /** + * Creates a new face at the specified index using the original and inset + * vertices. + * + * @param face the original face. + * @param i the index of the vertex to process. + */ private void createFaceAt(Face3D face, int i) { int n = face.indices.length; int index0 = face.indices[i]; @@ -122,22 +208,82 @@ private void createFaceAt(Face3D face, int i) { mesh.addFace(index0, index1, index2, index3); } + /** + * Validates that the faces collection is not null. + * + * @param faces the collection of faces to validate. + * @throws IllegalArgumentException if the collection is null. + */ + private void validateFaces(Collection faces) { + if (faces == null) { + throw new IllegalArgumentException("Faces cannot be null."); + } + } + + /** + * Validates that the mesh is not null. + * + * @param mesh the mesh to validate. + * @throws IllegalArgumentException if the mesh is null. + */ + private void validateMesh(Mesh3D mesh) { + if (mesh == null) { + throw new IllegalArgumentException("Mesh cannot be null."); + } + } + + /** + * Validates that the face is not null. + * + * @param face the face to validate. + * @throws IllegalArgumentException if the face is null. + */ + private void validateFace(Face3D face) { + if (face == null) { + throw new IllegalArgumentException("Face cannot be null."); + } + } + + /** + * Calculates the inset factor based on the edge length. + * + * @param edgeLength the length of the edge. + * @return the inset factor. + */ private float calculateInsetFactor(float edgeLength) { return edgeLength > 0 ? (1f / edgeLength) * inset : 0f; } + /** + * Updates the next available vertex index. + */ private void updateNextIndex() { nextIndex = mesh.vertices.size(); } + /** + * Sets the mesh to be modified. + * + * @param mesh the mesh to set. + */ private void setMesh(Mesh3D mesh) { this.mesh = mesh; } + /** + * Retrieves the inset factor. + * + * @return the inset factor. + */ public float getInset() { return inset; } + /** + * Sets the inset factor. + * + * @param inset the inset factor to set. + */ public void setInset(float inset) { this.inset = inset; } diff --git a/src/main/java/mesh/modifier/PseudoWireframeModifier.java b/src/main/java/mesh/modifier/PseudoWireframeModifier.java index 89893ad1..86101538 100644 --- a/src/main/java/mesh/modifier/PseudoWireframeModifier.java +++ b/src/main/java/mesh/modifier/PseudoWireframeModifier.java @@ -16,14 +16,34 @@ */ public class PseudoWireframeModifier implements IMeshModifier { + /** + * Default hole percentage used for the pseudo-wireframe effect. A value of + * 0.9 means 90% of the face will be converted into a hole. + */ private static final float DEFAULT_HOLE_PECENTAGE = 0.9f; + /** + * Default thickness used for solidifying the mesh. A value of 0.02 defines + * the thickness of the solidified areas. + */ private static final float DEFAULT_THICKNESS = 0.02f; + /** + * The percentage of the face area to be converted into holes. Must be a value + * between 0 and 1. + */ private float holePercentage; + /** + * The thickness of the solidified mesh after the hole-creation step. Must be + * greater than zero. + */ private float thickness; + /** + * The current 3D mesh being modified. This is set during the modification + * process. + */ private Mesh3D mesh; /** diff --git a/src/main/java/mesh/modifier/RandomHolesModifier.java b/src/main/java/mesh/modifier/RandomHolesModifier.java index 9340ac2c..5fbd1cef 100644 --- a/src/main/java/mesh/modifier/RandomHolesModifier.java +++ b/src/main/java/mesh/modifier/RandomHolesModifier.java @@ -7,89 +7,287 @@ import mesh.Face3D; import mesh.Mesh3D; +/** + * A mesh modifier that creates random holes in the given 3D mesh by extruding + * and removing specified faces. The size of the holes is determined randomly as + * a percentage of the original face size within a defined range. + *

    + * This modifier supports modifying all faces, a single face, or a subset of + * faces in a 3D mesh. + *

    + */ public class RandomHolesModifier implements IMeshModifier, FaceModifier { + /** + * The default minimum amount for the hole size as a percentage of the face + * area. + */ + private static final float DEFAULT_MIN_AMOUNT = 0.1f; + + /** + * The default maximum amount for the hole size as a percentage of the face + * area. + */ + private static final float DEFAULT_MAX_AMOUNT = 0.9f; + + /** + * The minimum amount for the hole size as a percentage of the face area. + */ private float minAmount; + /** + * The maximum amount for the hole size as a percentage of the face area. + */ private float maxAmount; + /** + * The seed for the random number generator used to determine hole sizes. + */ private long seed; + /** + * A random number generator used to calculate random hole sizes. + */ private Random random; + /** + * An {@link ExtrudeModifier} used to create the holes by extruding and + * removing faces. + */ private ExtrudeModifier modifier; + /** + * Creates a new RandomHolesModifier with the default minimum and maximum hole + * percentages. + *

    + * Default values: + *

      + *
    • Minimum hole percentage: 10% (0.1)
    • + *
    • Maximum hole percentage: 90% (0.9)
    • + *
    + *

    + */ public RandomHolesModifier() { - this(0.1f, 0.9f); + this(DEFAULT_MIN_AMOUNT, DEFAULT_MAX_AMOUNT); + validateAmountRange(DEFAULT_MIN_AMOUNT, DEFAULT_MAX_AMOUNT); } + /** + * Creates a new RandomHolesModifier with specified minimum and maximum hole + * percentages. + * + * @param minAmount the minimum size of a hole as a percentage of the original + * face size. Must be in the range [0, 1]. + * @param maxAmount the maximum size of a hole as a percentage of the original + * face size. Must be in the range [0, 1] and greater than or + * equal to {@code minAmount}. + * @throws IllegalArgumentException if {@code minAmount} or {@code maxAmount} + * are out of range or if + * {@code minAmount > maxAmount}. + */ public RandomHolesModifier(float minAmount, float maxAmount) { + validateAmountRange(minAmount, maxAmount); this.minAmount = minAmount; this.maxAmount = maxAmount; - this.random = new Random(seed); + this.random = new Random(); this.modifier = new ExtrudeModifier(); } + /** + * Modifies the entire mesh by creating random holes in all its faces. + * + * @param mesh the mesh to modify. + * @return the modified mesh. + * @throws IllegalArgumentException if the mesh is {@code null}. + */ @Override public Mesh3D modify(Mesh3D mesh) { + validateMesh(mesh); return modify(mesh, new ArrayList(mesh.faces)); } + /** + * Modifies a single face in the mesh by creating a random hole. + * + * @param mesh the mesh containing the face. + * @param face the face to modify. + * @return the modified mesh. + * @throws IllegalArgumentException if the mesh or face is {@code null}. + */ @Override public Mesh3D modify(Mesh3D mesh, Face3D face) { - if (mesh == null || face == null) - throw new IllegalArgumentException(); - + validateMesh(mesh); + validateFace(face); makeHole(mesh, face); - mesh.removeFace(face); return mesh; } + /** + * Modifies a collection of faces in the mesh by creating random holes. + * + * @param mesh the mesh containing the faces. + * @param faces the faces to modify. + * @return the modified mesh. + * @throws IllegalArgumentException if the mesh or faces are {@code null}. + */ @Override public Mesh3D modify(Mesh3D mesh, Collection faces) { - if (mesh == null || faces == null) - throw new IllegalArgumentException(); - - for (Face3D face : faces) + validateMesh(mesh); + validateFaces(faces); + Collection facesToModify = faces; + if (faces == mesh.faces) { + facesToModify = new ArrayList(mesh.faces); + } + for (Face3D face : facesToModify) { makeHole(mesh, face); - - mesh.faces.removeAll(faces); + } return mesh; } + /** + * Creates a hole by extruding (inset) and removing the specified face. + * + * @param mesh the mesh containing the face. + * @param face the face to modify. + */ private void makeHole(Mesh3D mesh, Face3D face) { float amount = createRandomAmount(); modifier.setScale(amount); + modifier.setRemoveFaces(true); modifier.modify(mesh, face); } + /** + * Generates a random hole percentage within the range defined by + * {@code minAmount} and {@code maxAmount}. + * + * @return a random hole percentage. + */ private float createRandomAmount() { return minAmount + random.nextFloat() * (maxAmount - minAmount); } + /** + * Validates that the given mesh is not null. + * + * @param mesh the {@link Mesh3D} instance to validate. + * @throws IllegalArgumentException if {@code mesh} is null. + */ + private void validateMesh(Mesh3D mesh) { + if (mesh == null) { + throw new IllegalArgumentException("Mesh cannot be null."); + } + } + + /** + * Validates that the given collection of faces is not null. + * + * @param faces a collection of {@link Face3D} instances to validate. + * @throws IllegalArgumentException if {@code faces} is null. + */ + private void validateFaces(Collection faces) { + if (faces == null) { + throw new IllegalArgumentException("Faces cannot be null."); + } + } + + /** + * Validates that the given face is not null. + * + * @param face the {@link Face3D} instance to validate. + * @throws IllegalArgumentException if {@code face} is null. + */ + private void validateFace(Face3D face) { + if (face == null) { + throw new IllegalArgumentException("Face cannot be null."); + } + } + + /** + * Gets the minimum hole percentage. + * + * @return the minimum hole percentage. + */ public float getMinAmount() { return minAmount; } + /** + * Sets the minimum hole percentage. + * + * @param minAmount the minimum hole percentage. Must be in the range [0, 1]. + * @throws IllegalArgumentException if {@code minAmount} is out of range or + * greater than the current + * {@code maxAmount}. + */ public void setMinAmount(float minAmount) { + validateAmountRange(minAmount, maxAmount); this.minAmount = minAmount; } + /** + * Gets the maximum hole percentage. + * + * @return the maximum hole percentage. + */ public float getMaxAmount() { return maxAmount; } + /** + * Sets the maximum hole percentage. + * + * @param maxAmount the maximum hole percentage. Must be in the range [0, 1]. + * @throws IllegalArgumentException if {@code maxAmount} is out of range or + * less than the current {@code minAmount}. + */ public void setMaxAmount(float maxAmount) { + validateAmountRange(minAmount, maxAmount); this.maxAmount = maxAmount; } + /** + * Validates that the given range of amounts is within acceptable bounds. + *

    + * The method checks that the {@code minAmount} and {@code maxAmount} values + * satisfy the following conditions: + *

      + *
    • {@code minAmount} is greater than or equal to 0.
    • + *
    • {@code maxAmount} is less than or equal to 1.
    • + *
    • {@code minAmount} is less than or equal to {@code maxAmount}.
    • + *
    + *

    + * + * @param minAmount the minimum hole percentage to validate. + * @param maxAmount the maximum hole percentage to validate. + * @throws IllegalArgumentException if the range does not satisfy the + * conditions: + * {@code 0 <= minAmount <= maxAmount <= 1}. + */ + private void validateAmountRange(float minAmount, float maxAmount) { + if (minAmount < 0 || maxAmount > 1 || minAmount > maxAmount) { + throw new IllegalArgumentException( + "Amounts must satisfy: 0 <= minAmount <= maxAmount <= 1."); + } + } + + /** + * Gets the current random seed. + * + * @return the random seed. + */ public long getSeed() { return seed; } + /** + * Sets a new random seed. + * + * @param seed the new seed value. + */ public void setSeed(long seed) { this.seed = seed; - random = new Random(seed); + this.random = new Random(seed); } } diff --git a/src/main/java/mesh/modifier/ShearModifier.java b/src/main/java/mesh/modifier/ShearModifier.java new file mode 100644 index 00000000..7cefd734 --- /dev/null +++ b/src/main/java/mesh/modifier/ShearModifier.java @@ -0,0 +1,159 @@ +package mesh.modifier; + +import math.Vector3f; +import mesh.Mesh3D; + +/** + * A modifier that applies a shear transformation to a 3D mesh. The shear effect + * distorts the mesh along a specified axis, creating a slanted or skewed + * appearance. + * + * The transformation is applied to each vertex of the mesh based on the + * specified shear axis and factor. + */ +public class ShearModifier implements IMeshModifier { + + /** + * Represents the axis and plane along which the shear is applied. + * + *
    +	 * - XY: Shear along the X-axis based on the Y-coordinate.
    +	 * - XZ: Shear along the X-axis based on the Z-coordinate.
    +	 * - YX: Shear along the Y-axis based on the X-coordinate.
    +	 * - YZ: Shear along the Y-axis based on the Z-coordinate.
    +	 * - ZX: Shear along the Z-axis based on the X-coordinate.
    +	 * - ZY: Shear along the Z-axis based on the Y-coordinate.
    +	 * 
    + */ + public enum ShearAxis { + XY, XZ, YX, YZ, ZX, ZY + } + + /** + * The factor by which the mesh is sheared. + */ + private float shearFactor; + + /** + * The axis along which the shear transformation is applied. + */ + private ShearAxis axis; + + /** + * Constructs a ShearModifier with the specified shear axis and factor. + * + * @param axis the axis along which the shear transformation is applied + * @param shearFactor the factor by which the mesh is sheared + * @throws IllegalArgumentException if axis is null + */ + public ShearModifier(ShearAxis axis, float shearFactor) { + if (axis == null) { + throw new IllegalArgumentException("Shear axis cannot be null."); + } + this.axis = axis; + this.shearFactor = shearFactor; + } + + /** + * Applies the shear transformation to the given mesh. + * + * @param mesh the mesh to be modified + * @return the modified mesh with the shear transformation applied + */ + @Override + public Mesh3D modify(Mesh3D mesh) { + validateMesh(mesh); + if (mesh.vertices.isEmpty()) { + return mesh; + } + applyShear(mesh); + return mesh; + } + + /** + * Applies the shear transformation to all vertices in the given mesh. + * + * This method uses a parallel stream to process the vertices, applying the + * shear transformation in a concurrent manner for improved performance on + * large meshes. The shear transformation is determined by the specified shear + * axis and factor. + * + * @param mesh the mesh whose vertices will be sheared + * @throws IllegalArgumentException if the mesh is null or contains null + * vertices + */ + private void applyShear(Mesh3D mesh) { + mesh.vertices.parallelStream().forEach(this::applyShearToVertex); + } + + /** + * Applies the shear transformation to a single vertex based on the specified + * shear axis and factor. + * + * @param vertex the vertex to modify + */ + private void applyShearToVertex(Vector3f vertex) { + switch (axis) { + case XY -> vertex.x += shearFactor * vertex.y; + case XZ -> vertex.x += shearFactor * vertex.z; + case YX -> vertex.y += shearFactor * vertex.x; + case YZ -> vertex.y += shearFactor * vertex.z; + case ZX -> vertex.z += shearFactor * vertex.x; + case ZY -> vertex.z += shearFactor * vertex.y; + default -> + throw new IllegalArgumentException("Unsupported shear axis: " + axis); + } + } + + /** + * Validates that the mesh is not null. + * + * @param mesh the mesh to validate. + * @throws IllegalArgumentException if the mesh is null. + */ + private void validateMesh(Mesh3D mesh) { + if (mesh == null) { + throw new IllegalArgumentException("Mesh cannot be null."); + } + } + + /** + * Gets the current shear axis. + * + * @return the shear axis + */ + public ShearAxis getAxis() { + return axis; + } + + /** + * Sets the shear axis. + * + * @param axis the shear axis to set + */ + public void setAxis(ShearAxis axis) { + if (axis == null) { + throw new IllegalArgumentException("Shear axis cannot be null."); + } + this.axis = axis; + } + + /** + * Gets the current shear factor. + * + * @return the shear factor + */ + public float getShearFactor() { + return shearFactor; + } + + /** + * Sets the shear factor. + * + * @param shearFactor the shear factor to set + */ + public void setShearFactor(float shearFactor) { + this.shearFactor = shearFactor; + } + +} \ No newline at end of file diff --git a/src/main/java/mesh/modifier/SnapToGroundModifier.java b/src/main/java/mesh/modifier/SnapToGroundModifier.java index 847b6944..f735f288 100644 --- a/src/main/java/mesh/modifier/SnapToGroundModifier.java +++ b/src/main/java/mesh/modifier/SnapToGroundModifier.java @@ -37,10 +37,19 @@ public class SnapToGroundModifier implements IMeshModifier { /** The mesh to be modified. */ private Mesh3D mesh; + /** + * Constructs a new SnapToGroundModifier with a default ground level of 0. + */ public SnapToGroundModifier() { this(0); } + /** + * Constructs a new SnapToGroundModifier with a specified ground level. + * + * @param groundLevel the vertical level to which the mesh's highest point + * should be snapped. + */ public SnapToGroundModifier(float groundLevel) { this.groundLevel = groundLevel; } diff --git a/src/main/java/mesh/modifier/SolidifyModifier.java b/src/main/java/mesh/modifier/SolidifyModifier.java index 77d4696c..a7956940 100644 --- a/src/main/java/mesh/modifier/SolidifyModifier.java +++ b/src/main/java/mesh/modifier/SolidifyModifier.java @@ -11,28 +11,68 @@ import mesh.util.FaceBridging; import mesh.util.VertexNormals; +/** + * A modifier that solidifies a 3D mesh by creating an inner mesh offset along + * vertex normals, and bridging the edges between the original and inner meshes. + *

    + * This modifier is commonly used in modeling workflows to add thickness to 2D + * surfaces or thin meshes. + *

    + * + *
    + * Workflow:
    + * 1. Creates a copy of the input mesh as the inner mesh.
    + * 2. Offsets the inner mesh vertices along their normals by the 
    + *    specified thickness.
    + * 3. Reverses the face directions of the inner mesh to ensure proper normals.
    + * 4. Bridges the edges between the original and inner mesh to create a
    + *    closed solid.
    + * 
    + */ public class SolidifyModifier implements IMeshModifier { + /** The thickness to apply when solidifying the mesh. */ private float thickness; + /** The original mesh to modify. */ private Mesh3D mesh; + /** The inner mesh created by offsetting the original mesh. */ private Mesh3D innerMesh; + /** The vertex normals of the original mesh. */ private List vertexNormals; + /** The edges of the original mesh. */ private HashSet edges; + /** The faces of the original mesh before modifications. */ private List originalFaces; + /** + * Creates a new SolidifyModifier with the default thickness of 0.01. + */ public SolidifyModifier() { this(0.01f); } + /** + * Creates a new SolidifyModifier with the specified thickness. + * + * @param thickness The thickness to apply when solidifying the mesh. + * @throws IllegalArgumentException If the thickness is negative. + */ public SolidifyModifier(float thickness) { this.thickness = thickness; } + /** + * Modifies the given mesh by solidifying it. + * + * @param mesh The mesh to modify. + * @return The solidified mesh. + * @throws IllegalArgumentException If the mesh is null. + */ @Override public Mesh3D modify(Mesh3D mesh) { setMesh(mesh); @@ -50,6 +90,10 @@ public Mesh3D modify(Mesh3D mesh) { return mesh; } + /** + * Creates the inner mesh by copying the original mesh, flipping its faces, + * and offsetting its vertices along their normals. + */ private void createInnerMesh() { initializeInnerMesh(); appendInnerMesh(); @@ -57,16 +101,26 @@ private void createInnerMesh() { moveInnerMeshAlongVertexNormals(); } + /** + * Appends the inner mesh to the original mesh. + */ private void appendInnerMesh() { mesh.append(innerMesh); } + /** + * Initializes data structures required for the modification process. + */ private void initialize() { initializeOriginalFaces(); initializeEdgeMap(); createVertexNormals(); } + /** + * Bridges the gaps (holes) between the original and inner meshes by creating + * faces along unshared edges. + */ private void bridgeHoles() { for (Face3D face : originalFaces) { int size = face.indices.length; @@ -80,6 +134,12 @@ private void bridgeHoles() { } } + /** + * Bridges a single hole between the original and inner meshes for the given + * edge. + * + * @param forwardEdge The edge of the face to bridge. + */ private void bridgeHole(Edge3D forwardEdge) { Vector3f v0 = innerMesh.getVertexAt(forwardEdge.fromIndex); Vector3f v1 = innerMesh.getVertexAt(forwardEdge.toIndex); @@ -88,6 +148,9 @@ private void bridgeHole(Edge3D forwardEdge) { FaceBridging.bridge(mesh, v0, v1, v2, v3); } + /** + * Maps all edges of the mesh and stores them in a hash set. + */ private void mapEdges() { for (Face3D face : mesh.faces) { for (int i = 0; i < face.indices.length; i++) { @@ -96,12 +159,23 @@ private void mapEdges() { } } + /** + * Creates an edge for the specified face at the given index. + * + * @param face The face containing the edge. + * @param i The index of the edge in the face. + * @return The edge created at the specified index. + */ private Edge3D createEdgeAt(Face3D face, int i) { int fromIndex = face.indices[i]; int toIndex = face.indices[(i + 1) % face.indices.length]; return new Edge3D(fromIndex, toIndex); } + /** + * Moves the vertices of the inner mesh along their normals by the specified + * thickness. + */ private void moveInnerMeshAlongVertexNormals() { IntStream.range(0, innerMesh.vertices.size()).parallel().forEach(i -> { Vector3f vertex = innerMesh.getVertexAt(i); @@ -110,10 +184,18 @@ private void moveInnerMeshAlongVertexNormals() { }); } + /** + * Reverses the direction of the faces in the inner mesh. + */ private void flipDirectionOfInnerMesh() { innerMesh.apply(new FlipFacesModifier()); } + /** + * Validates that the provided mesh is not null. + * + * @throws IllegalArgumentException If the mesh is null. + */ private void validateMesh() { if (mesh == null) { throw new IllegalArgumentException("Mesh cannot be null."); @@ -121,38 +203,71 @@ private void validateMesh() { } /** - * Determines if the modification process can be skipped due to zero thickness - * or an empty mesh (no vertices or faces). + * Determines if the modification process can be skipped due to trivial cases + * such as zero thickness or an empty mesh. + * + * @return True if the modification can be skipped, false otherwise. */ private boolean canExitEarly() { return (thickness == 0 || mesh.vertices.isEmpty() || mesh.faces.isEmpty()); } + /** + * Initializes the inner mesh by creating a copy of the original mesh. + */ private void initializeInnerMesh() { innerMesh = mesh.copy(); } + /** + * Initializes the edge map for tracking unique edges in the mesh. + */ private void initializeEdgeMap() { edges = new HashSet<>(); } + /** + * Initializes the list of original faces of the mesh. + */ private void initializeOriginalFaces() { originalFaces = mesh.getFaces(0, mesh.getFaceCount()); } + /** + * Computes the vertex normals for the mesh. + */ private void createVertexNormals() { vertexNormals = new VertexNormals(mesh).getVertexNormals(); } + /** + * Sets the mesh to be modified. + * + * @param mesh The mesh to set. + */ private void setMesh(Mesh3D mesh) { this.mesh = mesh; } + /** + * Retrieves the thickness of the solidification process. + * + * @return The thickness value. + */ public float getThickness() { return thickness; } + /** + * Sets the thickness for the solidification process. + * + * @param thickness The thickness to set. + * @throws IllegalArgumentException If the thickness is negative. + */ public void setThickness(float thickness) { + if (thickness < 0) { + throw new IllegalArgumentException("Thickness cannot be negative."); + } this.thickness = thickness; } diff --git a/src/main/java/workspace/examples/HelloSceneObject.java b/src/main/java/workspace/examples/HelloSceneObject.java index 077ad10a..05bb9e8e 100644 --- a/src/main/java/workspace/examples/HelloSceneObject.java +++ b/src/main/java/workspace/examples/HelloSceneObject.java @@ -10,44 +10,44 @@ public class HelloSceneObject extends PApplet { - public static void main(String[] args) { - PApplet.main(HelloSceneObject.class.getName()); - } - - Mesh3D mesh; - - Mesh3DRenderer renderer; - - Workspace workspace; - - @Override - public void settings() { - size(1000, 1000, P3D); - smooth(8); - } - - @Override - public void setup() { - renderer = new Mesh3DRenderer(this); - workspace = new Workspace(this); - workspace.setGridVisible(true); - createMesh(); - } - - @Override - public void draw() { - workspace.draw(mesh); - } - - public void createMesh() { - CubeCreator creator = new CubeCreator(); - mesh = creator.create(); - - SceneObject sceneObject = new SceneObject(); - sceneObject.setName("Object-1"); - sceneObject.setMesh(mesh); - sceneObject.setFillColor(new Color(255, 255, 0)); - workspace.addSceneObject(sceneObject); - } + public static void main(String[] args) { + PApplet.main(HelloSceneObject.class.getName()); + } + + Mesh3D mesh; + + Mesh3DRenderer renderer; + + Workspace workspace; + + @Override + public void settings() { + size(1000, 1000, P3D); + smooth(8); + } + + @Override + public void setup() { + renderer = new Mesh3DRenderer(this); + workspace = new Workspace(this); + workspace.setGridVisible(true); + createMesh(); + } + + @Override + public void draw() { + workspace.draw(mesh); + } + + public void createMesh() { + CubeCreator creator = new CubeCreator(); + mesh = creator.create(); + + SceneObject sceneObject = new SceneObject(); + sceneObject.setName("Object-1"); + sceneObject.setMesh(mesh); + sceneObject.setFillColor(new Color(255, 255, 0)); + workspace.addSceneObject(sceneObject); + } } diff --git a/src/main/java/workspace/examples/HelloWorkspace.java b/src/main/java/workspace/examples/HelloWorkspace.java index a82741cf..4352087f 100644 --- a/src/main/java/workspace/examples/HelloWorkspace.java +++ b/src/main/java/workspace/examples/HelloWorkspace.java @@ -7,36 +7,36 @@ public class HelloWorkspace extends PApplet { - public static void main(String[] args) { - PApplet.main(HelloWorkspace.class.getName()); - } - - Mesh3D mesh; - - Workspace workspace; - - @Override - public void settings() { - size(1000, 1000, P3D); - smooth(8); - } - - @Override - public void setup() { - workspace = new Workspace(this); - workspace.setGridVisible(true); - workspace.setUiVisible(true); - createMesh(); - } - - @Override - public void draw() { - workspace.draw(mesh); - } - - public void createMesh() { - SegmentedCubeCreator creator = new SegmentedCubeCreator(); - mesh = creator.create(); - } + public static void main(String[] args) { + PApplet.main(HelloWorkspace.class.getName()); + } + + Mesh3D mesh; + + Workspace workspace; + + @Override + public void settings() { + size(1000, 1000, P3D); + smooth(8); + } + + @Override + public void setup() { + workspace = new Workspace(this); + workspace.setGridVisible(true); + workspace.setUiVisible(true); + createMesh(); + } + + @Override + public void draw() { + workspace.draw(mesh); + } + + public void createMesh() { + SegmentedCubeCreator creator = new SegmentedCubeCreator(); + mesh = creator.create(); + } } diff --git a/src/test/java/bugs/BevelEdgesModifierIndexOrderRegressionTest.java b/src/test/java/bugs/BevelEdgesModifierIndexOrderRegressionTest.java new file mode 100644 index 00000000..af58c466 --- /dev/null +++ b/src/test/java/bugs/BevelEdgesModifierIndexOrderRegressionTest.java @@ -0,0 +1,89 @@ +package bugs; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import mesh.Face3D; +import mesh.Mesh3D; +import mesh.creator.platonic.IcosahedronCreator; +import mesh.modifier.BevelEdgesModifier; +import util.MeshTestUtil; + +/** + * Test to validate the bug fix in BevelEdgesModifier. + * + *

    + * Bug Summary: The {@code BevelEdgesModifier} produced incorrect face + * indices for pentagonal faces when applied to an icosphere. This issue was + * traced to the {@code toReverseArray} method, which incorrectly sorted indices + * in reverse order instead of merely reversing them. + *

    + * + *

    + * Purpose: Ensure the {@code BevelEdgesModifier} correctly generates + * pentagonal faces with the proper index order after the bug fix. + *

    + * + *

    + * Reproduction Details: + *

      + *
    • Create an icosphere using {@code IcosahedronCreator}.
    • + *
    • Apply the {@code BevelEdgesModifier} with a small amount (e.g., + * 0.1).
    • + *
    • Compare the indices of generated pentagonal faces with expected + * values.
    • + *
    + *

    + * + *

    + * Expected Behavior: The modifier produces pentagonal faces with index + * order matching the predefined {@code expected} array. + *

    + * + *

    + * Validation: This test confirms that the revised {@code toReverseArray} + * implementation resolves the issue, ensuring correct face generation for an + * icosphere. + *

    + */ +public class BevelEdgesModifierIndexOrderRegressionTest { + + private Mesh3D mesh; + + @BeforeEach + public void setUp() { + float amount = 0.1f; + BevelEdgesModifier modifier = new BevelEdgesModifier(amount); + mesh = new IcosahedronCreator().create(); + modifier.modify(mesh); + } + + @Test + public void test() { + int[][] expected = new int[][] { { 4, 1, 7, 10, 13 }, { 19, 2, 3, 15, 32 }, + { 22, 8, 0, 18, 35 }, { 25, 11, 6, 21, 38 }, { 28, 14, 9, 24, 41 }, + { 16, 5, 12, 27, 44 }, { 33, 20, 31, 45, 49 }, { 36, 23, 34, 48, 52 }, + { 39, 26, 37, 51, 55 }, { 42, 29, 40, 54, 58 }, { 46, 30, 17, 43, 57 }, + { 56, 53, 50, 47, 59 } }; + + int faceIndex = 0; + for (int j = 0; j < mesh.getFaceCount(); j++) { + Face3D face = mesh.getFaceAt(j); + if (face.indices.length == 5) { + int[] expectedIndices = expected[faceIndex]; + int[] actualIndices = face.indices; + assertArrayEquals(expectedIndices, actualIndices); + faceIndex++; + } + } + } + + @Test + public void testNormalsPointOutwards() { + assertTrue(MeshTestUtil.normalsPointOutwards(mesh)); + } + +}