Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- Reverse UV: the audit report is written only at the Verbose log level (the last 20 are kept) and a failed write no longer fails the transfer; the audit itself still gates every result. The ancestry kept per mesh and saved in the sidecar is a compact record of per-face overlap flags, charts, chain keys and overlap relations instead of the node's whole report (earlier sidecars are read and rewritten on restore), and the viewer's quality check no longer hashes it every frame.

### Fixed
- Prepare reads a collision node's own material names before removing its renderer, then falls back to its matching render part. A `_COL` node without a surface in its name or a render sibling now gets the same surface in Preview and Apply. Existing surface references and collider placement rules are unchanged.
- Place Collision at Root (Sidecar) works on a model whose Read/Write is off, Unity's default: every import refused the listed nodes as "unreadable" and the place step then dropped them from the sidecar, though a model's import may change its meshes whatever that setting.
- A model's sidecar no longer counts as holding UV Transfer settings when none were saved in it. Unity gives every sidecar a settings block with the defaults, so selecting a model whose sidecar had none applied those defaults over the current settings, and a sidecar left with nothing else (after a UV2 reset, say) was kept instead of deleted. Settings saved before this change count when they differ from the defaults.
- Prepare's Preset step no longer applies the preset's avatar description (the importer's human description: bone mapping and its scale): it is the model's own, as its remaps are.
Expand Down
83 changes: 83 additions & 0 deletions Documentation~/PREPARE_SURFACE_BINDING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Prepare: component surface binding

The Colliders step can attach a game's component and assign one existing surface
asset to its serialized object-reference field. The package has no reference to a
game assembly. It does not create surface assets, set their physics/damage values,
or assign `Collider.sharedMaterial`.

## Project configuration

Open **Project Settings > Mesh Lab > Prepare > Collider Surfaces**.

| Setting | Value to obtain from the game |
| --- | --- |
| Component | Fully qualified, concrete runtime component type |
| Field | Serialized field name, including any leading underscore; not the C# property or setter |
| Asset Type | Fully qualified asset type assignable to that field |
| Surface words | Keyword to exact asset object name, without `.asset` |

The component's field may be public or private with `[SerializeField]`. The
binding writes it through `SerializedObject`; it does not invoke a custom setter.
Check whether the game's setter has necessary side effects before using this
binding. Prefer full type names: short names can collide across assemblies.

Enable **Colliders** and **Surfaces** in the matching recipe. For FBX hierarchy
changes also enable **Prefab**, which writes a variant. The first matching recipe
wins; the default only matches `SM_*`. **Run on Import** is opt-in and handles
first imports without an existing `.meta`, not ordinary reimports.

## Recognition and asset selection

1. First surface word in the part's name.
2. First recognized material name: the node's own materials, followed by matching
render siblings for a collision node. Siblings share `MeshNaming.GroupKey`
after LOD/collision suffix removal.
3. First surface word in the target model's name.

Surface words are case-insensitive prefixes of individual name tokens. Tokens
split at separators, case boundaries and digits. `wood` matches `Wooden`, but a
rule containing `reinforced_concrete` cannot match two tokens. Use a single
unambiguous token for a special surface and map it to the actual asset name.

The defaults are wood, concrete, metal, glass, plastic, tile and cardboard. A
game may need different names and additional rules. The binder does not inspect
a surface enum inside the asset. It searches assets of the configured type and
requires an exact object-name match ignoring case. Duplicate names select the
longest shared folder prefix with the target, then the ordinal path. A scene
target uses its scene path, so duplicate names can resolve differently from a
model target. Keep semantic asset names unique where possible.

## Boundaries to check before a batch

| Case | Current behavior / consequence |
| --- | --- |
| One node with Wood and Metal render materials | First recognized material wins for the entire collider; no submesh/triangle assignment, conflict warning or automatic split |
| Existing non-null reference on the destination object | Preserved even if naming suggests another surface; no asset lookup |
| Missing asset or unrecognized surface | Warning; no surface component is added; collider preparation can still succeed |
| Valid repeated Apply | Existing binding is kept and no duplicate component is added |
| Root already has any MeshCollider | Whole Colliders step is skipped, including all surface bindings below it |
| Asset becomes available after a previous failed binding | A non-root collider with recognizable naming can retry; a root MeshCollider prevents the retry |
| Largest collision node moves to root | Only the destination object's existing binding is checked; a custom binding on the source node is not transferred |
| Several colliders on one GameObject | One component/reference is shared by that object; not a per-collider mapping |

For mixed surfaces, author separate explicitly named collision parts, e.g.
`Seat_Wood_COL` and `Frame_Metal_COL`. Check any largest-part relocation when
manual surface components already exist. Do not remove an intentional root
collider merely to force Prepare to run. Existing root-collider models need a
separate migration/fill step, which this API does not currently provide.

A collision node whose material name is its only surface evidence loses that
evidence when its renderer is removed. A successful binding survives, but a
missing asset should be fixed before Apply; explicit surface names also allow a
later retry on non-root colliders.

## Verification

`PrepareCollidersTests` covers own-material Preview/Apply parity, explicit name
priority, matching render fallback, preservation of a different manual reference,
missing-asset retry on a non-root collider, repeated Apply, and incompatible types.
`PreparePrefabTests.ComponentBinding_UsesNearestExactAssetAndPersistsAcrossPrefabReload`
uses real temporary assets and reloads a prefab variant to check persistence.

Run these in Unity's EditMode Test Runner. A license-free compile check verifies
C# types but does not execute Unity serialization, Undo or AssetDatabase behavior.
15 changes: 10 additions & 5 deletions Editor/Prepare/ColliderStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,16 @@ void Prepare(PrepareContext context, PrepareReport report, bool apply, Source so
string offset = main ? LodHierarchy.OffsetFromRoot(root, source.node) : null;
var host = main && offset == null ? root : source.node;
string subject = Subject(root, source.node);
// Read the collision node's materials before Apply removes its renderer. Preview
// and Apply must choose the same surface, including a node with no render sibling.
string surface = null, origin = null;
if (binding != null && binding.Unavailable == null)
(surface, origin) = SurfaceBinding.Resolve(source.node.name, MaterialNames(root, source), context.Target.Name, vocabulary);
GiveCollider(context, report, apply, source, host, subject, Placement(main, offset, host == root));
if (host != source.node) DropNodeCollider(context, report, apply, source, subject);
else if (host != root) Activate(context, report, apply, source, subject);
if (source.collisionNode) RemoveRenderers(context, report, apply, source.node, subject);
if (binding == null || binding.Unavailable != null) return;
var (surface, origin) = SurfaceBinding.Resolve(source.node.name, MaterialNames(root, source), context.Target.Name, vocabulary);
binding.Bind(context, host.gameObject, surface, origin, Subject(root, host), report, apply);
}

Expand Down Expand Up @@ -229,17 +233,18 @@ static List<Source> LodSources(GameObject root, int lod, out string level)
return chosen.sources;
}

