Files
jeu-sans-image/Assets/Editor/SvgToFlatMeshEditor.cs
Banane_Rotative a43d48c438 Add: Polygon simplification
Polygons are simplified before tesselation
2026-01-17 16:36:48 +01:00

531 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SvgToFlatMeshEditor.cs
// Save to Assets/Editor/
// Requires com.unity.vectorgraphics package.
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;
using Unity.VectorGraphics; // From com.unity.vectorgraphics
public class SvgToFlatMeshEditor : EditorWindow
{
TextAsset svgFile;
Material editorMaterial;
float pixelsPerUnit = 100.0f;
Transform parentTransform;
float meshScale = 0.1f;
VectorUtils.TessellationOptions tessOptions = new VectorUtils.TessellationOptions() {
StepDistance = 1.0f,
MaxCordDeviation = 0.5f,
MaxTanAngleDeviation = 0.1f,
SamplingStepSize = 0.01f
};
Quaternion meshRotation = Quaternion.identity;
float polygonSimplificationTolerance = 0.5f;
bool enablePolygonSimplification = true;
[SerializeField] private ColorFolderMap colorFolderMap;
[MenuItem("Tools/SVG → Flat Mesh Regions")]
static void OpenWindow() {
var w = GetWindow<SvgToFlatMeshEditor>("SVG → Flat Mesh");
w.minSize = new Vector2(460, 320);
}
void OnGUI() {
EditorGUILayout.LabelField("SVG → Flat Mesh (separate GameObjects per fill color)", EditorStyles.boldLabel);
EditorGUILayout.Space();
svgFile = (TextAsset)EditorGUILayout.ObjectField("SVG File (.svg)", svgFile, typeof(TextAsset), false);
editorMaterial = (Material)EditorGUILayout.ObjectField("Default Material", editorMaterial, typeof(Material), false);
pixelsPerUnit = EditorGUILayout.FloatField(new GUIContent("Pixels Per Unit", "Rasterization scale used by VectorUtils. Higher = more detail"), pixelsPerUnit);
meshScale = EditorGUILayout.FloatField(new GUIContent("Global Mesh Scale", "Scale applied to resulting mesh in world units"), meshScale);
parentTransform = (Transform)EditorGUILayout.ObjectField("Parent Transform", parentTransform, typeof(Transform), true);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Tessellation Options", EditorStyles.boldLabel);
tessOptions.StepDistance = EditorGUILayout.FloatField(new GUIContent("Step Distance", "From manual: The uniform tessellation step distance."), tessOptions.StepDistance);
tessOptions.MaxCordDeviation = EditorGUILayout.FloatField(new GUIContent("Max Cord Deviation", "From manual: The maximum distance on the cord to a straight line between to points after which more tessellation will be generated"), tessOptions.MaxCordDeviation);
tessOptions.MaxTanAngleDeviation = EditorGUILayout.FloatField(new GUIContent("Max Tan Angle Deviation", "From manual: The maximum angle (in degrees) between the curve tangent and the next point after which more tessellation will be generated"), tessOptions.MaxTanAngleDeviation);
tessOptions.SamplingStepSize = EditorGUILayout.FloatField(new GUIContent("Sampling Step Size", "From manual: The number of samples used internally to evaluate the curves. More samples = higher quality. Should be between 0 and 1 (inclusive)"), tessOptions.SamplingStepSize);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Polygon Simplification", EditorStyles.boldLabel);
enablePolygonSimplification = EditorGUILayout.Toggle(new GUIContent("Enable Polygon Simplification", "Reduce points in polygons before tessellation for minimal triangles."), enablePolygonSimplification);
if (enablePolygonSimplification)
{
polygonSimplificationTolerance = EditorGUILayout.FloatField(new GUIContent("Simplification Tolerance", "Higher = fewer points, lower = more detail. Typical range: 0.1 - 2.0"), polygonSimplificationTolerance);
}
EditorGUILayout.Space();
EditorGUILayout.LabelField("Map rotation options", EditorStyles.boldLabel);
meshRotation = Quaternion.Euler(EditorGUILayout.Vector3Field(new GUIContent("Mesh Rotation (degrees)", "Rotation to apply to the generated meshes"), meshRotation.eulerAngles));
EditorGUILayout.Space();
if (GUILayout.Button("Generate Meshes from SVG")) {
if (svgFile == null) {
EditorUtility.DisplayDialog("Error", "Please assign an SVG (.svg) TextAsset.", "OK");
}
else {
try {
GenerateMeshesFromSVG();
}
catch (Exception e) {
Debug.LogException(e);
EditorUtility.DisplayDialog("Error", "Exception: " + e.Message, "OK");
}
}
}
EditorGUILayout.Space();
EditorGUILayout.HelpBox("This tool creates one GameObject per fill-color region in the SVG. Each GameObject receives a MeshRenderer + MeshFilter and a MeshCollider. Output meshes lie flat on the XZ plane (Y = 0).", MessageType.Info);
}
void GenerateMeshesFromSVG() {
// Parse SVG
var svgText = svgFile.text;
if (string.IsNullOrEmpty(svgText)) {
EditorUtility.DisplayDialog("Error", "SVG file is empty.", "OK");
return;
}
var sceneInfo = SVGParser.ImportSVG(new StringReader(svgText));
if (sceneInfo.Equals(default(SVGParser.SceneInfo)) || sceneInfo.Scene == null) {
EditorUtility.DisplayDialog("Error", "Failed to import SVG. Make sure the file is valid and Vector Graphics package is installed.", "OK");
return;
}
Vector2 sceneCenter = VectorUtils.SceneNodeBounds(sceneInfo.Scene.Root).center;
// Gather shapes by fill color. We'll traverse the scene tree.
var shapesByColor = new Dictionary<Color, List<SceneNodeShapeEntry>>(new ColorEqualityComparer());
var wallsByColor = new Dictionary<Color, List<BezierPathSegment[]>>(new ColorEqualityComparer());
TraverseAndCollectShapes(sceneInfo.Scene.Root, Matrix2D.identity, shapesByColor, wallsByColor);
if (shapesByColor.Count == 0) {
EditorUtility.DisplayDialog("Result", "No filled shapes found in the SVG.", "OK");
return;
}
if (wallsByColor.Count == 0)
{
EditorUtility.DisplayDialog("Result", "No wall shapes found in the SVG.", "OK");
return;
}
// Create parent container
GameObject container = new GameObject(Path.GetFileNameWithoutExtension(svgFile.name) + "_SVG_Meshes");
if (parentTransform != null) {
container.transform.SetParent(parentTransform, false);
}
// For each color group, tessellate shapes into geometry and build a mesh
foreach (var kv in shapesByColor) {
Color color = kv.Key;
List<SceneNodeShapeEntry> entries = kv.Value;
List<VectorUtils.Geometry> geoms = TesselateIntoGeometries(entries);
if (geoms == null || geoms.Count == 0) {
Debug.LogWarning($"No geometry generated for color {color} (skipping).");
continue;
}
// Build Mesh from geoms
Mesh mesh = BuildMeshFromGeometries(geoms, sceneCenter, meshScale);
// Create GameObject for this color region
string objectName = BuildObjectName(color, "Floor");
BuildGameObject(objectName, color, mesh, container);
}
foreach (var kv in wallsByColor)
{
Color color = kv.Key;
List<BezierPathSegment[]> entries = kv.Value;
// Build Mesh from
Mesh mesh = BuildExtrudedMeshFromBeziers(entries, sceneCenter, meshScale, 5.0f);
// Create GameObject for this wall color region
string objectName = BuildObjectName(color, "Wall");
BuildGameObject(objectName, color, mesh, container);
}
// Focus selection on created container
Selection.activeGameObject = container;
EditorUtility.DisplayDialog("Done", $"Generated {shapesByColor.Count} region GameObjects under '{container.name}'.", "OK");
}
// Tesselate a list of SceneNodeShapeEntry into VectorUtils.Geometry list
// Simplify polygon points using RamerDouglasPeucker algorithm
List<Vector2> SimplifyPolygon(List<Vector2> points, float tolerance)
{
if (points == null || points.Count < 3)
return points;
bool[] keep = new bool[points.Count];
keep[0] = true;
keep[points.Count - 1] = true;
void Simplify(int first, int last)
{
float maxDist = 0f;
int index = 0;
Vector2 a = points[first];
Vector2 b = points[last];
for (int i = first + 1; i < last; i++)
{
float dist = DistanceToSegment(points[i], a, b);
if (dist > maxDist)
{
maxDist = dist;
index = i;
}
}
if (maxDist > tolerance)
{
keep[index] = true;
Simplify(first, index);
Simplify(index, last);
}
}
Simplify(0, points.Count - 1);
List<Vector2> result = new List<Vector2>();
for (int i = 0; i < points.Count; i++)
if (keep[i]) result.Add(points[i]);
return result;
}
// Distance from point p to segment ab
float DistanceToSegment(Vector2 p, Vector2 a, Vector2 b)
{
float l2 = (b - a).sqrMagnitude;
if (l2 == 0f) return (p - a).magnitude;
float t = Mathf.Clamp01(Vector2.Dot(p - a, b - a) / l2);
Vector2 proj = a + t * (b - a);
return (p - proj).magnitude;
}
List<VectorUtils.Geometry> TesselateIntoGeometries(List<SceneNodeShapeEntry> entries)
{
Scene tmpScene = new Scene();
tmpScene.Root = new SceneNode();
tmpScene.Root.Children = new List<SceneNode>();
foreach (var entry in entries)
{
Shape shape = entry.Shape;
// Only simplify polygons (ignore curves for now)
if (shape.Contours != null && shape.Contours.Length == 1 && shape.Contours[0].Segments.Length > 2)
{
// Extract points from Bezier segments (P0)
List<Vector2> pts = new List<Vector2>();
foreach (var seg in shape.Contours[0].Segments)
pts.Add(seg.P0);
// If the shape is almost a polygon (all segments are lines or nearly lines)
bool almostPolygon = true;
foreach (var seg in shape.Contours[0].Segments)
{
if ((seg.P0 - seg.P1).magnitude > 0.01f || (seg.P0 - seg.P2).magnitude > 0.01f)
{
almostPolygon = false;
break;
}
}
if (almostPolygon && enablePolygonSimplification)
{
// Simplify polygon
pts = SimplifyPolygon(pts, polygonSimplificationTolerance);
// Rebuild contour with simplified points
BezierPathSegment[] newSegs = new BezierPathSegment[pts.Count];
for (int i = 0; i < pts.Count; i++)
{
newSegs[i] = new BezierPathSegment { P0 = pts[i], P1 = pts[i], P2 = pts[i] };
}
shape.Contours[0].Segments = newSegs;
}
}
SceneNode copyNode = new SceneNode()
{
Transform = entry.Node.Transform,
Shapes = new List<Shape>() { shape }
};
tmpScene.Root.Children.Add(copyNode);
}
return VectorUtils.TessellateScene(tmpScene, tessOptions);
}
string BuildObjectName(Color color, string prefix)
{
string colorName = ColorToName(color);
return $"{prefix}_{colorName}";
}
void BuildGameObject(string objectName, Color color, Mesh mesh, GameObject container)
{
GameObject go = new GameObject(objectName);
go.transform.SetParent(container.transform, false);
MeshFilter mf = go.AddComponent<MeshFilter>();
mf.sharedMesh = mesh;
MeshRenderer mr = go.AddComponent<MeshRenderer>();
if (editorMaterial != null) {
// instantiate a material so each region can have its own color without overwriting the original asset
Material matInstance = new Material(editorMaterial);
matInstance.color = color;
mr.sharedMaterial = matInstance;
}
else {
// Create a quick default material
Material mat = new Material(Shader.Find("Standard"));
mat.color = color;
mr.sharedMaterial = mat;
}
// Generate collider
var mc = go.AddComponent<MeshCollider>();
mc.sharedMesh = mesh;
mc.convex = false; // keep non-convex for flat terrain; set to true if needed for rigidbodies
// Add tag to disable mesh renderer before build
go.tag = "EditorOnlyMeshRenderer";
// Automatically assign audio triggers based on color
string folder = colorFolderMap.GetFolder(color);
if (folder != null)
{
// TODO: automatically assign audio triggers
}
}
// Recursively traverse scene nodes and collect filled shapes and walls by color
void TraverseAndCollectShapes(SceneNode node, Matrix2D parentTransform, Dictionary<Color, List<SceneNodeShapeEntry>> shapesByColor, Dictionary<Color, List<BezierPathSegment[]>> wallsByColor) {
if (node == null) {
return;
}
// Combine transforms (VectorGraphics uses Matrix2D)
Matrix2D currentTransform = parentTransform * node.Transform;
if (node.Shapes != null && node.Shapes.Count > 0) {
foreach (var shape in node.Shapes) {
if (shape == null) {
continue;
}
// Only treat fills (SolidFill) for floors
if (shape.Fill is SolidFill sf) {
Color col = sf.Color;
// Note: color comes as linear RGBA. Convert to Unity's Color (already same type)
if (!shapesByColor.TryGetValue(col, out List<SceneNodeShapeEntry> list)) {
list = new List<SceneNodeShapeEntry>();
shapesByColor[col] = list;
}
// Store the shape together with a node that carries the proper transform
SceneNode fakeNode = new SceneNode() {
Transform = currentTransform,
Shapes = new List<Shape>() { shape }
};
list.Add(new SceneNodeShapeEntry() { Node = fakeNode, Shape = shape });
}
// Treat contours as walls, and only those with stroke color defined
if (shape.Contours != null && shape.Contours.Length > 0 && shape.PathProps.Stroke != null)
{
Color wallColor = shape.PathProps.Stroke.Color;
if (!wallsByColor.TryGetValue(wallColor, out List<BezierPathSegment[]> wallList)) {
wallList = new List<BezierPathSegment[]>();
wallsByColor[wallColor] = wallList;
}
// Add all contours as wall segments
foreach (BezierContour contour in shape.Contours)
{
wallList.Add(contour.Segments);
}
}
}
}
if (node.Children != null && node.Children.Count > 0) {
foreach (var c in node.Children) {
TraverseAndCollectShapes(c, currentTransform, shapesByColor, wallsByColor);
}
}
}
// Build a Mesh from VectorUtils.Geometry list
Mesh BuildMeshFromGeometries(List<VectorUtils.Geometry> geoms, Vector2 geomsCenter, float globalScale) {
// Forget about UVs (unnecessary for our use case)
List<Vector3> verts = new List<Vector3>();
List<int> indices = new List<int>();
int baseIndex = 0;
foreach (VectorUtils.Geometry g in geoms) {
if (g == null || g.Vertices == null || g.Indices == null) {
continue;
}
// Add vertices (VectorUtils uses Vector2 for geometry XY)
for (int i = 0; i < g.Vertices.Length; i++) {
Vector2 v2 = g.Vertices[i];
// Map XY -> XZ plane; Y = 0
Vector3 v3 = new Vector3(v2.x-geomsCenter.x, 0f, -v2.y+geomsCenter.y) * globalScale;
v3 = meshRotation * v3; // Apply rotation
verts.Add(v3);
}
// Add indices (triangles)
for (int i = 0; i < g.Indices.Length; i += 3) {
int i1 = (baseIndex + g.Indices[i]) % g.Vertices.Length;
int i2 = (baseIndex + g.Indices[(i + 1)% g.Indices.Length]) % g.Vertices.Length;
int i3 = (baseIndex + g.Indices[(i + 2)% g.Indices.Length]) % g.Vertices.Length;
if (!IsClockwise( g.Vertices[i1], g.Vertices[i2], g.Vertices[i3] ))
{
// Add triangle with reversed winding
indices.Add(i1);
indices.Add(i3);
indices.Add(i2);
}
else
{
// Add triangle with correct winding
indices.Add(i3);
indices.Add(i1);
indices.Add(i2);
}
}
baseIndex += g.Vertices.Length;
}
Mesh mesh = new Mesh();
mesh.name = "SVG_Mesh";
mesh.indexFormat = (verts.Count > 65535) ? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16;
mesh.SetVertices(verts);
mesh.SetTriangles(indices, 0);
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
// Helper to determine if 3 points are in clockwise order
bool IsClockwise(Vector2 a, Vector2 b, Vector2 c)
{
return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
}
// Build an extruded Mesh from geometries
Mesh BuildExtrudedMeshFromBeziers(List<BezierPathSegment[]> beziers, Vector2 geomsCenter, float globalScale, float height){
// Forget about UVs (unnecessary for our use case)
List<Vector3> verts = new List<Vector3>();
List<int> indices = new List<int>();
Vector3 geomsCenter3D = new Vector3(geomsCenter.x, 0f, -geomsCenter.y);
// Treat each path separately as a closed shape to extrude
foreach (BezierPathSegment[] bezier in beziers)
{
// Add vertices: low and high for each point
for (int i=0; i<bezier.Length; i++)
{
Vector2 v2 = bezier[i].P0;
Vector3 v3_low = meshRotation * (new Vector3(v2.x, 0f, -v2.y) - geomsCenter3D) * globalScale;
Vector3 v3_high = meshRotation * (new Vector3(v2.x, height, -v2.y) - geomsCenter3D) * globalScale;
verts.Add(v3_low);
verts.Add(v3_low); // Back face duplicate
verts.Add(v3_high);
verts.Add(v3_high); // Back face duplicate
}
// Add indices for triangles
for (int i = 0; i < bezier.Length; i++)
{
int next_i = (i + 1) % bezier.Length;
// Each quad between points i and nextI is made of two triangles, double sided
int low0 = i*4;
int high0 = i*4 +2;
int low1 = next_i*4;
int high1 = next_i*4 +2;
// Triangle 1
indices.Add(low0);
indices.Add(high0);
indices.Add(low1);
// Triangle 2
indices.Add(high0);
indices.Add(high1);
indices.Add(low1);
// Triangle 1 (back face)
indices.Add(low1 +1);
indices.Add(high0 +1);
indices.Add(low0 +1);
// Triangle 2 (back face)
indices.Add(low1 +1);
indices.Add(high1 +1);
indices.Add(high0 +1);
}
}
Mesh mesh = new Mesh();
mesh.name = "SVG_ExtrudedMesh";
mesh.indexFormat = (verts.Count > 65535) ? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16;
mesh.SetVertices(verts);
mesh.SetTriangles(indices, 0);
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
// Helper to produce a safe string for color names
string ColorToName(Color c) {
// Try to present RGBA hex
Color32 cc = c;
return $"{cc.r:X2}{cc.g:X2}{cc.b:X2}{(cc.a < 255 ? cc.a.ToString("X2") : "")}";
}
// Small helper type to keep a shape and associated node (with transform)
class SceneNodeShapeEntry {
public SceneNode Node;
public Shape Shape;
}
// Simple color comparer for use as Dictionary key
class ColorEqualityComparer : IEqualityComparer<Color> {
public bool Equals(Color x, Color y) {
// Compare with exactness; you could add tolerance if you want near-colors grouped
return Mathf.Approximately(x.r, y.r) && Mathf.Approximately(x.g, y.g) &&
Mathf.Approximately(x.b, y.b) && Mathf.Approximately(x.a, y.a);
}
public int GetHashCode(Color obj) {
unchecked {
int hash = 17;
hash = hash * 23 + Mathf.RoundToInt(obj.r * 255f);
hash = hash * 23 + Mathf.RoundToInt(obj.g * 255f);
hash = hash * 23 + Mathf.RoundToInt(obj.b * 255f);
hash = hash * 23 + Mathf.RoundToInt(obj.a * 255f);
return hash;
}
}
}
}