From 1698e94bb23d4b5fccf4fc225f074731937b9a63 Mon Sep 17 00:00:00 2001 From: mrqsdf Date: Sat, 6 Dec 2025 19:28:36 +0100 Subject: [PATCH 1/2] Add Vector2 in api --- .../main/java/com/moud/api/math/Vector2.java | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 api/src/main/java/com/moud/api/math/Vector2.java diff --git a/api/src/main/java/com/moud/api/math/Vector2.java b/api/src/main/java/com/moud/api/math/Vector2.java new file mode 100644 index 00000000..f1685318 --- /dev/null +++ b/api/src/main/java/com/moud/api/math/Vector2.java @@ -0,0 +1,206 @@ +package com.moud.api.math; + +import org.graalvm.polyglot.HostAccess; + +public class Vector2 { + + @HostAccess.Export + public float x; + @HostAccess.Export + public float y; + + public Vector2() { + this.x = 0.0f; + this.y = 0.0f; + } + + public Vector2(float x, float y) { + this.x = x; + this.y = y; + } + + public Vector2(double x, double y) { + this.x = (float) x; + this.y = (float) y; + } + + public Vector2(Vector2 other) { + this.x = other.x; + this.y = other.y; + } + + @HostAccess.Export + public static Vector2 zero() { + return new Vector2(0.0f, 0.0f); + } + + @HostAccess.Export + public static Vector2 one() { + return new Vector2(1.0f, 1.0f); + } + + @HostAccess.Export + public Vector2 add(Vector2 other) { + return new Vector2(this.x + other.x, this.y + other.y); + } + + @HostAccess.Export + public Vector2 subtract(Vector2 other) { + return new Vector2(this.x - other.x, this.y - other.y); + } + + @HostAccess.Export + public Vector2 multiply(double scalar) { + return new Vector2(this.x * scalar, this.y * scalar); + } + + @HostAccess.Export + public Vector2 multiply(Vector2 other) { + return new Vector2(this.x * other.x, this.y * other.y); + } + + @HostAccess.Export + public Vector2 divide(double scalar) { + if (Math.abs(scalar) < MathUtils.EPSILON) { + throw new ArithmeticException("Division by zero or near-zero value"); + } + return new Vector2(this.x / scalar, this.y / scalar); + } + + @HostAccess.Export + public Vector2 divide(Vector2 other) { + return new Vector2(this.x / other.x, this.y / other.y); + } + + @HostAccess.Export + public Vector2 negate() { + return new Vector2(-this.x, -this.y); + } + + @HostAccess.Export + public float dot(Vector2 other) { + return this.x * other.x + this.y * other.y; + } + + @HostAccess.Export + public float length() { + return (float) Math.sqrt(x * x + y * y); + + } + + @HostAccess.Export + public float lengthSquared() { + return x * x + y * y; + } + + @HostAccess.Export + public Vector2 normalize() { + float len = length(); + if (len < MathUtils.EPSILON) { + return new Vector2(0.0f, 0.0f); + } + return new Vector2(x / len, y / len); + } + + @HostAccess.Export + public float distance(Vector2 other) { + return subtract(other).length(); + } + + @HostAccess.Export + public float distanceSquared(Vector2 other) { + return subtract(other).lengthSquared(); + } + + @HostAccess.Export + public Vector2 slerp(Vector2 target, float t) { + t = MathUtils.clamp(t, 0.0f, 1.0f); + float dot = MathUtils.clamp(this.normalize().dot(target.normalize()), -1.0f, 1.0f); + float theta = (float) Math.acos(dot) * t; + Vector2 relativeVec = target.subtract(this.multiply(dot)).normalize(); + return this.multiply(Math.cos(theta)).add(relativeVec.multiply(Math.sin(theta))); + } + + + @HostAccess.Export + public Vector2 reflect(Vector2 normal) { + return this.subtract(normal.multiply(2.0f * this.dot(normal))); + } + + @HostAccess.Export + public Vector2 project(Vector2 onto) { + float ontoLengthSq = onto.lengthSquared(); + if (ontoLengthSq < MathUtils.EPSILON) { + return Vector2.zero(); + } + return onto.multiply(this.dot(onto) / ontoLengthSq); + } + + @HostAccess.Export + public Vector2 reject(Vector2 onto) { + return this.subtract(this.project(onto)); + } + + @HostAccess.Export + public float angle(Vector2 other) { + float dot = this.normalize().dot(other.normalize()); + dot = MathUtils.clamp(dot, -1.0f, 1.0f); + return (float) Math.acos(dot); + } + + + @HostAccess.Export + public Vector2 abs() { + return new Vector2(Math.abs(x), Math.abs(y)); + } + + @HostAccess.Export + public Vector2 min(Vector2 other) { + return new Vector2(Math.min(x, other.x), Math.min(y, other.y)); + } + + @HostAccess.Export + public Vector2 max(Vector2 other) { + return new Vector2(Math.max(x, other.x), Math.max(y, other.y)); + } + + @HostAccess.Export + public Vector2 clamp(Vector2 min, Vector2 max) { + return new Vector2( + MathUtils.clamp(x, min.x, max.x), + MathUtils.clamp(y, min.y, max.y) + ); + } + + @HostAccess.Export + public boolean equals(Vector2 other, float tolerance) { + return Math.abs(x - other.x) <= tolerance && + Math.abs(y - other.y) <= tolerance; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + Vector2 vector2 = (Vector2) obj; + return Float.compare(vector2.x, x) == 0 && + Float.compare(vector2.y, y) == 0; + } + + @Override + public int hashCode() { + int result = Float.floatToIntBits(x); + result = 31 * result + Float.floatToIntBits(y); + return result; + } + + + + + + @Override + public String toString() { + return String.format("Vector2(%.3f, %.3f)", x, y); + } + +} From e753485e98e2568e45311293d4538a381b5e8cb3 Mon Sep 17 00:00:00 2001 From: mrqsdf Date: Sat, 6 Dec 2025 19:29:35 +0100 Subject: [PATCH 2/2] add object + mtl + atlas refactor --- .../server/assets/objatlas/obj/ObjFace.java | 14 ++ .../assets/objatlas/obj/ObjMaterial.java | 23 ++ .../server/assets/objatlas/obj/ObjModel.java | 20 ++ .../assets/objatlas/obj/ObjVertexRef.java | 9 + .../assets/objatlas/parser/MtlParser.java | 158 ++++++++++++++ .../assets/objatlas/parser/ObjParser.java | 198 ++++++++++++++++++ .../assets/objatlas/texture/RgbColor.java | 7 + .../assets/objatlas/texture/TextureAtlas.java | 16 ++ .../objatlas/texture/TextureAtlasBuilder.java | 143 +++++++++++++ .../objatlas/texture/TextureRegion.java | 13 ++ .../assets/objatlas/writer/MtlWriter.java | 76 +++++++ .../objatlas/writer/ObjAtlasRewriter.java | 135 ++++++++++++ .../assets/objatlas/writer/ObjWriter.java | 108 ++++++++++ 13 files changed, 920 insertions(+) create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/obj/ObjFace.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/obj/ObjMaterial.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/obj/ObjModel.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/obj/ObjVertexRef.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/parser/MtlParser.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/parser/ObjParser.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/texture/RgbColor.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/texture/TextureAtlas.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/texture/TextureAtlasBuilder.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/texture/TextureRegion.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/writer/MtlWriter.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/writer/ObjAtlasRewriter.java create mode 100644 server/src/main/java/com/moud/server/assets/objatlas/writer/ObjWriter.java diff --git a/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjFace.java b/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjFace.java new file mode 100644 index 00000000..2d4ad204 --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjFace.java @@ -0,0 +1,14 @@ +package com.moud.server.assets.objatlas.obj; + +import java.util.List; + +/** + * One face in an OBJ file. It can be a triangle, quad, or polygon. + */ +public record ObjFace( + List vertices, + String materialName, // may be null if no usemtl + String groupName, // may be null if no "g" + int smoothingGroup // -1 means "off" or not specified +) { +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjMaterial.java b/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjMaterial.java new file mode 100644 index 00000000..dd7198d5 --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjMaterial.java @@ -0,0 +1,23 @@ +package com.moud.server.assets.objatlas.obj; + +import com.moud.server.assets.objatlas.texture.RgbColor; + +/** + * Material definition from an MTL file. + */ +public record ObjMaterial( + String name, + RgbColor ambient, // Ka + RgbColor diffuse, // Kd + RgbColor specular, // Ks + RgbColor emissive, // Ke + float shininess, // Ns + float opticalDensity, // Ni + float dissolve, // d (opacity) + int illuminationModel, // illum + String mapDiffuse, // map_Kd + String mapAmbient, // map_Ka + String mapSpecular, // map_Ks + String mapBump // map_Bump / bump +) { +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjModel.java b/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjModel.java new file mode 100644 index 00000000..26aea288 --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjModel.java @@ -0,0 +1,20 @@ +package com.moud.server.assets.objatlas.obj; + +import com.moud.api.math.Vector2; +import com.moud.api.math.Vector3; + +import java.util.List; +import java.util.Map; + +/** + * Data model for a parsed OBJ file. + */ +public record ObjModel( + String name, + List positions, + List normals, + List texCoords, + List faces, + Map materials +) { +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjVertexRef.java b/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjVertexRef.java new file mode 100644 index 00000000..a3e58d09 --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/obj/ObjVertexRef.java @@ -0,0 +1,9 @@ +package com.moud.server.assets.objatlas.obj; + +/** + * Reference to a single OBJ vertex in a face. + * Indices are 0-based (converted from OBJ's 1-based indices). + * A value of -1 means "not present" (for vt or vn). + */ +public record ObjVertexRef(int positionIndex, int texCoordIndex, int normalIndex) { +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/parser/MtlParser.java b/server/src/main/java/com/moud/server/assets/objatlas/parser/MtlParser.java new file mode 100644 index 00000000..32b0e999 --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/parser/MtlParser.java @@ -0,0 +1,158 @@ +package com.moud.server.assets.objatlas.parser; + +import com.moud.server.assets.objatlas.obj.ObjMaterial; +import com.moud.server.assets.objatlas.texture.RgbColor; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Simple MTL parser supporting common properties: + * newmtl, Ka, Kd, Ks, Ke, Ns, Ni, d, Tr, illum, map_Kd, map_Ka, map_Ks, map_Bump/bump. + */ +public final class MtlParser { + + public Map parse(Path mtlPath) throws IOException { + Map materials = new LinkedHashMap<>(); + + String currentName = null; + RgbColor ambient = null; + RgbColor diffuse = null; + RgbColor specular = null; + RgbColor emissive = null; + float shininess = 0.0f; + float opticalDensity = 1.0f; + float dissolve = 1.0f; + int illum = -1; + String mapKd = null; + String mapKa = null; + String mapKs = null; + String mapBump = null; + + try (BufferedReader reader = Files.newBufferedReader(mtlPath)) { + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + + String[] tokens = trimmed.split("\\s+"); + String keyword = tokens[0]; + + switch (keyword) { + case "newmtl" -> { + // Flush previous material + if (currentName != null) { + ObjMaterial mat = new ObjMaterial( + currentName, + ambient, + diffuse, + specular, + emissive, + shininess, + opticalDensity, + dissolve, + illum, + mapKd, + mapKa, + mapKs, + mapBump + ); + materials.put(currentName, mat); + } + + // Start new material + currentName = tokens.length > 1 ? tokens[1] : null; + ambient = null; + diffuse = null; + specular = null; + emissive = null; + shininess = 0.0f; + opticalDensity = 1.0f; + dissolve = 1.0f; + illum = -1; + mapKd = null; + mapKa = null; + mapKs = null; + mapBump = null; + } + case "Ka" -> ambient = parseColor(tokens); + case "Kd" -> diffuse = parseColor(tokens); + case "Ks" -> specular = parseColor(tokens); + case "Ke" -> emissive = parseColor(tokens); + case "Ns" -> shininess = parseFloat(tokens, 1, shininess); + case "Ni" -> opticalDensity = parseFloat(tokens, 1, opticalDensity); + case "d" -> dissolve = parseFloat(tokens, 1, dissolve); + case "Tr" -> { + // Tr is transparency, usually 1 - d + float tr = parseFloat(tokens, 1, 1.0f - dissolve); + dissolve = 1.0f - tr; + } + case "illum" -> illum = (int) parseFloat(tokens, 1, illum); + case "map_Kd" -> mapKd = parseMapName(tokens); + case "map_Ka" -> mapKa = parseMapName(tokens); + case "map_Ks" -> mapKs = parseMapName(tokens); + case "map_Bump", "map_bump", "bump" -> mapBump = parseMapName(tokens); + default -> { + // Unknown/unsupported keyword: ignore + } + } + } + } + + // Flush last material + if (currentName != null) { + ObjMaterial mat = new ObjMaterial( + currentName, + ambient, + diffuse, + specular, + emissive, + shininess, + opticalDensity, + dissolve, + illum, + mapKd, + mapKa, + mapKs, + mapBump + ); + materials.put(currentName, mat); + } + + return materials; + } + + private RgbColor parseColor(String[] tokens) { + float r = tokens.length > 1 ? parseFloat(tokens[1], 0.0f) : 0.0f; + float g = tokens.length > 2 ? parseFloat(tokens[2], 0.0f) : 0.0f; + float b = tokens.length > 3 ? parseFloat(tokens[3], 0.0f) : 0.0f; + return new RgbColor(r, g, b); + } + + private float parseFloat(String[] tokens, int index, float defaultValue) { + if (tokens.length <= index) { + return defaultValue; + } + return parseFloat(tokens[index], defaultValue); + } + + private float parseFloat(String value, float defaultValue) { + try { + return Float.parseFloat(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private String parseMapName(String[] tokens) { + // Map names can have options like "-blendu on", we keep it simple and take the last token + if (tokens.length < 2) return null; + return tokens[tokens.length - 1]; + } +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/parser/ObjParser.java b/server/src/main/java/com/moud/server/assets/objatlas/parser/ObjParser.java new file mode 100644 index 00000000..c2dc9b1a --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/parser/ObjParser.java @@ -0,0 +1,198 @@ +package com.moud.server.assets.objatlas.parser; + +import com.moud.api.math.Vector2; +import com.moud.api.math.Vector3; +import com.moud.server.assets.objatlas.obj.ObjFace; +import com.moud.server.assets.objatlas.obj.ObjMaterial; +import com.moud.server.assets.objatlas.obj.ObjModel; +import com.moud.server.assets.objatlas.obj.ObjVertexRef; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Simple OBJ parser. + * Supports: v, vt, vn, f, o, g, s, mtllib, usemtl. + */ +public final class ObjParser { + + private final MtlParser mtlParser = new MtlParser(); + + public ObjModel parse(Path objPath) throws IOException { + List positions = new ArrayList<>(); + List normals = new ArrayList<>(); + List texCoords = new ArrayList<>(); + List faces = new ArrayList<>(); + Map materials = new LinkedHashMap<>(); + + String modelName = stripExtension(objPath.getFileName().toString()); + String currentGroup = null; + String currentMaterial = null; + int currentSmoothingGroup = -1; + + Path baseDir = objPath.toAbsolutePath().getParent(); + + try (BufferedReader reader = Files.newBufferedReader(objPath)) { + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + + String[] tokens = trimmed.split("\\s+"); + String keyword = tokens[0]; + + switch (keyword) { + case "o" -> { + if (tokens.length > 1) { + modelName = tokens[1]; + } + } + case "v" -> { + float x = parseFloat(tokens, 1, 0.0f); + float y = parseFloat(tokens, 2, 0.0f); + float z = parseFloat(tokens, 3, 0.0f); + positions.add(new Vector3(x, y, z)); + } + case "vt" -> { + float u = parseFloat(tokens, 1, 0.0f); + float v = parseFloat(tokens, 2, 0.0f); + texCoords.add(new Vector2(u, v)); + } + case "vn" -> { + float x = parseFloat(tokens, 1, 0.0f); + float y = parseFloat(tokens, 2, 0.0f); + float z = parseFloat(tokens, 3, 0.0f); + normals.add(new Vector3(x, y, z)); + } + case "f" -> { + List verts = new ArrayList<>(tokens.length - 1); + for (int i = 1; i < tokens.length; i++) { + String vertToken = tokens[i]; + ObjVertexRef ref = parseVertexRef( + vertToken, + positions.size(), + texCoords.size(), + normals.size() + ); + verts.add(ref); + } + faces.add(new ObjFace( + List.copyOf(verts), + currentMaterial, + currentGroup, + currentSmoothingGroup + )); + } + case "g" -> { + // Only keep the first group name if multiple are given + currentGroup = tokens.length > 1 ? tokens[1] : null; + } + case "s" -> { + if (tokens.length > 1) { + if ("off".equalsIgnoreCase(tokens[1])) { + currentSmoothingGroup = -1; + } else { + try { + currentSmoothingGroup = Integer.parseInt(tokens[1]); + } catch (NumberFormatException e) { + currentSmoothingGroup = -1; + } + } + } else { + currentSmoothingGroup = -1; + } + } + case "usemtl" -> { + currentMaterial = tokens.length > 1 ? tokens[1] : null; + } + case "mtllib" -> { + if (baseDir != null) { + for (int i = 1; i < tokens.length; i++) { + String mtlFileName = tokens[i]; + Path mtlPath = baseDir.resolve(mtlFileName); + if (Files.exists(mtlPath)) { + Map parsed = mtlParser.parse(mtlPath); + materials.putAll(parsed); + } + } + } + } + default -> { + // Unknown/unsupported keyword: ignore + } + } + } + } + + return new ObjModel( + modelName, + List.copyOf(positions), + List.copyOf(normals), + List.copyOf(texCoords), + List.copyOf(faces), + Map.copyOf(materials) + ); + } + + private ObjVertexRef parseVertexRef(String token, + int posCount, + int texCount, + int normCount) { + String[] parts = token.split("/"); + String vStr = parts.length > 0 ? parts[0] : ""; + String vtStr = parts.length > 1 ? parts[1] : ""; + String vnStr = parts.length > 2 ? parts[2] : ""; + + int vIdx = parseIndex(vStr, posCount); + int vtIdx = parseIndex(vtStr, texCount); + int vnIdx = parseIndex(vnStr, normCount); + + return new ObjVertexRef(vIdx, vtIdx, vnIdx); + } + + /** + * Parses an OBJ index which can be positive (1-based) or negative (relative). + * Returns 0-based index, or -1 if not present. + */ + private int parseIndex(String str, int size) { + if (str == null || str.isEmpty()) { + return -1; + } + try { + int idx = Integer.parseInt(str); + if (idx > 0) { + return idx - 1; // OBJ is 1-based + } else if (idx < 0) { + return size + idx; // Negative indices are relative to the end + } else { + return -1; + } + } catch (NumberFormatException e) { + return -1; + } + } + + private float parseFloat(String[] tokens, int index, float defaultValue) { + if (tokens.length <= index) { + return defaultValue; + } + try { + return Float.parseFloat(tokens[index]); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private String stripExtension(String name) { + int dot = name.lastIndexOf('.'); + return (dot >= 0) ? name.substring(0, dot) : name; + } +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/texture/RgbColor.java b/server/src/main/java/com/moud/server/assets/objatlas/texture/RgbColor.java new file mode 100644 index 00000000..660f5f35 --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/texture/RgbColor.java @@ -0,0 +1,7 @@ +package com.moud.server.assets.objatlas.texture; + +/** + * Simple RGB color, components are usually in [0, 1]. + */ +public record RgbColor(float r, float g, float b) { +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/texture/TextureAtlas.java b/server/src/main/java/com/moud/server/assets/objatlas/texture/TextureAtlas.java new file mode 100644 index 00000000..88524259 --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/texture/TextureAtlas.java @@ -0,0 +1,16 @@ +package com.moud.server.assets.objatlas.texture; + +import java.util.Map; + +/** + * Represents a built texture atlas. + * - atlasFileName : filename to put in map_Kd (e.g. "atlas_diffuse.png") + * - regionsByTextureName : key = original map_Kd string from MTL + */ +public record TextureAtlas( + String atlasFileName, + int width, + int height, + Map regionsByTextureName +) { +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/texture/TextureAtlasBuilder.java b/server/src/main/java/com/moud/server/assets/objatlas/texture/TextureAtlasBuilder.java new file mode 100644 index 00000000..c02865d1 --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/texture/TextureAtlasBuilder.java @@ -0,0 +1,143 @@ +package com.moud.server.assets.objatlas.texture; + +import com.moud.server.assets.objatlas.obj.ObjMaterial; +import com.moud.server.assets.objatlas.obj.ObjModel; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Build a simple vertical texture atlas from all diffuse textures (map_Kd) used in the ObjModel. + * + * Layout: + * - All textures stacked vertically. + * - atlasWidth = max(textureWidth) + * - atlasHeight = sum(textureHeight) + * + * UV remap logic (for a given texture): + * U' = uOffset + U * uScale + * V' = vOffset + V * vScale + */ +public final class TextureAtlasBuilder { + + /** + * Build an atlas and write it to disk. + * + * @param model Parsed OBJ model (with materials). + * @param baseDir Directory where original textures are located (usually objPath.getParent()). + * @param outputAtlasPath Path where the atlas image will be written (e.g. baseDir.resolve("atlas_diffuse.png")). + * @return TextureAtlas information (regions + atlas size). + */ + public TextureAtlas buildAtlas(ObjModel model, Path baseDir, Path outputAtlasPath) throws IOException { + Map imagesByTextureName = new LinkedHashMap<>(); + + // 1) Collect all diffuse texture names from materials + for (ObjMaterial mat : model.materials().values()) { + String mapKd = mat.mapDiffuse(); + if (mapKd == null || mapKd.isBlank()) { + continue; + } + if (imagesByTextureName.containsKey(mapKd)) { + continue; // already loaded + } + + Path texturePath = baseDir.resolve(mapKd); + if (!Files.exists(texturePath)) { + System.err.println("[TextureAtlasBuilder] Missing texture: " + texturePath); + continue; + } + + BufferedImage img = ImageIO.read(texturePath.toFile()); + if (img == null) { + System.err.println("[TextureAtlasBuilder] Cannot read texture as image: " + texturePath); + continue; + } + + imagesByTextureName.put(mapKd, img); + } + + if (imagesByTextureName.isEmpty()) { + throw new IOException("No diffuse textures (map_Kd) found to build an atlas."); + } + + // 2) Compute atlas dimensions + int atlasWidth = 0; + int atlasHeight = 0; + + for (BufferedImage img : imagesByTextureName.values()) { + atlasWidth = Math.max(atlasWidth, img.getWidth()); + atlasHeight += img.getHeight(); + } + + System.out.printf(Locale.ROOT, + "[TextureAtlasBuilder] Building atlas %dx%d from %d textures%n", + atlasWidth, atlasHeight, imagesByTextureName.size()); + + // 3) Create atlas image + BufferedImage atlas = new BufferedImage(atlasWidth, atlasHeight, BufferedImage.TYPE_INT_ARGB); + + Map regions = new LinkedHashMap<>(); + + int currentY = 0; + for (Map.Entry entry : imagesByTextureName.entrySet()) { + String texName = entry.getKey(); + BufferedImage img = entry.getValue(); + + int texWidth = img.getWidth(); + int texHeight = img.getHeight(); + + // Copy into atlas at x=0, y=currentY + for (int y = 0; y < texHeight; y++) { + for (int x = 0; x < texWidth; x++) { + int argb = img.getRGB(x, y); + atlas.setRGB(x, currentY + y, argb); + } + } + + // Normalized region infos + float uOffset = 0.0f; + float vOffset = (float) currentY / (float) atlasHeight; + float uScale = (float) texWidth / (float) atlasWidth; + float vScale = (float) texHeight / (float) atlasHeight; + + TextureRegion region = new TextureRegion( + 0, + currentY, + texWidth, + texHeight, + uOffset, + vOffset, + uScale, + vScale + ); + regions.put(texName, region); + + currentY += texHeight; + } + + // 4) Save atlas + if (outputAtlasPath.getParent() != null) { + Files.createDirectories(outputAtlasPath.getParent()); + } + String format = getImageFormatFromFileName(outputAtlasPath); + ImageIO.write(atlas, format, outputAtlasPath.toFile()); + + String atlasFileName = outputAtlasPath.getFileName().toString(); + return new TextureAtlas(atlasFileName, atlasWidth, atlasHeight, Map.copyOf(regions)); + } + + private String getImageFormatFromFileName(Path path) { + String name = path.getFileName().toString(); + int dot = name.lastIndexOf('.'); + if (dot >= 0 && dot < name.length() - 1) { + return name.substring(dot + 1).toLowerCase(Locale.ROOT); + } + return "png"; + } +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/texture/TextureRegion.java b/server/src/main/java/com/moud/server/assets/objatlas/texture/TextureRegion.java new file mode 100644 index 00000000..5fd4c882 --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/texture/TextureRegion.java @@ -0,0 +1,13 @@ +package com.moud.server.assets.objatlas.texture; + +public record TextureRegion( + int x, + int y, + int width, + int height, + float uOffset, + float vOffset, + float uScale, + float vScale +) { +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/writer/MtlWriter.java b/server/src/main/java/com/moud/server/assets/objatlas/writer/MtlWriter.java new file mode 100644 index 00000000..a35fb7bf --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/writer/MtlWriter.java @@ -0,0 +1,76 @@ +package com.moud.server.assets.objatlas.writer; + +import com.moud.server.assets.objatlas.obj.ObjMaterial; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; + +/** + * Writes materials to an MTL file. + */ +public final class MtlWriter { + + public void write(Map materials, Path outputPath) throws IOException { + if (outputPath.getParent() != null) { + Files.createDirectories(outputPath.getParent()); + } + + try (BufferedWriter writer = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8)) { + for (ObjMaterial mat : materials.values()) { + writer.write("newmtl " + mat.name()); + writer.newLine(); + + if (mat.ambient() != null) { + writer.write(String.format(Locale.ROOT, "Ka %f %f %f%n", + mat.ambient().r(), mat.ambient().g(), mat.ambient().b())); + } + if (mat.diffuse() != null) { + writer.write(String.format(Locale.ROOT, "Kd %f %f %f%n", + mat.diffuse().r(), mat.diffuse().g(), mat.diffuse().b())); + } + if (mat.specular() != null) { + writer.write(String.format(Locale.ROOT, "Ks %f %f %f%n", + mat.specular().r(), mat.specular().g(), mat.specular().b())); + } + if (mat.emissive() != null) { + writer.write(String.format(Locale.ROOT, "Ke %f %f %f%n", + mat.emissive().r(), mat.emissive().g(), mat.emissive().b())); + } + + if (mat.shininess() != 0.0f) { + writer.write(String.format(Locale.ROOT, "Ns %f%n", mat.shininess())); + } + if (mat.opticalDensity() != 1.0f) { + writer.write(String.format(Locale.ROOT, "Ni %f%n", mat.opticalDensity())); + } + + writer.write(String.format(Locale.ROOT, "d %f%n", mat.dissolve())); + writer.write(String.format(Locale.ROOT, "illum %d%n", mat.illuminationModel())); + + if (mat.mapAmbient() != null && !mat.mapAmbient().isBlank()) { + writer.write("map_Ka " + mat.mapAmbient()); + writer.newLine(); + } + if (mat.mapDiffuse() != null && !mat.mapDiffuse().isBlank()) { + writer.write("map_Kd " + mat.mapDiffuse()); + writer.newLine(); + } + if (mat.mapSpecular() != null && !mat.mapSpecular().isBlank()) { + writer.write("map_Ks " + mat.mapSpecular()); + writer.newLine(); + } + if (mat.mapBump() != null && !mat.mapBump().isBlank()) { + writer.write("map_Bump " + mat.mapBump()); + writer.newLine(); + } + + writer.newLine(); + } + } + } +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/writer/ObjAtlasRewriter.java b/server/src/main/java/com/moud/server/assets/objatlas/writer/ObjAtlasRewriter.java new file mode 100644 index 00000000..def2614c --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/writer/ObjAtlasRewriter.java @@ -0,0 +1,135 @@ +package com.moud.server.assets.objatlas.writer; + +import com.moud.api.math.Vector2; +import com.moud.api.math.Vector3; +import com.moud.server.assets.objatlas.obj.ObjFace; +import com.moud.server.assets.objatlas.obj.ObjMaterial; +import com.moud.server.assets.objatlas.obj.ObjModel; +import com.moud.server.assets.objatlas.obj.ObjVertexRef; +import com.moud.server.assets.objatlas.texture.TextureAtlas; +import com.moud.server.assets.objatlas.texture.TextureRegion; + +import java.util.*; + +/** + * Rewrites UV coordinates of an ObjModel so that all diffuse textures (map_Kd) + * are replaced by a single atlas texture. + * + * Steps: + * - For each material, find its original map_Kd (texture name). + * - For each face using that material, remap its UVs into the corresponding atlas region. + * - Materials are copied but map_Kd is replaced by the atlas texture filename. + */ +public final class ObjAtlasRewriter { + + public ObjModel rewriteModelForAtlas(ObjModel original, TextureAtlas atlas) { + List positions = original.positions(); + List normals = original.normals(); + List originalTexCoords = original.texCoords(); + + // 1) New texCoords list starts as a copy of the original UVs + List newTexCoords = new ArrayList<>(originalTexCoords); + + // 2) Cache: (materialName, oldVtIndex) -> newVtIndex + Map vtRemapCache = new HashMap<>(); + + // 3) Remember original map_Kd per material name + Map materialToOriginalTexture = new HashMap<>(); + for (Map.Entry entry : original.materials().entrySet()) { + String matName = entry.getKey(); + ObjMaterial mat = entry.getValue(); + materialToOriginalTexture.put(matName, mat.mapDiffuse()); + } + + // 4) Rewrite faces + List newFaces = new ArrayList<>(original.faces().size()); + + for (ObjFace face : original.faces()) { + String matName = face.materialName(); + String originalTexName = matName != null ? materialToOriginalTexture.get(matName) : null; + TextureRegion region = null; + + if (originalTexName != null) { + region = atlas.regionsByTextureName().get(originalTexName); + } + + List newVerts = new ArrayList<>(face.vertices().size()); + + for (ObjVertexRef vRef : face.vertices()) { + int posIndex = vRef.positionIndex(); + int normIndex = vRef.normalIndex(); + int texIndex = vRef.texCoordIndex(); + + int newTexIndex = texIndex; + + if (texIndex >= 0 && region != null) { + // we need to remap this UV + String key = matName + "#" + texIndex; + Integer cachedIndex = vtRemapCache.get(key); + if (cachedIndex == null) { + Vector2 uv = originalTexCoords.get(texIndex); + + float u = uv.x; + float v = uv.y; + + float newU = region.uOffset() + u * region.uScale(); + float newV = region.vOffset() + v * region.vScale(); + + newTexCoords.add(new Vector2(newU, newV)); + cachedIndex = newTexCoords.size() - 1; + vtRemapCache.put(key, cachedIndex); + } + newTexIndex = cachedIndex; + } + + newVerts.add(new ObjVertexRef(posIndex, newTexIndex, normIndex)); + } + + newFaces.add(new ObjFace( + List.copyOf(newVerts), + face.materialName(), + face.groupName(), + face.smoothingGroup() + )); + } + + // 5) Rewrite materials: same as original but with map_Kd = atlas.atlasFileName() + Map newMaterials = new LinkedHashMap<>(); + for (Map.Entry entry : original.materials().entrySet()) { + String name = entry.getKey(); + ObjMaterial mat = entry.getValue(); + String originalTexName = mat.mapDiffuse(); + + String newMapKd = mat.mapDiffuse(); + if (originalTexName != null && atlas.regionsByTextureName().containsKey(originalTexName)) { + newMapKd = atlas.atlasFileName(); + } + + ObjMaterial newMat = new ObjMaterial( + mat.name(), + mat.ambient(), + mat.diffuse(), + mat.specular(), + mat.emissive(), + mat.shininess(), + mat.opticalDensity(), + mat.dissolve(), + mat.illuminationModel(), + newMapKd, + mat.mapAmbient(), + mat.mapSpecular(), + mat.mapBump() + ); + newMaterials.put(name, newMat); + } + + return new ObjModel( + original.name(), + List.copyOf(positions), + List.copyOf(normals), + List.copyOf(newTexCoords), + List.copyOf(newFaces), + Map.copyOf(newMaterials) + ); + } +} diff --git a/server/src/main/java/com/moud/server/assets/objatlas/writer/ObjWriter.java b/server/src/main/java/com/moud/server/assets/objatlas/writer/ObjWriter.java new file mode 100644 index 00000000..25df3068 --- /dev/null +++ b/server/src/main/java/com/moud/server/assets/objatlas/writer/ObjWriter.java @@ -0,0 +1,108 @@ +package com.moud.server.assets.objatlas.writer; + +import com.moud.api.math.Vector2; +import com.moud.api.math.Vector3; +import com.moud.server.assets.objatlas.obj.ObjFace; +import com.moud.server.assets.objatlas.obj.ObjModel; +import com.moud.server.assets.objatlas.obj.ObjVertexRef; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; + +/** + * Writes an ObjModel to an OBJ file. + */ +public final class ObjWriter { + + public void write(ObjModel model, Path outputPath, String mtlFileName) throws IOException { + if (outputPath.getParent() != null) { + Files.createDirectories(outputPath.getParent()); + } + + try (BufferedWriter writer = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8)) { + if (mtlFileName != null && !mtlFileName.isBlank()) { + writer.write("mtllib " + mtlFileName); + writer.newLine(); + } + + if (model.name() != null && !model.name().isBlank()) { + writer.write("o " + model.name()); + writer.newLine(); + } + + // v + for (Vector3 p : model.positions()) { + writer.write(String.format(Locale.ROOT, "v %f %f %f%n", p.x, p.y, p.z)); + } + + // vt + for (Vector2 t : model.texCoords()) { + writer.write(String.format(Locale.ROOT, "vt %f %f%n", t.x, t.y)); + } + + // vn + for (Vector3 n : model.normals()) { + writer.write(String.format(Locale.ROOT, "vn %f %f %f%n", n.x, n.y, n.z)); + } + + // Faces + groups/materials/smoothing + String currentGroup = null; + String currentMaterial = null; + int currentSmooth = Integer.MIN_VALUE; + + for (ObjFace face : model.faces()) { + if (!Objects.equals(face.groupName(), currentGroup)) { + currentGroup = face.groupName(); + if (currentGroup != null && !currentGroup.isBlank()) { + writer.write("g " + currentGroup); + } else { + writer.write("g"); + } + writer.newLine(); + } + + if (!Objects.equals(face.materialName(), currentMaterial)) { + currentMaterial = face.materialName(); + if (currentMaterial != null && !currentMaterial.isBlank()) { + writer.write("usemtl " + currentMaterial); + writer.newLine(); + } + } + + if (face.smoothingGroup() != currentSmooth) { + currentSmooth = face.smoothingGroup(); + if (currentSmooth <= 0) { + writer.write("s off"); + } else { + writer.write("s " + currentSmooth); + } + writer.newLine(); + } + + writer.write("f"); + for (ObjVertexRef vRef : face.vertices()) { + int v = vRef.positionIndex() + 1; + int vt = vRef.texCoordIndex() + 1; + int vn = vRef.normalIndex() + 1; + + writer.write(" "); + if (vRef.texCoordIndex() >= 0 && vRef.normalIndex() >= 0) { + writer.write(v + "/" + vt + "/" + vn); + } else if (vRef.texCoordIndex() >= 0) { + writer.write(v + "/" + vt); + } else if (vRef.normalIndex() >= 0) { + writer.write(v + "//" + vn); + } else { + writer.write(Integer.toString(v)); + } + } + writer.newLine(); + } + } + } +}