diff --git a/Assets/Scripts/Editor/ShipModPreview.cs b/Assets/Scripts/Editor/ShipModPreview.cs new file mode 100644 index 000000000..859c0a80f --- /dev/null +++ b/Assets/Scripts/Editor/ShipModPreview.cs @@ -0,0 +1,51 @@ +using System; +using UnityEditor; +using UnityEngine; + +public static class ShipModPreview +{ + // Batch proof: -executeMethod ShipModPreview.Smoke -shipModPath + public static async void Smoke() + { + var args = Environment.GetCommandLineArgs(); + var option = Array.IndexOf(args, "-shipModPath"); + if (option < 0 || option + 1 >= args.Length) + { + Debug.LogError("-shipModPath is required"); + EditorApplication.Exit(1); + return; + } + ShipModVisual.Instance visual = null; + try + { + visual = await ShipModVisual.LoadAsync(args[option + 1], null); + if (visual.Anchors.Count == 0 || visual.LineMesh == null || visual.LineMesh.vertexCount == 0) + throw new InvalidOperationException("Mod visual did not import anchors and line points."); + Debug.Log($"SHIP_MOD_VISUAL_SMOKE anchors={visual.Anchors.Count} points={visual.LineMesh.vertexCount} segments={visual.LineMesh.GetIndexCount(0) / 2}"); + visual.Destroy(); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + visual?.Destroy(); + EditorApplication.Exit(1); + } + } + + [MenuItem("Aetheria/Preview Mod Ship Package")] + private static async void Open() + { + var path = EditorUtility.OpenFilePanel("Ship authoring record", "GameData/Mods", "cc"); + if (string.IsNullOrEmpty(path)) return; + try + { + var visual = await ShipModVisual.LoadAsync(path, null); + Undo.RegisterCreatedObjectUndo(visual.Root, "Preview mod ship"); + Selection.activeGameObject = visual.Root; + SceneView.FrameLastActiveSceneView(); + Debug.Log($"Previewed {path}: {visual.Anchors.Count} anchors, {visual.LineMesh?.vertexCount ?? 0} line points."); + } + catch (Exception exception) { Debug.LogException(exception); } + } +} diff --git a/Assets/Scripts/Editor/ShipModPreview.cs.meta b/Assets/Scripts/Editor/ShipModPreview.cs.meta new file mode 100644 index 000000000..2e8ea6b35 --- /dev/null +++ b/Assets/Scripts/Editor/ShipModPreview.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d32248a66f6454a9be20458fa43f3ef diff --git a/Assets/Scripts/Gameplay/ShipModVisual.cs b/Assets/Scripts/Gameplay/ShipModVisual.cs new file mode 100644 index 000000000..9c4c0c33b --- /dev/null +++ b/Assets/Scripts/Gameplay/ShipModVisual.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GLTFast; +using UnityEngine; +using Object = UnityEngine.Object; + +// A mod package's visual assembly. It does not decide hull cells or hardpoint roles. +public static class ShipModVisual +{ + public sealed class Instance + { + public GameObject Root; + public IReadOnlyDictionary Anchors; + public Mesh LineMesh; + public Material LineMaterial; + + public void Destroy() + { + DestroyObject(Root); + DestroyObject(LineMesh); + DestroyObject(LineMaterial); + } + } + + private sealed class Nodes : GameObjectInstantiator + { + public Nodes(IGltfReadable gltf, Transform parent) : base(gltf, parent) { } + public Transform Get(uint index) => m_Nodes.TryGetValue(index, out var node) ? node.transform : null; + } + + public static async Task LoadAsync(string shipPath, Transform parent) + { + var package = ShipModCatalog.ReadPackage(shipPath); + var ship = package.Ship; + + var root = new GameObject("mod-ship:" + ship.Id); + root.SetActive(false); + root.transform.SetParent(parent, false); + var result = new Instance { Root = root }; + try + { + // glTFast's default defer agent creates a DontDestroyOnLoad object, which is + // unavailable in edit-mode previews. Play mode keeps its frame-budgeted agent. + var gltf = new GltfImport(deferAgent: Application.isPlaying ? null : new UninterruptedDeferAgent()); + if (!await gltf.LoadFile(package.ModelPath)) + throw new InvalidOperationException($"{ship.Id}: GLB import failed."); + var nodes = new Nodes(gltf, root.transform); + if (!await gltf.InstantiateMainSceneAsync(nodes)) + throw new InvalidOperationException($"{ship.Id}: GLB scene instantiation failed."); + result.Anchors = ship.Anchors.ToDictionary(anchor => anchor.Id, anchor => + nodes.Get(package.NodeIndices[anchor.ModelNodeId]) ?? + throw new InvalidOperationException($"{ship.Id}: GLB scene omitted node {anchor.ModelNodeId}."), + StringComparer.Ordinal); + result.LineMesh = BuildLineMesh(ship.SchematicLines); + if (result.LineMesh != null) + { + var lines = new GameObject("Schematic Lines"); + lines.transform.SetParent(root.transform, false); + lines.AddComponent().sharedMesh = result.LineMesh; + var shader = Shader.Find("Sprites/Default") ?? + throw new InvalidOperationException("Sprites/Default shader is unavailable for ship lines."); + result.LineMaterial = new Material(shader) { name = "Ship Lines" }; + lines.AddComponent().sharedMaterial = result.LineMaterial; + } + root.SetActive(true); + return result; + } + catch + { + result.Destroy(); + throw; + } + } + + private static void DestroyObject(Object target) + { + if (target == null) return; + if (Application.isPlaying) Object.Destroy(target); + else Object.DestroyImmediate(target); + } + + // Blender Z-up to Unity Y-up through Blender's glTF export and glTFast's handedness conversion. + // Segment topology preserves every authored point; display width is currently one screen pixel. + public static Mesh BuildLineMesh(IReadOnlyList lines) + { + if (lines == null || lines.Count == 0) return null; + var vertices = new List(); + var colors = new List(); + var segments = new List(); + foreach (var line in lines) + { + var count = line.Points.Length / 3; + var start = vertices.Count; + for (var i = 0; i < count; i++) + { + var offset = i * 3; + vertices.Add(new Vector3(-line.Points[offset], line.Points[offset + 2], -line.Points[offset + 1])); + var rgba = line.Color; + colors.Add(new Color(rgba?[0] ?? 1, rgba?[1] ?? 1, rgba?[2] ?? 1, + (rgba?[3] ?? 1) * (line.Opacities?[i] ?? 1))); + if (i > 0) { segments.Add(start + i - 1); segments.Add(start + i); } + } + if (line.Cyclic && count > 2) { segments.Add(start + count - 1); segments.Add(start); } + } + var mesh = new Mesh { name = "Captured Ship Lines", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 }; + mesh.SetVertices(vertices); + mesh.SetColors(colors); + mesh.SetIndices(segments, MeshTopology.Lines, 0); + mesh.RecalculateBounds(); + return mesh; + } +} diff --git a/Assets/Scripts/Gameplay/ShipModVisual.cs.meta b/Assets/Scripts/Gameplay/ShipModVisual.cs.meta new file mode 100644 index 000000000..d64c22b9b --- /dev/null +++ b/Assets/Scripts/Gameplay/ShipModVisual.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a8a4f72a22b44819a71e43e21c7db894 diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index eff18b115..512f0509e 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -10,6 +10,13 @@ using System.Text; // mod-owned ship.cc; this output is disposable and never becomes an authoring surface. public static class ShipModCatalog { + public sealed class Package + { + public ShipAuthoring Ship; + public string ModelPath; + public Dictionary NodeIndices; + } + public static CultRecordKey HullKey(string id) => new CultRecordKey("mod-hull:" + id); public static CultRecordKey AuthoringKey(string id) => new CultRecordKey("mod-ship:" + id); @@ -31,20 +38,10 @@ public static class ShipModCatalog { var path = Path.Combine(directory, "ship.cc"); if (!File.Exists(path)) continue; - var ship = ShipAuthoringStore.Read(path); - if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal) || - !ids.Add(ship.Id)) - throw new InvalidOperationException($"{path}: ship ID must uniquely match its package directory name."); - var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset)); - if (!modelPath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || - !File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package."); - var modelNodes = ReadNodeIds(modelPath); - foreach (var anchor in ship.Anchors) - if (!modelNodes.Contains(anchor.ModelNodeId)) - throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}."); - CultRecordRefs.Validate(ship.Hull); - ships.Add(ship); + var package = ReadPackage(path); + if (!ids.Add(package.Ship.Id)) + throw new InvalidOperationException($"{path}: duplicate ship ID {package.Ship.Id}."); + ships.Add(package.Ship); } Directory.CreateDirectory(Path.GetDirectoryName(output)); @@ -75,8 +72,27 @@ public static class ShipModCatalog } } + public static Package ReadPackage(string shipPath) + { + var path = Path.GetFullPath(shipPath); + var directory = Path.GetDirectoryName(path); + var ship = ShipAuthoringStore.Read(path); + if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal)) + throw new InvalidOperationException($"{path}: ship ID must match its package directory name."); + var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset)); + if (!modelPath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + !File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package."); + var modelNodes = ReadNodeIds(modelPath); + foreach (var anchor in ship.Anchors) + if (!modelNodes.ContainsKey(anchor.ModelNodeId)) + throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}."); + CultRecordRefs.Validate(ship.Hull); + return new Package { Ship = ship, ModelPath = modelPath, NodeIndices = modelNodes }; + } + // GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer. - public static HashSet ReadNodeIds(string path) + public static Dictionary ReadNodeIds(string path) { using var stream = File.OpenRead(path); using var reader = new BinaryReader(stream); @@ -87,13 +103,16 @@ public static class ShipModCatalog if (reader.ReadUInt32() != 0x4E4F534A || chunkLength > stream.Length - stream.Position) throw new InvalidOperationException($"{path}: missing GLB JSON chunk."); var json = JObject.Parse(Encoding.UTF8.GetString(reader.ReadBytes((int)chunkLength))); - var ids = new HashSet(StringComparer.Ordinal); - foreach (var node in json["nodes"]?.OfType() ?? Enumerable.Empty()) + var ids = new Dictionary(StringComparer.Ordinal); + var nodes = json["nodes"]?.OfType() ?? Enumerable.Empty(); + uint index = 0; + foreach (var node in nodes) { var id = (string)node["extras"]?["aetheria.id"]; - if (string.IsNullOrEmpty(id)) continue; - if (!ids.Add(id)) + if (string.IsNullOrEmpty(id)) { index++; continue; } + if (!ids.TryAdd(id, index)) throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}."); + index++; } return ids; } diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 9bc285b75..f5f0a2771 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -3,7 +3,6 @@ using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Runtime.Remoting.Contexts; using TMPro; using UnityEngine; using UnityEngine.UI; diff --git a/Packages/manifest.json b/Packages/manifest.json index 6e31d3b05..c166932fd 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -6,6 +6,7 @@ "com.unity.ai.navigation": "2.0.14", "com.unity.burst": "1.8.30", "com.unity.cinemachine": "2.10.7", + "com.unity.cloud.gltfast": "6.20.0", "com.unity.ide.rider": "3.0.40", "com.unity.ide.visualstudio": "2.0.26", "com.unity.inputsystem": "1.20.0", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index d58d112e2..33cb60ccf 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -68,6 +68,19 @@ }, "url": "https://packages.unity.com" }, + "com.unity.cloud.gltfast": { + "version": "6.20.0", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.burst": "1.8.30", + "com.unity.collections": "2.6.8", + "com.unity.mathematics": "1.3.3", + "com.unity.modules.jsonserialize": "1.0.0", + "com.unity.modules.unitywebrequest": "1.0.0" + }, + "url": "https://packages.unity.com" + }, "com.unity.collections": { "version": "2.6.8", "depth": 1, diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index b44b7c2a3..3ded258c7 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -30,7 +30,12 @@ to Blender's add-on search path, enable `brokkr_bridge` then `aetheria_ships`, and install `msgpack` into Blender's Python environment. Create a draft with `dotnet run --project tools/AetherDb -- ship-authoring create `, choose that `.cc` in the **Aetheria -Ship** panel, select a Grease Pencil object, and press **Capture Ship Lines**. +Ship** panel, select an object in its ship collection, and press **Bind Ship +Collection**. The collection stores `aetheria.asset_kind=ship`, the stable +`aetheria.id`, and a relative `.cc` path. Select a Grease Pencil object in +that bound collection and press **Capture Ship Lines**. Capture checks the +collection ID against the record before writing; object and collection +display names are not IDs. `ship-authoring inspect ` reads drafts; `validate` requires complete hull, model, and anchor authoring. @@ -79,11 +84,14 @@ is not yet an end-to-end ship loader in the current game. deletes or demotes the old ship-specific catalog/prefab authority for each migrated hull; it does not keep two writable definitions of that hull. -At this proof stage, `ship-authoring validate` is the sole caller of the -semantic validator. The Blender capture command writes only line data and the -`inspect` command reads incomplete drafts. Editor preview and runtime loading -still need to be built. The current gameplay catalog and prefab loader do not -read these mod records. +`ship-authoring validate`, the catalog composer, and the visual importer call +the same semantic validator. Blender capture writes only line data and +`inspect` reads incomplete drafts. Unity's **Aetheria / Preview Mod Ship +Package** imports a validated package from disk, resolves stable GLB node IDs, +and draws the captured polylines directly as 3D line segments. Point opacity +and color are retained; radius and material-specific stroke styling are not +rendered yet. The current gameplay catalog and prefab loader do not read mod +records. ## Runtime catalog seam @@ -115,12 +123,14 @@ loader and a complete playable ship are still outstanding. In the Quiet probe, previous derived file. The probe's four anchors and zero hardpoints are only a data-path smoke, not a playable Quiet design. -The construction boundary will resolve a mod hull's authoring record from its -derived key, load the package's compiled GLB at runtime, and build the -`ShipInstance` references from validated stable node IDs. It must fail before -scene construction when an asset or anchor is absent. [Unity glTFast's runtime +The visual importer uses [Unity glTFast's runtime import](https://github.com/Unity-Technologies/com.unity.cloud.gltfast/blob/main/Packages/com.unity.cloud.gltfast/Documentation~/ImportRuntime.md) -is a candidate for the GLB step; it has not been added to the project. +to read the package GLB asynchronously and map its stable node IDs to Unity +transforms. It keeps a partially loaded object inactive and destroys it on +failure. The remaining construction boundary must wire those transforms to +`ShipInstance` and preload visual prototypes before synchronous zone loads. +It must preserve `capturepreset` writes to the shipped catalog and use the +derived catalog only for gameplay reads. ## Proof gates @@ -143,13 +153,14 @@ is a candidate for the GLB step; it has not been added to the project. ## Structural and build budget The proof reuses `HullData` simulation semantics, CultCache `.cc` persistence, -Brokkr's Blender host, and the current `ShipInstance` behavior. This pass adds -one authoring document type, one validator, and one Blender-facing editor -surface. A later pass needs one runtime construction boundary. It does not add -a daemon, a second gameplay -simulation, or another Unity prefab generator. The existing name-based FBX -builder remains available to existing content but is not an input to this -lane. Its retirement is a later cut, after migration. +Brokkr's Blender host, and the current `ShipInstance` behavior. It adds one +authoring document type and validator, a Blender-facing line capture surface, +a disposable catalog composer, and one runtime GLB/line visual importer with +an Editor preview. The remaining construction work is `ShipInstance` wiring +and boot-time preloading. No daemon, second gameplay simulation, or Unity +prefab generator is added. The existing FBX builder remains available to +existing content but is not an input to this lane. Its retirement follows +migration. Focused checks use `Aetheria.Shared` and `tests/Aetheria.Shared.Tests` under `netstandard2.1` / `net10.0`, Python CultCache interoperability checks, and diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index c512e8405..da2ae8043 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -12,7 +12,7 @@ bl_info = { import bpy -from .ship_cc import capture_grease_pencil, replace_lines +from .ship_cc import capture_grease_pencil, read, replace_lines def _brokkr_cultlib(context): @@ -22,6 +22,55 @@ def _brokkr_cultlib(context): return brokkr.preferences.cultlib_py_src +def _ship_collection(context): + obj = context.active_object + if obj is None: + raise ValueError("Select an object in the ship collection") + matches = [collection for collection in bpy.data.collections + if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects] + if len(matches) != 1: + raise ValueError("Selected object must belong to exactly one bound ship collection") + return matches[0] + + +class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): + bl_idname = "aetheria.bind_ship_collection" + bl_label = "Bind Ship Collection" + bl_description = "Bind the active collection to one typed ship .cc record" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + try: + if not context.scene.aetheria_ship_cc_path: + raise ValueError("Choose an existing ship authoring .cc file") + obj = context.active_object + if obj is None: + raise ValueError("Select an object in the ship collection") + direct = list(obj.users_collection) + marked = [collection for collection in bpy.data.collections + if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects] + if len(marked) == 1: + collection = marked[0] + elif len(marked) == 0 and len(direct) == 1: + collection = direct[0] + else: + raise ValueError("Select an object in exactly one ship collection") + path = bpy.path.abspath(context.scene.aetheria_ship_cc_path) + _, _, body, _ = read(path, _brokkr_cultlib(context)) + if collection.get("aetheria.asset_kind") not in (None, "ship"): + raise ValueError(f"{collection.name} has another aetheria.asset_kind") + if collection.get("aetheria.id") not in (None, body[0]): + raise ValueError(f"{collection.name} is bound to another ship ID") + collection["aetheria.asset_kind"] = "ship" + collection["aetheria.id"] = body[0] + collection["aetheria.ship_cc"] = bpy.path.relpath(path) + self.report({"INFO"}, f"Bound {collection.name} to {body[0]}") + return {"FINISHED"} + except (OSError, ValueError, RuntimeError, ImportError) as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} + + class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): bl_idname = "aetheria.capture_ship_lines" bl_label = "Capture Ship Lines" @@ -31,12 +80,14 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): def execute(self, context): try: obj = context.active_object - if obj is None: - raise ValueError("Select the ship's Grease Pencil LineArt object") - if not context.scene.aetheria_ship_cc_path: - raise ValueError("Choose an existing ship authoring .cc file") - path = bpy.path.abspath(context.scene.aetheria_ship_cc_path) + collection = _ship_collection(context) + if not collection.get("aetheria.ship_cc"): + raise ValueError(f"{collection.name} has no bound ship .cc path") + path = bpy.path.abspath(collection["aetheria.ship_cc"]) cultlib = _brokkr_cultlib(context) + _, _, body, _ = read(path, cultlib) + if body[0] != collection["aetheria.id"]: + raise ValueError("The bound collection ID does not match its .cc ship ID") lines = capture_grease_pencil( obj, context.evaluated_depsgraph_get(), context.scene.frame_current, evaluated=context.scene.aetheria_capture_evaluated_lines, @@ -59,13 +110,19 @@ class AETHERIA_PT_ship(bpy.types.Panel): def draw(self, context): layout = self.layout layout.prop(context.scene, "aetheria_ship_cc_path") + layout.operator("aetheria.bind_ship_collection", icon="LINKED") layout.prop(context.scene, "aetheria_capture_evaluated_lines") obj = context.active_object layout.label(text=f"Source: {obj.name if obj else '(none)'}") + try: + collection = _ship_collection(context) + layout.label(text=f"Ship: {collection['aetheria.id']}") + except (ValueError, KeyError): + layout.label(text="No bound ship collection", icon="ERROR") layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL") -classes = (AETHERIA_OT_capture_ship_lines, AETHERIA_PT_ship) +classes = (AETHERIA_OT_bind_ship_collection, AETHERIA_OT_capture_ship_lines, AETHERIA_PT_ship) def register():