// The part's materials: a render node's own; a collision node's, those of the render
// nodes of its part (the same name once LOD and collision suffixes are off).
// The part's materials: its own first, then for a collision node the render nodes of
// its part (the same name once LOD and collision suffixes are off).
static IEnumerable<string> MaterialNames(Transform root, Source source)
{
IEnumerable<Renderer> renderers;
if (!source.collisionNode) renderers = source.node.GetComponents<Renderer>();
else
{
string key = MeshNaming.GroupKey(source.node.name);
renderers = root.GetComponentsInChildren<Renderer>(true).Where(r => !MeshNaming.IsCollision(r.name)
&& string.Equals(MeshNaming.GroupKey(r.name), key, StringComparison.OrdinalIgnoreCase));
renderers = source.node.GetComponents<Renderer>().Concat(
root.GetComponentsInChildren<Renderer>(true).Where(r => !MeshNaming.IsCollision(r.name)
&& string.Equals(MeshNaming.GroupKey(r.name), key, StringComparison.OrdinalIgnoreCase)));
}
return renderers.SelectMany(r => r.sharedMaterials).Where(m => m != null).Select(m => m.name).Distinct();
}
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ Unity -batchmode -quit -projectPath <project> -executeMethod SashaRX.UnityMeshLa

The exit code is 1 when a model was not applied (with `-preparePreview`: would be refused), else 0.

