From 5f9e4c28e85825555d1db69156cd48e968ed81a3 Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 17:47:44 +0200 Subject: [PATCH 01/12] Place Where Materials Meet finds edges between brushes too (#32) A surface of one brush and a surface of another meet when 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: a wall standing on a floor, not one sunk into it. --- .../Components/Decals/ChiselTrimDecals.cs | 115 +++++++++++++++++- Components/Tests/ChiselTrimDecalTests.cs | 47 +++++++ README.md | 1 - 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index 47407b6..5071db9 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -454,7 +454,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) { @@ -497,6 +498,118 @@ public static void EdgesBetween(ChiselModelComponent model, ulong materialA, ulo } } } + 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) || !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 + }); + } + } + } + + // 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) + { + middle = default; + var direction = math.cross(a.normal, b.normal); + var lengthSq = math.lengthsq(direction); + if (lengthSq < kEpsilon * kEpsilon) + return false; + var point = (math.cross(b.normal, direction) * a.distance + math.cross(direction, a.normal) * b.distance) / lengthSq; + direction = math.normalize(direction); + 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 (!InFrontOf(a, b) || !InFrontOf(b, a)) + return false; + middle = point + direction * ((from + to) * 0.5f); + return true; + } + + 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/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index c2948c2..1a6b6ff 100644 --- a/Components/Tests/ChiselTrimDecalTests.cs +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -280,6 +280,53 @@ 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(); + } + + // 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"); + 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/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) From 91333664d2ccdce796cb6cec3d4237909d240b5a Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 17:47:50 +0200 Subject: [PATCH 02/12] Decal inspector: Rotate Left/Right buttons for box decals; Place Where Materials Meet across brushes Rotate turns the selected box decals a quarter turn about the direction they project in. Placing trims where materials meet now also skips edges between two brushes that already have a trim (#32). --- .../Decals/ChiselDecalEditor.cs | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs index 4f37ab9..f43ba96 100644 --- a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs +++ b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs @@ -17,7 +17,10 @@ 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 decal a quarter turn about the direction it projects in."); + static readonly GUIContent kRotateLeftContent = new GUIContent("Left", "A quarter turn counter-clockwise, as seen looking at the surface."); + static readonly GUIContent kRotateRightContent = new GUIContent("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."); @@ -49,7 +52,7 @@ 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)."); + 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: give it two."; const string kTrimChainMessage = "Runs on round {0} more edge(s), added with Shift-click while picking."; const string kNoMaterialMessage = "A decal without a material draws nothing."; @@ -103,6 +106,7 @@ public override void OnInspectorGUI() EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.center)), kCenterContent); EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.size)), kSizeContent); + RotateButtons(); EditorGUILayout.PropertyField(serializedObject.FindProperty(ChiselDecalComponent.kRepeatName), kRepeatContent); } ImageField(material, serializedObject.FindProperty(ChiselDecalComponent.kImageTileName)); @@ -180,6 +184,29 @@ void ImageField(SerializedProperty material, SerializedProperty tile) } } + // A quarter turn of every selected decal about its forward axis, the direction its image is projected in + void RotateButtons() + { + 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 angle = 0f; + if (GUI.Button(left, kRotateLeftContent, EditorStyles.miniButtonLeft)) angle = 90; + if (GUI.Button(right, kRotateRightContent, EditorStyles.miniButtonRight)) angle = -90; + EditorGUI.indentLevel = indent; + if (angle == 0) + return; + foreach (var decal in targets) + { + var transform = ((ChiselDecalComponent)decal).transform; + Undo.RecordObject(transform, "Rotate Decal"); + transform.localRotation *= Quaternion.AngleAxis(angle, Vector3.forward); + } + } + // Under the material, on one line: Transparent, and for a trim decal Mirror static void MaterialToggles(SerializedProperty transparent, SerializedProperty mirror) { @@ -312,11 +339,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,7 +359,7 @@ 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; From 5f52b6f6ce3b51d23438efc25f55a9ff9c59876d Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 18:03:27 +0200 Subject: [PATCH 03/12] Trims between brushes: the whole edge, and seams of flush brushes When only one of the two surfaces has an edge on the line they meet on (a wall standing on a floor), the trim runs along that whole edge rather than only where the named floor brush reaches. Two surfaces in one plane (brushes side by side) meet on the edge they share, so a trim on such a seam lies flat across it, and Place Where Materials Meet finds those seams. --- .../Components/Decals/ChiselTrimDecals.cs | 82 +++++++++++++++---- Components/Tests/ChiselTrimDecalTests.cs | 53 ++++++++++++ 2 files changed, 120 insertions(+), 15 deletions(-) diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index 5071db9..288eb1e 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -178,18 +178,21 @@ 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) + if (!LineOf(faceA, faceB, out var point, out var 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 { @@ -572,23 +575,72 @@ static void EdgesBetweenBrushes(ChiselModelComponent model, ulong materialA, ulo static bool Meet(in Face a, in Face b, out float3 middle) { middle = default; - var direction = math.cross(a.normal, b.normal); - var lengthSq = math.lengthsq(direction); - if (lengthSq < kEpsilon * kEpsilon) + if (!LineOf(a, b, out var point, out var direction, out var coplanar)) return false; - var point = (math.cross(b.normal, direction) * a.distance + math.cross(direction, a.normal) * b.distance) / lengthSq; - direction = math.normalize(direction); 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 (!InFrontOf(a, b) || !InFrontOf(b, a)) + if (!coplanar && (!InFrontOf(a, b) || !InFrontOf(b, a))) return false; middle = point + direction * ((from + to) * 0.5f); return true; } + // The line two faces meet on: where their planes cross, or for two faces in one plane (brushes side by side) the + // line an edge of the one lies on that an edge of the other lies on too + static bool LineOf(in Face a, in Face b, out float3 point, out float3 direction, out bool coplanar) + { + direction = math.cross(a.normal, b.normal); + var lengthSq = math.lengthsq(direction); + coplanar = lengthSq < kEpsilon * kEpsilon; + if (!coplanar) + { + point = (math.cross(b.normal, direction) * a.distance + math.cross(direction, a.normal) * b.distance) / lengthSq; + direction = math.normalize(direction); + return true; + } + point = default; + if (math.dot(a.normal, b.normal) < 0 || math.abs(a.distance - b.distance) > kMargin) + return false; + var outline = a.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; + if (SharesEdge(b, from, along, length)) + { + point = from; + direction = along; + return true; + } + } + return false; + } + + // 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; diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index 1a6b6ff..de95f42 100644 --- a/Components/Tests/ChiselTrimDecalTests.cs +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -288,6 +288,59 @@ static void Paint(ChiselBoxComponent box, Material 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)); + 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"); + } + + // 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] From 75e8ff9c1c37f5c9ae064e11cac3b387e32c3b89 Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 18:13:28 +0200 Subject: [PATCH 04/12] =?UTF-8?q?Turn=20a=20decal's=20image=20a=20quarter?= =?UTF-8?q?=20turn:=2090=C2=B0=20Left=20/=2090=C2=B0=20Right=20for=20box?= =?UTF-8?q?=20and=20trim=20decals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChiselDecalSettings.imageTurns turns the image about its middle before uvScale and uvOffset. A box decal's width, height and repeats swap with each turn so the image keeps its shape; on a trim decal what ran along the edge runs across it. The buttons sit above UV Scale. --- .../Components/Decals/ChiselTrimDecals.cs | 5 ++- Components/Managers/ChiselDecalManager.cs | 5 ++- Core/1.Input/Decals/ChiselDecal.cs | 30 +++++++++++++- Core/2.Processing/Decals/DecalVolume.cs | 4 +- .../Managers/CSGManager.SkipUnchangedTrees.cs | 1 + Core/Tests/Decals/DecalClippingTests.cs | 34 +++++++++++++++ .../Decals/ChiselDecalEditor.cs | 41 ++++++++++++------- 7 files changed, 100 insertions(+), 20 deletions(-) diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index 288eb1e..05f6a65 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -297,8 +297,9 @@ 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); diff --git a/Components/Managers/ChiselDecalManager.cs b/Components/Managers/ChiselDecalManager.cs index 9848007..f0715a6 100644 --- a/Components/Managers/ChiselDecalManager.cs +++ b/Components/Managers/ChiselDecalManager.cs @@ -472,8 +472,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/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 f43ba96..285acfe 100644 --- a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs +++ b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs @@ -17,9 +17,9 @@ 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 kRotateContent = new GUIContent("Rotate", "Turns the decal a quarter turn about the direction it projects in."); - static readonly GUIContent kRotateLeftContent = new GUIContent("Left", "A quarter turn counter-clockwise, as seen looking at the surface."); - static readonly GUIContent kRotateRightContent = new GUIContent("Right", "A quarter turn clockwise, as seen looking at the surface."); + 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."); @@ -106,12 +106,12 @@ public override void OnInspectorGUI() EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.center)), kCenterContent); EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.size)), kSizeContent); - RotateButtons(); EditorGUILayout.PropertyField(serializedObject.FindProperty(ChiselDecalComponent.kRepeatName), kRepeatContent); } 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); @@ -184,8 +184,8 @@ void ImageField(SerializedProperty material, SerializedProperty tile) } } - // A quarter turn of every selected decal about its forward axis, the direction its image is projected in - void RotateButtons() + // 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); @@ -193,18 +193,31 @@ void RotateButtons() var right = new Rect(left.xMax, rect.y, rect.width - left.width, rect.height); var indent = EditorGUI.indentLevel; EditorGUI.indentLevel = 0; - var angle = 0f; - if (GUI.Button(left, kRotateLeftContent, EditorStyles.miniButtonLeft)) angle = 90; - if (GUI.Button(right, kRotateRightContent, EditorStyles.miniButtonRight)) angle = -90; + 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 (angle == 0) + if (turn == 0) return; - foreach (var decal in targets) + serializedObject.ApplyModifiedProperties(); + foreach (var decalObject in serializedObject.targetObjects) { - var transform = ((ChiselDecalComponent)decal).transform; - Undo.RecordObject(transform, "Rotate Decal"); - transform.localRotation *= Quaternion.AngleAxis(angle, Vector3.forward); + 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 From 0eed7c46c34617dadede67b013ae948a9040e17f Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 18:26:37 +0200 Subject: [PATCH 05/12] Trims: not mirrored, across nearly flush brushes, and onto the surfaces beside a floor brush A trim's image reads left to right from its surface whichever way the line the surfaces meet on runs. Two faces meet on an edge they share (up to 1 cm apart) before where their planes cross, so brushes that are nearly flush, a step of millimetres or a fraction of a degree, get a trim across their seam; the edge picker accepts the neighbouring surface within 1 cm too. A half that runs past the end of its surface (a wall standing across two floor brushes) also draws on the surfaces in the same plane beside it. --- .../Components/Decals/ChiselTrimDecals.cs | 117 +++++++++++++++--- Components/Managers/ChiselDecalManager.cs | 16 ++- Components/Tests/ChiselTrimDecalTests.cs | 60 ++++++++- 3 files changed, 171 insertions(+), 22 deletions(-) diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index 05f6a65..ca60c97 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -35,7 +35,8 @@ public static class ChiselTrimDecals public struct Half { public ChiselDecalInstance decal; - public ChiselDecalTarget target; + // its surface, and where it runs past that surface's end the surfaces in the same plane it reaches + public ChiselDecalTarget[] targets; } // A surface of a brush, in the tree's space: its plane (unit normal, distance) and its outline @@ -126,6 +127,12 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong for (int i = 0; i < edgeCount; i++) { ref var edge = ref edges[i]; + // seen from the first surface, with the image's top towards the edge, the image reads along the edge's + // direction only when that runs left to right; otherwise it runs from the far end (both halves alike) + var backwards = math.dot(math.cross(edge.direction, -edge.acrossA), edge.faceA.normal) > 0; + var uEnd = sheet ? 1 : total * perMetre; + var besideA = edge.pastEndA ? SurfacesBeside(model, edge.faceA, targets[i * 2], edge.start, edge.direction, edge.length, edge.acrossA, size.y * 0.5f) : null; + var besideB = edge.pastEndB ? SurfacesBeside(model, edge.faceB, targets[i * 2 + 1], edge.start, edge.direction, edge.length, edge.acrossB, size.y * 0.5f) : null; // the whole texture repeats by wrapping; an image in a trim sheet can't, so it is a piece for every image int pieces = 1; float pieceLength = edge.length; @@ -147,9 +154,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[i * 2], besideA, 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[i * 2 + 1], besideB, 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; @@ -168,6 +180,9 @@ struct ChainEdge public float length; public float3 acrossA, acrossB; public float slantStartA, slantEndA, slantStartB, slantEndB; + // the edge is only one of the two surfaces' (a wall standing on a floor): the other's half can run past that + // surface's end, onto surfaces beside it + public bool pastEndA, pastEndB; } static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, out ChiselModelComponent model, out ChainEdge edge) @@ -193,6 +208,8 @@ static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, out 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 pastEndA = edgeOnB && !edgeOnA && (from < fromA - kEpsilon || to > toA + kEpsilon); + var pastEndB = edgeOnA && !edgeOnB && (from < fromB - kEpsilon || to > toB + kEpsilon); var start = point + direction * from; edge = new ChainEdge { @@ -201,6 +218,8 @@ static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, out start = start, direction = direction, length = to - from, + pastEndA = pastEndA, + pastEndB = pastEndB, acrossA = Across(faceA, faceB.normal, start, direction), acrossB = Across(faceB, faceA.normal, start, direction) }; @@ -287,7 +306,7 @@ static float3 Across(in Face face, float3 otherNormal, float3 start, float3 dire return across; } - static Half MakeHalf(in Face face, float3 across, ChiselDecalSurfaceTarget surface, float3 start, float3 direction, float length, + static Half MakeHalf(in Face face, float3 across, ChiselDecalSurfaceTarget surface, List beside, float3 start, float3 direction, float length, float uStart, float shown, float slantStart, float slantEnd, Rect rect, ChiselDecalSettings settings, ulong entityID, ulong renderMaterial, SurfaceDestinationFlags destinationFlags, bool lower) { @@ -318,12 +337,61 @@ static Half MakeHalf(in Face face, float3 across, ChiselDecalSurfaceTarget surfa renderMaterial = renderMaterial, destinationFlags = destinationFlags }, - target = new ChiselDecalTarget + targets = Targets(surface, beside) + }; + } + + static ChiselDecalTarget[] Targets(ChiselDecalSurfaceTarget surface, List beside) + { + var targets = new ChiselDecalTarget[1 + (beside?.Count ?? 0)]; + targets[0] = new ChiselDecalTarget { brushEntityID = UnityEngine.EntityId.ToULong(surface.brush.GetEntityId()), surfaceIndex = surface.surfaceIndex }; + for (int i = 1; i < targets.Length; i++) + targets[i] = beside[i - 1]; + return targets; + } + + // The surfaces of the model's other brushes (of generators that make one brush) in the face's plane, facing its + // way, that reach the stretch from start along direction for length, height deep along across + static List SurfacesBeside(ChiselModelComponent model, in Face face, ChiselDecalSurfaceTarget surface, + float3 start, float3 direction, float length, float3 across, float height) + { + var corners = new[] { start, start + direction * length, start + across * height, start + direction * length + across * height }; + float3 min = corners[0], max = corners[0]; + foreach (var corner in corners) + { + min = math.min(min, corner); + max = math.max(max, corner); + } + min -= kMargin; + max += kMargin; + var found = new List(); + foreach (var generator in model.GetComponentsInChildren()) + { + if (generator == surface.brush || 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++) { - brushEntityID = UnityEngine.EntityId.ToULong(surface.brush.GetEntityId()), - surfaceIndex = surface.surfaceIndex + var surfaceIndex = blob.Value.polygons[p].descriptionIndex; + if (!TryBrushFace(brush, surfaceIndex, out var other) || math.dot(other.normal, face.normal) < 0.999f || + math.abs(other.distance - face.distance) > kMargin) + continue; + float3 otherMin = other.outline[0], otherMax = other.outline[0]; + foreach (var vertex in other.outline) + { + otherMin = math.min(otherMin, vertex); + otherMax = math.max(otherMax, vertex); + } + if (math.any(otherMin > max) || math.any(min > otherMax)) + continue; + found.Add(new ChiselDecalTarget { brushEntityID = UnityEngine.EntityId.ToULong(generator.GetEntityId()), surfaceIndex = surfaceIndex }); } - }; + } + return found; } // The part of the line inside the face's outline, narrowing [from, to] @@ -346,7 +414,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; } @@ -436,7 +505,7 @@ 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; } @@ -593,18 +662,28 @@ static bool Meet(in Face a, in Face b, out float3 middle) // line an edge of the one lies on that an edge of the other lies on too static bool LineOf(in Face a, in Face b, out float3 point, out float3 direction, out bool coplanar) { + // an edge the two share (up to kMargin apart), so faces that are nearly in one plane meet on it too; failing + // that, where their planes cross + coplanar = math.dot(a.normal, b.normal) > kNearlyParallel; + if (SharedEdge(a, b, out point, out direction)) + return true; direction = math.cross(a.normal, b.normal); var lengthSq = math.lengthsq(direction); - coplanar = lengthSq < kEpsilon * kEpsilon; - if (!coplanar) - { - point = (math.cross(b.normal, direction) * a.distance + math.cross(direction, a.normal) * b.distance) / lengthSq; - direction = math.normalize(direction); - return true; - } - point = default; - if (math.dot(a.normal, b.normal) < 0 || math.abs(a.distance - b.distance) > kMargin) + 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; + + static bool SharedEdge(in Face a, in Face b, out float3 point, out float3 direction) + { + point = default; + direction = default; var outline = a.outline; for (int i = 0; i < outline.Count; i++) { diff --git a/Components/Managers/ChiselDecalManager.cs b/Components/Managers/ChiselDecalManager.cs index f0715a6..e0285f5 100644 --- a/Components/Managers/ChiselDecalManager.cs +++ b/Components/Managers/ChiselDecalManager.cs @@ -349,7 +349,19 @@ 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.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 +383,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; } diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index de95f42..a3f05b1 100644 --- a/Components/Tests/ChiselTrimDecalTests.cs +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -295,7 +295,7 @@ public void ATrimBetweenTwoBrushes_RunsAlongTheWholeEdgeOfTheOneThatHasIt() { var model = Model(); var left = Box(model, new float3(-4, -1, -4), new float3(0, 0, 4)); - Box(model, new float3(0, -1, -4), new float3(4, 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(); @@ -303,6 +303,64 @@ public void ATrimBetweenTwoBrushes_RunsAlongTheWholeEdgeOfTheOneThatHasIt() 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 the other floor brush's top too, the wall half only on the wall + var rightTop = new ChiselDecalTarget { brushEntityID = UnityEngine.EntityId.ToULong(right.GetEntityId()), surfaceIndex = SurfaceFacing(right, new float3(0, 1, 0)) }; + var leftTop = new ChiselDecalTarget { brushEntityID = UnityEngine.EntityId.ToULong(left.GetEntityId()), surfaceIndex = SurfaceFacing(left, new float3(0, 1, 0)) }; + Assert.That(halves.Exists(h => h.targets[0].Equals(leftTop) && System.Array.IndexOf(h.targets, rightTop) > 0), Is.True, "the floor half reaches the right brush's top"); + Assert.That(halves.TrueForAll(h => h.targets[0].Equals(leftTop) || h.targets.Length == 1), Is.True, "the wall half stays on the wall"); + } + + // 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"); } // Two floor brushes side by side, their tops in one plane: a trim on the seam lies flat, half on each, along the seam From 7443032fe6a86f4b8e8b8b1757de8f8120b1aeab Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 18:44:31 +0200 Subject: [PATCH 06/12] Trims: the seam that was picked, and halves that draw on everything in their range Of the edges two surfaces share, or (in one plane) an edge of one running through the other, a trim takes the one nearest to the decal (where its edge was picked), and the next edges of a chain the one nearest to the edge before; an edge that only lies along the other surface's border no longer counts. Like a box decal, each half now draws on every surface in its thin box that faces it (within 45 degrees), so a trim reaches across neighbouring and overlapping brushes; the surfaces beside a half are no longer collected. The test helper that made boxes now makes the boxes it is given. --- .../Components/Decals/ChiselTrimDecals.cs | 141 +++++++----------- Components/Managers/ChiselDecalManager.cs | 2 + Components/Tests/ChiselTrimDecalTests.cs | 67 ++++++++- 3 files changed, 112 insertions(+), 98 deletions(-) diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index ca60c97..13a09da 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,7 @@ public static class ChiselTrimDecals public struct Half { public ChiselDecalInstance decal; - // its surface, and where it runs past that surface's end the surfaces in the same plane it reaches + // none: like a box decal, a half draws on every surface in its range that faces it public ChiselDecalTarget[] targets; } @@ -78,7 +78,10 @@ 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 first edge nearest to the decal (where it was picked), the others nearest 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 * 0.5f)); + if (!TryEdge(targets[i * 2], targets[i * 2 + 1], reference, out var edgeModel, out edges[i]) || (model != null && edgeModel != model)) return false; model = edgeModel; } @@ -131,8 +134,6 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong // direction only when that runs left to right; otherwise it runs from the far end (both halves alike) var backwards = math.dot(math.cross(edge.direction, -edge.acrossA), edge.faceA.normal) > 0; var uEnd = sheet ? 1 : total * perMetre; - var besideA = edge.pastEndA ? SurfacesBeside(model, edge.faceA, targets[i * 2], edge.start, edge.direction, edge.length, edge.acrossA, size.y * 0.5f) : null; - var besideB = edge.pastEndB ? SurfacesBeside(model, edge.faceB, targets[i * 2 + 1], edge.start, edge.direction, edge.length, edge.acrossB, size.y * 0.5f) : null; // the whole texture repeats by wrapping; an image in a trim sheet can't, so it is a piece for every image int pieces = 1; float pieceLength = edge.length; @@ -159,9 +160,9 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong uStart = uEnd - uStart; shown = -shown; } - halves.Add(MakeHalf(edge.faceA, edge.acrossA, targets[i * 2], besideA, pieceStart, edge.direction, span, uStart, shown, + halves.Add(MakeHalf(edge.faceA, edge.acrossA, targets[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], besideB, pieceStart, edge.direction, span, uStart, shown, + halves.Add(MakeHalf(edge.faceB, edge.acrossB, targets[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; @@ -180,12 +181,9 @@ struct ChainEdge public float length; public float3 acrossA, acrossB; public float slantStartA, slantEndA, slantStartB, slantEndB; - // the edge is only one of the two surfaces' (a wall standing on a floor): the other's half can run past that - // surface's end, onto surfaces beside it - public bool pastEndA, pastEndB; } - static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, out ChiselModelComponent model, out ChainEdge edge) + static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, float3 worldReference, out ChiselModelComponent model, out ChainEdge edge) { edge = default; model = null; @@ -193,7 +191,8 @@ static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, out return false; model = modelA; - if (!LineOf(faceA, faceB, out var point, out var direction, out _)) + var reference = (float3)model.transform.worldToLocalMatrix.MultiplyPoint3x4(worldReference); + if (!LineOf(faceA, faceB, reference, out var point, out var direction, out _)) return false; // where both surfaces reach it; when only one of them has an edge on the line (a wall standing on a floor), @@ -208,8 +207,6 @@ static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, out 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 pastEndA = edgeOnB && !edgeOnA && (from < fromA - kEpsilon || to > toA + kEpsilon); - var pastEndB = edgeOnA && !edgeOnB && (from < fromB - kEpsilon || to > toB + kEpsilon); var start = point + direction * from; edge = new ChainEdge { @@ -218,10 +215,8 @@ static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, out start = start, direction = direction, length = to - from, - pastEndA = pastEndA, - pastEndB = pastEndB, - 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; } @@ -290,7 +285,7 @@ static void Mitre(ref ChainEdge edge, int h, ref ChainEdge next, int k) // 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; @@ -301,12 +296,14 @@ 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; } - static Half MakeHalf(in Face face, float3 across, ChiselDecalSurfaceTarget surface, List beside, float3 start, float3 direction, float length, + static Half MakeHalf(in Face face, float3 across, ChiselDecalSurfaceTarget surface, float3 start, float3 direction, float length, float uStart, float shown, float slantStart, float slantEnd, Rect rect, ChiselDecalSettings settings, ulong entityID, ulong renderMaterial, SurfaceDestinationFlags destinationFlags, bool lower) { @@ -325,6 +322,8 @@ static Half MakeHalf(in Face face, float3 across, ChiselDecalSurfaceTarget surfa 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 @@ -337,62 +336,11 @@ static Half MakeHalf(in Face face, float3 across, ChiselDecalSurfaceTarget surfa renderMaterial = renderMaterial, destinationFlags = destinationFlags }, - targets = Targets(surface, beside) + targets = null }; } - static ChiselDecalTarget[] Targets(ChiselDecalSurfaceTarget surface, List beside) - { - var targets = new ChiselDecalTarget[1 + (beside?.Count ?? 0)]; - targets[0] = new ChiselDecalTarget { brushEntityID = UnityEngine.EntityId.ToULong(surface.brush.GetEntityId()), surfaceIndex = surface.surfaceIndex }; - for (int i = 1; i < targets.Length; i++) - targets[i] = beside[i - 1]; - return targets; - } - - // The surfaces of the model's other brushes (of generators that make one brush) in the face's plane, facing its - // way, that reach the stretch from start along direction for length, height deep along across - static List SurfacesBeside(ChiselModelComponent model, in Face face, ChiselDecalSurfaceTarget surface, - float3 start, float3 direction, float length, float3 across, float height) - { - var corners = new[] { start, start + direction * length, start + across * height, start + direction * length + across * height }; - float3 min = corners[0], max = corners[0]; - foreach (var corner in corners) - { - min = math.min(min, corner); - max = math.max(max, corner); - } - min -= kMargin; - max += kMargin; - var found = new List(); - foreach (var generator in model.GetComponentsInChildren()) - { - if (generator == surface.brush || 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 other) || math.dot(other.normal, face.normal) < 0.999f || - math.abs(other.distance - face.distance) > kMargin) - continue; - float3 otherMin = other.outline[0], otherMax = other.outline[0]; - foreach (var vertex in other.outline) - { - otherMin = math.min(otherMin, vertex); - otherMax = math.max(otherMax, vertex); - } - if (math.any(otherMin > max) || math.any(min > otherMax)) - continue; - found.Add(new ChiselDecalTarget { brushEntityID = UnityEngine.EntityId.ToULong(generator.GetEntityId()), surfaceIndex = surfaceIndex }); - } - } - return found; - } + 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) @@ -645,7 +593,7 @@ static void EdgesBetweenBrushes(ChiselModelComponent model, ulong materialA, ulo static bool Meet(in Face a, in Face b, out float3 middle) { middle = default; - if (!LineOf(a, b, out var point, out var direction, out var coplanar)) + if (!LineOf(a, b, (a.centroid + b.centroid) * 0.5f, 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) @@ -658,14 +606,19 @@ static bool Meet(in Face a, in Face b, out float3 middle) return true; } - // The line two faces meet on: where their planes cross, or for two faces in one plane (brushes side by side) the - // line an edge of the one lies on that an edge of the other lies on too - static bool LineOf(in Face a, in Face b, out float3 point, out float3 direction, out bool coplanar) + // 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, out float3 point, out float3 direction, out bool coplanar) { - // an edge the two share (up to kMargin apart), so faces that are nearly in one plane meet on it too; failing - // that, where their planes cross coplanar = math.dot(a.normal, b.normal) > kNearlyParallel; - if (SharedEdge(a, b, out point, out direction)) + var inPlane = coplanar && math.abs(a.distance - b.distance) <= kMargin; + point = default; + direction = default; + var best = float.PositiveInfinity; + NearestSharedEdge(a, b, inPlane, reference, ref best, ref point, ref direction); + NearestSharedEdge(b, a, inPlane, reference, ref best, ref point, ref direction); + if (best < float.PositiveInfinity) return true; direction = math.cross(a.normal, b.normal); var lengthSq = math.lengthsq(direction); @@ -680,11 +633,11 @@ static bool LineOf(in Face a, in Face b, out float3 point, out float3 direction, // no use const float kNearlyParallel = 0.999f; - static bool SharedEdge(in Face a, in Face b, out float3 point, out float3 direction) + // 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, ref float best, ref float3 point, ref float3 direction) { - point = default; - direction = default; - var outline = a.outline; + var outline = face.outline; for (int i = 0; i < outline.Count; i++) { var from = outline[i]; @@ -693,14 +646,22 @@ static bool SharedEdge(in Face a, in Face b, out float3 point, out float3 direct if (length < kEpsilon) continue; along /= length; - if (SharesEdge(b, from, along, length)) + var candidate = SharesEdge(other, from, along, length); + if (!candidate && inPlane && !HasEdgeOn(other, from, along)) { - point = from; - direction = along; - return true; + 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); + if (distance >= best) + continue; + best = distance; + point = from; + direction = along; } - return false; } // Whether an edge of the face lies on the line and overlaps its stretch [0, length] by more than kMargin diff --git a/Components/Managers/ChiselDecalManager.cs b/Components/Managers/ChiselDecalManager.cs index e0285f5..16165a5 100644 --- a/Components/Managers/ChiselDecalManager.cs +++ b/Components/Managers/ChiselDecalManager.cs @@ -357,6 +357,8 @@ static bool SameTrim(DecalState state, ChiselModelComponent model) 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++) diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index a3f05b1..3af9c13 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; } @@ -304,11 +306,19 @@ public void ATrimBetweenTwoBrushes_RunsAlongTheWholeEdgeOfTheOneThatHasIt() 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 the other floor brush's top too, the wall half only on the wall - var rightTop = new ChiselDecalTarget { brushEntityID = UnityEngine.EntityId.ToULong(right.GetEntityId()), surfaceIndex = SurfaceFacing(right, new float3(0, 1, 0)) }; - var leftTop = new ChiselDecalTarget { brushEntityID = UnityEngine.EntityId.ToULong(left.GetEntityId()), surfaceIndex = SurfaceFacing(left, new float3(0, 1, 0)) }; - Assert.That(halves.Exists(h => h.targets[0].Equals(leftTop) && System.Array.IndexOf(h.targets, rightTop) > 0), Is.True, "the floor half reaches the right brush's top"); - Assert.That(halves.TrueForAll(h => h.targets[0].Equals(leftTop) || h.targets.Length == 1), Is.True, "the wall half stays on the wall"); + // 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 @@ -363,6 +373,47 @@ public void ATrimOnTheSeamOfNearlyFlushBrushes_LiesAcrossIt(float step, float ti 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))); + decal.transform.position = new Vector3(x + 0.1f, 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(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)); + } + // 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() @@ -421,7 +472,7 @@ public void TheEdgesWhereTwoBrushesMaterialsMeet_AreFound() 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"); + 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) { From a9a08e772895572f11a8b517d7a8e74f21e70f6a Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 19:09:16 +0200 Subject: [PATCH 07/12] Trims keep to the edge they were given; Pick Edge takes drawn surfaces; chaining off for now A trim decal stores the edge it lies on (its number along a surface's outline), set by Pick Edge and Place Where Materials Meet, so it lies on exactly that edge and follows it; a trim without one keeps to the line it lay on while its decal is on it. Pick Edge skips surfaces the CSG discarded, and when its probe past the edge misses, takes another brush's drawn surface that goes on across the edge before the clicked brush's own face. Chains round corners are off until they are fixed (#53): a trim uses its first edge only and Shift-click no longer adds edges. The target fields are gone from the decal inspector. --- .../Components/Decals/ChiselDecalComponent.cs | 23 ++ .../Components/Decals/ChiselTrimDecals.cs | 197 ++++++++++++++++-- Components/Tests/ChiselTrimDecalTests.cs | 78 ++++++- .../Decals/ChiselDecalEditor.cs | 100 +++------ README.md | 3 +- 5 files changed, 302 insertions(+), 99 deletions(-) diff --git a/Components/Components/Decals/ChiselDecalComponent.cs b/Components/Components/Decals/ChiselDecalComponent.cs index efde548..5ed3fee 100644 --- a/Components/Components/Decals/ChiselDecalComponent.cs +++ b/Components/Components/Decals/ChiselDecalComponent.cs @@ -65,6 +65,16 @@ public sealed class ChiselDecalComponent : MonoBehaviour [SerializeField] int imageTile = 0; [SerializeField] Vector2 repeat = Vector2.one; + // Which edge of its first surface (of its second, with trimEdgeOnSecond) a trim decal lies on: the edge's number + // along the surface's outline; -1 to find it from where the decal is + [SerializeField] int trimEdge = -1; + [SerializeField] bool trimEdgeOnSecond; + + // 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,6 +157,19 @@ public void SetTargets(IEnumerable newTargets) targets.Clear(); if (newTargets != null) targets.AddRange(newTargets); + trimEdge = -1; + ChiselDecalManager.SetDirty(); + } + + /// A trim decal's edge: which edge of its first surface (or second) it lies on, its number along the + /// surface's outline; -1 to find it from where the decal is + public int TrimEdge => trimEdge; + public bool TrimEdgeOnSecond => trimEdgeOnSecond; + + public void SetTrimEdge(bool onSecond, int edgeIndex) + { + trimEdge = edgeIndex; + trimEdgeOnSecond = onSecond; ChiselDecalManager.SetDirty(); } diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index 13a09da..4f74236 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -63,7 +63,8 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong edgeFrom = edgeTo = float3.zero; model = null; var targets = decal.Targets; - var edgeCount = targets.Count / 2; + // only the first edge: chains round corners are off until they are fixed (com.chisel#53) + var edgeCount = math.min(1, targets.Count / 2); if (edgeCount < 1) return false; var settings = decal.Settings; @@ -81,9 +82,19 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong // the first edge nearest to the decal (where it was picked), the others nearest 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 * 0.5f)); - if (!TryEdge(targets[i * 2], targets[i * 2 + 1], reference, out var edgeModel, out edges[i]) || (model != null && edgeModel != model)) + var keep = i == 0 && decal.hasTrimLine; + var edgeIndex = i == 0 ? decal.TrimEdge : -1; + if (!TryEdge(targets[i * 2], targets[i * 2 + 1], edgeIndex, decal.TrimEdgeOnSecond, 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; + } } if (fill == ChiselTrimFill.Single) PlaceSingle(decal, model, size.x, ref edges[0]); @@ -183,7 +194,8 @@ struct ChainEdge public float slantStartA, slantEndA, slantStartB, slantEndB; } - static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, float3 worldReference, 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; @@ -191,8 +203,20 @@ static bool TryEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, floa return false; model = modelA; - var reference = (float3)model.transform.worldToLocalMatrix.MultiplyPoint3x4(worldReference); - if (!LineOf(faceA, faceB, reference, out var point, out var direction, out _)) + 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; // where both surfaces reach it; when only one of them has an edge on the line (a wall standing on a floor), @@ -388,9 +412,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; @@ -412,6 +443,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; @@ -457,11 +489,93 @@ public static bool PlaneHolds(CSGTreeBrush brush, int polygonIndex, float3 a, fl 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 @@ -512,9 +626,10 @@ 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 }); } } @@ -576,13 +691,15 @@ static void EdgesBetweenBrushes(ChiselModelComponent model, ulong materialA, ulo 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) || !seen.Add(key)) + 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 + a = new ChiselDecalSurfaceTarget { brush = a.generator, surfaceIndex = a.surfaceIndex }, + b = new ChiselDecalSurfaceTarget { brush = b.generator, surfaceIndex = b.surfaceIndex }, + middle = middle, + edgeIndex = edgeIndex, + edgeOnB = edgeOnB }); } } @@ -590,10 +707,12 @@ static void EdgesBetweenBrushes(ChiselModelComponent model, ulong materialA, ulo // 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) + static bool Meet(in Face a, in Face b, out float3 middle, out int edgeIndex, out bool edgeOnB) { middle = default; - if (!LineOf(a, b, (a.centroid + b.centroid) * 0.5f, out var point, out var direction, out var coplanar)) + 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) @@ -603,21 +722,53 @@ static bool Meet(in Face a, in Face b, out float3 middle) if (!coplanar && (!InFrontOf(a, b) || !InFrontOf(b, a))) return false; middle = point + direction * ((from + to) * 0.5f); - return true; + 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, while the reference point is still on it + 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, out float3 point, out float3 direction, out bool coplanar) + 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; - NearestSharedEdge(a, b, inPlane, reference, ref best, ref point, ref direction); - NearestSharedEdge(b, a, inPlane, reference, ref best, ref point, ref direction); + 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); @@ -635,7 +786,8 @@ static bool LineOf(in Face a, in Face b, float3 reference, out float3 point, out // 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, ref float best, ref float3 point, ref float3 direction) + 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++) @@ -656,8 +808,13 @@ static void NearestSharedEdge(in Face face, in Face other, bool inPlane, float3 continue; var t = math.clamp(math.dot(reference - from, along), 0, length); var distance = math.distance(reference, from + along * t); - if (distance >= best) + // the line the trim lay on wins while the reference point is on it + var isKept = distance < kMargin && kept.Holds(from, from + along * length); + if (onKept && !isKept) + continue; + if (distance >= best && !(isKept && !onKept)) continue; + onKept |= isKept; best = distance; point = from; direction = along; diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index 3af9c13..6a6e5d8 100644 --- a/Components/Tests/ChiselTrimDecalTests.cs +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -414,6 +414,80 @@ public void OfTwoSeamsBetweenTheSameSurfaces_TheTrimTakesTheOneItWasPutOn(float 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)); + } + // 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() @@ -541,7 +615,7 @@ ChiselDecalComponent TrimRoundTheTop(ChiselModelComponent model, ChiselBoxCompon // A 2 m box's top, framed half a metre deep: 4 m2 less the 1 m2 middle, each corner once (overlapping square ends // would draw 4 m2); each side half a metre down - [Test] + [Test, Ignore("Chains round corners are off until they are fixed (com.chisel#53)")] public void ATrimRoundCorners_IsMitredWhereItsEdgesMeet() { var model = Model(); @@ -553,7 +627,7 @@ public void ATrimRoundCorners_IsMitredWhereItsEdgesMeet() Assert.That(AreaFacing(triangles, side), Is.EqualTo(1.0).Within(kTolerance), $"the side facing {side}"); } - [Test] + [Test, Ignore("Chains round corners are off until they are fixed (com.chisel#53)")] public void ATrimRoundCorners_RunsOnFromEdgeToEdge() { var model = Model(); diff --git a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs index 285acfe..5012ab9 100644 --- a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs +++ b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs @@ -27,11 +27,6 @@ public sealed class ChiselDecalEditor : Editor 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 }; @@ -50,11 +45,10 @@ public sealed class ChiselDecalEditor : Editor static readonly GUIContent kTrimFillContent = new GUIContent("Fill", "Stretch: one image over the whole edge. Repeat: the image repeated at its length, the last one cut short. Fit: the whole number of images nearest to what fits, stretched to fill the edge. Single: one image at its length, round the decal's position (move the decal along the edge to place it)."); static readonly GUIContent kTrimLengthContent = new GUIContent("Edge Length", "The image's length along the edge."); 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 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. 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, within a brush or between two."); - 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."; + 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)] @@ -115,25 +109,11 @@ public override void OnInspectorGUI() 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)) { @@ -273,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; @@ -378,7 +321,9 @@ static void PlaceWhereMaterialsMeet(ChiselDecalComponent template) 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); } @@ -396,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) @@ -415,24 +361,15 @@ static void PickEdge(ChiselDecalComponent decal) if (evt.button != 0 || evt.alt) break; s_Candidate = FindEdge(evt.mousePosition); - // shift adds the edge to the chain, and goes on picking - var addToChain = evt.shift && decal.Targets.Count >= 2; if (s_Candidate.valid) { 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.SetTargets(new[] { s_Candidate.a, s_Candidate.b }); - decal.transform.position = s_Candidate.at; - } + decal.SetTargets(new[] { s_Candidate.a, s_Candidate.b }); + decal.SetTrimEdge(false, s_Candidate.edgeIndex); + decal.transform.position = s_Candidate.at; EditorUtility.SetDirty(decal); } - if (!addToChain) - s_PickingFor = null; + s_PickingFor = null; s_Candidate = default; evt.Use(); break; @@ -481,7 +418,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)); @@ -497,10 +434,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; @@ -511,7 +458,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 450a7d3..fa3b456 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Surfaces and texturing * [Double sided surfaces](https://github.com/RadicalCSG/com.chisel/wiki/Double-Sided-Surfaces): a material can make its surfaces drawn from behind too * [Hotspot mapping](https://github.com/RadicalCSG/com.chisel/wiki/Hotspot-Mapping): surfaces take their texture coordinates from the best matching tile of a layout (borders, repeats, shaped tiles and trim sheets), with a layout editor * [Decals](https://github.com/RadicalCSG/com.chisel/wiki/Decals): boxes that project a material onto the surfaces they reach, drawn over them or replacing them, their image repeated in x and y, from the whole texture or an image in a trim sheet -* [Trim decals](https://github.com/RadicalCSG/com.chisel/wiki/Trim-Decals): a decal on the edge between two surfaces, the lower half of its image on one and the upper half on the other (or mirrored), stretched, repeated or fitted along the edge or one image placed on it, running round corners, its image from the whole texture or a trim sheet, following the edge when either brush changes; placed by picking an edge in the scene view, or on every edge where two materials meet +* [Trim decals](https://github.com/RadicalCSG/com.chisel/wiki/Trim-Decals): a decal on the edge between two surfaces, the lower half of its image on one and the upper half on the other (or mirrored), stretched, repeated or fitted along the edge or one image placed on it, its image from the whole texture or a trim sheet, following the edge when either brush changes; placed by picking an edge in the scene view, or on every edge where two materials meet Output * Ordinary Unity meshes and colliders, that work with Unity's lightmapping, occlusion culling and navigation meshes @@ -53,6 +53,7 @@ Output See [the project](https://github.com/orgs/RadicalCSG/projects/1) for everything that is planned. * Decals, still to do: + * [Trim decals round corners](https://github.com/RadicalCSG/com.chisel/issues/53) * [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) From 92814a01bd138611c5d5455aa92d5bf1ed438a76 Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 19:19:12 +0200 Subject: [PATCH 08/12] Trim decals round corners again (#53) Every edge of a chain keeps the edge it was picked on, not only the first; corners are joined within 1 cm and mitred where the two halves lie in one plane, whichever brushes they are on; the image reads one way along the whole chain. Shift-click with Pick Edge adds edges again. --- .../Components/Decals/ChiselDecalComponent.cs | 41 +++++++++---- .../Components/Decals/ChiselTrimDecals.cs | 27 ++++---- Components/Tests/ChiselTrimDecalTests.cs | 61 ++++++++++++++++++- .../Decals/ChiselDecalEditor.cs | 18 ++++-- README.md | 3 +- 5 files changed, 116 insertions(+), 34 deletions(-) diff --git a/Components/Components/Decals/ChiselDecalComponent.cs b/Components/Components/Decals/ChiselDecalComponent.cs index 5ed3fee..9edf490 100644 --- a/Components/Components/Decals/ChiselDecalComponent.cs +++ b/Components/Components/Decals/ChiselDecalComponent.cs @@ -65,10 +65,9 @@ public sealed class ChiselDecalComponent : MonoBehaviour [SerializeField] int imageTile = 0; [SerializeField] Vector2 repeat = Vector2.one; - // Which edge of its first surface (of its second, with trimEdgeOnSecond) a trim decal lies on: the edge's number - // along the surface's outline; -1 to find it from where the decal is - [SerializeField] int trimEdge = -1; - [SerializeField] bool trimEdgeOnSecond; + // 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) @@ -157,19 +156,39 @@ public void SetTargets(IEnumerable newTargets) targets.Clear(); if (newTargets != null) targets.AddRange(newTargets); - trimEdge = -1; + trimEdges.Clear(); ChiselDecalManager.SetDirty(); } - /// A trim decal's edge: which edge of its first surface (or second) it lies on, its number along the - /// surface's outline; -1 to find it from where the decal is - public int TrimEdge => trimEdge; - public bool TrimEdgeOnSecond => trimEdgeOnSecond; + /// 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) { - trimEdge = edgeIndex; - trimEdgeOnSecond = onSecond; + 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 + public void AddTrimEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, bool onSecond, int edgeIndex) + { + var pair = targets.Count / 2; + 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(edgeIndex < 0 ? -1 : edgeIndex * 2 + (onSecond ? 1 : 0)); ChiselDecalManager.SetDirty(); } diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index 4f74236..8979b26 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -63,8 +63,7 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong edgeFrom = edgeTo = float3.zero; model = null; var targets = decal.Targets; - // only the first edge: chains round corners are off until they are fixed (com.chisel#53) - var edgeCount = math.min(1, targets.Count / 2); + var edgeCount = targets.Count / 2; if (edgeCount < 1) return false; var settings = decal.Settings; @@ -79,12 +78,12 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong var edges = new ChainEdge[edgeCount]; for (int i = 0; i < edgeCount; i++) { - // the first edge nearest to the decal (where it was picked), the others nearest to the edge before them + // 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 * 0.5f)); + : (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 = i == 0 ? decal.TrimEdge : -1; - if (!TryEdge(targets[i * 2], targets[i * 2 + 1], edgeIndex, decal.TrimEdgeOnSecond, reference, keep, decal.trimLinePoint, decal.trimLineDirection, out var edgeModel, out edges[i]) || + 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; @@ -109,13 +108,14 @@ 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; + // halves in one plane meet at the corner: on one surface, or on surfaces of brushes side by side for (int h = 0; h < 2; h++) { for (int k = 0; k < 2; k++) { - if (SameSurface(targets[i * 2 + h], targets[n * 2 + k])) + if (InOnePlane(h == 0 ? edges[i].faceA : edges[i].faceB, k == 0 ? edges[n].faceA : edges[n].faceB)) Mitre(ref edges[i], h, ref edges[n], k); } } @@ -138,13 +138,13 @@ 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]; - // seen from the first surface, with the image's top towards the edge, the image reads along the edge's - // direction only when that runs left to right; otherwise it runs from the far end (both halves alike) - var backwards = math.dot(math.cross(edge.direction, -edge.acrossA), edge.faceA.normal) > 0; - var uEnd = sheet ? 1 : total * perMetre; // the whole texture repeats by wrapping; an image in a trim sheet can't, so it is a piece for every image int pieces = 1; float pieceLength = edge.length; @@ -181,7 +181,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) @@ -305,7 +304,7 @@ 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 diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index 6a6e5d8..4c525a0 100644 --- a/Components/Tests/ChiselTrimDecalTests.cs +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -488,6 +488,63 @@ public void ATrimKeepsToItsEdge_WhereverTheDecalIs() 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 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() @@ -615,7 +672,7 @@ ChiselDecalComponent TrimRoundTheTop(ChiselModelComponent model, ChiselBoxCompon // A 2 m box's top, framed half a metre deep: 4 m2 less the 1 m2 middle, each corner once (overlapping square ends // would draw 4 m2); each side half a metre down - [Test, Ignore("Chains round corners are off until they are fixed (com.chisel#53)")] + [Test] public void ATrimRoundCorners_IsMitredWhereItsEdgesMeet() { var model = Model(); @@ -627,7 +684,7 @@ public void ATrimRoundCorners_IsMitredWhereItsEdgesMeet() Assert.That(AreaFacing(triangles, side), Is.EqualTo(1.0).Within(kTolerance), $"the side facing {side}"); } - [Test, Ignore("Chains round corners are off until they are fixed (com.chisel#53)")] + [Test] public void ATrimRoundCorners_RunsOnFromEdgeToEdge() { var model = Model(); diff --git a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs index 5012ab9..5a21e9c 100644 --- a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs +++ b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs @@ -45,7 +45,7 @@ public sealed class ChiselDecalEditor : Editor static readonly GUIContent kTrimFillContent = new GUIContent("Fill", "Stretch: one image over the whole edge. Repeat: the image repeated at its length, the last one cut short. Fit: the whole number of images nearest to what fits, stretched to fill the edge. Single: one image at its length, round the decal's position (move the decal along the edge to place it)."); static readonly GUIContent kTrimLengthContent = new GUIContent("Edge Length", "The image's length along the edge."); 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. Escape stops."); + 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, within a brush or between two."); const string kTrimNeedsTwoMessage = "A trim decal lies on the edge between two surfaces: pick its edge."; @@ -361,15 +361,23 @@ static void PickEdge(ChiselDecalComponent decal) if (evt.button != 0 || evt.alt) break; s_Candidate = FindEdge(evt.mousePosition); + // shift adds the edge to the chain, and goes on picking + var addToChain = evt.shift && decal.Targets.Count >= 2; if (s_Candidate.valid) { Undo.RecordObjects(new Object[] { decal, decal.transform }, "Pick Trim Edge"); - decal.SetTargets(new[] { s_Candidate.a, s_Candidate.b }); - decal.SetTrimEdge(false, s_Candidate.edgeIndex); - decal.transform.position = s_Candidate.at; + if (addToChain) + 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); } - s_PickingFor = null; + if (!addToChain) + s_PickingFor = null; s_Candidate = default; evt.Use(); break; diff --git a/README.md b/README.md index fa3b456..450a7d3 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Surfaces and texturing * [Double sided surfaces](https://github.com/RadicalCSG/com.chisel/wiki/Double-Sided-Surfaces): a material can make its surfaces drawn from behind too * [Hotspot mapping](https://github.com/RadicalCSG/com.chisel/wiki/Hotspot-Mapping): surfaces take their texture coordinates from the best matching tile of a layout (borders, repeats, shaped tiles and trim sheets), with a layout editor * [Decals](https://github.com/RadicalCSG/com.chisel/wiki/Decals): boxes that project a material onto the surfaces they reach, drawn over them or replacing them, their image repeated in x and y, from the whole texture or an image in a trim sheet -* [Trim decals](https://github.com/RadicalCSG/com.chisel/wiki/Trim-Decals): a decal on the edge between two surfaces, the lower half of its image on one and the upper half on the other (or mirrored), stretched, repeated or fitted along the edge or one image placed on it, its image from the whole texture or a trim sheet, following the edge when either brush changes; placed by picking an edge in the scene view, or on every edge where two materials meet +* [Trim decals](https://github.com/RadicalCSG/com.chisel/wiki/Trim-Decals): a decal on the edge between two surfaces, the lower half of its image on one and the upper half on the other (or mirrored), stretched, repeated or fitted along the edge or one image placed on it, running round corners, its image from the whole texture or a trim sheet, following the edge when either brush changes; placed by picking an edge in the scene view, or on every edge where two materials meet Output * Ordinary Unity meshes and colliders, that work with Unity's lightmapping, occlusion culling and navigation meshes @@ -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 round corners](https://github.com/RadicalCSG/com.chisel/issues/53) * [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) From 01d2735d1b34c165348e5e73e470d9cb74c2c354 Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 21:09:08 +0200 Subject: [PATCH 09/12] A trim stays on its line while its decal is dragged The line a trim lies on wins for as long as its two surfaces still meet on it, however far the decal is dragged off it before it is put back on the edge; only picking moves a trim to another seam. Giving a decal new targets forgets the line. --- Components/Components/Decals/ChiselDecalComponent.cs | 1 + Components/Components/Decals/ChiselTrimDecals.cs | 7 ++++--- Components/Tests/ChiselTrimDecalTests.cs | 8 ++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Components/Components/Decals/ChiselDecalComponent.cs b/Components/Components/Decals/ChiselDecalComponent.cs index 9edf490..b1fe4e5 100644 --- a/Components/Components/Decals/ChiselDecalComponent.cs +++ b/Components/Components/Decals/ChiselDecalComponent.cs @@ -157,6 +157,7 @@ public void SetTargets(IEnumerable newTargets) if (newTargets != null) targets.AddRange(newTargets); trimEdges.Clear(); + hasTrimLine = false; ChiselDecalManager.SetDirty(); } diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index 8979b26..41a803c 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -743,7 +743,7 @@ static int EdgeOn(in Face face, float3 point, float3 direction) return -1; } - // A line to keep to: the one the trim lay on, while the reference point is still on it + // A line to keep to: the one the trim lay on struct KeptLine { public bool keep; @@ -807,8 +807,9 @@ static void NearestSharedEdge(in Face face, in Face other, bool inPlane, float3 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 while the reference point is on it - var isKept = distance < kMargin && kept.Holds(from, from + along * length); + // 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)) diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index 4c525a0..43f3587 100644 --- a/Components/Tests/ChiselTrimDecalTests.cs +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -406,7 +406,9 @@ public void OfTwoSeamsBetweenTheSameSurfaces_TheTrimTakesTheOneItWasPutOn(float 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); @@ -486,6 +488,12 @@ public void ATrimKeepsToItsEdge_WhereverTheDecalIs() 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 From 0eeaaf8d06963d4d19ad812bc11cd3a3d34ddb97 Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 21:29:43 +0200 Subject: [PATCH 10/12] Trim chains round corners where brushes overlap Edges of a chain that run past a corner (the walls overlapping there) or stop short of it end where their lines cross, when that is within reach of their ends, so the corner is mitred instead of drawn twice. --- .../Components/Decals/ChiselTrimDecals.cs | 33 +++++++++++++++++++ Components/Tests/ChiselTrimDecalTests.cs | 27 +++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index 41a803c..536f0a4 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -98,6 +98,7 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong 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; @@ -256,6 +257,38 @@ static void PlaceSingle(ChiselDecalComponent decal, ChiselModelComponent model, edge.length = imageLength; } + // Edges that run past a corner (the surfaces of brushes overlapping there) or stop short of it end where their lines + // cross, when that is within reach of their ends + 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 (math.abs(s - edge.length) > reach || math.abs(t) > reach || s <= kMargin || 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) { diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index 43f3587..607f1b9 100644 --- a/Components/Tests/ChiselTrimDecalTests.cs +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -553,6 +553,33 @@ public void AChainRoundAWallsFoot_AcrossTwoFloorBrushes_DrawsEachCornerOnce() 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 + [Test] + public void AChainRoundACornerWhereWallsOverlap_EndsBothEdgesAtTheCorner() + { + var model = Model(); + var floor = Box(model, new float3(-5, -1, -5), new float3(5, 0, 5)); + var wall1 = Box(model, new float3(-3, 0, 0), new float3(1, 2, 1)); + var wall2 = Box(model, new float3(0, 0, -3), new float3(1, 2, 1)); + 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)); + 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"); + } + // 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() From 3d1b99aff1f2369933680230be4e40fcb568eb8e Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 21:45:04 +0200 Subject: [PATCH 11/12] Trim chain corners: join edges that run past the corner however far Edges that overlap at a corner (thick walls overlapping) are always ended where their lines cross; the reach only limits edges that stop short of it. --- Components/Components/Decals/ChiselTrimDecals.cs | 6 +++--- Components/Tests/ChiselTrimDecalTests.cs | 13 ++++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Components/Components/Decals/ChiselTrimDecals.cs b/Components/Components/Decals/ChiselTrimDecals.cs index 536f0a4..054930c 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -257,8 +257,8 @@ static void PlaceSingle(ChiselDecalComponent decal, ChiselModelComponent model, edge.length = imageLength; } - // Edges that run past a corner (the surfaces of brushes overlapping there) or stop short of it end where their lines - // cross, when that is within reach of their ends + // 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; @@ -281,7 +281,7 @@ static void JoinCorners(ChainEdge[] edges, float reach) var t = (e - along * d) / denominator; if (math.distance(edge.start + edge.direction * s, next.start + next.direction * t) > kMargin) continue; - if (math.abs(s - edge.length) > reach || math.abs(t) > reach || s <= kMargin || t >= next.length - kMargin) + if (s <= kMargin || s > edge.length + reach || t < -reach || t >= next.length - kMargin) continue; edge.length = s; next.start += next.direction * t; diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index 607f1b9..fa2bd2d 100644 --- a/Components/Tests/ChiselTrimDecalTests.cs +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -555,13 +555,14 @@ public void AChainRoundAWallsFoot_AcrossTwoFloorBrushes_DrawsEachCornerOnce() // 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 - [Test] - public void AChainRoundACornerWhereWallsOverlap_EndsBothEdgesAtTheCorner() + [TestCase(1f, 1f)] + [TestCase(3f, 0.25f)] + public void AChainRoundACornerWhereWallsOverlap_EndsBothEdgesAtTheCorner(float thickness, float imageSize) { var model = Model(); - var floor = Box(model, new float3(-5, -1, -5), new float3(5, 0, 5)); - var wall1 = Box(model, new float3(-3, 0, 0), new float3(1, 2, 1)); - var wall2 = Box(model, new float3(0, 0, -3), new float3(1, 2, 1)); + 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); @@ -570,6 +571,8 @@ public void AChainRoundACornerWhereWallsOverlap_EndsBothEdgesAtTheCorner() 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); From 448eb1942f9b3975d858a43136967830173df0cb Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 22:01:34 +0200 Subject: [PATCH 12/12] Trim chains: no edge twice, and corners mitred side with side An edge on the line of an earlier one in the chain that overlaps it (the same surface's edge picked beside two brushes) is drawn once, and Shift-click doesn't add an edge the chain already has. At a corner a half is mitred only with the next edge's half on its own side of the chain (left or right of the way it runs), so on a seam where all four halves lie in one plane the halves across the line no longer overwrite each other's cuts and overlap. --- .../Components/Decals/ChiselDecalComponent.cs | 17 +++++- .../Components/Decals/ChiselTrimDecals.cs | 46 +++++++++++++-- Components/Tests/ChiselTrimDecalTests.cs | 56 +++++++++++++++++++ 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/Components/Components/Decals/ChiselDecalComponent.cs b/Components/Components/Decals/ChiselDecalComponent.cs index b1fe4e5..866f630 100644 --- a/Components/Components/Decals/ChiselDecalComponent.cs +++ b/Components/Components/Decals/ChiselDecalComponent.cs @@ -179,18 +179,29 @@ public void SetTrimEdge(bool onSecond, int edgeIndex) ChiselDecalManager.SetDirty(); } - /// Adds an edge to a trim decal's chain: a pair of targets and the edge they lie on - public void AddTrimEdge(ChiselDecalSurfaceTarget a, ChiselDecalSurfaceTarget b, bool onSecond, int edgeIndex) + /// 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(edgeIndex < 0 ? -1 : edgeIndex * 2 + (onSecond ? 1 : 0)); + 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 054930c..bce8bcc 100644 --- a/Components/Components/Decals/ChiselTrimDecals.cs +++ b/Components/Components/Decals/ChiselTrimDecals.cs @@ -95,6 +95,19 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong 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); @@ -111,14 +124,23 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong break; if (math.distance(edges[i].start + edges[i].direction * edges[i].length, edges[n].start) > kMargin) continue; - // halves in one plane meet at the corner: on one surface, or on surfaces of brushes side by side + // 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 (InOnePlane(h == 0 ? edges[i].faceA : edges[i].faceB, k == 0 ? edges[n].faceA : edges[n].faceB)) - 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); } } @@ -172,9 +194,9 @@ public static bool TryDescribe(ChiselDecalComponent decal, ulong entityID, ulong uStart = uEnd - uStart; shown = -shown; } - halves.Add(MakeHalf(edge.faceA, edge.acrossA, targets[i * 2], pieceStart, edge.direction, span, uStart, 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; @@ -257,6 +279,18 @@ 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) diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs index fa2bd2d..e4bf96a 100644 --- a/Components/Tests/ChiselTrimDecalTests.cs +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -583,6 +583,62 @@ public void AChainRoundACornerWhereWallsOverlap_EndsBothEdgesAtTheCorner(float t 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()