diff --git a/Components/Components/Decals/ChiselDecalComponent.cs b/Components/Components/Decals/ChiselDecalComponent.cs index efde548..866f630 100644 --- a/Components/Components/Decals/ChiselDecalComponent.cs +++ b/Components/Components/Decals/ChiselDecalComponent.cs @@ -65,6 +65,15 @@ public sealed class ChiselDecalComponent : MonoBehaviour [SerializeField] int imageTile = 0; [SerializeField] Vector2 repeat = Vector2.one; + // Per pair of targets, the edge a trim decal lies on: the edge's number along the first surface's outline, times + // two, plus one when it is the second surface's; -1 (or missing) to find it from where the decal is + [SerializeField] List trimEdges = new(); + + // The line a trim decal's first edge lay on at its last update, in world space: of two surfaces' edges, the trim + // keeps to this one while the decal is on it (so dragging it past where another edge starts doesn't jump) + [NonSerialized] internal bool hasTrimLine; + [NonSerialized] internal Vector3 trimLinePoint, trimLineDirection; + public const string kMaterialName = nameof(material); public const string kSettingsName = nameof(settings); public const string kTargetsName = nameof(targets); @@ -147,7 +156,52 @@ public void SetTargets(IEnumerable newTargets) targets.Clear(); if (newTargets != null) targets.AddRange(newTargets); + trimEdges.Clear(); + hasTrimLine = false; + ChiselDecalManager.SetDirty(); + } + + /// The edge a trim decal's pair of targets lies on: its number along the first surface's outline (the + /// second's, with onSecond); -1 when it is found from where the decal is + public int TrimEdgeOf(int pair, out bool onSecond) + { + var code = pair >= 0 && pair < trimEdges.Count ? trimEdges[pair] : -1; + onSecond = code >= 0 && (code & 1) != 0; + return code < 0 ? -1 : code >> 1; + } + + /// The edge the first pair of targets lies on (see ) + public void SetTrimEdge(bool onSecond, int edgeIndex) + { + if (trimEdges.Count == 0) + trimEdges.Add(-1); + trimEdges[0] = edgeIndex < 0 ? -1 : edgeIndex * 2 + (onSecond ? 1 : 0); + ChiselDecalManager.SetDirty(); + } + + /// Adds an edge to a trim decal's chain: a pair of targets and the edge they lie on. False, and nothing + /// added, when the chain already has that edge of that surface. + public bool AddTrimEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, bool onSecond, int edgeIndex) + { + var pair = targets.Count / 2; + var code = edgeIndex < 0 ? -1 : edgeIndex * 2 + (onSecond ? 1 : 0); + var owner = onSecond ? b : a; + for (int p = 0; p < pair && code >= 0; p++) + { + var existing = TrimEdgeOf(p, out var existingOnSecond); + var existingOwner = targets[p * 2 + (existingOnSecond ? 1 : 0)]; + if (existing == edgeIndex && existingOwner.brush == owner.brush && existingOwner.surfaceIndex == owner.surfaceIndex) + return false; + } + targets.RemoveRange(pair * 2, targets.Count - pair * 2); + targets.Add(a); + targets.Add(b); + while (trimEdges.Count < pair) + trimEdges.Add(-1); + trimEdges.RemoveRange(pair, trimEdges.Count - pair); + trimEdges.Add(code); ChiselDecalManager.SetDirty(); + return true; } public Material Material diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index 47407b6..bce8bcc 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -8,7 +8,7 @@ namespace Chisel.Components /// /// A trim decal lies on the edge where two surfaces meet (surface A and B), its image split along its centre line: the /// lower half on A, the upper half on B, each laid flat on its own surface (Documentation~/Design/Decals.md). Each half - /// is an orthographic box decal limited to its surface: a box in the surface's plane, one side on the edge. + /// is an orthographic box decal in the surface's plane, one side on the edge, drawn on every surface in its range. /// public static class ChiselTrimDecals { @@ -35,7 +35,8 @@ public static class ChiselTrimDecals public struct Half { public ChiselDecalInstance decal; - public ChiselDecalTarget target; + // none: like a box decal, a half draws on every surface in its range that faces it + public ChiselDecalTarget[] targets; } // A surface of a brush, in the tree's space: its plane (unit normal, distance) and its outline @@ -77,13 +78,40 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong var edges = new ChainEdge[edgeCount]; for (int i = 0; i < edgeCount; i++) { - if (!TryEdge(targets[i * 2], targets[i * 2 + 1], out var edgeModel, out edges[i]) || (model != null && edgeModel != model)) + // the edge it was given; without one, the first edge nearest to the decal, the others to the edge before them + var reference = i == 0 ? (float3)decal.transform.position + : (float3)model.transform.localToWorldMatrix.MultiplyPoint3x4(edges[i - 1].start + edges[i - 1].direction * edges[i - 1].length); + var keep = i == 0 && decal.hasTrimLine; + var edgeIndex = decal.TrimEdgeOf(i, out var edgeOnSecond); + if (!TryEdge(targets[i * 2], targets[i * 2 + 1], edgeIndex, edgeOnSecond, reference, keep, decal.trimLinePoint, decal.trimLineDirection, out var edgeModel, out edges[i]) || + (model != null && edgeModel != model)) return false; model = edgeModel; + if (i == 0) + { + var toWorld = model.transform.localToWorldMatrix; + decal.hasTrimLine = true; + decal.trimLinePoint = toWorld.MultiplyPoint3x4(edges[0].start); + decal.trimLineDirection = toWorld.MultiplyVector(edges[0].direction).normalized; + } + } + // an edge on the line of an earlier one that overlaps it is that edge again (the same surface's edge picked on + // both brushes beside it): its halves would draw over the earlier ones + var pairOf = new List(edgeCount); + var kept = new List(edgeCount); + for (int i = 0; i < edgeCount; i++) + { + if (kept.Exists(earlier => Overlaps(earlier, edges[i]))) + continue; + kept.Add(edges[i]); + pairOf.Add(i); } + edges = kept.ToArray(); + edgeCount = edges.Length; if (fill == ChiselTrimFill.Single) PlaceSingle(decal, model, size.x, ref edges[0]); Orient(edges); + JoinCorners(edges, math.max(size.x, size.y)); edgeFrom = edges[0].start; edgeTo = edges[0].start + edges[0].direction * edges[0].length; @@ -94,15 +122,25 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong var n = (i + 1) % edgeCount; if (n == 0 && edgeCount < 3) break; - if (math.distance(edges[i].start + edges[i].direction * edges[i].length, edges[n].start) > kCornerTolerance) + if (math.distance(edges[i].start + edges[i].direction * edges[i].length, edges[n].start) > kMargin) continue; + // a half meets the next edge's half in its plane (on one surface, or surfaces of brushes side by side) on + // the same side of the chain, left or right of the way it runs: with all four halves in one plane (a seam + // on a floor), not the one across the line from it for (int h = 0; h < 2; h++) { - for (int k = 0; k < 2; k++) + var face = h == 0 ? edges[i].faceA : edges[i].faceB; + var side = math.dot(math.cross(edges[i].direction, h == 0 ? edges[i].acrossA : edges[i].acrossB), face.normal) > 0; + var meets = -1; + for (int k = 0; k < 2 && meets < 0; k++) { - if (SameSurface(targets[i * 2 + h], targets[n * 2 + k])) - Mitre(ref edges[i], h, ref edges[n], k); + var nextFace = k == 0 ? edges[n].faceA : edges[n].faceB; + if (InOnePlane(face, nextFace) && + math.dot(math.cross(edges[n].direction, k == 0 ? edges[n].acrossA : edges[n].acrossB), nextFace.normal) > 0 == side) + meets = k; } + if (meets >= 0) + Mitre(ref edges[i], h, ref edges[n], meets); } } @@ -123,6 +161,10 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong var sheet = rect != new Rect(0, 0, 1, 1); var piece = 0; var along = 0.0f; + // seen from the first surface, with the image's top towards the edge, the image reads along the chain only + // when it runs left to right; otherwise it runs from the far end (both halves, every edge alike) + var backwards = math.dot(math.cross(edges[0].direction, -edges[0].acrossA), edges[0].faceA.normal) > 0; + var uEnd = sheet ? 1 : total * perMetre; for (int i = 0; i < edgeCount; i++) { ref var edge = ref edges[i]; @@ -147,9 +189,14 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong var pieceStart = edge.start + edge.direction * offset; var first = p == 0; var last = p == pieces - 1; - halves.Add(MakeHalf(edge.faceA, edge.acrossA, targets[i * 2], pieceStart, edge.direction, span, uStart, shown, + if (backwards) + { + uStart = uEnd - uStart; + shown = -shown; + } + halves.Add(MakeHalf(edge.faceA, edge.acrossA, targets[pairOf[i] * 2], pieceStart, edge.direction, span, uStart, shown, first ? edge.slantStartA : 0, last ? edge.slantEndA : 0, rect, settings, id, renderMaterial, destinationFlags, lower: true)); - halves.Add(MakeHalf(edge.faceB, edge.acrossB, targets[i * 2 + 1], pieceStart, edge.direction, span, uStart, shown, + halves.Add(MakeHalf(edge.faceB, edge.acrossB, targets[pairOf[i] * 2 + 1], pieceStart, edge.direction, span, uStart, shown, first ? edge.slantStartB : 0, last ? edge.slantEndB : 0, rect, settings, id | kSecondHalf, renderMaterial, destinationFlags, lower: decal.TrimMirror)); } along += edge.length; @@ -157,7 +204,6 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong return true; } - const float kCornerTolerance = 1e-3f; // One edge of a trim: its surfaces, the stretch both reach, and per half the direction into its surface and how its // ends lean (x per y in the half's box, 0: square; a corner's mitre) @@ -170,7 +216,8 @@ struct ChainEdge public float slantStartA, slantEndA, slantStartB, slantEndB; } - static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, out ChiselModelComponent model, out ChainEdge edge) + static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, int edgeIndex, bool edgeOfB, float3 worldReference, bool keep, + float3 worldKeepPoint, float3 worldKeepDirection, out ChiselModelComponent model, out ChainEdge edge) { edge = default; model = null; @@ -178,18 +225,34 @@ static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, out return false; model = modelA; - // the line both planes lie on - var direction = math.cross(faceA.normal, faceB.normal); - var lengthSq = math.lengthsq(direction); - if (lengthSq < kEpsilon * kEpsilon) + var toModel = model.transform.worldToLocalMatrix; + var reference = (float3)toModel.MultiplyPoint3x4(worldReference); + var kept = new KeptLine { keep = keep, point = toModel.MultiplyPoint3x4(worldKeepPoint), direction = math.normalizesafe((float3)toModel.MultiplyVector(worldKeepDirection)) }; + float3 point, direction; + var owner = edgeOfB ? faceB : faceA; + if (edgeIndex >= 0 && edgeIndex < owner.outline.Count) + { + // the edge it was given + point = owner.outline[edgeIndex]; + direction = owner.outline[(edgeIndex + 1) % owner.outline.Count] - point; + if (math.lengthsq(direction) < kEpsilon * kEpsilon) + return false; + direction = math.normalize(direction); + } else if (!LineOf(faceA, faceB, reference, kept, out point, out direction, out _)) return false; - var point = (math.cross(faceB.normal, direction) * faceA.distance + math.cross(direction, faceA.normal) * faceB.distance) / lengthSq; - direction = math.normalize(direction); - // where both surfaces reach it - float from = float.NegativeInfinity, to = float.PositiveInfinity; - if (!Clip(faceA, point, direction, ref from, ref to) || !Clip(faceB, point, direction, ref from, ref to) || to - from <= kEpsilon) + // where both surfaces reach it; when only one of them has an edge on the line (a wall standing on a floor), + // that whole edge, as the floor beside it may be another brush + float fromA = float.NegativeInfinity, toA = float.PositiveInfinity, fromB = float.NegativeInfinity, toB = float.PositiveInfinity; + if (!Clip(faceA, point, direction, ref fromA, ref toA) || !Clip(faceB, point, direction, ref fromB, ref toB) || + math.min(toA, toB) - math.max(fromA, fromB) <= kEpsilon) return false; + var edgeOnA = HasEdgeOn(faceA, point, direction); + var edgeOnB = HasEdgeOn(faceB, point, direction); + float from, to; + if (edgeOnA && !edgeOnB) { from = fromA; to = toA; } + else if (edgeOnB && !edgeOnA) { from = fromB; to = toB; } + else { from = math.max(fromA, fromB); to = math.min(toA, toB); } var start = point + direction * from; edge = new ChainEdge { @@ -198,8 +261,8 @@ static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, out start = start, direction = direction, length = to - from, - acrossA = Across(faceA, faceB.normal, start, direction), - acrossB = Across(faceB, faceA.normal, start, direction) + acrossA = Across(faceA, faceB, start, direction), + acrossB = Across(faceB, faceA, start, direction) }; return true; } @@ -216,6 +279,50 @@ static void PlaceSingle(ChiselDecalComponent decal, ChiselModelComponent model, edge.length = imageLength; } + // Whether the edge lies on the line of the other and overlaps it + static bool Overlaps(in ChainEdge other, in ChainEdge edge) + { + var end = edge.start + edge.direction * edge.length; + if (math.length(math.cross(edge.start - other.start, other.direction)) >= kMargin || + math.length(math.cross(end - other.start, other.direction)) >= kMargin) + return false; + var t0 = math.dot(edge.start - other.start, other.direction); + var t1 = math.dot(end - other.start, other.direction); + return math.min(other.length, math.max(t0, t1)) - math.max(0, math.min(t0, t1)) > kMargin; + } + + // Edges that run past a corner (the surfaces of brushes overlapping there, however far) or stop short of it (by + // no more than reach) end where their lines cross + static void JoinCorners(ChainEdge[] edges, float reach) + { + var count = edges.Length; + for (int i = 0; i < count && count > 1; i++) + { + var n = (i + 1) % count; + if (n == 0 && count < 3) + break; + ref var edge = ref edges[i]; + ref var next = ref edges[n]; + // the closest points of the two lines: edge.start + s along it, next.start + t along the next + var along = math.dot(edge.direction, next.direction); + var denominator = 1 - along * along; + if (denominator < 1e-6f) + continue; + var offset = edge.start - next.start; + var d = math.dot(edge.direction, offset); + var e = math.dot(next.direction, offset); + var s = (along * e - d) / denominator; + var t = (e - along * d) / denominator; + if (math.distance(edge.start + edge.direction * s, next.start + next.direction * t) > kMargin) + continue; + if (s <= kMargin || s > edge.length + reach || t < -reach || t >= next.length - kMargin) + continue; + edge.length = s; + next.start += next.direction * t; + next.length -= t; + } + } + // Each edge running on from the one before it: its start at the end it shares with its neighbour static void Orient(ChainEdge[] edges) { @@ -264,11 +371,11 @@ static void Mitre(ref ChainEdge edge, int h, ref ChainEdge next, int k) if (k == 0) next.slantStartA = nextSlantStart; else next.slantStartB = nextSlantStart; } - static bool SameSurface(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b) => a.brush == b.brush && a.surfaceIndex == b.surfaceIndex; + static bool InOnePlane(in Face a, in Face b) => math.dot(a.normal, b.normal) > kNearlyParallel && math.abs(a.distance - b.distance) <= kMargin; // Across the edge, into the surface: the side its outline is on, or where it lies on both (a wall standing on a // floor), the side the other surface faces, as the other side is under the other's brush - static float3 Across(in Face face, float3 otherNormal, float3 start, float3 direction) + static float3 Across(in Face face, in Face other, float3 start, float3 direction) { var across = math.normalize(math.cross(face.normal, direction)); float below = 0, above = 0; @@ -279,7 +386,9 @@ static float3 Across(in Face face, float3 otherNormal, float3 start, float3 dire above = math.max(above, side); } var bothSides = below < -kEpsilon && above > kEpsilon; - if (bothSides ? math.dot(otherNormal, across) < 0 : -below > above) + // in one plane with the other face (which overlaps it), away from the other face, where this one shows + var coplanar = math.dot(face.normal, other.normal) > kNearlyParallel; + if (bothSides ? (coplanar ? math.dot(other.centroid - start, across) > 0 : math.dot(other.normal, across) < 0) : -below > above) across = -across; return across; } @@ -294,14 +403,17 @@ static Half MakeHalf(in Face face, float3 across, ChiselDecalSurfaceTarget surfa // the image's v is 0.5 on the edge: down to 0 across A, up to 1 across B; u runs along the edge from uStart; the // image is the rect of the texture - var trimScale = new float2(shown, lower ? -0.5f : 0.5f) * (float2)rect.size; - var trimOffset = (float2)rect.position + new float2(uStart, 0.5f) * (float2)rect.size; + ChiselDecalSettings.TurnMapping(new float2(shown, lower ? -0.5f : 0.5f), new float2(uStart, 0.5f), settings.imageTurns, out var imageScale, out var imageOffset); + var trimScale = imageScale * (float2)rect.size; + var trimOffset = (float2)rect.position + imageOffset * (float2)rect.size; var userScale = math.all((float2)settings.uvScale == float2.zero) ? new float2(1) : (float2)settings.uvScale; var half = settings; half.center = new float3(0, height * 0.25f, 0); half.size = new float3(length, height * 0.5f, kMargin * 2); half.projection = ChiselDecalProjection.Orthographic; half.endSlant = new float2(slantStart, slantEnd); + // only the surfaces that face it, not ones standing across its range (a wall on the floor it lies on) + half.maxAngle = kHalfMaxAngle; half.uvScale = userScale * trimScale; half.uvOffset = (float2)settings.uvOffset + userScale * trimOffset; return new Half @@ -314,14 +426,12 @@ static Half MakeHalf(in Face face, float3 across, ChiselDecalSurfaceTarget surfa renderMaterial = renderMaterial, destinationFlags = destinationFlags }, - target = new ChiselDecalTarget - { - brushEntityID = UnityEngine.EntityId.ToULong(surface.brush.GetEntityId()), - surfaceIndex = surface.surfaceIndex - } + targets = null }; } + const float kHalfMaxAngle = 45; + // The part of the line inside the face's outline, narrowing [from, to] static bool Clip(in Face face, float3 point, float3 direction, ref float from, ref float to) { @@ -342,7 +452,8 @@ static bool Clip(in Face face, float3 point, float3 direction, ref float from, r var offset = math.dot(point - v0, inward); if (math.abs(slope) < kEpsilon) { - if (offset < -kEpsilon) + // along an edge of the face: on it, up to kMargin outside (a neighbour nearly flush with it) + if (offset < -kMargin) return false; continue; } @@ -367,9 +478,16 @@ public static int SurfaceOf(CSGTreeBrush brush, int polygonIndex) /// same brush on its other side /// public static bool NearestEdge(CSGTreeBrush brush, int polygonIndex, float3 treePoint, out float3 from, out float3 to, out int otherPolygon) + { + return NearestEdge(brush, polygonIndex, treePoint, out from, out to, out otherPolygon, out _); + } + + /// The same, with the edge's number along the polygon's outline + public static bool NearestEdge(CSGTreeBrush brush, int polygonIndex, float3 treePoint, out float3 from, out float3 to, out int otherPolygon, out int edgeIndex) { from = to = float3.zero; otherPolygon = -1; + edgeIndex = -1; var blob = BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh); if (!blob.IsCreated || polygonIndex < 0 || polygonIndex >= blob.Value.polygons.Length) return false; @@ -391,6 +509,7 @@ public static bool NearestEdge(CSGTreeBrush brush, int polygonIndex, float3 tree nearest = distance; from = a; to = b; + edgeIndex = e; // the polygon on the other side: the one across either half edge here that has both its vertices var vertexA = mesh.halfEdges[edge].vertexIndex; var vertexB = mesh.halfEdges[next].vertexIndex; @@ -432,15 +551,97 @@ public static bool PlaneHolds(CSGTreeBrush brush, int polygonIndex, float3 a, fl if (!(length > kEpsilon)) return false; plane /= length; - const float kOnPlane = 1e-3f; + const float kOnPlane = kMargin; return math.abs(math.dot(plane.xyz, a) + plane.w) < kOnPlane && math.abs(math.dot(plane.xyz, b) + plane.w) < kOnPlane; } + /// + /// A surface of another brush in the model (of a generator that makes one brush) that lies along the edge from..to + /// and goes on past it to beyond, facing against viewDirection: the surface that shows across an edge of the clicked + /// brush. All in the model's space. + /// + public static bool SurfaceAcross(ChiselModelComponent model, ChiselNodeComponent clicked, float3 from, float3 to, float3 beyond, + float3 viewDirection, out ChiselDecalSurfaceTarget target) + { + target = default; + if (model == null) + return false; + var best = float.PositiveInfinity; + foreach (var generator in model.GetComponentsInChildren()) + { + if (generator == clicked || generator.hierarchyItem.Model != model || !generator.TopTreeNode.Valid || + generator.TopTreeNode.Type != CSGNodeType.Brush) + continue; + var brush = (CSGTreeBrush)generator.TopTreeNode; + var blob = BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh); + if (!blob.IsCreated) + continue; + for (int p = 0; p < blob.Value.polygons.Length; p++) + { + var surfaceIndex = blob.Value.polygons[p].descriptionIndex; + if (!TryBrushFace(brush, surfaceIndex, out var face) || math.dot(face.normal, viewDirection) >= 0 || !IsDrawn(model, brush, p)) + continue; + if (math.abs(math.dot(face.normal, from) - face.distance) > kMargin || math.abs(math.dot(face.normal, to) - face.distance) > kMargin) + continue; + var onFace = beyond - face.normal * (math.dot(face.normal, beyond) - face.distance); + if (!Inside(face, onFace)) + continue; + // of several, the one most in line with where the click went on to + var distance = math.distance(onFace, beyond); + if (distance >= best) + continue; + best = distance; + target = new ChiselDecalSurfaceTarget { brush = generator, surfaceIndex = surfaceIndex }; + } + } + return best < float.PositiveInfinity; + } + + /// + /// Whether the CSG drew any of a brush's polygon at its last build: false for a surface it discarded (inside + /// another brush); true when the model hasn't been built + /// + public static bool IsDrawn(ChiselModelComponent model, CSGTreeBrush brush, int polygonIndex) + { + if (model == null || !model.Node.Valid || !brush.Valid) + return true; + var lookups = ChiselTreeLookup.Value; + if (!lookups.HasTree(model.Node)) + return true; + var data = lookups[model.Node]; + data.lastJobHandle.Complete(); + if (!data.brushRenderBufferLookup.IsCreated || + !data.brushRenderBufferLookup.TryGetValue(CompactHierarchyManager.GetCompactNodeID(brush), out var buffer) || !buffer.IsCreated) + return true; + ref var surfaces = ref buffer.Value.surfaces; + return polygonIndex < 0 || polygonIndex >= surfaces.Length || surfaces[polygonIndex].indexCount > 0; + } + + static bool Inside(in Face face, float3 point) + { + var outline = face.outline; + for (int i = 0; i < outline.Count; i++) + { + var v0 = outline[i]; + var inward = math.cross(face.normal, outline[(i + 1) % outline.Count] - v0); + if (math.lengthsq(inward) < kEpsilon * kEpsilon) + continue; + if (math.dot(face.centroid - v0, inward) < 0) + inward = -inward; + if (math.dot(point - v0, math.normalize(inward)) < -kEpsilon) + return false; + } + return true; + } + /// An edge a trim decal can lie on: its two surfaces, and the edge's middle in the model's space public struct Edge { public ChiselDecalSurfaceTarget a, b; public float3 middle; + // the edge: its number along a's outline, or b's with edgeOnB (ChiselDecalComponent.SetTrimEdge) + public int edgeIndex; + public bool edgeOnB; } /// The render material of a target's surface (its id), 0 when it has none or doesn't exist @@ -454,7 +655,8 @@ public static ulong MaterialOf(ChiselDecalSurfaceTarget target) /// /// Every edge in the model where a surface drawn with materialA meets one drawn with materialB, each once, the - /// materialA surface first. Only edges within one brush, of generators that make one brush. + /// materialA surface first: edges of one brush, and where surfaces of two brushes meet (a wall standing on a floor). + /// Only generators that make one brush. /// public static void EdgesBetween(ChiselModelComponent model, ulong materialA, ulong materialB, List edges) { @@ -490,13 +692,241 @@ public static void EdgesBetween(ChiselModelComponent model, ulong materialA, ulo continue; edges.Add(new Edge { - a = new ChiselDecalSurfaceTarget { brush = generator, surfaceIndex = mesh.polygons[p].descriptionIndex }, - b = new ChiselDecalSurfaceTarget { brush = generator, surfaceIndex = mesh.polygons[other].descriptionIndex }, - middle = math.transform(nodeToTree, middle) + a = new ChiselDecalSurfaceTarget { brush = generator, surfaceIndex = mesh.polygons[p].descriptionIndex }, + b = new ChiselDecalSurfaceTarget { brush = generator, surfaceIndex = mesh.polygons[other].descriptionIndex }, + middle = math.transform(nodeToTree, middle), + edgeIndex = e }); } } } + EdgesBetweenBrushes(model, materialA, materialB, edges); + } + + // A surface of a generator's brush with one of the two materials, and its bounds + struct MaterialFace + { + public ChiselGeneratorComponent generator; + public int surfaceIndex; + public bool isA, isB; + public Face face; + public float3 min, max; + } + + // Where a materialA surface of one brush meets a materialB surface of another, along an edge of either + static void EdgesBetweenBrushes(ChiselModelComponent model, ulong materialA, ulong materialB, List edges) + { + var faces = new List(); + foreach (var generator in model.GetComponentsInChildren()) + { + if (generator.hierarchyItem.Model != model || !generator.TopTreeNode.Valid || generator.TopTreeNode.Type != CSGNodeType.Brush) + continue; + var brush = (CSGTreeBrush)generator.TopTreeNode; + var blob = BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh); + if (!blob.IsCreated) + continue; + for (int p = 0; p < blob.Value.polygons.Length; p++) + { + var material = blob.Value.polygons[p].surface.parameters.parameter1; + if (material != materialA && material != materialB) + continue; + var surfaceIndex = blob.Value.polygons[p].descriptionIndex; + if (!TryBrushFace(brush, surfaceIndex, out var face)) + continue; + float3 min = face.outline[0], max = face.outline[0]; + foreach (var vertex in face.outline) + { + min = math.min(min, vertex); + max = math.max(max, vertex); + } + faces.Add(new MaterialFace { generator = generator, surfaceIndex = surfaceIndex, isA = material == materialA, isB = material == materialB, + face = face, min = min - kMargin, max = max + kMargin }); + } + } + + var seen = new HashSet<(ulong, int, ulong, int)>(); + foreach (var a in faces) + { + if (!a.isA) + continue; + var keyA = UnityEngine.EntityId.ToULong(a.generator.GetEntityId()); + foreach (var b in faces) + { + if (!b.isB || b.generator == a.generator || math.any(a.min > b.max) || math.any(b.min > a.max)) + continue; + var keyB = UnityEngine.EntityId.ToULong(b.generator.GetEntityId()); + // each pair of surfaces once, also when both materials are the same + var key = keyA < keyB ? (keyA, a.surfaceIndex, keyB, b.surfaceIndex) : (keyB, b.surfaceIndex, keyA, a.surfaceIndex); + if (!Meet(a.face, b.face, out var middle, out var edgeIndex, out var edgeOnB) || !seen.Add(key)) + continue; + edges.Add(new Edge + { + a = new ChiselDecalSurfaceTarget { brush = a.generator, surfaceIndex = a.surfaceIndex }, + b = new ChiselDecalSurfaceTarget { brush = b.generator, surfaceIndex = b.surfaceIndex }, + middle = middle, + edgeIndex = edgeIndex, + edgeOnB = edgeOnB + }); + } + } + } + + // Two faces of different brushes that meet: their planes cross on a line along an edge of either, both reach a + // stretch of it, and each has some of itself in front of the other's plane (not all of it inside the other's brush) + static bool Meet(in Face a, in Face b, out float3 middle, out int edgeIndex, out bool edgeOnB) + { + middle = default; + edgeIndex = -1; + edgeOnB = false; + if (!LineOf(a, b, (a.centroid + b.centroid) * 0.5f, default, out var point, out var direction, out var coplanar)) + return false; + float from = float.NegativeInfinity, to = float.PositiveInfinity; + if (!Clip(a, point, direction, ref from, ref to) || !Clip(b, point, direction, ref from, ref to) || to - from <= kMargin) + return false; + if (!HasEdgeOn(a, point, direction) && !HasEdgeOn(b, point, direction)) + return false; + if (!coplanar && (!InFrontOf(a, b) || !InFrontOf(b, a))) + return false; + middle = point + direction * ((from + to) * 0.5f); + edgeIndex = EdgeOn(a, point, direction); + if (edgeIndex < 0) + { + edgeIndex = EdgeOn(b, point, direction); + edgeOnB = true; + } + return edgeIndex >= 0; + } + + // The number of the face's outline edge that lies on the line, -1 when none does + static int EdgeOn(in Face face, float3 point, float3 direction) + { + var outline = face.outline; + for (int i = 0; i < outline.Count; i++) + { + if (math.length(math.cross(outline[i] - point, direction)) < kMargin && + math.length(math.cross(outline[(i + 1) % outline.Count] - point, direction)) < kMargin) + return i; + } + return -1; + } + + // A line to keep to: the one the trim lay on + struct KeptLine + { + public bool keep; + public float3 point, direction; + + public readonly bool Holds(float3 from, float3 to) + { + return keep && math.length(math.cross(from - point, direction)) < kMargin && math.length(math.cross(to - point, direction)) < kMargin; + } + } + + // The line two faces meet on, nearest to the reference point: an edge they share (up to kMargin apart), or for two + // faces in one plane (brushes side by side, or overlapping) an edge of the one that runs through the other; failing + // that, where their planes cross + static bool LineOf(in Face a, in Face b, float3 reference, KeptLine kept, out float3 point, out float3 direction, out bool coplanar) + { + coplanar = math.dot(a.normal, b.normal) > kNearlyParallel; + var inPlane = coplanar && math.abs(a.distance - b.distance) <= kMargin; + point = default; + direction = default; + var best = float.PositiveInfinity; + var onKept = false; + NearestSharedEdge(a, b, inPlane, reference, kept, ref onKept, ref best, ref point, ref direction); + NearestSharedEdge(b, a, inPlane, reference, kept, ref onKept, ref best, ref point, ref direction); + if (best < float.PositiveInfinity) + return true; + direction = math.cross(a.normal, b.normal); + var lengthSq = math.lengthsq(direction); + if (coplanar || lengthSq < kEpsilon * kEpsilon) + return false; + point = (math.cross(b.normal, direction) * a.distance + math.cross(direction, a.normal) * b.distance) / lengthSq; + direction = math.normalize(direction); + return true; + } + + // Faces whose normals are closer than this (about 2.5 degrees) are nearly in one plane, where their planes cross is + // no use + const float kNearlyParallel = 0.999f; + + // The edge of the face, nearest to the reference point, that the other face shares, or that runs through the other + // face when the two lie in one plane (not along its border) + static void NearestSharedEdge(in Face face, in Face other, bool inPlane, float3 reference, KeptLine kept, ref bool onKept, ref float best, + ref float3 point, ref float3 direction) + { + var outline = face.outline; + for (int i = 0; i < outline.Count; i++) + { + var from = outline[i]; + var along = outline[(i + 1) % outline.Count] - from; + var length = math.length(along); + if (length < kEpsilon) + continue; + along /= length; + var candidate = SharesEdge(other, from, along, length); + if (!candidate && inPlane && !HasEdgeOn(other, from, along)) + { + float start = 0, end = length; + candidate = Clip(other, from, along, ref start, ref end) && end - start > kMargin; + } + if (!candidate) + continue; + var t = math.clamp(math.dot(reference - from, along), 0, length); + var distance = math.distance(reference, from + along * t); + // the line the trim lay on wins as long as it is still there: dragging the decal off it doesn't move the + // trim to another seam, only picking does + var isKept = kept.Holds(from, from + along * length); + if (onKept && !isKept) + continue; + if (distance >= best && !(isKept && !onKept)) + continue; + onKept |= isKept; + best = distance; + point = from; + direction = along; + } + } + + // Whether an edge of the face lies on the line and overlaps its stretch [0, length] by more than kMargin + static bool SharesEdge(in Face face, float3 point, float3 direction, float length) + { + var outline = face.outline; + for (int i = 0; i < outline.Count; i++) + { + var v0 = outline[i]; + var v1 = outline[(i + 1) % outline.Count]; + if (math.length(math.cross(v0 - point, direction)) >= kMargin || math.length(math.cross(v1 - point, direction)) >= kMargin) + continue; + var t0 = math.dot(v0 - point, direction); + var t1 = math.dot(v1 - point, direction); + if (math.min(length, math.max(t0, t1)) - math.max(0, math.min(t0, t1)) > kMargin) + return true; + } + return false; + } + + static bool HasEdgeOn(in Face face, float3 point, float3 direction) + { + var outline = face.outline; + for (int i = 0; i < outline.Count; i++) + { + if (math.length(math.cross(outline[i] - point, direction)) < kMargin && + math.length(math.cross(outline[(i + 1) % outline.Count] - point, direction)) < kMargin) + return true; + } + return false; + } + + // Some of the face lies in front of the other face's plane + static bool InFrontOf(in Face face, in Face other) + { + foreach (var vertex in face.outline) + { + if (math.dot(other.normal, vertex) - other.distance > kMargin) + return true; + } + return false; } static bool TryBrushOf(CSGTreeNode node, int surfaceIndex, out CSGTreeBrush brush, out int polygon) diff --git a/Components/Managers/ChiselDecalManager.cs b/Components/Managers/ChiselDecalManager.cs index 9848007..16165a5 100644 --- a/Components/Managers/ChiselDecalManager.cs +++ b/Components/Managers/ChiselDecalManager.cs @@ -349,7 +349,21 @@ static bool SameTrim(DecalState state, ChiselModelComponent model) return false; for (int p = 0; p < s_TrimHalves.Count; p++) { - if (!s_TrimHalves[p].decal.Equals(state.trimPieces[p]) || !s_TrimHalves[p].target.Equals(state.trimTargets[p][0])) + if (!s_TrimHalves[p].decal.Equals(state.trimPieces[p]) || !SameTargets(s_TrimHalves[p].targets, state.trimTargets[p])) + return false; + } + return true; + } + + static bool SameTargets(ChiselDecalTarget[] a, ChiselDecalTarget[] b) + { + if (a == null || b == null) + return a == b; + if (a.Length != b.Length) + return false; + for (int i = 0; i < a.Length; i++) + { + if (!a[i].Equals(b[i])) return false; } return true; @@ -371,7 +385,7 @@ static bool UpdateTrim(DecalState state) foreach (var half in s_TrimHalves) { state.trimPieces.Add(half.decal); - state.trimTargets.Add(new[] { half.target }); + state.trimTargets.Add(half.targets); } return true; } @@ -472,8 +486,9 @@ static void Describe(ChiselDecalComponent decal, DecalState decalState, Matrix4x var tile = settings; tile.center = new float3((from + to) * 0.5f, settings.center.z); tile.size = new float3(to - from, size.z); - tile.uvScale = userScale * (float2)rect.size * shown; - tile.uvOffset = (float2)settings.uvOffset + userScale * (float2)rect.position; + ChiselDecalSettings.TurnMapping(shown, float2.zero, settings.imageTurns, out var imageScale, out var imageOffset); + tile.uvScale = userScale * (float2)rect.size * imageScale; + tile.uvOffset = (float2)settings.uvOffset + userScale * ((float2)rect.position + (float2)rect.size * imageOffset); instance.entityID = ChiselTrimDecals.PieceEntityID(decalState.entityID, piece); instance.settings = tile; tiles.Add(instance); diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index c2948c2..e4bf96a 100644 --- a/Components/Tests/ChiselTrimDecalTests.cs +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -58,8 +58,10 @@ static ChiselBoxComponent Box(ChiselModelComponent model, float3 min, float3 max var go = new GameObject("Box"); go.transform.SetParent(model.transform, false); var box = go.AddComponent(); - if (math.any(min > box.Max)) { box.Max = max; box.Min = min; } - else { box.Min = min; box.Max = max; } + // Max far out first, so setting Min never has to move it + box.Max = new float3(1e4f); + box.Min = min; + box.Max = max; return box; } @@ -280,6 +282,438 @@ public void TheEdgesWhereTwoMaterialsMeet_AreFound() Assert.That(edges.Count, Is.EqualTo(8), "the four vertical edges and the bottom's four (it has the side material too), each once"); } + static void Paint(ChiselBoxComponent box, Material material) + { + var surfaces = box.SurfaceDefinition.surfaces; + for (int i = 0; i < surfaces.Length; i++) + surfaces[i].SetMaterial(material); + box.OnValidate(); + } + + // A wall standing across the seam of two floor brushes: its trim runs along the wall's whole foot, not only where + // the one floor brush it names reaches + [Test] + public void ATrimBetweenTwoBrushes_RunsAlongTheWholeEdgeOfTheOneThatHasIt() + { + var model = Model(); + var left = Box(model, new float3(-4, -1, -4), new float3(0, 0, 4)); + var right = Box(model, new float3(0, -1, -4), new float3(4, 0, 4)); + var wall = Box(model, new float3(-1, 0, -1), new float3(1, 2, 1)); + var decal = Trim(model, left, SurfaceFacing(left, new float3(0, 1, 0)), wall, SurfaceFacing(wall, new float3(0, 0, 1))); + Update(); + var halves = new List(); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out var from, out var to), Is.True); + Assert.That(math.min(from.x, to.x), Is.EqualTo(-1).Within(1e-4f), "from the wall's one end"); + Assert.That(math.max(from.x, to.x), Is.EqualTo(1).Within(1e-4f), "to its other end, over the other floor brush"); + + // the floor half draws on both floor brushes' tops in front of the wall, the wall half on the wall's front only + double leftFloor = 0, rightFloor = 0, wallFront = 0, other = 0; + foreach (var triangle in DecalTriangles(model)) + { + var middle = (triangle.a + triangle.b + triangle.c) / 3; + if (triangle.normal.y > 0.9f) { if (middle.x < 0) leftFloor += triangle.area; else rightFloor += triangle.area; } + else if (triangle.normal.z > 0.9f) wallFront += triangle.area; + else other += triangle.area; + } + Assert.That(leftFloor, Is.EqualTo(0.5).Within(1e-3), "on the left floor brush"); + Assert.That(rightFloor, Is.EqualTo(0.5).Within(1e-3), "on the right floor brush"); + Assert.That(wallFront, Is.EqualTo(1.0).Within(1e-3), "on the wall's front"); + Assert.That(other, Is.EqualTo(0).Within(1e-6), "nowhere else"); + } + + // Seen from its surface, every half's image runs left to right with its top up: not mirrored, whichever way the + // line the two surfaces meet on happens to run + [Test] + public void TrimHalves_AreNotMirrored() + { + var model = Model(); + var box = Box(model, new float3(-6, -1, -1), new float3(-4, 1, 1)); + var floor = Box(model, new float3(-4, -1, -4), new float3(4, 0, 4)); + var wall = Box(model, new float3(-1, 0, -1), new float3(1, 2, 1)); + var trims = new[] + { + Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(1, 0, 0))), + Trim(model, box, SurfaceFacing(box, new float3(1, 0, 0)), box, SurfaceFacing(box, new float3(0, 1, 0))), + Trim(model, floor, SurfaceFacing(floor, new float3(0, 1, 0)), wall, SurfaceFacing(wall, new float3(0, 0, 1))), + Trim(model, wall, SurfaceFacing(wall, new float3(0, 0, -1)), floor, SurfaceFacing(floor, new float3(0, 1, 0))), + }; + Update(); + var halves = new List(); + foreach (var trim in trims) + { + Assert.That(ChiselTrimDecals.TryDescribe(trim, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out _, out _), Is.True); + foreach (var half in halves) + { + var toTree = half.decal.decalToTree; + var right = math.mul(toTree, new float4(1, 0, 0, 0)).xyz * math.sign(half.decal.settings.uvScale.x); + var up = math.mul(toTree, new float4(0, 1, 0, 0)).xyz * math.sign(half.decal.settings.uvScale.y); + var into = math.mul(toTree, new float4(0, 0, 1, 0)).xyz; + Assert.That(math.dot(math.cross(right, up), into), Is.GreaterThan(0), $"{trim.name}: a half is mirrored"); + } + } + } + + // Brushes side by side that are nearly flush (one a few millimetres higher, or tilted a fraction of a degree): the + // trim still lies across their seam + [TestCase(0.003f, 0f)] + [TestCase(0f, 0.2f)] + public void ATrimOnTheSeamOfNearlyFlushBrushes_LiesAcrossIt(float step, float tilt) + { + var model = Model(); + var left = Box(model, new float3(-4, -1, -4), new float3(0, 0, 4)); + var right = Box(model, new float3(-2, -1, -4), new float3(2, step, 4)); + right.transform.localPosition = new Vector3(2, 0, 0); + right.transform.localRotation = Quaternion.Euler(0, 0, tilt); + var decal = Trim(model, left, SurfaceFacing(left, new float3(0, 1, 0)), right, SurfaceFacing(right, new float3(0, 1, 0))); + Update(); + var halves = new List(); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out var from, out var to), Is.True, "the seam is an edge"); + Assert.That(math.abs(from.x), Is.LessThan(0.01f)); + Assert.That(math.abs(to.z - from.z), Is.EqualTo(8).Within(0.01f), "along the whole seam"); + Assert.That(model.Node.GetDecalCount(), Is.GreaterThan(0), "and it is drawn"); + } + + // A and B flush, C overlapping B by a metre with its top in the same plane: the seam is C's edge on B's top, and the + // trim lies across it, B's half on the part of B that shows + [Test] + public void ATrimOnTheSeamOfOverlappingBrushes_LiesAcrossIt() + { + var model = Model(); + Box(model, new float3(-4, -1, -4), new float3(0, 0, 4)); + var b = Box(model, new float3(0, -1, -4), new float3(4, 0, 4)); + var c = Box(model, new float3(3, -1, -4), new float3(7, 0, 4)); + var decal = Trim(model, b, SurfaceFacing(b, new float3(0, 1, 0)), c, SurfaceFacing(c, new float3(0, 1, 0))); + Update(); + var halves = new List(); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out var from, out var to), Is.True, "the seam is an edge"); + Assert.That(from.x, Is.EqualTo(3).Within(1e-4f), "on C's edge"); + Assert.That(math.abs(to.z - from.z), Is.EqualTo(8).Within(1e-4f), "along the whole seam"); + for (int h = 0; h < halves.Count; h++) + { + var into = math.mul(halves[h].decal.decalToTree, new float4(0, 1, 0, 0)).x; + Assert.That(h % 2 == 0 ? into < -0.9f : into > 0.9f, Is.True, "B's half towards B, C's into C"); + } + Assert.That(model.Node.GetDecalCount(), Is.GreaterThan(0), "and it is drawn"); + } + + // A box set into a floor, its top flush with the floor's: two seams between the same two surfaces, and the trim + // takes the one nearest to where the decal was put (where the edge was picked) + [TestCase(-3f)] + [TestCase(-2f)] + public void OfTwoSeamsBetweenTheSameSurfaces_TheTrimTakesTheOneItWasPutOn(float x) + { + var model = Model(); + var floor = Box(model, new float3(-4, -1, -4), new float3(0, 0, 4)); + var inset = Box(model, new float3(-3, -0.5f, -4), new float3(-2, 0, 4)); + var decal = Trim(model, floor, SurfaceFacing(floor, new float3(0, 1, 0)), inset, SurfaceFacing(inset, new float3(0, 1, 0))); + // its surfaces given again with the decal where the edge was picked + decal.transform.position = new Vector3(x + 0.1f, 0, 1); + decal.SetTargets(new List(decal.Targets)); + Update(); + var halves = new List(); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out var from, out var to), Is.True); + Assert.That(from.x, Is.EqualTo(x).Within(1e-4f), "on the seam the decal was put on"); + Assert.That(to.x, Is.EqualTo(x).Within(1e-4f)); + } + + // Where two boxes touch, the CSG discards both faces: the edge picker never takes those, the tops are drawn + [Test] + public void TheFacesWhereTwoBoxesTouch_AreNotDrawn() + { + var model = Model(); + var left = Box(model, new float3(-4, -1, -4), new float3(0, 0, 4)); + var right = Box(model, new float3(0, -1, -4), new float3(4, 0, 4)); + Update(); + int PolygonFacing(ChiselBoxComponent box, float3 normal) + { + ref var mesh = ref BrushMeshManager.GetBrushMeshBlob(((CSGTreeBrush)box.TopTreeNode).BrushMesh).Value; + for (int p = 0; p < mesh.polygons.Length; p++) + { + if (math.dot(math.normalize(mesh.localPlanes[p].xyz), normal) > 0.999f) + return p; + } + return -1; + } + Assert.That(ChiselTrimDecals.IsDrawn(model, (CSGTreeBrush)left.TopTreeNode, PolygonFacing(left, new float3(1, 0, 0))), Is.False, "the left box's face against the right"); + Assert.That(ChiselTrimDecals.IsDrawn(model, (CSGTreeBrush)right.TopTreeNode, PolygonFacing(right, new float3(-1, 0, 0))), Is.False, "the right box's face against the left"); + Assert.That(ChiselTrimDecals.IsDrawn(model, (CSGTreeBrush)left.TopTreeNode, PolygonFacing(left, new float3(0, 1, 0))), Is.True, "the left top"); + Assert.That(ChiselTrimDecals.IsDrawn(model, (CSGTreeBrush)right.TopTreeNode, PolygonFacing(right, new float3(-0, 0, 1))), Is.True, "the right box's front"); + } + + // A trim given its edge keeps to it wherever the decal is, and so does one whose decal is dragged along its edge + // past where another seam between the same surfaces starts + [Test] + public void ATrimKeepsToItsEdge_WhereverTheDecalIs() + { + var model = Model(); + var floor = Box(model, new float3(-4, -1, -4), new float3(0, 0, 4)); + var inset = Box(model, new float3(-3, -0.5f, -4), new float3(-2, 0, 4)); + var decal = Trim(model, floor, SurfaceFacing(floor, new float3(0, 1, 0)), inset, SurfaceFacing(inset, new float3(0, 1, 0))); + + // the inset top's edge at x = -3, by its number along the outline + var brush = (CSGTreeBrush)inset.TopTreeNode; + ref var mesh = ref BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh).Value; + var top = -1; + for (int p = 0; p < mesh.polygons.Length; p++) + { + if (mesh.polygons[p].descriptionIndex == SurfaceFacing(inset, new float3(0, 1, 0))) + top = p; + } + var edgeIndex = -1; + var nodeToTree = brush.NodeToTreeSpaceMatrix; + for (int e = 0; e < mesh.polygons[top].edgeCount; e++) + { + var from = math.transform(nodeToTree, mesh.localVertices[mesh.halfEdges[mesh.polygons[top].firstEdge + e].vertexIndex]); + var to = math.transform(nodeToTree, mesh.localVertices[mesh.halfEdges[mesh.polygons[top].firstEdge + (e + 1) % mesh.polygons[top].edgeCount].vertexIndex]); + if (math.abs(from.x + 3) < 1e-4f && math.abs(to.x + 3) < 1e-4f) + edgeIndex = e; + } + Assert.That(edgeIndex, Is.GreaterThanOrEqualTo(0)); + + decal.SetTrimEdge(true, edgeIndex); + decal.transform.position = new Vector3(-2, 0, 1); + Update(); + var halves = new List(); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out var edgeFrom, out _), Is.True); + Assert.That(edgeFrom.x, Is.EqualTo(-3).Within(1e-4f), "on the edge it was given, not the seam the decal is nearest"); + + // without a given edge, dragged along x = -3 to where the floor's edge at z = 4 meets it, it stays on x = -3 + decal.SetTargets(new[] { new ChiselDecalSurfaceTarget { brush = floor, surfaceIndex = SurfaceFacing(floor, new float3(0, 1, 0)) }, + new ChiselDecalSurfaceTarget { brush = inset, surfaceIndex = SurfaceFacing(inset, new float3(0, 1, 0)) } }); + decal.transform.position = new Vector3(-3, 0, 1); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out edgeFrom, out _), Is.True); + Assert.That(edgeFrom.x, Is.EqualTo(-3).Within(1e-4f)); + // at its end, where the shared edge along z = 4 starts too: both run through the decal, and it keeps its line + decal.transform.position = new Vector3(-3, 0, 4); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out edgeFrom, out var edgeTo), Is.True); + Assert.That(edgeFrom.x, Is.EqualTo(-3).Within(1e-4f), "still on x = -3"); + Assert.That(edgeTo.x, Is.EqualTo(-3).Within(1e-4f)); + + // dragged off its line, right up to the other seam: the trim stays on its line, only picking moves it + decal.transform.position = new Vector3(-2.05f, 0, 1); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out edgeFrom, out edgeTo), Is.True); + Assert.That(edgeFrom.x, Is.EqualTo(-3).Within(1e-4f), "still on x = -3, though the decal is next to x = -2"); + Assert.That(edgeTo.x, Is.EqualTo(-3).Within(1e-4f)); + } + + // The edge of a box's surface whose two ends both pass the test, by its number along the surface's outline + static int EdgeOf(ChiselBoxComponent box, float3 normal, System.Func onIt) + { + var brush = (CSGTreeBrush)box.TopTreeNode; + ref var mesh = ref BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh).Value; + var nodeToTree = brush.NodeToTreeSpaceMatrix; + for (int p = 0; p < mesh.polygons.Length; p++) + { + if (math.dot(math.normalize(mesh.localPlanes[p].xyz), normal) < 0.999f) + continue; + ref var polygon = ref mesh.polygons[p]; + for (int e = 0; e < polygon.edgeCount; e++) + { + var from = math.transform(nodeToTree, mesh.localVertices[mesh.halfEdges[polygon.firstEdge + e].vertexIndex]); + var to = math.transform(nodeToTree, mesh.localVertices[mesh.halfEdges[polygon.firstEdge + (e + 1) % polygon.edgeCount].vertexIndex]); + if (onIt(from) && onIt(to)) + return e; + } + } + return -1; + } + + // A chain round the foot of a wall that stands across the seam of two floor brushes, each edge given: the floor + // band is the 3 x 3 square less the wall's 2 x 2, once (the corners mitred, nothing drawn twice, nothing missing), + // and the wall band half a metre up all four sides + [Test] + public void AChainRoundAWallsFoot_AcrossTwoFloorBrushes_DrawsEachCornerOnce() + { + var model = Model(); + var left = Box(model, new float3(-4, -1, -4), new float3(0, 0, 4)); + var right = Box(model, new float3(0, -1, -4), new float3(4, 0, 4)); + var wall = Box(model, new float3(-1, 0, -1), new float3(1, 2, 1)); + Update(); + var decal = Trim(model, left, SurfaceFacing(left, new float3(0, 1, 0)), wall, SurfaceFacing(wall, new float3(0, 0, 1))); + var sides = new[] { new float3(0, 0, 1), new float3(1, 0, 0), new float3(0, 0, -1), new float3(-1, 0, 0) }; + var floors = new[] { left, right, right, left }; + decal.SetTargets(null); + for (int s = 0; s < sides.Length; s++) + { + var side = sides[s]; + var foot = EdgeOf(wall, side, v => math.abs(v.y) < 1e-4f); + Assert.That(foot, Is.GreaterThanOrEqualTo(0)); + decal.AddTrimEdge(new ChiselDecalSurfaceTarget { brush = floors[s], surfaceIndex = SurfaceFacing(floors[s], new float3(0, 1, 0)) }, + new ChiselDecalSurfaceTarget { brush = wall, surfaceIndex = SurfaceFacing(wall, side) }, true, foot); + } + Update(); + + double floorArea = 0, wallArea = 0; + foreach (var triangle in DecalTriangles(model)) + { + if (triangle.normal.y > 0.9f) floorArea += triangle.area; + else wallArea += triangle.area; + } + Assert.That(floorArea, Is.EqualTo(5).Within(1e-3), "the floor band round the wall, each corner once"); + Assert.That(wallArea, Is.EqualTo(4).Within(1e-3), "half a metre up each of the wall's four sides"); + } + + // Two walls that overlap at a corner: each wall's foot runs past the corner, inside the other. A chain round the + // corner ends both edges where they cross, and mitres them there + [TestCase(1f, 1f)] + [TestCase(3f, 0.25f)] + public void AChainRoundACornerWhereWallsOverlap_EndsBothEdgesAtTheCorner(float thickness, float imageSize) + { + var model = Model(); + var floor = Box(model, new float3(-8, -1, -8), new float3(8, 0, 8)); + var wall1 = Box(model, new float3(-3, 0, 0), new float3(thickness, 2, thickness)); + var wall2 = Box(model, new float3(0, 0, -3), new float3(thickness, 2, thickness)); + Update(); + var decal = Trim(model, floor, SurfaceFacing(floor, new float3(0, 1, 0)), wall1, SurfaceFacing(wall1, new float3(0, 0, -1))); + decal.SetTargets(null); + var floorTop = new ChiselDecalSurfaceTarget { brush = floor, surfaceIndex = SurfaceFacing(floor, new float3(0, 1, 0)) }; + decal.AddTrimEdge(floorTop, new ChiselDecalSurfaceTarget { brush = wall1, surfaceIndex = SurfaceFacing(wall1, new float3(0, 0, -1)) }, true, + EdgeOf(wall1, new float3(0, 0, -1), v => math.abs(v.y) < 1e-4f)); + decal.AddTrimEdge(floorTop, new ChiselDecalSurfaceTarget { brush = wall2, surfaceIndex = SurfaceFacing(wall2, new float3(-1, 0, 0)) }, true, + EdgeOf(wall2, new float3(-1, 0, 0), v => math.abs(v.y) < 1e-4f)); + decal.Size = new Vector3(imageSize, imageSize, 1); + decal.TrimFill = ChiselTrimFill.Stretch; + Update(); + var halves = new List(); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out _, out _), Is.True); + Assert.That(halves.Count, Is.EqualTo(4), "two edges, two halves each"); + Assert.That(halves[0].decal.settings.size.x, Is.EqualTo(3).Within(1e-4f), "the first wall's foot up to the corner, not into the other wall"); + Assert.That(halves[2].decal.settings.size.x, Is.EqualTo(3).Within(1e-4f), "the second wall's foot from the corner"); + Assert.That(math.abs(halves[0].decal.settings.endSlant.y), Is.GreaterThan(0.5f), "the floor half of the first edge mitred at the corner"); + Assert.That(math.abs(halves[2].decal.settings.endSlant.x), Is.GreaterThan(0.5f), "the floor half of the second edge mitred at the corner"); + } + + // C overlapping both A and B: picking C's edge on A's stretch and again on B's gives the same edge twice. It is added + // once, and a chain that has it twice draws it once + [Test] + public void TheSameEdgeTwiceInAChain_IsDrawnOnce() + { + var model = Model(); + var a = Box(model, new float3(-4, -1, -4), new float3(0, 0, 0)); + var b = Box(model, new float3(0, -1, -4), new float3(4, 0, 0)); + var c = Box(model, new float3(-4, -1, -1), new float3(4, 0, 4)); + Update(); + var cTop = new ChiselDecalSurfaceTarget { brush = c, surfaceIndex = SurfaceFacing(c, new float3(0, 1, 0)) }; + var edge = EdgeOf(c, new float3(0, 1, 0), v => math.abs(v.z + 1) < 1e-4f); + var decal = Trim(model, c, SurfaceFacing(c, new float3(0, 1, 0)), a, SurfaceFacing(a, new float3(0, 1, 0))); + decal.SetTargets(null); + Assert.That(decal.AddTrimEdge(cTop, new ChiselDecalSurfaceTarget { brush = a, surfaceIndex = SurfaceFacing(a, new float3(0, 1, 0)) }, false, edge), Is.True); + Assert.That(decal.AddTrimEdge(cTop, new ChiselDecalSurfaceTarget { brush = b, surfaceIndex = SurfaceFacing(b, new float3(0, 1, 0)) }, false, edge), Is.False, "the same edge of C again"); + Assert.That(decal.Targets.Count, Is.EqualTo(2)); + + // a chain saved with it twice + decal.SetTargets(null); + decal.AddTrimEdge(cTop, new ChiselDecalSurfaceTarget { brush = a, surfaceIndex = SurfaceFacing(a, new float3(0, 1, 0)) }, false, edge); + decal.AddTrimEdge(cTop, new ChiselDecalSurfaceTarget { brush = b, surfaceIndex = SurfaceFacing(b, new float3(0, 1, 0)) }, false, -1); + Update(); + var halves = new List(); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out _, out _), Is.True); + Assert.That(halves.Count, Is.EqualTo(2), "one edge, two halves"); + Assert.That(halves[0].decal.settings.size.x, Is.EqualTo(8).Within(1e-4f), "C's whole edge, along A and B"); + } + + // A seam on a floor turning a corner (all four halves in one plane): each half is mitred with the next edge's half on + // its own side, so at a right angle every cut is 45 degrees and nothing overlaps + [Test] + public void AChainAlongAFloorSeam_MitresEachSideWithItsOwnSide() + { + var model = Model(); + var floor = Box(model, new float3(-6, -1, -6), new float3(6, 0, 6)); + var inset = Box(model, new float3(-2, -0.5f, -2), new float3(2, 0, 2)); + Update(); + var floorTop = new ChiselDecalSurfaceTarget { brush = floor, surfaceIndex = SurfaceFacing(floor, new float3(0, 1, 0)) }; + var insetTop = new ChiselDecalSurfaceTarget { brush = inset, surfaceIndex = SurfaceFacing(inset, new float3(0, 1, 0)) }; + var decal = Trim(model, floor, SurfaceFacing(floor, new float3(0, 1, 0)), inset, SurfaceFacing(inset, new float3(0, 1, 0))); + decal.SetTargets(null); + decal.AddTrimEdge(floorTop, insetTop, true, EdgeOf(inset, new float3(0, 1, 0), v => math.abs(v.z - 2) < 1e-4f)); + decal.AddTrimEdge(floorTop, insetTop, true, EdgeOf(inset, new float3(0, 1, 0), v => math.abs(v.x - 2) < 1e-4f)); + Update(); + var halves = new List(); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out _, out _), Is.True); + Assert.That(halves.Count, Is.EqualTo(4)); + foreach (var half in halves) + { + var slant = half.decal.settings.endSlant; + var cut = math.abs(slant.x) > 1e-4f ? slant.x : slant.y; + Assert.That(math.abs(cut), Is.EqualTo(1).Within(1e-3f), "a 45 degree cut at the right angle, " + slant); + } + } + + // Two floor brushes side by side, their tops in one plane: a trim on the seam lies flat, half on each, along the seam + [Test] + public void ATrimOnTheSeamOfTwoFlushBrushes_LiesFlatAcrossIt() + { + var model = Model(); + var left = Box(model, new float3(-4, -1, -4), new float3(0, 0, 4)); + var right = Box(model, new float3(0, -1, -4), new float3(4, 0, 4)); + var decal = Trim(model, left, SurfaceFacing(left, new float3(0, 1, 0)), right, SurfaceFacing(right, new float3(0, 1, 0))); + Update(); + var halves = new List(); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out var from, out var to), Is.True, "the seam is an edge"); + Assert.That(from.x, Is.EqualTo(0).Within(1e-4f)); + Assert.That(to.x, Is.EqualTo(0).Within(1e-4f)); + Assert.That(math.abs(to.z - from.z), Is.EqualTo(8).Within(1e-4f), "along the whole seam"); + var across = new List(); + foreach (var half in halves) + across.Add(math.mul(half.decal.decalToTree, new float4(0, 1, 0, 0)).x); + Assert.That(across, Has.Some.LessThan(-0.9f).And.Some.GreaterThan(0.9f), "one half into each brush's top, both flat"); + foreach (var half in halves) + Assert.That(math.abs(math.mul(half.decal.decalToTree, new float4(0, 0, 1, 0)).y), Is.EqualTo(1).Within(1e-4f), "projected straight down"); + Assert.That(model.Node.GetDecalCount(), Is.GreaterThan(0), "and it is drawn"); + + // and placing by rule finds the seam between two materials + var floorA = new Material(decalMaterial) { name = "A" }; + var floorB = new Material(decalMaterial) { name = "B" }; + created.Add(floorA); + created.Add(floorB); + Paint(left, floorA); + Paint(right, floorB); + Update(); + var edges = new List(); + ChiselTrimDecals.EdgesBetween(model, UnityEngine.EntityId.ToULong(floorA.GetEntityId()), UnityEngine.EntityId.ToULong(floorB.GetEntityId()), edges); + Assert.That(edges.Exists(e => e.a.brush == left && e.b.brush == right && math.abs(e.middle.x) < 1e-4f && math.abs(e.middle.y) < 1e-4f), + Is.True, "the seam between the two tops"); + } + + // Where two brushes' materials meet: a wall standing on a floor meets it along the four sides of its foot; a wall + // sunk into the floor, and one that doesn't touch it, don't + [Test] + public void TheEdgesWhereTwoBrushesMaterialsMeet_AreFound() + { + var model = Model(); + var floor = Box(model, new float3(-4, -1, -4), new float3(4, 0, 4)); + var wall = Box(model, new float3(-1, 0, -1), new float3(1, 2, 1)); + var sunk = Box(model, new float3(2, -0.75f, 2), new float3(3, -0.25f, 3)); + var floating = Box(model, new float3(-3, 1, 2), new float3(-2, 2, 3)); + var floorMaterial = new Material(decalMaterial) { name = "Floor" }; + var wallMaterial = new Material(decalMaterial) { name = "Wall" }; + created.Add(floorMaterial); + created.Add(wallMaterial); + Paint(floor, floorMaterial); + Paint(wall, wallMaterial); + Paint(sunk, wallMaterial); + Paint(floating, wallMaterial); + Update(); + + var edges = new List(); + ChiselTrimDecals.EdgesBetween(model, UnityEngine.EntityId.ToULong(floorMaterial.GetEntityId()), UnityEngine.EntityId.ToULong(wallMaterial.GetEntityId()), edges); + Assert.That(edges.Count, Is.EqualTo(4), "the four sides of the wall's foot: " + string.Join("; ", edges.ConvertAll(e => $"{e.a.brush.name}#{e.a.surfaceIndex}/{e.b.brush.name}#{e.b.surfaceIndex} at {e.middle}"))); + var top = SurfaceFacing(floor, new float3(0, 1, 0)); + foreach (var edge in edges) + { + Assert.That(edge.a.brush, Is.SameAs(floor), "the first material's surface first"); + Assert.That(edge.a.surfaceIndex, Is.EqualTo(top)); + Assert.That(edge.b.brush, Is.SameAs(wall)); + Assert.That(edge.middle.y, Is.EqualTo(0).Within(1e-5f), "on the floor"); + Assert.That(math.max(math.abs(edge.middle.x), math.abs(edge.middle.z)), Is.EqualTo(1).Within(1e-5f), "along the wall's foot"); + } + + // and a trim on such an edge is drawn + var decal = Trim(model, floor, top, wall, edges[0].b.surfaceIndex); + Update(); + Assert.That(model.Node.GetDecalCount(), Is.GreaterThan(0), "the trim between the two brushes is drawn"); + } + // An image in a trim sheet: a piece for every image along the edge, each inside its rect of the texture, the last // one cut short [Test] diff --git a/Core/1.Input/Decals/ChiselDecal.cs b/Core/1.Input/Decals/ChiselDecal.cs index 3fe8f3b..1e1e5ba 100644 --- a/Core/1.Input/Decals/ChiselDecal.cs +++ b/Core/1.Input/Decals/ChiselDecal.cs @@ -57,10 +57,37 @@ public struct ChiselDecalSettings : IEquatable public float2 uvScale; public float2 uvOffset; + /// How many quarter turns counter-clockwise the image is turned, about its middle, before uvScale and + /// uvOffset: uvOffset + uvScale * TurnImage(p, imageTurns) + public int imageTurns; + /// How an orthographic box's start (x) and end (y) lean: x moved per unit of y in the decal's space, 0 for /// square ends. A trim decal's pieces are cut this way where they meet round a corner. public float2 endSlant; + /// Where in the image a point p of the decal (0 to 1 across it) looks, for an image turned this many quarter + /// turns counter-clockwise + public static float2 TurnImage(float2 p, int quarterTurns) + { + switch (quarterTurns & 3) + { + case 1: return new float2(p.y, 1 - p.x); + case 2: return 1 - p; + case 3: return new float2(1 - p.y, p.x); + default: return p; + } + } + + /// + /// For an image coordinate q = offset + scale * p, the scale and offset that give the same coordinate turned: + /// TurnImage(q, quarterTurns) = turnedOffset + turnedScale * TurnImage(p, quarterTurns) + /// + public static void TurnMapping(float2 scale, float2 offset, int quarterTurns, out float2 turnedScale, out float2 turnedOffset) + { + turnedScale = (quarterTurns & 1) != 0 ? scale.yx : scale; + turnedOffset = TurnImage(offset, quarterTurns) - turnedScale * TurnImage(float2.zero, quarterTurns); + } + public static ChiselDecalSettings Default => new ChiselDecalSettings { center = float3.zero, @@ -85,6 +112,7 @@ public readonly bool Equals(ChiselDecalSettings other) surfaceOffset == other.surfaceOffset && math.all(uvScale == other.uvScale) && math.all(uvOffset == other.uvOffset) && + (imageTurns & 3) == (other.imageTurns & 3) && math.all(endSlant == other.endSlant); } @@ -97,7 +125,7 @@ public override readonly int GetHashCode() hash = math.hash(new uint3(hash, (uint)projection, transparent ? 1u : 0u)); hash = math.hash(new uint2(hash, math.hash(new float3(fieldOfView, maxAngle, surfaceOffset)))); hash = math.hash(new uint3(hash, math.hash(uvScale), math.hash(uvOffset))); - hash = math.hash(new uint2(hash, math.hash(endSlant))); + hash = math.hash(new uint3(hash, math.hash(endSlant), (uint)(imageTurns & 3))); return (int)hash; } } diff --git a/Core/2.Processing/Decals/DecalVolume.cs b/Core/2.Processing/Decals/DecalVolume.cs index f2c1d2d..a194a58 100644 --- a/Core/2.Processing/Decals/DecalVolume.cs +++ b/Core/2.Processing/Decals/DecalVolume.cs @@ -71,6 +71,7 @@ internal struct DecalVolume public float surfaceOffset; public float2 uvScale; public float2 uvOffset; + public int imageTurns; public double cosMaxAngle; // The projection, in decal space @@ -217,6 +218,7 @@ public static bool TryCreate(in ChiselDecalInstance instance, out DecalVolume vo surfaceOffset = settings.transparent ? math.max(0, settings.surfaceOffset) : 0, uvScale = uvScale, uvOffset = settings.uvOffset, + imageTurns = settings.imageTurns, cosMaxAngle = cosMaxAngle, treeToDecal = treeToDecal, @@ -300,7 +302,7 @@ public readonly double2 ProjectImage(double3 treePosition) [MethodImpl(MethodImplOptions.AggressiveInlining)] public readonly float2 ProjectUV(double3 treePosition) { - return uvOffset + uvScale * (float2)ProjectImage(treePosition); + return uvOffset + uvScale * ChiselDecalSettings.TurnImage((float2)ProjectImage(treePosition), imageTurns); } // Whether a surface takes this decal. surfaceNormal is the unit normal of its visible side, and diff --git a/Core/2.Processing/Managers/CSGManager.SkipUnchangedTrees.cs b/Core/2.Processing/Managers/CSGManager.SkipUnchangedTrees.cs index 34fe2b2..e7bb6ef 100644 --- a/Core/2.Processing/Managers/CSGManager.SkipUnchangedTrees.cs +++ b/Core/2.Processing/Managers/CSGManager.SkipUnchangedTrees.cs @@ -250,6 +250,7 @@ static unsafe void AppendDecals(ref UnsafeAppendBuffer buffer, CSGTree tree, Dic decalBuffer.Add(settings.uvScale); decalBuffer.Add(settings.uvOffset); decalBuffer.Add(settings.endSlant); + decalBuffer.Add(settings.imageTurns & 3); decalBuffer.Add(GetIdentity(identities, surfaceParameterIdentity, decal.renderMaterial)); decalBuffer.Add((int)decal.destinationFlags); decalBuffer.Add(decal.targetCount); diff --git a/Core/Tests/Decals/DecalClippingTests.cs b/Core/Tests/Decals/DecalClippingTests.cs index 97a4fc5..e94b7fe 100644 --- a/Core/Tests/Decals/DecalClippingTests.cs +++ b/Core/Tests/Decals/DecalClippingTests.cs @@ -170,6 +170,40 @@ public void UVScaleAndOffset_MapTheImage() Assert.That(volume.ProjectUV(new double3(0.5, 0.5, 0)), Is.EqualTo(new float2(0, 2.5f))); } + // A quarter turn counter-clockwise: the image's lower left corner is at the decal's lower right, its lower right + // at the decal's upper right + [Test] + public void AnImageTurnedLeft_IsTurnedCounterClockwise() + { + var instance = Decal(new float3(1), float4x4.identity); + instance.settings.imageTurns = 1; + var volume = Volume(instance); + Assert.That(volume.ProjectUV(new double3(0.5, -0.5, 0)), Is.EqualTo(new float2(0, 0))); + Assert.That(volume.ProjectUV(new double3(0.5, 0.5, 0)), Is.EqualTo(new float2(1, 0))); + Assert.That(volume.ProjectUV(new double3(-0.5, 0.5, 0)), Is.EqualTo(new float2(1, 1))); + + instance.settings.imageTurns = 3; + Assert.That(Volume(instance).ProjectUV(new double3(-0.5, 0.5, 0)), Is.EqualTo(new float2(0, 0)), "turned right, the lower left is at the upper left"); + } + + // A mapping turned with TurnMapping gives the same image coordinates as the mapping, turned + [Test] + public void ATurnedMapping_IsTheMappingTurned() + { + var random = new Random(7); + for (int i = 0; i < 100; i++) + { + var scale = random.NextFloat2(-3, 3); + var offset = random.NextFloat2(-3, 3); + var turns = random.NextInt(0, 4); + var p = random.NextFloat2(); + ChiselDecalSettings.TurnMapping(scale, offset, turns, out var turnedScale, out var turnedOffset); + var expected = ChiselDecalSettings.TurnImage(offset + scale * p, turns); + var actual = turnedOffset + turnedScale * ChiselDecalSettings.TurnImage(p, turns); + Assert.That(math.distance(actual, expected), Is.LessThan(1e-5f), $"{turns} turns, {scale} {offset} at {p}"); + } + } + [Test] public void TransformedDecal_MatchesItsTransformation() { diff --git a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs index 4f37ab9..5a21e9c 100644 --- a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs +++ b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs @@ -17,18 +17,16 @@ public sealed class ChiselDecalEditor : Editor static readonly GUIContent kMaterialContent = new GUIContent("Material", "What the decal draws with. A decal without a material draws nothing."); static readonly GUIContent kCenterContent = new GUIContent("Center", "The center of the box, in the decal's own space."); - static readonly GUIContent kSizeContent = new GUIContent("Size", "X and Y are the image's width and height (for a perspective decal: at the center of the box), Z is how deep the decal reaches."); + static readonly GUIContent kRotateContent = new GUIContent("Rotate", "Turns the image a quarter turn. A box decal's width and height swap with it, so the image keeps its shape; on a trim decal what ran along the edge runs across it."); + static readonly GUIContent kRotateLeftContent = new GUIContent("90° Left", "A quarter turn counter-clockwise, as seen looking at the surface."); + static readonly GUIContent kRotateRightContent = new GUIContent("90° Right", "A quarter turn clockwise, as seen looking at the surface."); + static readonly GUIContent kSizeContent = new GUIContent("Size", "X and Y are the image's width and height (for a perspective decal: at the center of the box), Z is how deep the decal reaches."); static readonly GUIContent kProjectionContent = new GUIContent("Projection", "Orthographic keeps the image's size at every depth; perspective projects it from a point behind the box, like a projector."); static readonly GUIContent kFieldOfViewContent = new GUIContent("Field Of View", "The perspective projection's vertical angle, in degrees."); static readonly GUIContent kTransparentContent = new GUIContent("Transparent", "A transparent decal is drawn over the surfaces it covers. An opaque decal replaces them in the rendered mesh; the collider keeps them."); static readonly GUIContent kMaxAngleContent = new GUIContent("Max Angle", "Surfaces turned further than this from the direction the image comes from don't take the decal. 180 takes every surface that doesn't face away from the box's center."); static readonly GUIContent kUVScaleContent = new GUIContent("UV Scale", "The texture coordinates are UV Offset + UV Scale * p, where p runs from (0,0) at the image's lower left to (1,1) at its upper right."); static readonly GUIContent kUVOffsetContent = new GUIContent("UV Offset"); - static readonly GUIContent kTargetContent = new GUIContent("Target", "Limits the decal to this brush's surface (surface index -1: all of the brush's surfaces). None: every surface it reaches."); - static readonly GUIContent kFirstSurfaceContent = new GUIContent("First Surface", "The surface that takes the image's lower half."); - static readonly GUIContent kSecondSurfaceContent = new GUIContent("Second Surface", "The surface that takes the image's upper half (with Mirror, its lower half mirrored)."); - static readonly GUIContent kSurfaceIndexContent = new GUIContent("Surface Index", "The brush's surface, or -1 for all of them."); - static readonly GUIContent kClearChainContent = new GUIContent("Clear Chain", "Keeps only the first edge."); static readonly GUIContent kBoxShapeContent = new GUIContent("Box", "The image is projected through the box."); static readonly GUIContent kTrimShapeContent = new GUIContent("Trim", "The image lies on the edge between two surfaces, its lower half on the first, its upper half on the second."); static readonly GUIContent[] kShapeOptions = { kBoxShapeContent, kTrimShapeContent }; @@ -49,9 +47,8 @@ public sealed class ChiselDecalEditor : Editor static readonly GUIContent kTrimHeightContent = new GUIContent("Size", "The image's size across the edge, over both surfaces: each half reaches half of it into its surface."); static readonly GUIContent kPickEdgeContent = new GUIContent("Pick Edge", "Click a surface near one of its edges in the scene view: the trim goes on that edge, between the surface and the one that meets it there. Shift-click adds the edge to the trim's chain round corners, and goes on picking. Escape stops."); static readonly GUIContent kCancelPickContent = new GUIContent("Cancel Picking"); - static readonly GUIContent kPlaceWhereMeetContent = new GUIContent("Place Where Materials Meet", "Puts a copy of this trim decal on every edge in its model where a surface with its first surface's material meets one with its second surface's material (edges within one brush)."); - const string kTrimNeedsTwoMessage = "A trim decal lies on the edge between two surfaces: give it two."; - const string kTrimChainMessage = "Runs on round {0} more edge(s), added with Shift-click while picking."; + static readonly GUIContent kPlaceWhereMeetContent = new GUIContent("Place Where Materials Meet", "Puts a copy of this trim decal on every edge in its model where a surface with its first surface's material meets one with its second surface's material, within a brush or between two."); + const string kTrimNeedsTwoMessage = "A trim decal lies on the edge between two surfaces: pick its edge."; const string kNoMaterialMessage = "A decal without a material draws nothing."; [MenuItem(kCreateMenuPath, false, -2)] @@ -108,28 +105,15 @@ public override void OnInspectorGUI() ImageField(material, serializedObject.FindProperty(ChiselDecalComponent.kImageTileName)); + RotateButtons(settings, trim); EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.uvScale)), kUVScaleContent); EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.uvOffset)), kUVOffsetContent); - var targets = serializedObject.FindProperty(ChiselDecalComponent.kTargetsName); - if (!trim) - TargetField(targets, 0, kTargetContent, single: true); - else - { - TargetField(targets, 0, kFirstSurfaceContent, single: false); - TargetField(targets, 1, kSecondSurfaceContent, single: false); - } if (trim && !serializedObject.isEditingMultipleObjects) { var decal = (ChiselDecalComponent)target; if (decal.Targets.Count < 2) EditorGUILayout.HelpBox(kTrimNeedsTwoMessage, MessageType.Info); - if (decal.Targets.Count > 2) - { - EditorGUILayout.HelpBox(string.Format(kTrimChainMessage, decal.Targets.Count / 2 - 1), MessageType.None); - if (GUILayout.Button(kClearChainContent)) - targets.arraySize = 2; - } var picking = s_PickingFor == decal; if (GUILayout.Button(picking ? kCancelPickContent : kPickEdgeContent)) { @@ -180,6 +164,42 @@ void ImageField(SerializedProperty material, SerializedProperty tile) } } + // A quarter turn of the image; a box decal's width and height (and repeats) swap, so the image keeps its shape + void RotateButtons(SerializedProperty settings, bool trim) + { + var rect = EditorGUILayout.GetControlRect(); + rect = EditorGUI.PrefixLabel(rect, kRotateContent); + var left = new Rect(rect.x, rect.y, rect.width * 0.5f, rect.height); + var right = new Rect(left.xMax, rect.y, rect.width - left.width, rect.height); + var indent = EditorGUI.indentLevel; + EditorGUI.indentLevel = 0; + var turn = 0; + if (GUI.Button(left, kRotateLeftContent, EditorStyles.miniButtonLeft)) turn = 1; + if (GUI.Button(right, kRotateRightContent, EditorStyles.miniButtonRight)) turn = 3; + EditorGUI.indentLevel = indent; + if (turn == 0) + return; + serializedObject.ApplyModifiedProperties(); + foreach (var decalObject in serializedObject.targetObjects) + { + using var decal = new SerializedObject(decalObject); + var decalSettings = decal.FindProperty(ChiselDecalComponent.kSettingsName); + var turns = decalSettings.FindPropertyRelative(nameof(ChiselDecalSettings.imageTurns)); + turns.intValue = (turns.intValue + turn) & 3; + if (!trim) + { + var size = decalSettings.FindPropertyRelative(nameof(ChiselDecalSettings.size)); + var sizeX = size.FindPropertyRelative("x"); + var sizeY = size.FindPropertyRelative("y"); + (sizeX.floatValue, sizeY.floatValue) = (sizeY.floatValue, sizeX.floatValue); + var repeat = decal.FindProperty(ChiselDecalComponent.kRepeatName); + repeat.vector2Value = new Vector2(repeat.vector2Value.y, repeat.vector2Value.x); + } + decal.ApplyModifiedProperties(); + } + serializedObject.Update(); + } + // Under the material, on one line: Transparent, and for a trim decal Mirror static void MaterialToggles(SerializedProperty transparent, SerializedProperty mirror) { @@ -233,43 +253,6 @@ static void EnumToggles(SerializedProperty property, GUIContent label, GUIConten } } - // One target of the list: its brush, and once it has one, its surface. A box decal's target is its only one (none: - // every surface); a trim decal's first two are its first edge - static void TargetField(SerializedProperty targets, int index, GUIContent label, bool single) - { - var exists = index < targets.arraySize; - var element = exists ? targets.GetArrayElementAtIndex(index) : null; - var brush = exists ? element.FindPropertyRelative(nameof(ChiselDecalSurfaceTarget.brush)) : null; - EditorGUI.showMixedValue = targets.hasMultipleDifferentValues; - EditorGUI.BeginChangeCheck(); - var newBrush = EditorGUILayout.ObjectField(label, brush?.objectReferenceValue, typeof(ChiselNodeComponent), true); - EditorGUI.showMixedValue = false; - if (EditorGUI.EndChangeCheck()) - { - // none: every surface, unless there are more (a trim's, kept for switching back) - if (single && newBrush == null && targets.arraySize <= 1) - { - targets.arraySize = 0; - return; - } - while (targets.arraySize <= index) - { - targets.arraySize++; - var added = targets.GetArrayElementAtIndex(targets.arraySize - 1); - added.FindPropertyRelative(nameof(ChiselDecalSurfaceTarget.brush)).objectReferenceValue = null; - added.FindPropertyRelative(nameof(ChiselDecalSurfaceTarget.surfaceIndex)).intValue = ChiselDecalSurfaceTarget.kAllSurfaces; - } - element = targets.GetArrayElementAtIndex(index); - element.FindPropertyRelative(nameof(ChiselDecalSurfaceTarget.brush)).objectReferenceValue = newBrush; - exists = true; - } - if (!exists || element.FindPropertyRelative(nameof(ChiselDecalSurfaceTarget.brush)).objectReferenceValue == null) - return; - EditorGUI.indentLevel++; - EditorGUILayout.PropertyField(element.FindPropertyRelative(nameof(ChiselDecalSurfaceTarget.surfaceIndex)), kSurfaceIndexContent); - EditorGUI.indentLevel--; - } - void OnSceneGUI() { var decal = target as ChiselDecalComponent; @@ -312,11 +295,11 @@ static void PlaceWhereMaterialsMeet(ChiselDecalComponent template) if (edges.Count == 0) return; - var existing = new HashSet<(ChiselNodeComponent, int, int)>(); + var existing = new HashSet<(ChiselNodeComponent, int, ChiselNodeComponent, int)>(); foreach (var decal in Object.FindObjectsByType(FindObjectsSortMode.None)) { - if (decal.Shape == ChiselDecalShape.Trim && decal.Targets.Count >= 2 && decal.Targets[0].brush == decal.Targets[1].brush) - existing.Add((decal.Targets[0].brush, decal.Targets[0].surfaceIndex, decal.Targets[1].surfaceIndex)); + if (decal.Shape == ChiselDecalShape.Trim && decal.Targets.Count >= 2) + existing.Add((decal.Targets[0].brush, decal.Targets[0].surfaceIndex, decal.Targets[1].brush, decal.Targets[1].surfaceIndex)); } Undo.SetCurrentGroupName("Place Trim Decals"); @@ -332,13 +315,15 @@ static void PlaceWhereMaterialsMeet(ChiselDecalComponent template) var modelToWorld = model.transform.localToWorldMatrix; foreach (var edge in edges) { - if (!existing.Add((edge.a.brush, edge.a.surfaceIndex, edge.b.surfaceIndex))) + if (!existing.Add((edge.a.brush, edge.a.surfaceIndex, edge.b.brush, edge.b.surfaceIndex))) continue; var copy = Object.Instantiate(template.gameObject, group); copy.name = template.name; Undo.RegisterCreatedObjectUndo(copy, "Place Trim Decals"); copy.transform.position = modelToWorld.MultiplyPoint3x4(edge.middle); - copy.GetComponent().SetTargets(new[] { edge.a, edge.b }); + var copyDecal = copy.GetComponent(); + copyDecal.SetTargets(new[] { edge.a, edge.b }); + copyDecal.SetTrimEdge(edge.edgeOnB, edge.edgeIndex); } Undo.CollapseUndoOperations(undoGroup); } @@ -356,6 +341,7 @@ struct EdgeCandidate public ChiselDecalSurfaceTarget a, b; public Vector3 from, to; // the edge, in world space public Vector3 at; // the point on it nearest to where was clicked + public int edgeIndex; // its number along a's outline } static void PickEdge(ChiselDecalComponent decal) @@ -381,12 +367,11 @@ static void PickEdge(ChiselDecalComponent decal) { Undo.RecordObjects(new Object[] { decal, decal.transform }, "Pick Trim Edge"); if (addToChain) - { - var chain = new List(decal.Targets) { s_Candidate.a, s_Candidate.b }; - decal.SetTargets(chain); - } else + decal.AddTrimEdge(s_Candidate.a, s_Candidate.b, false, s_Candidate.edgeIndex); + else { decal.SetTargets(new[] { s_Candidate.a, s_Candidate.b }); + decal.SetTrimEdge(false, s_Candidate.edgeIndex); decal.transform.position = s_Candidate.at; } EditorUtility.SetDirty(decal); @@ -441,7 +426,7 @@ static EdgeCandidate FindEdge(Vector2 mousePosition) var brush = hit.brushIntersection.brush; var polygon = hit.brushIntersection.surfaceIndex; var point = (Unity.Mathematics.float3)hit.brushIntersection.surfaceIntersection.treePlaneIntersection; - if (!ChiselTrimDecals.NearestEdge(brush, polygon, point, out var from, out var to, out var otherPolygon)) + if (!ChiselTrimDecals.NearestEdge(brush, polygon, point, out var from, out var to, out var otherPolygon, out var edgeIndex)) return default; var edge = to - from; var nearest = from + edge * Unity.Mathematics.math.saturate(Unity.Mathematics.math.dot(point - from, edge) / Unity.Mathematics.math.max(Unity.Mathematics.math.lengthsq(edge), 1e-12f)); @@ -457,10 +442,20 @@ static EdgeCandidate FindEdge(Vector2 mousePosition) if (PickSurface(HandleUtility.WorldToGUIPoint(modelToWorld.MultiplyPoint3x4(probe)), out var beyond) && !(beyond.brushIntersection.brush == brush && beyond.brushIntersection.surfaceIndex == polygon) && beyond.model == hit.model && + ChiselTrimDecals.IsDrawn(hit.model, beyond.brushIntersection.brush, beyond.brushIntersection.surfaceIndex) && ChiselTrimDecals.PlaneHolds(beyond.brushIntersection.brush, beyond.brushIntersection.surfaceIndex, from, to)) b = new ChiselDecalSurfaceTarget { brush = beyond.treeNode, surfaceIndex = ChiselTrimDecals.SurfaceOf(beyond.brushIntersection.brush, beyond.brushIntersection.surfaceIndex) }; } - if (b.brush == null && otherPolygon >= 0) + // the click past the edge can miss (it depends on the zoom and the angle): then another brush's drawn surface + // that goes on across the edge, and only failing that the clicked brush's own face there, when it is drawn + if (b.brush == null && acrossLength > 1e-5f && Camera.current != null) + { + var viewDirection = (Unity.Mathematics.float3)hit.model.transform.InverseTransformDirection(Camera.current.transform.forward); + var beyondPoint = nearest + across / acrossLength * 0.02f; + if (ChiselTrimDecals.SurfaceAcross(hit.model, hit.treeNode, from, to, beyondPoint, viewDirection, out var acrossTarget)) + b = acrossTarget; + } + if (b.brush == null && otherPolygon >= 0 && ChiselTrimDecals.IsDrawn(hit.model, brush, otherPolygon)) b = new ChiselDecalSurfaceTarget { brush = hit.treeNode, surfaceIndex = ChiselTrimDecals.SurfaceOf(brush, otherPolygon) }; if (b.brush == null || b.surfaceIndex < 0) return default; @@ -471,7 +466,8 @@ static EdgeCandidate FindEdge(Vector2 mousePosition) b = b, from = modelToWorld.MultiplyPoint3x4(from), to = modelToWorld.MultiplyPoint3x4(to), - at = worldNearest + at = worldNearest, + edgeIndex = edgeIndex }; } diff --git a/README.md b/README.md index d295f45..450a7d3 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,6 @@ Output See [the project](https://github.com/orgs/RadicalCSG/projects/1) for everything that is planned. * Decals, still to do: - * [Trim decals placed where the materials of two brushes meet](https://github.com/RadicalCSG/com.chisel/issues/32) (now only within one brush) * [Omni directional decals](https://github.com/RadicalCSG/com.chisel/issues/33) * [Skewed decals](https://github.com/RadicalCSG/com.chisel/issues/34) * [Fade distances](https://github.com/RadicalCSG/com.chisel/issues/35)