For game surface components, see [binding configuration and limitations](Documentation~/PREPARE_SURFACE_BINDING.md), including mixed materials, existing assignments and root-collider models.

## Cleanup

Scan and fix the hygiene issues the other operations leave behind: **Mesh** (empty channels, attribute set, duplicate material slots, Read/Write handling without importer flips), **Colliders** (stray attributes on collider meshes), **Split / Merge** (same-material merge, per-material split), import settings. Every fix records Undo.
Expand Down
124 changes: 124 additions & 0 deletions Tests/Editor/PrepareCollidersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;

Expand Down Expand Up @@ -229,6 +230,9 @@ public void Resolve_ReadsThePartThenItsMaterialsThenTheModel()
Assert.AreEqual("Wood", SurfaceBinding.Resolve("Seat_COL", new[] { "M_Building_Atlas_01", "M_Wood_Oak" }, "SM_Glass", words).surface);
Assert.AreEqual("Glass", SurfaceBinding.Resolve("Seat_COL", new[] { "M_Atlas" }, "SM_Glass_Door", words).surface);
Assert.IsNull(SurfaceBinding.Resolve("Seat_COL", null, "Bench", words).surface);
Assert.AreEqual("Wood", SurfaceBinding.Resolve("Seat_COL", new[] { "M_Wood", "M_Metal" }, "Bench", words).surface,
"a multi-material node gets one surface: the first recognized material, not one per submesh");
Assert.AreEqual("Metal", SurfaceBinding.Resolve("Seat_COL", new[] { "M_Metal", "M_Wood" }, "Bench", words).surface);
}

[Test]
Expand Down Expand Up @@ -309,5 +313,125 @@ public void ComponentBinding_SaysWhyItCannotBind()
};
StringAssert.Contains("no serialized field", new ComponentSurfaceBinding(wrongField).Unavailable);
}

PrepareTestSurfaceData Surface(string name)
{
var asset = ScriptableObject.CreateInstance<PrepareTestSurfaceData>();
asset.name = name;
owned.Add(asset);
return asset;
}

Material RenderMaterial(string name)
{
var material = new Material(Shader.Find("Standard")) { name = name };
owned.Add(material);
return material;
}

static SurfaceBindingSettings BindingSettings() => new SurfaceBindingSettings
{
componentType = typeof(PrepareTestSurface).FullName,
field = "_material",
dataType = typeof(PrepareTestSurfaceData).FullName,
};

[TestCase(false)]
[TestCase(true)]
public void ComponentBinding_ReadsCollisionNodeMaterialBeforeRemovingItsRenderer(bool onRoot)
{
var wood = Surface("Wood");
var root = Root("Bench");
var node = Node(root, "Seat_COL", Box("Seat_COL", 1), onRoot ? Vector3.zero : Vector3.up,
RenderMaterial("M_Wood_Oak"));
var host = onRoot ? root : node;
var binding = new ComponentSurfaceBinding(BindingSettings(), (type, name, near) => name == "Wood" ? wood : null);
var step = new ColliderStep(binding);

var preview = PrepareRunner.Preview(PrepareTarget.ForSceneObject(root), new[] { step });

Assert.IsNull(host.GetComponent<PrepareTestSurface>(), "Preview must not add the component");
Assert.IsNotNull(node.GetComponent<MeshRenderer>());
var planned = preview.Steps.Single().Report.Lines.Single(l => l.check == ComponentSurfaceBinding.Check);
Assert.AreEqual(PrepareLevel.Changed, planned.level, preview.ToString());

var applied = Apply(root, step);

Assert.AreSame(wood, host.GetComponent<PrepareTestSurface>()?.Material, applied.ToString());
Assert.IsNull(node.GetComponent<MeshRenderer>());
Assert.AreEqual(planned.message, applied.Steps.Single().Report.Lines.Single(l => l.check == ComponentSurfaceBinding.Check).message);
Assert.IsFalse(Apply(root, step).Changes, "a bound collider is stable on a second Apply");
Assert.AreEqual(1, host.GetComponents<PrepareTestSurface>().Length);
}

[TestCase("Seat_COL", "M_Wood_Oak", "Wood")]
[TestCase("Seat_COL", "M_Atlas", "Metal")]
[TestCase("Seat_Glass_COL", "M_Wood_Oak", "Glass")]
public void ComponentBinding_OwnMaterialsThenMatchedRenderPartWithExplicitNameFirst(string part, string ownMaterial, string expected)
{
var asset = Surface(expected);
var root = Root("Bench");
var node = Node(root, part, Box(part, 1), Vector3.up, RenderMaterial(ownMaterial));
Node(root, MeshNaming.GroupKey(part) + "_LOD0", Box("Render", 1), Vector3.up, RenderMaterial("M_Metal"));
var binding = new ComponentSurfaceBinding(BindingSettings(), (type, name, near) => name == expected ? asset : null);

var result = Apply(root, new ColliderStep(binding));

Assert.AreSame(asset, node.GetComponent<PrepareTestSurface>()?.Material, result.ToString());
}

[Test]
public void ComponentBinding_PreservesAnExistingDifferentAssignmentWithoutLookingUpAssets()
{
var original = Surface("CustomSurface");
var root = Root("Bench");
var node = Node(root, "Seat_Wood_COL", Box("Seat_Wood_COL", 1), Vector3.up);
var component = node.AddComponent<PrepareTestSurface>();
using (var serialized = new SerializedObject(component))
{
serialized.FindProperty("_material").objectReferenceValue = original;
serialized.ApplyModifiedPropertiesWithoutUndo();
}
int lookups = 0;
var binding = new ComponentSurfaceBinding(BindingSettings(), (type, name, near) => { lookups++; return null; });

Apply(root, new ColliderStep(binding));
var again = Apply(root, new ColliderStep(binding));

Assert.AreSame(original, component.Material);
Assert.AreEqual(0, lookups, "a manual assignment must survive even when the inferred asset is missing");
Assert.AreEqual(1, node.GetComponents<PrepareTestSurface>().Length);
Assert.IsFalse(again.Changes, again.ToString());
}

[Test]
public void ComponentBinding_MissingAssetAddsNoComponentAndCanRetryOnANonRootCollider()
{
var root = Root("Bench");
var node = Node(root, "Seat_Wood_COL", Box("Seat_Wood_COL", 1), Vector3.up);
PrepareTestSurfaceData available = null;
var binding = new ComponentSurfaceBinding(BindingSettings(), (type, name, near) => available);

var first = Apply(root, new ColliderStep(binding));

Assert.IsNull(node.GetComponent<PrepareTestSurface>());
Assert.IsNotNull(node.GetComponent<MeshCollider>());
Assert.IsTrue(first.Steps.Single().Report.Lines.Any(l => l.check == ComponentSurfaceBinding.Check && l.level == PrepareLevel.Warning));
available = Surface("Wood");
Apply(root, new ColliderStep(binding));

Assert.AreSame(available, node.GetComponent<PrepareTestSurface>()?.Material);
Assert.AreEqual(1, node.GetComponents<MeshCollider>().Length);
Assert.IsFalse(Apply(root, new ColliderStep(binding)).Changes);
}

[Test]
public void ComponentBinding_RejectsIncompatibleAssetType()
{
var settings = BindingSettings();
settings.dataType = typeof(Material).FullName;

StringAssert.Contains("does not take", new ComponentSurfaceBinding(settings).Unavailable);
}
}
}
Loading
Loading