diff --git a/.cache/kotor_net_area_designer/AreaExportation_AreaExporter.cs b/.cache/kotor_net_area_designer/AreaExportation_AreaExporter.cs new file mode 100644 index 000000000..eff574b31 --- /dev/null +++ b/.cache/kotor_net_area_designer/AreaExportation_AreaExporter.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Kotor.NET.Resources.KotorMDL; +using Kotor.NET.Resources.KotorMDL.Controllers; +using Kotor.NET.Resources.KotorMDL.Nodes; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.AreaExportation; + +public static class AreaExporter +{ + public static MDL RoomToMDL(Room room) + { + var mdl = new MDL(); + mdl.Name = "test"; + + foreach (var tile in room.Tiles) + { + mdl.Root.Children.Add(FloorToMDLNode(tile.Floor)); + //mdl.Root.Children.Add(CeilingToMDLNode(tile.Ceiling)); + mdl.Root.Children.AddRange(tile.Walls.Where(x => x.Visible).Select(WallToMDLNode)); + mdl.Root.Children.AddRange(tile.Walls.Select(x => x.DoorFrame).Where(x => x?.Visible == true).Select(DoorFrameToMDLNode)); + mdl.Root.Children.AddRange(tile.InnerCorners.Where(x => x.Visible == true).Select(InnerCornerToMDLNode)); + mdl.Root.Children.AddRange(room.Objects.Select(ObjectToMDLNode)); + } + + mdl.Root.GetAllDescendants().Select((x, i) => x.Name = i.ToString()).ToArray(); + mdl.RedoNodeNumbers(); + return mdl; + } + + private static MDLNode FloorToMDLNode(Floor floor) + { + var floorMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{floor.KitID}/{floor.Template.Model}.mdl"); + floorMDL.Root.GetController().AddLinear(0, new(floor.Position)); + floorMDL.Root.GetController().AddLinear(0, new(floor.Orientation)); + return floorMDL.Root; + } + + private static MDLNode CeilingToMDLNode(Ceiling ceiling) + { + var ceilingMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{ceiling.KitID}/{ceiling.Template.Model}.mdl"); + ceilingMDL.Root.GetController().AddLinear(0, new(ceiling.Position)); + ceilingMDL.Root.GetController().AddLinear(0, new(ceiling.Orientation)); + return ceilingMDL.Root; + } + + private static MDLNode WallToMDLNode(Wall wall) + { + var wallMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{wall.KitID}/{wall.Template.Model}.mdl"); + wallMDL.Root.GetController().AddLinear(0, new(wall.Position)); + wallMDL.Root.GetController().AddLinear(0, new(wall.Orientation)); + return wallMDL.Root; + } + + private static MDLNode DoorFrameToMDLNode(DoorFrame doorframe) + { + var doorframeMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{doorframe.KitID}/{doorframe.Template.Model}.mdl"); + doorframeMDL.Root.GetController().AddLinear(0, new(doorframe.Position)); + doorframeMDL.Root.GetController().AddLinear(0, new(doorframe.Orientation)); + return doorframeMDL.Root; + } + + private static MDLNode InnerCornerToMDLNode(InnerCorner corner) + { + var cornerMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{corner.KitID}/{corner.Template.Model}.mdl"); + cornerMDL.Root.GetController().AddLinear(0, new(corner.Position)); + cornerMDL.Root.GetController().AddLinear(0, new(corner.Orientation)); + return cornerMDL.Root; + } + + private static MDLNode ObjectToMDLNode(Object @object) + { + var objectMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{@object.KitID}/{@object.Template.Model}.mdl"); + objectMDL.Root.GetController().AddLinear(0, new(@object.LocalPosition)); + objectMDL.Root.GetController().AddLinear(0, new(@object.LocalOrientation)); + return objectMDL.Root; + } +} diff --git a/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer.cs b/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer.cs new file mode 100644 index 000000000..5facfadca --- /dev/null +++ b/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Kotor.DevelopmentKit.AreaDesigner.relocate.KitSerialization; +using Newtonsoft.Json; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.AreaSerialization; + +public class AreaSerializer +{ + public static Area Load(string filepath) + { + var json = File.ReadAllText(filepath); + dynamic data = JsonConvert.DeserializeObject(json); + string format = (string)data.format.Value; + + return format switch + { + "0.1" => AreaSerializer_V0_1.Load(filepath), + _ => throw new ArgumentException("Kit version is unsupported.") + }; + } + + public static void Save(string filepath, Area area) + { + AreaSerializer_V0_1.Save(filepath, area); + } +} diff --git a/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer_V0_1.cs b/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer_V0_1.cs new file mode 100644 index 000000000..d863a026f --- /dev/null +++ b/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer_V0_1.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using Kotor.DevelopmentKit.AreaDesigner.relocate.KitSerialization; +using Kotor.NET.Graphics.Extensions; +using Newtonsoft.Json; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.AreaSerialization; + +public class AreaSerializer_V0_1 +{ + public const string FormatID = "0.1"; + + public static Area Load(string filepath) + { + var json = File.ReadAllText(filepath); + dynamic data = JsonConvert.DeserializeObject(json); + + var area = new Area(); + + foreach (var roomData in data.rooms.ToObject()) + { + var room = new Room(); + area.AddRoom(room); + + foreach (var tileData in roomData.tiles.ToObject()) + { + var tile = new Tile(room, Kit.Manager.Get(tileData.kitID.Value).Tile(tileData.templateID.Value)); + tile.LocalPosition = new Vector3(tileData.position.ToObject()); + tile.LocalOrientation = ((float[])tileData.orientation.ToObject()).ToQuaternion(); + + var floorData = tileData.floor; + var floorTemplate = Kit.Manager.Get(floorData.kitID.Value).Floor(floorData.templateID.Value); + tile.Floor.SwitchTemplate(floorTemplate); + + // TODO + //var ceilingData = tileData.ceiling; + //var ceilingTemplate = Kit.Manager.Get(ceilingData.kitID.Value).Ceiling(ceilingData.templateID.Value); + //tile.Ceiling.SwitchTemplate(ceilingTemplate); + + for (int i = 0; i < tileData.walls.Count; i++) + { + var wallData = tileData.walls[i]; + var wallTemplate = Kit.Manager.Get(wallData.kitID.Value).Wall(wallData.templateID.Value); + var wall = tile.Walls.ElementAt(i); + wall.SwitchTemplate(wallTemplate); + } + + room.Tiles.Add(tile); + } + + room.FixWalls(); + } + + return area; + } + + public static void Save(string filepath, Area area) + { + dynamic data = new ExpandoObject(); + + data.format = FormatID; + + data.rooms = area.Rooms.Select(room => new + { + position = room.Position.ToFloatArray(), + orientation = room.Orientation.ToFloatArray(), + tiles = room.Tiles.Select(tile => new + { + kitID = tile.KitID, + templateID = tile.TemplateID, + position = tile.LocalPosition.ToFloatArray(), + orientation = tile.LocalOrientation.ToFloatArray(), + floor = new + { + kitID = tile.Floor.KitID, + templateID = tile.Floor.TemplateID, + }, + ceiling = new + { + kitID = "", + templateID = "", + }, + walls = tile.Walls.Select(x => new + { + kitID = x.KitID, + templateID = x.TemplateID, + }), + }) + }); + + var json = JsonConvert.SerializeObject(data, Formatting.Indented); + File.WriteAllText(filepath, json); + } +} diff --git a/.cache/kotor_net_area_designer/Kit.cs b/.cache/kotor_net_area_designer/Kit.cs new file mode 100644 index 000000000..aa032d8cf --- /dev/null +++ b/.cache/kotor_net_area_designer/Kit.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Kotor.DevelopmentKit.AreaDesigner.relocate.KitSerialization; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate; + +public class Kit +{ + public static KitManager Manager { get; } = new(); + + public string ID { get; } + public string Name { get; } + public string FilePath { get; } + public int Version { get; } + public ICollection Floors { get; init; } = []; + public ICollection Tiles { get; init; } = []; + public ICollection Walls { get; init; } = []; + public ICollection DoorFrames { get; init; } = []; + public ICollection Ceilings { get; init; } = []; + public ICollection InnerCorners { get; init; } = []; + public ICollection OuterCorners { get; init; } = []; + public ICollection Objects { get; init; } = []; + + public FloorTemplate Floor(string id) => Floors.Single(x => x.ID == id); + public TileTemplate Tile(string id) => Tiles.Single(x => x.ID == id); + public WallTemplate Wall(string id) => Walls.Single(x => x.ID == id); + public DoorFrameTemplate DoorFrame(string id) => DoorFrames.Single(x => x.ID == id); + public CeilingTemplate Ceiling(string id) => Ceilings.Single(x => x.ID == id); + public InnerCornerTemplate InnerCorner(string id) => InnerCorners.Single(x => x.ID == id); + public OuterCornerTemplate OuterCorner(string id) => OuterCorners.Single(x => x.ID == id); + public ObjectTemplate Object(string id) => Objects.Single(x => x.ID == id); + + public Kit(string filepath, string id, int version, string name) + { + FilePath = filepath; + ID = id; + Name = name; + Version = version; + } +} + +public class KitManager +{ + public string ActiveDirectory = @"C:/Kits"; + public ICollection Kits { get; } = []; + + public void Refresh() + { + Kits.Clear(); + Directory.GetFiles(Kit.Manager.ActiveDirectory) + .Where(x => Path.GetExtension(x).ToLower() == ".kit") + .Select(KitSerializer.Load) + .ToList() + .ForEach(Kits.Add); + } + public Kit Get(string id) => Kits.Single(x => x.ID == id); +} diff --git a/.cache/kotor_net_area_designer/KitSerialization_KitSerializer_V0_1.cs b/.cache/kotor_net_area_designer/KitSerialization_KitSerializer_V0_1.cs new file mode 100644 index 000000000..e3c303051 --- /dev/null +++ b/.cache/kotor_net_area_designer/KitSerialization_KitSerializer_V0_1.cs @@ -0,0 +1,244 @@ +using System; +using System.Dynamic; +using System.IO; +using System.Linq; +using System.Numerics; +using Kotor.NET.Graphics.Extensions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.KitSerialization; + +public class KitSerializer_V0_1 +{ + public const string FormatID = "0.1"; + + public static Kit Load(string filepath) + { + var json = File.ReadAllText(filepath); + dynamic data = JsonConvert.DeserializeObject(json); + + string kitName = data.name.Value; + string kitID = data.id.Value; + int kitVersion = (int)data.version.Value; + + if (kitID != Path.GetFileNameWithoutExtension(filepath)) + throw new ArgumentException($"Kit ID {kitID} does not match filename {Path.GetFileName(filepath)}."); + + var kit = new Kit(filepath, kitID, kitVersion, kitName); + + foreach (var floor in data.floors) + { + kit.Floors.Add(new FloorTemplate + { + KitID = kitID, + ID = floor.id.Value, + Name = floor.name.Value, + Model = floor.model.Value, + }); + } + + foreach (var ceiling in data.ceilings) + { + kit.Ceilings.Add(new CeilingTemplate + { + KitID = kitID, + ID = ceiling.id.Value, + Name = ceiling.name.Value, + Model = ceiling.model.Value, + }); + } + + foreach (var door in data.doorframes) + { + kit.DoorFrames.Add(new DoorFrameTemplate + { + KitID = kitID, + ID = door.id.Value, + Name = door.name.Value, + Model = door.model.Value, + Hooks = ((JArray)door.hooks).Select(x => (dynamic)x).Select(hook => new DoorFrameHookTemplate + { + Position = new Vector3(hook.position.ToObject()), + Orientation = ((float[])hook.orientation.ToObject()).ToQuaternion() + }).ToArray() + }); + } + + foreach (var wall in data.walls) + { + kit.Walls.Add(new WallTemplate + { + KitID = kitID, + ID = wall.id.Value, + Name = wall.name.Value, + Model = wall.model.Value, + DoorFrameID = wall.doorframeID?.Value, + }); + } + + foreach (var tile in data.tiles) + { + kit.Tiles.Add(new TileTemplate + { + KitID = kitID, + ID = tile.id.Value, + Name = tile.name.Value, + DefaultFloorID = tile.defaultFloorID.Value, + DefaultCeilingID = tile.defaultCeilingID?.Value ?? "", + Walls = ((JArray)tile.wallHooks).Select(x => (dynamic)x).Select(hook => new WallHookTemplate + { + DefaultWallID = hook.defaultWallID, + LocalPosition = new Vector3(hook.position.ToObject()), + LocalOrientation = ((float[])hook.orientation.ToObject()).ToQuaternion() + }).ToArray(), + InnerCorners = ((JArray)tile.innerCornerHooks).Select(x => (dynamic)x).Select(hook => new InnerCornerHookTemplate + { + DefaultCornerID = hook.defaultInnerCornerID.Value, + Adjacent = hook.adjacencies?.ToObject() ?? new int[0], + LocalPosition = new Vector3(hook.position.ToObject()), + LocalOrientation = ((float[])hook.orientation.ToObject()).ToQuaternion() + }).ToArray(), + OuterCorners = ((JArray)tile.outerCornerHooks).Select(x => (dynamic)x).Select(hook => new OuterCornerHookTemplate + { + DefaultCornerID = hook.defaultOuterCornerID.Value, + Adjacent = hook.adjacencies?.ToObject() ?? new int[0], + LocalPosition = new Vector3(hook.position.ToObject()), + LocalOrientation = ((float[])hook.orientation.ToObject()).ToQuaternion() + }).ToArray(), + CeilingHooks = [] + }); + } + + foreach (var innerCorner in data.innerCorners) + { + kit.InnerCorners.Add(new InnerCornerTemplate + { + KitID = kitID, + ID = innerCorner.id.Value, + Name = innerCorner.name.Value, + Model = innerCorner.model.Value, + }); + } + + foreach (var outerCorner in data.outerCorners) + { + kit.OuterCorners.Add(new OuterCornerTemplate + { + KitID = kitID, + ID = outerCorner.id.Value, + Name = outerCorner.name.Value, + Model = outerCorner.model.Value, + }); + } + + foreach (var @object in data.objects) + { + kit.Objects.Add(new ObjectTemplate + { + KitID = kitID, + ID = @object.id.Value, + Name = @object.name.Value, + Model = @object.model.Value, + }); + } + + return kit; + } + + public static void Save(string filepath, Kit kit) + { + dynamic data = new ExpandoObject(); + + data.id = kit.ID; + data.version = kit.Version; + data.name = kit.Name; + data.format = FormatID; + + data.tiles = kit.Tiles.Select(tile => new + { + id = tile.ID, + name = tile.Name, + defaultFloorID = tile.DefaultFloorID, + defaultCeilingID = tile.DefaultCeilingID, + wallHooks = tile.Walls.Select(x => new + { + defaultWallID = x.DefaultWallID, + position = x.LocalPosition.ToFloatArray(), + orientation = x.LocalOrientation.ToFloatArray(), + }), + innerCornerHooks = tile.InnerCorners.Select(x => new + { + defaultInnerCornerID = x.DefaultCornerID, + position = x.LocalPosition.ToFloatArray(), + orientation = x.LocalOrientation.ToFloatArray(), + adjacencies = x.Adjacent, + }), + outerCornerHooks = tile.OuterCorners.Select(x => new + { + defaultOuterCornerID = x.DefaultCornerID, + position = x.LocalPosition.ToFloatArray(), + orientation = x.LocalOrientation.ToFloatArray(), + adjacencies = x.Adjacent, + }), + }); + + data.floors = kit.Floors.Select(floor => new + { + id = floor.ID, + name = floor.Name, + model = floor.Model, + }); + + data.ceilings = kit.Ceilings.Select(ceiling => new + { + id = ceiling.ID, + name = ceiling.Name, + model = ceiling.Model, + }); + + data.doorframes = kit.DoorFrames.Select(doorframe => new + { + id = doorframe.ID, + name = doorframe.Name, + model = doorframe.Model, + hooks = doorframe.Hooks.Select(hook => new + { + position = hook.Position.ToFloatArray(), + orientation = hook.Orientation.ToFloatArray(), + }) + }); + + data.walls = kit.Walls.Select(wall => new + { + id = wall.ID, + name = wall.Name, + model = wall.Model, + doorframeID = wall.DoorFrameID, + }); + + data.innerCorners = kit.InnerCorners.Select(obj => new + { + id = obj.ID, + name = obj.Name, + model = obj.Model, + }); + + data.outerCorners = kit.OuterCorners.Select(obj => new + { + id = obj.ID, + name = obj.Name, + model = obj.Model, + }); + + data.objects = kit.Objects.Select(obj => new + { + id = obj.ID, + name = obj.Name, + model = obj.Model, + }); + + var json = JsonConvert.SerializeObject(data, Formatting.Indented); + File.WriteAllText(filepath, json); + } +} diff --git a/.cache/kotor_net_area_designer/Mode_AddObjectMode.cs b/.cache/kotor_net_area_designer/Mode_AddObjectMode.cs new file mode 100644 index 000000000..ec7f4c7ba --- /dev/null +++ b/.cache/kotor_net_area_designer/Mode_AddObjectMode.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using Avalonia; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Cameras; +using Kotor.NET.Graphics.Model; +using Kotor.NET.Graphics.OpenGL; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.Mode; + +public class AddObjectMode : BaseMode +{ + public override string Name => "Add Object"; + + private Object _addObject = null; + private float angle = 0; + + public AddObjectMode(GLEngine engine, Area area) : base(engine, area) + { + } + + public override async Task RenderIntercept(OrbitCamera camera, Point mouse, List descriptors) + { + var ray = camera.ProjectRay((int)mouse.X, (int)mouse.Y, _engine.Width, _engine.Height); + var point = ray.FindPointOnPlane(Axis.Z, 0); + + // todo - should be placed in room where walkmesh intersects. + // todo - should not be hardcoded + _addObject = new(_area.Rooms.Last(), Kit.Manager.Get("sandral").Object("sandral_object_0")); + _addObject.LocalPosition = point; + _addObject.LocalOrientation = Quaternion.CreateFromYawPitchRoll(0, 0, angle * (float)Math.PI / 180); + + var roomMeshDescriptors = new List(); + _areaEntity.RenderObject(_engine.AssetManager, _addObject, ref roomMeshDescriptors); + roomMeshDescriptors.ForEach(x => x.AmbientColor = new Vector3(1.5f, 1.5f, 1.5f)); + descriptors.AddRange(roomMeshDescriptors); + } + + public override async Task Trigger() + { + // todo - add to room within bounds of the cursor + var room = _area.Rooms.First(); + room.AddObject(_addObject); + } +} diff --git a/.cache/kotor_net_area_designer/Mode_AddRoomMode.cs b/.cache/kotor_net_area_designer/Mode_AddRoomMode.cs new file mode 100644 index 000000000..1ee84fd52 --- /dev/null +++ b/.cache/kotor_net_area_designer/Mode_AddRoomMode.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using Avalonia; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Cameras; +using Kotor.NET.Graphics.Model; +using Kotor.NET.Graphics.OpenGL; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.Mode; + +public class AddRoomMode : BaseMode +{ + public override string Name => "Add Room"; + + private Room _addRoomRoom = new Room(null); + private float angle = 0; + + public AddRoomMode(GLEngine engine, Area area) : base(engine, area) + { + } + + public override async Task RenderIntercept(OrbitCamera camera, Point mouse, List descriptors) + { + var ray = camera.ProjectRay((int)mouse.X, (int)mouse.Y, _engine.Width, _engine.Height); + var point = ray.FindPointOnPlane(Axis.Z, 0); + + _addRoomRoom = new Room(null); + _addRoomRoom.Position = point; + _addRoomRoom.Orientation = Quaternion.CreateFromYawPitchRoll(0, 0, angle * (float)Math.PI / 180); + + (var newWall, var oldWall, var distance) = NearestAdjacentWall(_addRoomRoom); + if (oldWall is not null) + { + newWall.SwitchTemplate(oldWall.Template); + newWall.DoorFrame.Enabled = false; + + if (oldWall.DoorFrame is not null) + { + _addRoomRoom.Orientation = oldWall.Orientation / newWall.Orientation * Quaternion.CreateFromYawPitchRoll(0, 0, MathF.PI); + _addRoomRoom.Position = oldWall.DoorFrame.Hooks.First().Position; + _addRoomRoom.Position += newWall.Parent.Position - newWall.DoorFrame.Hooks.Last().Position; + } + else + { + _addRoomRoom.Position = new(-1000, 0, 0); + } + } + + var roomMeshDescriptors = new List(); + _areaEntity.RenderRoom(_engine.AssetManager, _addRoomRoom, ref roomMeshDescriptors); + roomMeshDescriptors.ForEach(x => x.AmbientColor = new Vector3(1.5f, 1.5f, 1.5f)); + descriptors.AddRange(roomMeshDescriptors); + } + + public override async Task Trigger() + { + _area.AddRoom(_addRoomRoom); + } +} diff --git a/.cache/kotor_net_area_designer/Mode_BaseMode.cs b/.cache/kotor_net_area_designer/Mode_BaseMode.cs new file mode 100644 index 000000000..4b9602ae8 --- /dev/null +++ b/.cache/kotor_net_area_designer/Mode_BaseMode.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using Avalonia; +using Kotor.DevelopmentKit.AreaDesigner.Views; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Cameras; +using Kotor.NET.Graphics.Model; +using Kotor.NET.Graphics.OpenGL; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.Mode; + +public class BaseMode +{ + public virtual string Name { get; } + + protected readonly GLEngine _engine; + protected readonly Area _area; + + protected AreaEntity _areaEntity => _engine.Scene.Entities.OfType().Single(x => x.Area == _area); + + public BaseMode(GLEngine engine, Area area) + { + _engine = engine; + _area = area; + } + + public virtual Task RenderIntercept(OrbitCamera camera, Point mouse, List descriptors) + { + return Task.CompletedTask; + } + + public virtual Task Trigger() + { + return Task.CompletedTask; + } + + public virtual Task AlternativeTrigger() + { + return Task.CompletedTask; + } + + protected RaycastResult? NearestWallMagnest(OrbitCamera camera, double x, double y) + { + var ray = camera.ProjectRay((int)x, (int)y, _engine.Width, _engine.Height); + + return _area.Rooms + .SelectMany(x => x.Walls) + .Where(x => x.LinkedTile is null) + .OrderBy(x => ray.ShortestDistanceTo(x.Position)) + .Select(x => new RaycastResult(x, ray.ShortestDistanceTo(x.Position))) + .Where(x => x.Distance < 3) + .FirstOrDefault(); + } + + protected (Wall ThisHook, Wall OtherHook, float distance) NearestAdjacentWall(Room room) + { + var near = new List<(Wall NewHook, Wall OldHook, float distance)>(); + var otherWalls = _area.Rooms.SelectMany(x => x.Walls).ToList(); + + foreach (var wall in room.Walls) + { + var match = otherWalls + .Where(x => x.DoorFrame is not null) + .Where(x => Vector3.Distance(wall.Position, x.Position) < 3) + .OrderBy(x => Vector3.Distance(wall.Position, x.Position)) + .Select(x => (wall, x, Vector3.Distance(wall.Position, x.Position))) + .ToList(); + + if (match.Count > 0) + near.AddRange(match); + } + + return near.OrderBy(x => x.distance).FirstOrDefault(); + } +} diff --git a/.cache/kotor_net_area_designer/Mode_ExtendRoomMode.cs b/.cache/kotor_net_area_designer/Mode_ExtendRoomMode.cs new file mode 100644 index 000000000..966d85800 --- /dev/null +++ b/.cache/kotor_net_area_designer/Mode_ExtendRoomMode.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia; +using Kotor.DevelopmentKit.AreaDesigner.Views; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Cameras; +using Kotor.NET.Graphics.Model; +using Kotor.NET.Graphics.OpenGL; +using ReactiveUI; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.Mode; + +public class ExtendRoomMode : BaseMode +{ + public required Interaction GetMousePoint { get; init; } + public required Interaction SelectTileTemplate { get; init; } + + public override string Name => "Extend Room"; + + private Wall? _wall; + private bool validWall => _wall is not null && _wall.DoorFrame is null; + + public ExtendRoomMode(GLEngine engine, Area area) : base(engine, area) + { + } + + public override async Task RenderIntercept(OrbitCamera camera, Point mouse, List descriptors) + { + _wall = NearestWallMagnest(camera, (int)mouse.X, (int)mouse.Y)?.Result; + + if (_wall is not null) + { + if (!validWall) + descriptors.Where(x => x.Tag == _wall).ToList().ForEach(x => x.AmbientColor = new(1.5f, 0.5f, 0.5f)); + else + descriptors.Where(x => x.Tag == _wall).ToList().ForEach(x => x.AmbientColor = new(1.5f, 1.5f, 1.5f)); + } + } + + public override async Task Trigger() + { + //if (validWall) + // _wall!.Extend(TileTemplate.Sandral8x8); + } + + public override async Task AlternativeTrigger() + { + if (!validWall) + return; + + var template = await SelectTileTemplate.Handle(Unit.Default); + + if (template is null) + return; + + var tile = _wall!.Extend(template); + } +} diff --git a/.cache/kotor_net_area_designer/Mode_SwitchWallMode.cs b/.cache/kotor_net_area_designer/Mode_SwitchWallMode.cs new file mode 100644 index 000000000..65ffa93c9 --- /dev/null +++ b/.cache/kotor_net_area_designer/Mode_SwitchWallMode.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia; +using Kotor.DevelopmentKit.AreaDesigner.Views; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Cameras; +using Kotor.NET.Graphics.Model; +using Kotor.NET.Graphics.OpenGL; +using ReactiveUI; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.Mode; + +public class SwitchWallMode : BaseMode +{ + public required Interaction GetMousePoint { get; init; } + public required Interaction SelectWallTemplate { get; init; } + + public override string Name => "Switch Wall"; + + private Wall? _wall; + + public SwitchWallMode(GLEngine engine, Area area) : base(engine, area) + { + } + + public override async Task RenderIntercept(OrbitCamera camera, Point mouse, List descriptors) + { + _wall = NearestWallMagnest(camera, mouse.X, mouse.Y)?.Result; + + if (_wall is not null) + descriptors.Where(x => x.Tag == _wall).ToList().ForEach(x => x.AmbientColor = new(1.5f, 1.5f, 1.5f)); + } + + public override async Task Trigger() + { + var template = await SelectWallTemplate.Handle(Unit.Default); + + if (_wall is not null && template is not null) + { + _wall.SwitchTemplate(template); + } + } +} diff --git a/.cache/kotor_net_area_designer/Room.cs b/.cache/kotor_net_area_designer/Room.cs new file mode 100644 index 000000000..276ca0fce --- /dev/null +++ b/.cache/kotor_net_area_designer/Room.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Avalonia.Markup.Xaml.Templates; +using DynamicData; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate; + +public class Area +{ + private List _rooms = new(); + public IReadOnlyList Rooms => _rooms.AsReadOnly(); + + public void AddRoom(Room room) + { + _rooms.Add(room); + } +} + +public class Room +{ + public Vector3 Position { get; set; } = new(); + public Quaternion Orientation { get; set; } = new(); + public Matrix4x4 Transform => Matrix4x4.CreateFromQuaternion(Orientation) * Matrix4x4.CreateTranslation(Position); + + public ICollection Tiles { get; } = new List(); + public ICollection Walls => Tiles.SelectMany(x => x.Walls).ToList(); + public ICollection InnerCorners => Tiles.SelectMany(x => x.InnerCorners).ToList(); + public ICollection OuterCorners => Tiles.SelectMany(x => x.OuterCorners).ToList(); + public ICollection DoorFrames => Walls.Select(x => x.DoorFrame).Where(x => x is not null).ToList(); + public ICollection Objects = []; + + public Room() + { + } + public Room(RoomTemplate template) + { + Tiles.Add(new(this, Kit.Manager.Get("sandral").Tiles.ElementAt(0))); + } + + public void FixWalls() + { + foreach (var tileA in Tiles) + { + foreach (var tileB in Tiles) + { + if (tileA == tileB) + continue; + + foreach (var adjacent in GetCombinations(tileA.Walls, tileB.Walls)) + { + if (Vector3.Distance(adjacent.Item1.Position, adjacent.Item2.Position) < 0.01f) + { + adjacent.Item1.LinkedTile = tileB; + adjacent.Item2.LinkedTile = tileA; + } + } + } + } + } + + public void AddObject(Object @object) + { + Objects.Add(@object); + } + + // todo ienumerable extension + private List<(T Item1, T Item2)> GetCombinations(IEnumerable listA, IEnumerable listB) + { + // TODO convert to list extensions method? + + List<(T A, T B)> combinations = new(); + + foreach (var a in listA) + { + foreach (var b in listB) + { + var tuple = (a, b); + if (!combinations.Contains(tuple)) + combinations.Add(tuple); + } + } + + return combinations; + } +} + +public class Tile +{ + public Room Parent { get; } + + public Floor Floor { get; private set; } + public Ceiling Ceiling { get; private set; } + public IReadOnlyCollection Walls { get; private set; } + public IReadOnlyCollection InnerCorners { get; private set; } + public IReadOnlyCollection OuterCorners { get; private set; } + + public string KitID { get; private set; } + public string TemplateID { get; private set; } + public TileTemplate Template => Kit.Manager.Get(KitID).Tile(TemplateID); + + public Vector3 LocalPosition { get; set; } + public Quaternion LocalOrientation { get; set; } = new(0, 0, 0, 1); + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => LocalTransform * Parent.Transform; + + public Tile(Room parent, TileTemplate template) + { + Parent = parent; + KitID = template.KitID; + TemplateID = template.ID; + Floor = new(this, template.Floor); + Walls = template.Walls.Select(x => new Wall(this, x.DefaultTemplate, x)).ToArray(); + InnerCorners = template.InnerCorners.Select(x => new InnerCorner(this, x.DefaultTemplate, x)).ToArray(); + OuterCorners = template.OuterCorners.Select(x => new OuterCorner(this, x.DefaultTemplate, x)).ToArray(); + } + + public Tile Extend(Wall wall, TileTemplate template) + { + var newTile = new Tile(Parent, template); + + // todo - first compatible + var adjacent = newTile.Walls + .Where(x => x.Template.ID == wall.Template.ID) + //.OrderBy(x => x.LocalOrientaiton == wall.LocalOrientaiton) + .First(); + + newTile.LocalOrientation = wall.LocalOrientation + / adjacent.Hook.LocalOrientation + * Quaternion.CreateFromYawPitchRoll(0, 0, MathF.PI) + * Orientation + / Parent.Orientation; + + newTile.LocalPosition = LocalPosition + + Vector3.Transform(wall.LocalPosition, LocalOrientation) + - Vector3.Transform(adjacent.LocalPosition, newTile.LocalOrientation); + + Parent.Tiles.Add(newTile); + + // Link the new tile to the old tile, as well as any other touching tiles + Parent.FixWalls(); + + return newTile; + } + + public void SwitchTemplate(TileTemplate template) + { + //Template = template; + KitID = template.KitID; + TemplateID = template.ID; + Floor = new(this, template.Floor); + Walls = template.Walls.Select(x => new Wall(this, x.DefaultTemplate, x)).ToArray(); + InnerCorners = template.InnerCorners.Select(x => new InnerCorner(this, x.DefaultTemplate, x)).ToArray(); + OuterCorners = template.OuterCorners.Select(x => new OuterCorner(this, x.DefaultTemplate, x)).ToArray(); + } +} + +public class Wall +{ + public Tile Parent { get; } + public Room? LinkedRoom { get; set; } + public Tile? LinkedTile { get; set; } + public DoorFrame? DoorFrame { get; set; } + public WallHookTemplate Hook { get; set; } + + public string KitID { get; private set;} + public string TemplateID { get; private set; } + public WallTemplate Template => Kit.Manager.Get(KitID).Wall(TemplateID); + + public Vector3 LocalPosition => Hook.LocalPosition; + public Quaternion LocalOrientation => Hook.LocalOrientation; + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => Hook.LocalTransform * Parent.Transform; + + public bool Visible => LinkedTile is null; + + public Wall(Tile parent, WallTemplate template, WallHookTemplate hook) + { + Parent = parent; + Hook = hook; + KitID = template.KitID; + TemplateID = template.ID; + } + + public Tile Extend(TileTemplate template) + { + return Parent.Extend(this, template); + } + + public void SwitchTemplate(WallTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + + if (template.DoorFrame is not null) + { + DoorFrame = new(this, template.DoorFrame); + } + else + { + DoorFrame = null; + } + } +} + +public class Floor +{ + public Tile Parent { get; } + + public string KitID { get; private set; } = ""; + public string TemplateID { get; private set; } = ""; + public FloorTemplate Template => Kit.Manager.Get(KitID).Floor(TemplateID); + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => Parent.Transform; + + public Floor(Tile parent, FloorTemplate template) + { + Parent = parent; + SwitchTemplate(template); + } + + public void SwitchTemplate(FloorTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} + +public class Ceiling +{ + public Tile Parent { get; } + + public string KitID { get; private set; } = ""; + public string TemplateID { get; private set; } = ""; + public CeilingTemplate Template => Kit.Manager.Get(KitID).Ceiling(TemplateID); + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => Parent.Transform; + + public Ceiling(Tile parent, CeilingTemplate template) + { + Parent = parent; + SwitchTemplate(template); + } + + public void SwitchTemplate(CeilingTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} + +public class InnerCorner +{ + public Tile Parent { get; } + public InnerCornerHookTemplate Hook { get; } + + public string KitID { get; private set; } = ""; + public string TemplateID { get; private set; } = ""; + public InnerCornerTemplate Template => Kit.Manager.Get(KitID).InnerCorner(TemplateID); + + public Vector3 LocalPosition => Hook.LocalPosition; + public Quaternion LocalOrientation => Hook.LocalOrientation; + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => Hook.LocalTransform * Parent.Transform; + + public bool Visible + { + get + { + return Hook.Adjacent.Any() && Hook.Adjacent.All(x => Parent.Walls.ElementAt(x).LinkedTile is null); + } + } + + public InnerCorner(Tile parent, InnerCornerTemplate template, InnerCornerHookTemplate hook) + { + Parent = parent; + Hook = hook; + SwitchTemplate(template); + } + + public void SwitchTemplate(InnerCornerTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} + +public class OuterCorner +{ + public Tile Parent { get; } + public OuterCornerHookTemplate Hook { get; } + + public string KitID { get; private set; } = ""; + public string TemplateID { get; private set; } = ""; + public OuterCornerTemplate Template => Kit.Manager.Get(KitID).OuterCorner(TemplateID); + + public Vector3 Position => Hook.LocalPosition; + public Quaternion Orientation => Hook.LocalOrientation; + public Matrix4x4 Transform => Hook.LocalTransform * Parent.Transform; + + public bool Visible + { + get + { + if (Hook.Adjacent.Count() != 2) + return false; + if (Hook.Adjacent.Any(x => Parent.Walls.ElementAt(x).LinkedTile is null)) + return false; + + var a = Parent.Walls.ElementAt(Hook.Adjacent[0]).LinkedTile!.Walls.Select(x => x.LinkedTile).Where(x => x != Parent); + var b = Parent.Walls.ElementAt(Hook.Adjacent[1]).LinkedTile!.Walls.Select(x => x.LinkedTile).Where(x => x != Parent); + + var circuit = a.Intersect(b).Any(); + return !circuit; + } + } + + public OuterCorner(Tile parent, OuterCornerTemplate template, OuterCornerHookTemplate hook) + { + Parent = parent; + Hook = hook; + SwitchTemplate(template); + } + + public void SwitchTemplate(OuterCornerTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} + +public class DoorFrame +{ + public Wall Parent { get; } + + public string KitID { get; private set; } = ""; + public string TemplateID { get; private set; } = ""; + public DoorFrameTemplate Template => Kit.Manager.Get(KitID).DoorFrame(TemplateID); + + public bool Enabled { get; set; } = true; + + public IEnumerable Hooks => Template.Hooks.Select(x => new DoorFrameHook(this, x)); + + public Vector3 LocalPosition => Template.Hooks.Last().Position; + public Quaternion LocalOrientation => Template.Hooks.Last().Orientation; + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => LocalTransform * Parent.Transform; + + public bool Visible => Enabled; + + public DoorFrame(Wall parent, DoorFrameTemplate template) + { + Parent = parent; + SwitchTemplate(template); + } + + public void SwitchTemplate(DoorFrameTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} + +public class DoorFrameHook +{ + public DoorFrame Parent { get; } + public DoorFrameHookTemplate Template { get; } + + public Vector3 LocalPosition => Template.Position; + public Quaternion LocalOrientation => Template.Orientation; + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => LocalTransform * Parent.Transform; + + public DoorFrameHook(DoorFrame parent, DoorFrameHookTemplate template) + { + Parent = parent; + Template = template; + } +} + +public class Object +{ + public Room Parent { get; } + + public string KitID { get; private set; } + public string TemplateID { get; private set; } + public ObjectTemplate Template => Kit.Manager.Get(KitID).Object(TemplateID); + + public Vector3 LocalPosition { get; set; } + public Quaternion LocalOrientation { get; set; } + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + + public Object(Room parent, ObjectTemplate template) + { + Parent = parent; + SwitchTemplate(template); + } + + public void SwitchTemplate(ObjectTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} diff --git a/.cache/kotor_net_area_designer/RoomEntity.cs b/.cache/kotor_net_area_designer/RoomEntity.cs new file mode 100644 index 000000000..1d8713b78 --- /dev/null +++ b/.cache/kotor_net_area_designer/RoomEntity.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Controls; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Entities; +using Kotor.NET.Graphics.Model; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate; + +public class AreaEntity : BaseEntity +{ + public Area Area { get; set; } = new(); + + public override ICollection GetMeshDescriptors(IAssetManager assets) + { + var descriptors = new List(); + + foreach (var room in Area.Rooms) + { + RenderRoom(assets, room, ref descriptors); + } + + return descriptors; + } + public void RenderRoom(IAssetManager assets, Room room, ref List descriptors) + { + foreach (var tile in room.Tiles) + { + RenderTile(assets, tile, ref descriptors); + } + foreach (var wall in room.Walls) + { + RenderWall(assets, wall, ref descriptors); + } + foreach (var doorframe in room.DoorFrames) + { + RenderDoorFrame(assets, doorframe, ref descriptors); + } + foreach (var corner in room.InnerCorners) + { + RenderInnerCorner(assets, corner, ref descriptors); + } + foreach (var corner in room.OuterCorners) + { + RenderOuterCorner(assets, corner, ref descriptors); + } + foreach (var @object in room.Objects) + { + RenderObject(assets, @object, ref descriptors); + } + } + private void RenderTile(IAssetManager assets, Tile tile, ref List descriptors) + { + descriptors.AddRange(DescriptorsForModel(assets, tile.Floor.Template.Model, tile.Transform)); + } + private void RenderWall(IAssetManager assets, Wall wall, ref List descriptors) + { + if (!wall.Visible) + return; + + descriptors.AddRange(DescriptorsForModel(assets, wall.Template.Model, wall.Transform, wall)); + } + private void RenderDoorFrame(IAssetManager assets, DoorFrame doorframe, ref List descriptors) + { + if (!doorframe.Visible) + return; + + descriptors.AddRange(DescriptorsForModel(assets, doorframe.Template.Model, doorframe.Transform, doorframe)); + } + private void RenderInnerCorner(IAssetManager assets, InnerCorner corner, ref List descriptors) + { + if (!corner.Visible) + return; + + descriptors.AddRange(DescriptorsForModel(assets, corner.Template.Model, corner.Transform)); + } + private void RenderOuterCorner(IAssetManager assets, OuterCorner corner, ref List descriptors) + { + if (!corner.Visible) + return; + + descriptors.AddRange(DescriptorsForModel(assets, corner.Template.Model, corner.Transform)); + } + public void RenderObject(IAssetManager assets, Object @object, ref List descriptors) + { + descriptors.AddRange(DescriptorsForModel(assets, @object.Template.Model, @object.LocalTransform)); + } + // TODO - clean this up somehow + private ICollection DescriptorsForModel(IAssetManager assets, string modelName, Matrix4x4 transform, object tag = null) + { + var model = assets.GetModel(modelName); + model.Root.GenerateTransform([]); + return model.GetAllNodes() + .SelectMany(node => node.GetMeshDescriptors(transform)) + .Select(x => + { + x.Tag = tag; + return x; + }) + .ToList(); + } + + public override void Update(IAssetManager assetManager, float delta) + { + + } +} diff --git a/.cache/kotor_net_area_designer/RoomTemplate.cs b/.cache/kotor_net_area_designer/RoomTemplate.cs new file mode 100644 index 000000000..f03f34dc9 --- /dev/null +++ b/.cache/kotor_net_area_designer/RoomTemplate.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using Kotor.DevelopmentKit.AreaDesigner.relocate; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate; + +public class RoomTemplate +{ +} + +public class FloorTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } +} + +public class TileTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string DefaultFloorID { get; init; } + public required string DefaultCeilingID { get; init; } + public required WallHookTemplate[] Walls { get; init; } + public required InnerCornerHookTemplate[] InnerCorners { get; init; } + public required OuterCornerHookTemplate[] OuterCorners { get; init; } + public required Vector3[] CeilingHooks { get; init; } + + public FloorTemplate Floor => Kit.Manager.Get(KitID).Floor(DefaultFloorID); +} + + +public class WallTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } + public required string DoorFrameID { get; init; } + + public DoorFrameTemplate? DoorFrame => (DoorFrameID is not null) ? Kit.Manager.Get(KitID).DoorFrame(DoorFrameID) : null; + public bool CanBeDoor => DoorFrame is not null; +} +public class WallHookTemplate +{ + public required string DefaultWallID { get; init; } + public WallTemplate DefaultTemplate => Kit.Manager.Get("sandral").Wall(DefaultWallID); // todo - remove hardcoding + + public required Vector3 LocalPosition { get; init; } + public required Quaternion LocalOrientation { get; init; } + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + + public int[] AdjacentWalls { get; init; } = []; + + //public ICollection CompatibleWallTemplates { get; } + //public ICollection CompatibleTileTemplates { get; } +} +public class InnerCornerHookTemplate +{ + public required string DefaultCornerID { get; init; } + public InnerCornerTemplate DefaultTemplate => Kit.Manager.Get("sandral").InnerCorner(DefaultCornerID); // todo - remove hardcoding + + public required int[] Adjacent { get; init; } + + public required Vector3 LocalPosition { get; init; } + public required Quaternion LocalOrientation { get; init; } + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + +} +public class OuterCornerHookTemplate +{ + public required string DefaultCornerID { get; init; } + public OuterCornerTemplate DefaultTemplate => Kit.Manager.Get("sandral").OuterCorner(DefaultCornerID); // todo - remove hardcoding + + public required int[] Adjacent { get; init; } + + public required Vector3 LocalPosition { get; init; } + public required Quaternion LocalOrientation { get; init; } + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + +} + +public class DoorFrameTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } + public required DoorFrameHookTemplate[] Hooks { get; init; } +} +public class DoorFrameHookTemplate +{ + public required Vector3 Position { get; init; } + public required Quaternion Orientation { get; init; } +} + +public class CeilingTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } +} + +public class ObjectTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } +} + + +public class InnerCornerTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } +} + +public class OuterCornerTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } +} diff --git a/.github/scripts/discover_tools.py b/.github/scripts/discover_tools.py index f33264b0a..5dbfe60d0 100644 --- a/.github/scripts/discover_tools.py +++ b/.github/scripts/discover_tools.py @@ -32,8 +32,12 @@ def main() -> None: tools = [tool for tool in tools if tool.is_cli] if not tools: - print("Error: No tools discovered", file=sys.stderr) - sys.exit(1) + if args.format == "json": + print("[]") + else: + print("tools_matrix=[]") + print("Discovered 0 tools (workspace may lack vendored Tools/* checkouts)", file=sys.stderr) + return payload = [tool.to_dict() for tool in tools] if args.format == "json": diff --git a/.github/workflows/defender-for-devops.yml b/.github/workflows/defender-for-devops.yml index 12421b24e..b27f256d1 100644 --- a/.github/workflows/defender-for-devops.yml +++ b/.github/workflows/defender-for-devops.yml @@ -48,15 +48,17 @@ jobs: 5.0.x 6.0.x - name: Enable long paths on Windows - if: runner.os == 'Windows' + if: ${{ runner.os == 'Windows' && (success() || failure()) }} shell: pwsh run: | Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 git config --system core.longpaths true - name: Run Microsoft Security DevOps + if: ${{ success() || failure() }} uses: microsoft/security-devops-action@v1.12.0 id: msdo - name: Upload results to Security tab + if: ${{ success() || failure() }} uses: github/codeql-action/upload-sarif@v4 with: sarif_file: ${{ steps.msdo.outputs.sarifFile }} diff --git a/.gitmodules b/.gitmodules index 986875514..770e1d8e8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,7 +14,8 @@ [submodule "Tools/HolocronToolset"] path = Tools/HolocronToolset - url = https://github.com/OldRepublicDevs/HolocronToolset.git + url = https://github.com/OpenKotOR/HolocronToolset.git + branch = cursor/indoor-builder-tilekit-v2 [submodule "Tools/HoloPatcher"] path = Tools/HoloPatcher url = https://github.com/OldRepublicDevs/HoloPatcher.git @@ -33,9 +34,6 @@ # Vendor # =============================== -[submodule "vendor/KotOR.js"] - path = vendor/KotOR.js - url = https://github.com/KobaltBlu/KotOR.js [submodule "vendor/kotorblender"] path = vendor/kotorblender url = https://github.com/OpenKotOR/kotorblender diff --git a/Libraries/PyKotor/pyproject.toml b/Libraries/PyKotor/pyproject.toml index 462de3cf2..21b81b11d 100644 --- a/Libraries/PyKotor/pyproject.toml +++ b/Libraries/PyKotor/pyproject.toml @@ -4,7 +4,7 @@ requires = ["setuptools>=67.8.0", "wheel"] [project] name = "pykotor" -version = "2.3.12" +version = "2.3.13" description = "Read, modify and write files used by KotOR's game engine." authors = [{ name = "Nick Hugi" }, { name = "th3w1zard1" }] maintainers = [{ name = "th3w1zard1", email = "boden.crouch@gmail.com" }] @@ -153,7 +153,7 @@ Repository = "https://github.com/OpenKotOR/PyKotor.git" # Poetry configuration [tool.poetry] name = "pykotor" -version = "2.3.12" +version = "2.3.13" description = "Read, modify and write files used by KotOR's game engine." authors = ["Nick Hugi", "th3w1zard1 "] maintainers = ["th3w1zard1 "] diff --git a/Libraries/PyKotor/src/pykotor/gl/scene/scene.py b/Libraries/PyKotor/src/pykotor/gl/scene/scene.py index 9dbb4bfef..5f281360b 100644 --- a/Libraries/PyKotor/src/pykotor/gl/scene/scene.py +++ b/Libraries/PyKotor/src/pykotor/gl/scene/scene.py @@ -161,6 +161,10 @@ def _invalidate_object_cache(self): self._cached_encounter_objects = None self._cached_trigger_objects = None + def invalidate_render_cache(self) -> None: + """Public alias for `_invalidate_object_cache` (e.g. tile kit preview repopulating `objects`).""" + self._invalidate_object_cache() + def _rebuild_object_caches(self): """Rebuild cached object lists for efficient iteration. diff --git a/Libraries/PyKotor/src/pykotor/tools/area_designer_io.py b/Libraries/PyKotor/src/pykotor/tools/area_designer_io.py new file mode 100644 index 000000000..8ac9d2d9c --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/area_designer_io.py @@ -0,0 +1,84 @@ +"""Kotor.NET AreaDesigner area JSON (`AreaSerializer` / `AreaSerializer_V0_1`). + +Mirrors `Kotor.DevelopmentKit.AreaDesigner.relocate.AreaSerialization.AreaSerializer`: +**load** dispatches on `format`; **save** always writes `AreaSerializer_V0_1` (same as .NET). + +PyKotor `.indoor` maps may embed the same payload under `IndoorMap.area_designer_v01`. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, TypedDict + + +class _FloorRefDict(TypedDict): + kitID: str + templateID: str + + +class _WallRefDict(TypedDict): + kitID: str + templateID: str + + +class _TileDict(TypedDict, total=False): + kitID: str + templateID: str + position: list[float] + orientation: list[float] + floor: _FloorRefDict + ceiling: _FloorRefDict + walls: list[_WallRefDict] + + +class _RoomDict(TypedDict, total=False): + position: list[float] + orientation: list[float] + tiles: list[_TileDict] + + +class AreaDesignerFileV01(TypedDict, total=False): + format: str + rooms: list[_RoomDict] + + +FORMAT_ID = "0.1" + + +def load_area_designer(path: Path | str) -> AreaDesignerFileV01: + """Load an Area Designer JSON file; dispatch on ``format`` like `AreaSerializer.Load`.""" + p = Path(path) + raw = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + msg = "Area file must be a JSON object" + raise ValueError(msg) + fmt = raw.get("format") + if fmt != FORMAT_ID: + msg = f"Unsupported area format {fmt!r} (expected '{FORMAT_ID}')" + raise ValueError(msg) + return raw # type: ignore[return-value] + + +def save_area_designer(path: Path | str, data: AreaDesignerFileV01) -> None: + """Write area JSON (`AreaSerializer.Save` → `AreaSerializer_V0_1`).""" + save_area_designer_v01(path, data) + + +def load_area_designer_v01(path: Path | str) -> AreaDesignerFileV01: + """Alias for `load_area_designer` (v0.1 is the only supported on-disk version today).""" + return load_area_designer(path) + + +def save_area_designer_v01(path: Path | str, data: AreaDesignerFileV01) -> None: + """Write an Area Designer JSON file (pretty-printed, UTF-8).""" + p = Path(path) + out: dict[str, Any] = dict(data) + out["format"] = FORMAT_ID + p.write_text(json.dumps(out, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def empty_area_designer_v01() -> AreaDesignerFileV01: + """Return an empty area matching a new `Area` in Kotor.NET (`AreaDesignerViewModel.NewArea`).""" + return {"format": FORMAT_ID, "rooms": []} diff --git a/Libraries/PyKotor/src/pykotor/tools/area_designer_ops.py b/Libraries/PyKotor/src/pykotor/tools/area_designer_ops.py new file mode 100644 index 000000000..3b1d75ecc --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/area_designer_ops.py @@ -0,0 +1,43 @@ +"""Stable operations API for the indoor Area Designer runtime (Kotor.NET ``Room`` / ``Tile`` parity). + +Prefer importing from here in Toolset code so call sites stay decoupled from +``area_designer_runtime`` internals. +""" + +from __future__ import annotations + +from pykotor.tools.area_designer_runtime import ( + ADArea, + ADObject, + ADRoom, + ADTile, + ADWall, + add_object_to_room, + add_room_with_tile, + build_runtime_from_v01, + fix_walls, + inner_corner_visible, + iter_render_instances, + outer_corner_visible, + runtime_to_v01, + switch_wall_template, + tile_extend_wall, +) + +__all__ = [ + "ADArea", + "ADObject", + "ADRoom", + "ADTile", + "ADWall", + "add_object_to_room", + "add_room_with_tile", + "build_runtime_from_v01", + "fix_walls", + "inner_corner_visible", + "iter_render_instances", + "outer_corner_visible", + "runtime_to_v01", + "switch_wall_template", + "tile_extend_wall", +] diff --git a/Libraries/PyKotor/src/pykotor/tools/area_designer_runtime.py b/Libraries/PyKotor/src/pykotor/tools/area_designer_runtime.py new file mode 100644 index 000000000..37a57b722 --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/area_designer_runtime.py @@ -0,0 +1,669 @@ +"""Kotor.NET ``relocate/Room.cs`` + ``RoomEntity`` runtime mirror (pure Python). + +Provides the same structural behaviour as the C# AreaDesigner domain layer: + +- ``Room.FixWalls`` interior edge pairing +- ``Wall.Visible``, ``DoorFrame.Visible``, ``InnerCorner.Visible``, ``OuterCorner.Visible`` +- JSON ↔ runtime round-trip aligned with ``AreaSerializer_V0_1`` (plus optional ``objects[]``) + +Rendering order matches ``AreaEntity.RenderRoom``: floors (per tile), walls, doorframes, inner corners, +outer corners, room objects — see ``iter_render_instances``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterator + +from pykotor.common.tilekit import ( + CornerHookTemplate, + KitTileRecord, + QuaternionWXYZ, + TileKit, + TileTemplate, +) +from pykotor.gl import decompose, inverse, mat4_cast, quat, translate, vec3, vec4 +from pykotor.gl import glm as glm_mod +from utility.common.geometry import Vector3 + +# Match ``Room.FixWalls``: ``Vector3.Distance(...) < 0.01f`` +_LINK_EPS_SQ = (0.01) ** 2 + + +def _multiply(a: Any, b: Any) -> Any: + return a * b + + +def _template_for_resref(kit: TileKit, resref: str) -> TileTemplate | None: + if not resref: + return None + for t in kit.all_templates(): + if t.resref == resref or t.template_id == resref: + return t + return None + + +def _kit_tile_record(kit: TileKit, tile_template_id: str) -> KitTileRecord | None: + for tr in kit.tiles: + if tr.tile_id == tile_template_id: + return tr + return None + + +def _v3(v: Vector3) -> Any: + return vec3(float(v.x), float(v.y), float(v.z)) + + +def _mat_rt(q: Any, pos: Vector3) -> Any: + return mat4_cast(q) * translate(_v3(pos)) + + +def _glm_quat_from_net_xyzw(seq: list[float] | None) -> Any: + if not seq or len(seq) < 4: + return quat(1.0, 0.0, 0.0, 0.0) + x, y, z, w = (float(seq[0]), float(seq[1]), float(seq[2]), float(seq[3])) + return quat(w, x, y, z) + + +def _glm_quat_from_wxyz(qw: QuaternionWXYZ) -> Any: + return quat(qw.w, qw.x, qw.y, qw.z) + + +def _mat4_translation_xyz(m: Any) -> tuple[float, float, float]: + return (float(m[3][0]), float(m[3][1]), float(m[3][2])) + + +def _dist_sq(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: + dx = a[0] - b[0] + dy = a[1] - b[1] + dz = a[2] - b[2] + return dx * dx + dy * dy + dz * dz + + +@dataclass +class ADWall: + """Runtime wall slot on a tile (``relocate/Wall``).""" + + parent_tile: ADTile + hook_index: int + kit_id: str + template_id: str + doorframe_enabled: bool = True + linked_tile: ADTile | None = None + + @property + def visible(self) -> bool: + return self.linked_tile is None + + +@dataclass +class ADFloor: + kit_id: str + template_id: str + + +@dataclass +class ADObject: + kit_id: str + template_id: str + position: Vector3 + orientation_net: list[float] + + +@dataclass +class ADTile: + room: ADRoom + kit_id: str + template_id: str + local_position: Vector3 + orientation_net: list[float] + floor: ADFloor + ceiling: ADFloor | None = None + walls: list[ADWall] = field(default_factory=list) + kit_record: KitTileRecord | None = None + + def world_matrix(self, m_room: Any) -> Any: + q_tile = _glm_quat_from_net_xyzw(self.orientation_net) + m_local = _mat_rt(q_tile, self.local_position) + return _multiply(m_room, m_local) + + +@dataclass +class ADRoom: + position: Vector3 + orientation_net: list[float] + tiles: list[ADTile] = field(default_factory=list) + objects: list[ADObject] = field(default_factory=list) + + def world_matrix(self) -> Any: + q = _glm_quat_from_net_xyzw(self.orientation_net) + return _mat_rt(q, self.position) + + +@dataclass +class ADArea: + rooms: list[ADRoom] = field(default_factory=list) + + +def fix_walls(room: ADRoom) -> None: + """Mirror ``Room.FixWalls``: pair walls from different tiles by world hook positions.""" + for w in _iter_walls(room): + w.linked_tile = None + + samples: list[tuple[ADWall, tuple[float, float, float]]] = [] + m_room = room.world_matrix() + for tile in room.tiles: + m_tile = tile.world_matrix(m_room) + kt = tile.kit_record + if kt is None: + continue + for w in tile.walls: + if w.hook_index >= len(kt.wall_hooks): + continue + hook = kt.wall_hooks[w.hook_index] + qw = _glm_quat_from_wxyz(hook.orientation) + m_hook = _mat_rt(qw, hook.position) + m_wall = _multiply(m_tile, m_hook) + samples.append((w, _mat4_translation_xyz(m_wall))) + + for a in range(len(samples)): + wa, pa = samples[a] + for b in range(a + 1, len(samples)): + wb, pb = samples[b] + if wa.parent_tile is wb.parent_tile: + continue + if _dist_sq(pa, pb) <= _LINK_EPS_SQ: + wa.linked_tile = wb.parent_tile + wb.linked_tile = wa.parent_tile + + +def _iter_walls(room: ADRoom) -> Iterator[ADWall]: + for t in room.tiles: + yield from t.walls + + +def inner_corner_visible(ic: CornerHookTemplate, tile: ADTile) -> bool: + """``InnerCorner.Visible`` from ``Room.cs``.""" + if not ic.adjacent: + return False + return all(tile.walls[j].linked_tile is None for j in ic.adjacent) + + +def outer_corner_visible(oc: CornerHookTemplate, tile: ADTile) -> bool: + """``OuterCorner.Visible`` from ``Room.cs`` (circuit test).""" + adj = oc.adjacent + if len(adj) != 2: + return False + w0, w1 = tile.walls[adj[0]], tile.walls[adj[1]] + if w0.linked_tile is None or w1.linked_tile is None: + return False + lt0, lt1 = w0.linked_tile, w1.linked_tile + parent = tile + + def neighbor_tiles(other: ADTile) -> set[ADTile]: + out: set[ADTile] = set() + for w in other.walls: + if w.linked_tile is not None and w.linked_tile is not parent: + out.add(w.linked_tile) + return out + + set_a = neighbor_tiles(lt0) + set_b = neighbor_tiles(lt1) + circuit = bool(set_a & set_b) + return not circuit + + +def build_runtime_from_v01( + area: dict[str, Any], + kits_by_id: dict[str, TileKit], +) -> ADArea: + """Construct runtime model from ``format: \"0.1\"`` JSON (optionally with ``objects`` per room).""" + out = ADArea() + rooms = area.get("rooms") + if not isinstance(rooms, list): + return out + + for room_data in rooms: + if not isinstance(room_data, dict): + continue + pos_l = room_data.get("position") or [0.0, 0.0, 0.0] + if len(pos_l) < 3: + pos_l = [0.0, 0.0, 0.0] + room = ADRoom( + position=Vector3(float(pos_l[0]), float(pos_l[1]), float(pos_l[2])), + orientation_net=list(room_data.get("orientation") or [0.0, 0.0, 0.0, 1.0])[:4], + ) + + tiles_l = room_data.get("tiles") + if isinstance(tiles_l, list): + for tile_data in tiles_l: + if not isinstance(tile_data, dict): + continue + kit_id = str(tile_data.get("kitID", "")) + template_id = str(tile_data.get("templateID", "")) + kit = kits_by_id.get(kit_id) + if kit is None: + continue + kt = _kit_tile_record(kit, template_id) + pos_t = tile_data.get("position") or [0.0, 0.0, 0.0] + if len(pos_t) < 3: + pos_t = [0.0, 0.0, 0.0] + ori_t = tile_data.get("orientation") or [0.0, 0.0, 0.0, 1.0] + floor_block = tile_data.get("floor") + fk, ftid = kit_id, "" + if isinstance(floor_block, dict): + fk = str(floor_block.get("kitID", kit_id)) + ftid = str(floor_block.get("templateID", "")) + ceiling_ad: ADFloor | None = None + ceiling_block = tile_data.get("ceiling") + if isinstance(ceiling_block, dict): + ck = str(ceiling_block.get("kitID", "")) + ctid = str(ceiling_block.get("templateID", "")) + if ck or ctid: + ceiling_ad = ADFloor(kit_id=ck or kit_id, template_id=ctid) + tile = ADTile( + room=room, + kit_id=kit_id, + template_id=template_id, + local_position=Vector3(float(pos_t[0]), float(pos_t[1]), float(pos_t[2])), + orientation_net=list(ori_t)[:4] if isinstance(ori_t, list) else [0.0, 0.0, 0.0, 1.0], + floor=ADFloor(kit_id=fk, template_id=ftid), + ceiling=ceiling_ad, + kit_record=kt, + ) + + walls_saved = tile_data.get("walls") + if kt is not None and isinstance(walls_saved, list): + for i, wall_block in enumerate(walls_saved): + if not isinstance(wall_block, dict): + continue + if i >= len(kt.wall_hooks): + break + wk = str(wall_block.get("kitID", kit_id)) + wtid = str(wall_block.get("templateID", "")) + tile.walls.append( + ADWall( + parent_tile=tile, + hook_index=i, + kit_id=wk, + template_id=wtid, + ), + ) + room.tiles.append(tile) + + objs = room_data.get("objects") + if isinstance(objs, list): + for od in objs: + if not isinstance(od, dict): + continue + op = od.get("position") or [0.0, 0.0, 0.0] + if len(op) < 3: + op = [0.0, 0.0, 0.0] + room.objects.append( + ADObject( + kit_id=str(od.get("kitID", "")), + template_id=str(od.get("templateID", "")), + position=Vector3(float(op[0]), float(op[1]), float(op[2])), + orientation_net=list(od.get("orientation") or [0.0, 0.0, 0.0, 1.0])[:4], + ), + ) + + fix_walls(room) + out.rooms.append(room) + + return out + + +def runtime_to_v01(area: ADArea) -> dict[str, Any]: + """Serialize to ``AreaSerializer_V0_1``-shaped JSON (includes ``objects`` when present).""" + rooms_out: list[dict[str, Any]] = [] + for room in area.rooms: + pos = room.position + tiles_js: list[dict[str, Any]] = [] + for tile in room.tiles: + floor = tile.floor + walls_js = [{"kitID": w.kit_id, "templateID": w.template_id} for w in tile.walls] + ceil_js = {"kitID": "", "templateID": ""} + if tile.ceiling: + ceil_js = {"kitID": tile.ceiling.kit_id, "templateID": tile.ceiling.template_id} + tiles_js.append( + { + "kitID": tile.kit_id, + "templateID": tile.template_id, + "position": [tile.local_position.x, tile.local_position.y, tile.local_position.z], + "orientation": list(tile.orientation_net), + "floor": {"kitID": floor.kit_id, "templateID": floor.template_id}, + "ceiling": ceil_js, + "walls": walls_js, + }, + ) + rd: dict[str, Any] = { + "position": [pos.x, pos.y, pos.z], + "orientation": list(room.orientation_net), + "tiles": tiles_js, + } + if room.objects: + rd["objects"] = [ + { + "kitID": o.kit_id, + "templateID": o.template_id, + "position": [o.position.x, o.position.y, o.position.z], + "orientation": list(o.orientation_net), + } + for o in room.objects + ] + rooms_out.append(rd) + + return {"format": "0.1", "rooms": rooms_out} + + +def iter_render_instances( + area: ADArea, + kits_by_id: dict[str, TileKit], + *, + show_walls: bool = True, + show_doors: bool = True, + show_corners: bool = True, + show_ceilings: bool = False, + show_objects: bool = True, + respect_adjacency_visibility: bool = True, +) -> Iterator[tuple[str, Any]]: + """Yield ``(resref, world_mat4)`` in ``AreaEntity.RenderRoom`` order.""" + + def add_model(resref: str, world: Any) -> Iterator[tuple[str, Any]]: + if resref: + yield (resref, world) + + for room in area.rooms: + m_room = room.world_matrix() + # --- Floors (per tile, tile iteration order) --- + for tile in room.tiles: + kit = kits_by_id.get(tile.kit_id) + if kit is None: + continue + ftpl = _template_for_resref(kit, tile.floor.template_id) + if ftpl is not None and ftpl.resref: + yield from add_model(ftpl.resref, tile.world_matrix(m_room)) + + if show_ceilings and tile.ceiling and tile.ceiling.template_id: + ckit = kits_by_id.get(tile.ceiling.kit_id) or kit + ctpl = _template_for_resref(ckit, tile.ceiling.template_id) + if ctpl is not None and ctpl.resref: + yield from add_model(ctpl.resref, tile.world_matrix(m_room)) + + # --- Walls + doorframes --- + if show_walls: + for tile in room.tiles: + kit = kits_by_id.get(tile.kit_id) + kt = tile.kit_record + if kit is None or kt is None: + continue + m_tile = tile.world_matrix(m_room) + for i, w in enumerate(tile.walls): + if respect_adjacency_visibility and not w.visible: + continue + if i >= len(kt.wall_hooks): + continue + wkit = kits_by_id.get(w.kit_id) or kit + wtpl = _template_for_resref(wkit, w.template_id) + if wtpl is None or not wtpl.resref: + continue + hook = kt.wall_hooks[i] + q_h = _glm_quat_from_wxyz(hook.orientation) + m_hook = _mat_rt(q_h, hook.position) + m_wall = _multiply(m_tile, m_hook) + yield from add_model(wtpl.resref, m_wall) + if ( + show_doors + and w.doorframe_enabled + and wtpl.doorframe_id + and wtpl.doorframe_hooks + and (df := _template_for_resref(wkit, wtpl.doorframe_id)) is not None + ): + dh = wtpl.doorframe_hooks[-1] + q_df = _glm_quat_from_wxyz(dh.orientation) + m_df_loc = _mat_rt(q_df, dh.position) + m_df = _multiply(m_wall, m_df_loc) + yield from add_model(df.resref, m_df) + + # --- Corners --- + if show_corners: + for tile in room.tiles: + kit = kits_by_id.get(tile.kit_id) + kt = tile.kit_record + if kit is None or kt is None: + continue + m_tile = tile.world_matrix(m_room) + for ic in kt.inner_corner_hooks: + if respect_adjacency_visibility and not inner_corner_visible(ic, tile): + continue + itpl = _template_for_resref(kit, ic.default_corner_id) + if itpl is None or not itpl.resref: + continue + q_h = _glm_quat_from_wxyz(ic.orientation) + m_h = _multiply(m_tile, _mat_rt(q_h, ic.position)) + yield from add_model(itpl.resref, m_h) + for oc in kt.outer_corner_hooks: + if respect_adjacency_visibility and not outer_corner_visible(oc, tile): + continue + otpl = _template_for_resref(kit, oc.default_corner_id) + if otpl is None or not otpl.resref: + continue + q_h = _glm_quat_from_wxyz(oc.orientation) + m_h = _multiply(m_tile, _mat_rt(q_h, oc.position)) + yield from add_model(otpl.resref, m_h) + + # --- Objects (room scope), ``AreaExporter`` uses local transform then ``RoomToMDL`` — + # ``AreaEntity`` applies ``LocalTransform`` only; we apply ``room`` like PyKotor preview. --- + if show_objects: + for obj in room.objects: + okit = kits_by_id.get(obj.kit_id) + if okit is None: + continue + otpl = _template_for_resref(okit, obj.template_id) + if otpl is None or not otpl.resref: + continue + q_o = _glm_quat_from_net_xyzw(obj.orientation_net) + m_obj_local = _mat_rt(q_o, obj.position) + m_obj = _multiply(m_room, m_obj_local) + yield from add_model(otpl.resref, m_obj) + + +def quat_yaw_pi() -> Any: + """``Quaternion.CreateFromYawPitchRoll(0, 0, PI)`` for ``Tile.Extend``.""" + return quat(0.0, 0.0, 0.0, -1.0) # 180° about Z in common game coords — matches yaw roll 0,0,pi z-up + + +def tile_extend_wall( + tile: ADTile, + wall_index: int, + new_tile_template_id: str, + kits_by_id: dict[str, TileKit], +) -> ADTile: + """Mirror ``Tile.Extend`` / ``Wall.Extend`` from ``Room.cs`` (new tile in same room).""" + kit = kits_by_id.get(tile.kit_id) + if kit is None: + msg = f"Unknown kit {tile.kit_id!r}" + raise ValueError(msg) + kt_src = tile.kit_record + if kt_src is None: + msg = "Source tile has no kit tile record" + raise ValueError(msg) + new_tpl_rec = _kit_tile_record(kit, new_tile_template_id) + if new_tpl_rec is None: + msg = f"Unknown tile template {new_tile_template_id!r} in kit {tile.kit_id!r}" + raise ValueError(msg) + + if wall_index < 0 or wall_index >= len(tile.walls): + msg = f"Wall index {wall_index} out of range" + raise ValueError(msg) + wall = tile.walls[wall_index] + # Matching wall on new tile: same wall **template id** as source wall's slot default was designed for; + # C# matches ``x.Template.ID == wall.Template.ID`` on **WallTemplate.ID**. + candidate_idx: int | None = None + for j, wn in enumerate(new_tpl_rec.wall_hooks): + dw = _template_for_resref(kit, wn.default_wall_id) + if dw is not None and dw.template_id == wall.template_id: + candidate_idx = j + break + if candidate_idx is None: + # Fallback: first hook with same default wall template id string + for j, wn in enumerate(new_tpl_rec.wall_hooks): + if wn.default_wall_id == wall.template_id: + candidate_idx = j + break + if candidate_idx is None: + msg = "Could not find compatible wall hook on new tile template for Extend" + raise ValueError(msg) + + adjacent_hook = new_tpl_rec.wall_hooks[candidate_idx] + room = tile.room + hook_old = kt_src.wall_hooks[wall_index] + q_wall = _glm_quat_from_wxyz(hook_old.orientation) + q_adj = _glm_quat_from_wxyz(adjacent_hook.orientation) + q_yaw = quat_yaw_pi() + + m_room = room.world_matrix() + m_tile_old = tile.world_matrix(m_room) + q_tile_world = _decompose_rotation(m_tile_old) + q_room_world = _decompose_rotation(m_room) + + # ``Tile.Extend``: wall.LocalOrientation / adjacent.Hook.LocalOrientation * yaw * Orientation / Parent.Orientation + q_local = q_wall * inverse(q_adj) * q_yaw * q_tile_world * inverse(q_room_world) + + q_tile_old = _glm_quat_from_net_xyzw(tile.orientation_net) + t_add = _transform_vec3_by_quat(q_tile_old, hook_old.position) + t_sub = _transform_vec3_by_quat(q_local, adjacent_hook.position) + new_pos = tile.local_position + t_add - t_sub + + ceil_ad: ADFloor | None = None + if new_tpl_rec.default_ceiling_id: + ceil_ad = ADFloor(kit_id=tile.kit_id, template_id=new_tpl_rec.default_ceiling_id) + + new_tile = ADTile( + room=room, + kit_id=tile.kit_id, + template_id=new_tile_template_id, + local_position=new_pos, + orientation_net=_net_xyzw_from_glm_quat(q_local), + floor=ADFloor(kit_id=tile.kit_id, template_id=new_tpl_rec.default_floor_id or ""), + ceiling=ceil_ad, + kit_record=new_tpl_rec, + ) + # Populate walls from kit hooks + default templates + for i, _wh in enumerate(new_tpl_rec.wall_hooks): + dw = _template_for_resref(kit, _wh.default_wall_id) + tid = dw.template_id if dw is not None else _wh.default_wall_id + kid = dw.kit_id if dw is not None else tile.kit_id + new_tile.walls.append( + ADWall(parent_tile=new_tile, hook_index=i, kit_id=kid, template_id=tid), + ) + + room.tiles.append(new_tile) + fix_walls(room) + return new_tile + + +def _decompose_rotation(m: Any) -> Any: + """Extract quaternion rotation from a 4×4 transform (PyGLM ``decompose``).""" + scale = glm_mod.vec3() + rotation = glm_mod.quat() + translation = glm_mod.vec3() + skew = glm_mod.vec3() + persp = glm_mod.vec4() + decompose(m, scale, rotation, translation, skew, persp) + return rotation + + +def _transform_vec3_by_quat(q: Any, v: Vector3) -> Vector3: + """``Vector3.Transform(v, q)`` (System.Numerics): rotate vector by unit quaternion.""" + mm = mat4_cast(q) + r = mm * vec4(float(v.x), float(v.y), float(v.z), 1.0) + return Vector3(float(r.x), float(r.y), float(r.z)) + + +def _net_xyzw_from_glm_quat(q: Any) -> list[float]: + """glm quat (w,x,y,z) → .NET ``[x,y,z,w]``.""" + return [float(q.x), float(q.y), float(q.z), float(q.w)] + + +def switch_wall_template( + wall: ADWall, + wall_template_id: str, + kits_by_id: dict[str, TileKit], +) -> None: + """Mirror ``Wall.SwitchTemplate`` — updates kit/template refs and doorframe presence.""" + kit = kits_by_id.get(wall.kit_id) + if kit is None: + msg = f"Unknown kit {wall.kit_id!r}" + raise ValueError(msg) + wtpl = _template_for_resref(kit, wall_template_id) + if wtpl is None: + msg = f"Unknown wall template {wall_template_id!r}" + raise ValueError(msg) + wall.template_id = wtpl.template_id + wall.kit_id = kit.kit_id # TileKit.kit_id matches wall kit from template + wall.doorframe_enabled = bool(wtpl.doorframe_id) + + +def add_room_with_tile( + area: ADArea, + *, + kit_id: str, + tile_template_id: str, + position: Vector3, + orientation_net: list[float], + kits_by_id: dict[str, TileKit], +) -> ADRoom: + """Create a room with a single tile (minimal ``AddRoomMode`` / ``Room(RoomTemplate)`` analogue).""" + kit = kits_by_id.get(kit_id) + if kit is None: + msg = f"Unknown kit {kit_id!r}" + raise ValueError(msg) + kt = _kit_tile_record(kit, tile_template_id) + if kt is None: + msg = f"Unknown tile template {tile_template_id!r}" + raise ValueError(msg) + + room = ADRoom(position=position, orientation_net=list(orientation_net)[:4]) + ceil_ad: ADFloor | None = None + if kt.default_ceiling_id: + ceil_ad = ADFloor(kit_id=kit_id, template_id=kt.default_ceiling_id) + tile = ADTile( + room=room, + kit_id=kit_id, + template_id=tile_template_id, + local_position=Vector3(0.0, 0.0, 0.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=ADFloor(kit_id=kit_id, template_id=kt.default_floor_id or ""), + ceiling=ceil_ad, + kit_record=kt, + ) + for i, _wh in enumerate(kt.wall_hooks): + dw = _template_for_resref(kit, _wh.default_wall_id) + tid = dw.template_id if dw is not None else _wh.default_wall_id + kid = dw.kit_id if dw is not None else kit_id + tile.walls.append(ADWall(parent_tile=tile, hook_index=i, kit_id=kid, template_id=tid)) + room.tiles.append(tile) + fix_walls(room) + area.rooms.append(room) + return room + + +def add_object_to_room( + room: ADRoom, + *, + kit_id: str, + template_id: str, + position: Vector3, + orientation_net: list[float], +) -> None: + """Append a room-scoped placeable (``Room.AddObject``).""" + room.objects.append( + ADObject( + kit_id=kit_id, + template_id=template_id, + position=position, + orientation_net=list(orientation_net)[:4], + ), + ) diff --git a/Libraries/PyKotor/src/pykotor/tools/indoorkit.py b/Libraries/PyKotor/src/pykotor/tools/indoorkit.py index 11640fed1..12987a8f3 100644 --- a/Libraries/PyKotor/src/pykotor/tools/indoorkit.py +++ b/Libraries/PyKotor/src/pykotor/tools/indoorkit.py @@ -19,8 +19,10 @@ from pykotor.common.indoorkit import Kit, KitComponent, KitComponentHook, KitDoor, MDLMDXTuple from pykotor.common.tilekit import TileKit from pykotor.common.stream import BinaryReader +from pykotor.common.tilekit import TileKit from pykotor.resource.formats.bwm import read_bwm from pykotor.resource.generics.utd import read_utd +from pykotor.tools.tilekit_io import load_tile_kit_v2 from utility.common.geometry import Vector3 if TYPE_CHECKING: @@ -240,8 +242,9 @@ def _load_kits_internal( path: os.PathLike | str, *, record_missing: bool, -) -> tuple[list[Kit], list[MissingFileInfo]]: +) -> tuple[list[Kit], list[TileKit], list[MissingFileInfo]]: kits: list[Kit] = [] + tile_kits: list[TileKit] = [] missing_files: list[MissingFileInfo] = [] missing_ref: list[MissingFileInfo] | None = missing_files if record_missing else None @@ -255,13 +258,37 @@ def _load_kits_internal( kit_json_raw = json.loads(BinaryReader.load_file(file)) except Exception: continue - if not isinstance(kit_json_raw, dict) or "name" not in kit_json_raw: + if not isinstance(kit_json_raw, dict): + continue + fmt = kit_json_raw.get("format") + is_net_v01 = isinstance(fmt, str) and fmt.strip() == "0.1" + if kit_json_raw.get("format_version") == 2 or is_net_v01: + try: + tk, tmiss = load_tile_kit_v2(file, record_missing=record_missing) + except (OSError, ValueError, TypeError, KeyError): + continue + tile_kits.append(tk) + missing_files.extend(tmiss) + continue + if "name" not in kit_json_raw: continue kit_json = kit_json_raw kit_id = str(kit_json.get("id") or file.stem) kit_name = str(kit_json["name"]) else: - kit_json = json.loads(BinaryReader.load_file(file)) + try: + kit_json = json.loads(BinaryReader.load_file(file)) + except (OSError, ValueError, UnicodeDecodeError): + continue + fmt2 = kit_json.get("format") + is_net_v01_b = isinstance(fmt2, str) and fmt2.strip() == "0.1" + if kit_json.get("format_version") == 2 or is_net_v01_b: + try: + tk, _ = load_tile_kit_v2(file, record_missing=False) + except (OSError, ValueError, TypeError, KeyError): + continue + tile_kits.append(tk) + continue kit_id = kit_json.get("id") or file.stem kit_name = kit_json["name"] @@ -315,7 +342,22 @@ def _load_kits_internal( kits.append(kit) - return kits, missing_files + return kits, tile_kits, missing_files + + +def load_kits_unified( + path: os.PathLike | str, +) -> tuple[list[Kit], list[TileKit]]: + """Load v1 Holocron `Kit`s and v2 Kotor.NET-style `TileKit`s from the same directory.""" + kits, tile_kits, _ = _load_kits_internal(path, record_missing=False) + return kits, tile_kits + + +def load_kits_unified_with_missing( + path: os.PathLike | str, +) -> tuple[list[Kit], list[TileKit], list[tuple[str, Path, str]]]: + """Like `load_kits_unified` but also return missing v1/v2 asset paths.""" + return _load_kits_internal(path, record_missing=True) def load_kits(path: os.PathLike | str) -> list[Kit]: @@ -324,8 +366,10 @@ def load_kits(path: os.PathLike | str) -> list[Kit]: Expected layout matches Holocron Toolset kits: - `/.json` - `//...` (folders with resources) + + Files with ``format_version: 2`` are v2 tile kits; use `load_kits_unified` to load them. """ - kits, _missing = _load_kits_internal(path, record_missing=False) + kits, _tk, _missing = _load_kits_internal(path, record_missing=False) return kits @@ -337,28 +381,8 @@ def load_kits_with_missing_files( This mirrors the Toolset's historical `load_kits()` behavior (minus Qt preview loading), so Toolset UI can report missing resources while keeping all non-Qt logic in PyKotor. """ - return _load_kits_internal(path, record_missing=True) - - -def load_kits_unified(path: os.PathLike | str) -> tuple[list[Kit], list[TileKit]]: - """Load v1 component kits and v2 tile kits from the same directory.""" - from pykotor.tools.tilekit_io import load_tile_kits_v2_from_folder - - kits, _missing = _load_kits_internal(path, record_missing=False) - tile_kits = load_tile_kits_v2_from_folder(path, missing_files=None) - return kits, tile_kits - - -def load_kits_unified_with_missing( - path: os.PathLike | str, -) -> tuple[list[Kit], list[TileKit], list[tuple[str, Path, str]]]: - """Like :func:`load_kits_unified` but merges missing-file lists from both loaders.""" - from pykotor.tools.tilekit_io import load_tile_kits_v2_from_folder - - kits, missing_v1 = _load_kits_internal(path, record_missing=True) - missing_v2: list[tuple[str, Path, str]] = [] - tile_kits = load_tile_kits_v2_from_folder(path, missing_files=missing_v2) - return kits, tile_kits, missing_v1 + missing_v2 + kits, _tk, missing = _load_kits_internal(path, record_missing=True) + return kits, missing def kits_for_indoor_build(kits: list[Kit], tile_kits: list[TileKit]) -> list[Kit]: diff --git a/Libraries/PyKotor/src/pykotor/tools/tile_bwm.py b/Libraries/PyKotor/src/pykotor/tools/tile_bwm.py index f5b4cae17..a6909d6a4 100644 --- a/Libraries/PyKotor/src/pykotor/tools/tile_bwm.py +++ b/Libraries/PyKotor/src/pykotor/tools/tile_bwm.py @@ -4,25 +4,16 @@ from copy import deepcopy -from pykotor.common.tilekit import QuaternionWXYZ from pykotor.resource.formats.bwm.bwm_data import BWM, BWMFace, BWMType from utility.common.geometry import SurfaceMaterial, Vector3 -def rotate_bwm_at_origin(bwm: BWM, rotation: QuaternionWXYZ) -> BWM: - """Deep-copy *bwm* and rotate all face vertices about the origin by *rotation*.""" - out = deepcopy(bwm) - if not out.faces: - return out - for face in out.faces: - face.v1 = rotation.rotate_vector(face.v1) - face.v2 = rotation.rotate_vector(face.v2) - face.v3 = rotation.rotate_vector(face.v3) - return out - - def merge_translated_bwms(sources: list[tuple[BWM, float, float, float]]) -> BWM: - """Merge BWMs into one area walkmesh; each piece translated by (tx, ty, tz).""" + """Merge multiple BWMs into one area walkmesh, each translated by (tx, ty, tz) in world space. + + Vertices are copied per face to avoid shared-mutation issues. Empty input yields an empty + area BWM (caller may substitute a generated floor). + """ out = BWM() out.walkmesh_type = BWMType.AreaModel for bwm, tx, ty, tz in sources: @@ -43,7 +34,11 @@ def generate_flat_floor_quad( z: float = 0.0, material: SurfaceMaterial = SurfaceMaterial.STONE, ) -> BWM: - """Two walkable triangles on the X/Y plane at fixed Z (no template WOK fallback).""" + """Two walkable triangles covering an axis-aligned rectangle in the X/Y plane at fixed Z. + + Used when a floor tile has no WOK; coarse stand-in for procedural walkmesh. KotOR area + walkmeshes are consumed in world space; match your tile compiler's placement convention. + """ b = BWM() b.walkmesh_type = BWMType.AreaModel v0 = Vector3(min_x, min_y, z) diff --git a/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py b/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py new file mode 100644 index 000000000..dce7cbe55 --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py @@ -0,0 +1,190 @@ +"""Bridge Kotor.NET AreaDesigner scene graph to `pykotor.gl.scene.Scene`. + +`AreaEntity.GetMeshDescriptors` draws **floors, walls, doorframes, inner corners, outer corners** +(per tile), then **objects** at room scope (`Room.Objects`). `AreaExporter.RoomToMDL` stitches the +same categories (ceilings commented out in .NET). + +Doorframe world pose matches `DoorFrame` in `Room.cs`: ``LocalTransform`` uses **only the last** +template hook (`Template.Hooks.Last()`). + +When ``respect_adjacency_visibility`` is set (Toolset default), wall pairing follows ``Room.FixWalls`` +(interior walls hidden), ``InnerCorner.Visible`` / ``OuterCorner.Visible`` from ``Room.cs`` (via +``area_designer_runtime``). + +Also supports PyKotor `TileLayout` (floors only) for `.indoor` `tile_layout`. +""" + +from __future__ import annotations + +import io +from typing import TYPE_CHECKING, Any + +from pykotor.common.stream import BinaryReader +from pykotor.common.tilekit import ( + KitTileRecord, + QuaternionWXYZ, + TileKit, + TileTemplate, +) +from pykotor.gl import eulerAngles, mat4_cast, quat, translate, vec3 +from pykotor.gl.models.read_mdl import gl_load_stitched_model +from pykotor.tools.area_designer_runtime import build_runtime_from_v01, iter_render_instances +from pykotor.gl.scene.render_object import RenderObject +from pykotor.gl.scene.scene import Scene +from pykotor.gl.shader import Texture +from pykotor.resource.formats.tpc.tpc_auto import read_tpc +from pykotor.tools.tilemap_compile import TileLayout +from utility.common.geometry import Vector3 + +if TYPE_CHECKING: + pass + + +def _quat_to_euler_v3(q_wxyz: QuaternionWXYZ) -> Vector3: + r = quat(q_wxyz.w, q_wxyz.x, q_wxyz.y, q_wxyz.z) + e = eulerAngles(r) + return Vector3(float(e.x), float(e.y), float(e.z)) + + +def _v3(v: Vector3) -> Any: + return vec3(float(v.x), float(v.y), float(v.z)) + + +def _quat_from_net_xyzw(seq: list[float] | None) -> Any: + """System.Numerics JSON order ``[x, y, z, w]``.""" + if not seq or len(seq) < 4: + return quat(1.0, 0.0, 0.0, 0.0) + x, y, z, w = (float(seq[0]), float(seq[1]), float(seq[2]), float(seq[3])) + return quat(w, x, y, z) + + +def _quat_from_py_wxyz(q: QuaternionWXYZ) -> Any: + return quat(q.w, q.x, q.y, q.z) + + +def _mat_rt(q: Any, pos: Vector3) -> Any: + """Match C# ``Matrix4x4.CreateFromQuaternion * CreateFromTranslation``.""" + return mat4_cast(q) * translate(_v3(pos)) + + +def _multiply(a: Any, b: Any) -> Any: + return a * b + + +def upload_tile_kit_assets(scene: Scene, tile_kit: TileKit) -> None: + """Upload kit TGAs as TPC-derived textures and register template MDL/MDX in `scene.models`.""" + for resref, raw in tile_kit.textures.items(): + try: + tpc = read_tpc(io.BytesIO(raw)) + scene.textures[resref] = Texture.from_tpc(tpc) + except (OSError, ValueError): + continue + + for tpl in tile_kit.all_templates(): + if len(tpl.mdl) < 12 or not tpl.mdx: + continue + try: + mdl_r = BinaryReader.from_bytes(tpl.mdl, 12) + mdx_r = BinaryReader.from_bytes(tpl.mdx) + scene.models[tpl.resref] = gl_load_stitched_model(scene, mdl_r, mdx_r) + except (OSError, ValueError, RuntimeError): + continue + + +def _template_for_resref(kit: TileKit, resref: str) -> TileTemplate | None: + for t in kit.all_templates(): + if t.resref == resref or t.template_id == resref: + return t + return None + + +def _kit_tile_record(kit: TileKit, tile_template_id: str) -> KitTileRecord | None: + for tr in kit.tiles: + if tr.tile_id == tile_template_id: + return tr + return None + + +def populate_scene_tile_grid_floor_preview( + scene: Scene, + tile_kit: TileKit, + layout: TileLayout, + *, + floor_z: float = 0.0, + cell_override: float | None = None, +) -> None: + """Place one `RenderObject` per non-empty floor cell (template `resref` model names).""" + scene.objects.clear() + scene.selection.clear() + scene.invalidate_render_cache() + + cell = float(layout.cell_size if cell_override is None else cell_override) + if layout.grid_w <= 0 or layout.grid_h <= 0: + return + + for iy in range(layout.grid_h): + for ix in range(layout.grid_w): + idx = layout.cell_index(ix, iy) + if idx >= len(layout.floor_cells): + continue + tid = layout.floor_cells[idx] + if not tid: + continue + tpl = tile_kit.template_by_id(tid) + if tpl is None or not tpl.resref: + continue + wx = float(ix) * cell + tpl.offset.x + wy = float(iy) * cell + tpl.offset.y + wz = floor_z + tpl.offset.z + rot_euler = _quat_to_euler_v3(tpl.rotation) + ro = RenderObject(tpl.resref, Vector3(wx, wy, wz), rot_euler) + scene.objects[ro] = ro + + scene.invalidate_render_cache() + + +def populate_scene_from_area_designer_v01( + scene: Scene, + area: dict[str, Any], + kits_by_id: dict[str, TileKit], + *, + show_walls: bool = True, + show_doors: bool = True, + show_corners: bool = True, + show_ceilings: bool = False, + show_objects: bool = True, + respect_adjacency_visibility: bool = True, +) -> None: + """Populate `scene.objects` like `AreaEntity.GetMeshDescriptors` (Kotor.NET). + + Expects `area` JSON with ``format: \"0.1\"`` and ``rooms[]`` as saved by `AreaSerializer_V0_1`. + + Delegates to :func:`build_runtime_from_v01` and :func:`iter_render_instances` so preview matches + the Area Designer runtime (``Room.FixWalls``, wall/corner visibility). + """ + scene.objects.clear() + scene.selection.clear() + scene.invalidate_render_cache() + + runtime = build_runtime_from_v01(area, kits_by_id) + + def add_model(resref: str, world: Any) -> None: + if not resref: + return + ro = RenderObject(resref) + ro.set_transform(world) + scene.objects[ro] = ro + + for resref, world in iter_render_instances( + runtime, + kits_by_id, + show_walls=show_walls, + show_doors=show_doors, + show_corners=show_corners, + show_ceilings=show_ceilings, + show_objects=show_objects, + respect_adjacency_visibility=respect_adjacency_visibility, + ): + add_model(resref, world) + + scene.invalidate_render_cache() diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.mdl b/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.mdl new file mode 100644 index 000000000..e69de29bb diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.mdx b/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.wok b/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.wok new file mode 100644 index 000000000..e7add1d41 Binary files /dev/null and b/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.wok differ diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_kotor_net.json b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_kotor_net.json new file mode 100644 index 000000000..2cb5bf58b --- /dev/null +++ b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_kotor_net.json @@ -0,0 +1,31 @@ +{ + "format": "0.1", + "version": 1, + "name": "Minimal Kotor.NET-shaped fixture", + "id": "minimal_kotor_net", + "doors": [], + "tiles": [ + { + "id": "cell_a", + "name": "Cell A", + "defaultFloorID": "floor_plain", + "defaultCeilingID": "", + "wallHooks": [], + "innerCornerHooks": [], + "outerCornerHooks": [] + } + ], + "floors": [ + { + "id": "floor_plain", + "name": "Plain", + "model": "floor_plain" + } + ], + "ceilings": [], + "doorframes": [], + "walls": [], + "innerCorners": [], + "outerCorners": [], + "objects": [] +} diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles.json b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles.json index f8f2bbd71..c4b6e592d 100644 --- a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles.json +++ b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles.json @@ -3,11 +3,12 @@ "serializer": "Kotor.NET KitSerializer_V0_1", "name": "Minimal Tile Fixture", "id": "minimal_tiles", + "doors": [], "templates": { "floors": [ { - "id": "floor_a", - "resref": "floor_a", + "id": "floor_plain", + "resref": "floor_plain", "offset": [0, 0, 0], "rotation": [1, 0, 0, 0] } diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.mdl b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.mdl new file mode 100644 index 000000000..e69de29bb diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.mdx b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.wok b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.wok new file mode 100644 index 000000000..e7add1d41 Binary files /dev/null and b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.wok differ diff --git a/Libraries/PyKotor/tests/tools/test_area_designer_runtime.py b/Libraries/PyKotor/tests/tools/test_area_designer_runtime.py new file mode 100644 index 000000000..fed97ed58 --- /dev/null +++ b/Libraries/PyKotor/tests/tools/test_area_designer_runtime.py @@ -0,0 +1,220 @@ +"""Unit tests for ``area_designer_runtime`` (Kotor.NET ``Room`` / ``FixWalls`` / corner visibility).""" + +from __future__ import annotations + +from pykotor.common.tilekit import ( + CornerHookTemplate, + KitTileRecord, + QuaternionWXYZ, + TileKit, + WallHookTemplate, +) +from pykotor.tools.area_designer_runtime import ( + ADFloor, + ADRoom, + ADTile, + ADWall, + build_runtime_from_v01, + fix_walls, + inner_corner_visible, + outer_corner_visible, + runtime_to_v01, +) +from utility.common.geometry import Vector3 + + +def _hook(px: float = 1.0, py: float = 0.0, pz: float = 0.0) -> WallHookTemplate: + return WallHookTemplate( + default_wall_id="wall_a", + position=Vector3(px, py, pz), + orientation=QuaternionWXYZ(), + ) + + +def test_fix_walls_pairs_across_tiles_when_hooks_coincide() -> None: + kt = KitTileRecord( + tile_id="cell", + name="", + default_floor_id="", + default_ceiling_id="", + wall_hooks=[_hook(1.0, 0.0, 0.0)], + ) + room = ADRoom(position=Vector3(0.0, 0.0, 0.0), orientation_net=[0.0, 0.0, 0.0, 1.0]) + floor = ADFloor(kit_id="k", template_id="") + + t1 = ADTile( + room=room, + kit_id="k", + template_id="cell", + local_position=Vector3(0.0, 0.0, 0.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=floor, + kit_record=kt, + ) + t1.walls.append(ADWall(parent_tile=t1, hook_index=0, kit_id="k", template_id="wall_a")) + + t2 = ADTile( + room=room, + kit_id="k", + template_id="cell", + local_position=Vector3(0.0, 0.0, 0.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=floor, + kit_record=kt, + ) + t2.walls.append(ADWall(parent_tile=t2, hook_index=0, kit_id="k", template_id="wall_a")) + + room.tiles.extend([t1, t2]) + fix_walls(room) + + assert t1.walls[0].linked_tile is t2 + assert t2.walls[0].linked_tile is t1 + + +def test_inner_corner_visible_only_when_adjacent_walls_unlinked() -> None: + kt = KitTileRecord( + tile_id="cell", + name="", + default_floor_id="", + default_ceiling_id="", + wall_hooks=[ + _hook(0.0, 0.0, 0.0), + _hook(1.0, 0.0, 0.0), + ], + ) + room = ADRoom(position=Vector3(0.0, 0.0, 0.0), orientation_net=[0.0, 0.0, 0.0, 1.0]) + floor = ADFloor(kit_id="k", template_id="") + tile = ADTile( + room=room, + kit_id="k", + template_id="cell", + local_position=Vector3(0.0, 0.0, 0.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=floor, + kit_record=kt, + ) + tile.walls.append(ADWall(parent_tile=tile, hook_index=0, kit_id="k", template_id="w")) + tile.walls.append(ADWall(parent_tile=tile, hook_index=1, kit_id="k", template_id="w")) + + ic = CornerHookTemplate( + default_corner_id="c", + adjacent=[0, 1], + position=Vector3(0.0, 0.0, 0.0), + orientation=QuaternionWXYZ(), + ) + + assert inner_corner_visible(ic, tile) is True + + dummy = ADTile( + room=room, + kit_id="k", + template_id="other", + local_position=Vector3(9.0, 9.0, 9.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=floor, + kit_record=None, + ) + tile.walls[0].linked_tile = dummy + assert inner_corner_visible(ic, tile) is False + + +def test_outer_corner_requires_circuit_negative() -> None: + """Smoke-test outer-corner predicate shape (full circuit logic lives in ``outer_corner_visible``).""" + kt = KitTileRecord( + tile_id="cell", + name="", + default_floor_id="", + default_ceiling_id="", + wall_hooks=[ + _hook(0.0, 0.0, 0.0), + _hook(1.0, 0.0, 0.0), + ], + ) + room = ADRoom(position=Vector3(0.0, 0.0, 0.0), orientation_net=[0.0, 0.0, 0.0, 1.0]) + floor = ADFloor(kit_id="k", template_id="") + center = ADTile( + room=room, + kit_id="k", + template_id="cell", + local_position=Vector3(0.0, 0.0, 0.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=floor, + kit_record=kt, + ) + center.walls.append(ADWall(parent_tile=center, hook_index=0, kit_id="k", template_id="w")) + center.walls.append(ADWall(parent_tile=center, hook_index=1, kit_id="k", template_id="w")) + + oc = CornerHookTemplate( + default_corner_id="o", + adjacent=[0, 1], + position=Vector3(0.0, 0.0, 0.0), + orientation=QuaternionWXYZ(), + ) + + assert outer_corner_visible(oc, center) is False + + +def test_runtime_json_round_trip_minimal_room_without_tiles() -> None: + area_js = { + "format": "0.1", + "rooms": [ + { + "position": [1.0, 2.0, -3.5], + "orientation": [0.1, 0.2, 0.3, 0.9330127], + "tiles": [], + }, + ], + } + r = build_runtime_from_v01(area_js, {}) + back = runtime_to_v01(r) + assert back["format"] == "0.1" + assert len(back["rooms"]) == 1 + assert back["rooms"][0]["position"] == [1.0, 2.0, -3.5] + assert len(back["rooms"][0]["tiles"]) == 0 + + +def test_runtime_round_trip_tile_with_kit() -> None: + kt = KitTileRecord( + tile_id="cell01", + name="", + default_floor_id="floor1", + default_ceiling_id="", + wall_hooks=[_hook(1.0, 0.0, 0.0)], + ) + kit = TileKit(name="testkit", kit_id="sandral") + kit.tiles.append(kt) + + area_js = { + "format": "0.1", + "rooms": [ + { + "position": [0.0, 0.0, 0.0], + "orientation": [0.0, 0.0, 0.0, 1.0], + "tiles": [ + { + "kitID": "sandral", + "templateID": "cell01", + "position": [0.5, 0.0, 0.0], + "orientation": [0.0, 0.0, 0.0, 1.0], + "floor": {"kitID": "sandral", "templateID": "floor1"}, + "walls": [{"kitID": "sandral", "templateID": "wall_a"}], + }, + ], + }, + ], + } + r = build_runtime_from_v01(area_js, {"sandral": kit}) + assert len(r.rooms) == 1 + assert len(r.rooms[0].tiles) == 1 + tile = r.rooms[0].tiles[0] + assert tile.local_position.x == 0.5 + assert tile.floor.template_id == "floor1" + assert len(tile.walls) == 1 + + back = runtime_to_v01(r) + t0 = back["rooms"][0]["tiles"][0] + assert t0["kitID"] == "sandral" + assert t0["templateID"] == "cell01" + assert t0["position"] == [0.5, 0.0, 0.0] + assert t0["floor"] == {"kitID": "sandral", "templateID": "floor1"} + assert t0["walls"] == [{"kitID": "sandral", "templateID": "wall_a"}] diff --git a/Tools/HolocronToolset b/Tools/HolocronToolset index dadfbb301..c85ff5484 160000 --- a/Tools/HolocronToolset +++ b/Tools/HolocronToolset @@ -1 +1 @@ -Subproject commit dadfbb301c7df2d420ce02800f0948de5ad190f1 +Subproject commit c85ff54848eca3ff8e20d1d697b1cbd994340aee diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 000000000..767a92dad --- /dev/null +++ b/patches/README.md @@ -0,0 +1,11 @@ +# Patches (CI / fork note) + +`holocron-indoor-builder-3d-preview.patch` is a unified diff against the checked-in HolocronToolset submodule ref (`f7a656b38…`) for Indoor Map Builder’s OpenGL tile preview. Apply when the HolocronToolset remote is unreachable: + +```bash +cd Tools/HolocronToolset && git apply ../../patches/holocron-indoor-builder-3d-preview.patch +``` + +Then commit inside `Tools/HolocronToolset` and update the submodule gitlink in the PyKotor root. + +The patch matches **`AreaEntity` / `AreaExporter`** mesh categories (floors, walls, doorframes from last hook, inner/outer corners, room objects) and wires **`OpenGLSceneRenderer`** (`QOpenGLWidget`) with map refresh after load/new/module extract. Separate Avalonia apps (**Kit Editor**, interaction **modes**) are not duplicated in PyKotor—only data + preview/export parity. diff --git a/patches/holocron-indoor-builder-3d-preview.patch b/patches/holocron-indoor-builder-3d-preview.patch new file mode 100644 index 000000000..f120d63c4 --- /dev/null +++ b/patches/holocron-indoor-builder-3d-preview.patch @@ -0,0 +1,354 @@ +diff --git a/src/toolset/gui/windows/indoor_builder/builder.py b/src/toolset/gui/windows/indoor_builder/builder.py +index b2c1294..dbe7446 100644 +--- a/src/toolset/gui/windows/indoor_builder/builder.py ++++ b/src/toolset/gui/windows/indoor_builder/builder.py +@@ -218,6 +218,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self._installation: HTInstallation | None = installation + self._kits: list[Kit] = [] + self._tile_kits: list[TileKit] = [] ++ self._tile_gl = None # IndoorTileGridRenderer | None (optional 3D preview) + self._map: IndoorMap = IndoorMap() + # Synthetic components (e.g. merged rooms) are stored in an embedded kit so they + # can be serialized into `.indoor` and restored on load. +@@ -244,15 +245,20 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self.ui: Ui_MainWindow = Ui_MainWindow() + self.ui.setupUi(self) + from toolset.gui.windows.indoor_builder.tile_editor_3d import ( # noqa: PLC0415 ++ IndoorTileGridRenderer, + setup_indoor_builder_tile_3d, + ) + +- setup_indoor_builder_tile_3d( ++ self._tile_gl = setup_indoor_builder_tile_3d( + main_splitter=self.ui.mainViewSplitter, + host=self.ui.tileGrid3DHost, + host_layout=self.ui.tileGrid3DHostLayout, + fallback_label=self.ui.tileGrid3DFallbackLabel, ++ installation=self._installation, + ) ++ if isinstance(self._tile_gl, IndoorTileGridRenderer): ++ self._undo_stack.cleanChanged.connect(self._refresh_tile_gl_view) ++ self._undo_stack.indexChanged.connect(self._refresh_tile_gl_view) + + # Add a missing "Open .mod" action at runtime (UI code is generated; do not edit it). + self._action_open_mod: QAction = QAction(tr("Open .mod..."), self) +@@ -316,6 +322,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self.ui.mapRenderer.set_map(self._map) + self.ui.mapRenderer.set_undo_stack(self._undo_stack) + self.ui.mapRenderer.set_status_callback(self._refresh_status_bar) ++ self._refresh_tile_gl_view() + self._nav_helper = Viewport2DNavigationHelper( + self.ui.mapRenderer, + get_content_bounds=self._content_bounds, +@@ -343,6 +350,9 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + def _on_installation_changed(self, installation: HTInstallation | None) -> None: + # self._installation is already set by Editor._handle_installation_changed before this call. + self._module_kit_manager = None if installation is None else ModuleKitManager(installation) ++ gl = getattr(self, "_tile_gl", None) ++ if gl is not None and hasattr(gl, "set_installation"): ++ gl.set_installation(installation) + try: + self._setup_settings_toolbar() + self._setup_modules() +@@ -810,6 +820,13 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self.ui.mapRenderer.invalidate_rooms(rooms) + self._refresh_status_bar() + ++ def _refresh_tile_gl_view(self, *_args: object) -> None: ++ """Sync optional 3D preview with map + tile kits (AreaDesigner / tile_layout).""" ++ gl = getattr(self, "_tile_gl", None) ++ if gl is None or not hasattr(gl, "refresh_from_map"): ++ return ++ gl.refresh_from_map(self._map, self._tile_kits) ++ + def _kits_for_build(self) -> list[Kit]: + """K1 room kits plus v2 tile kit shells (textures/skyboxes) for `IndoorMap.build`.""" + out: list[Kit] = list(self._kits) +@@ -832,6 +849,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self.ui.kitSelect.addItem(kit.name, kit) + # v2 tile kits are loaded into `_tile_kits` for tile-layout / 3D workflows; the v1 + # component listbox remains for classic room components only. ++ self._refresh_tile_gl_view() + + def _show_no_kits_dialog(self): + """Show dialog asking if user wants to open kit downloader. +@@ -1631,6 +1649,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self._undo_stack.setClean() # Mark as clean for new file + self._update_settings_ui() + self._refresh_window_title() ++ self._refresh_tile_gl_view() + + def open(self): + if not self._undo_stack.isClean(): +@@ -1663,6 +1682,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self._update_settings_ui() + self._sync_module_combo_to_current_map() + self._refresh_window_title() ++ self._refresh_tile_gl_view() + + if missing_rooms: + self._show_missing_rooms_dialog(missing_rooms) +@@ -1921,6 +1941,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self._update_settings_ui() + self._sync_module_combo_to_current_map() + self._refresh_window_title() ++ self._refresh_tile_gl_view() + + return True + +diff --git a/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py b/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py +index 28a0f72..6bb8183 100644 +--- a/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py ++++ b/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py +@@ -1,19 +1,33 @@ +-"""Optional 3D tile-grid preview for Indoor Map Builder (PyQt + OpenGL). ++"""3D tile / area preview for Indoor Map Builder (PyQt + PyKotor GL). + +-When ``INDOOR_BUILDER_DISABLE_3D`` is set, or PyOpenGL is unavailable, the UI keeps the +-fallback label from ``indoor_builder.ui``. ++Replaces the solid-color placeholder with a real `OpenGLSceneRenderer` that mirrors Kotor.NET's ++``GLEngine.Render`` → mesh draws for placed tiles (see ``AreaEntity.GetMeshDescriptors``). + """ + + from __future__ import annotations + + import os +-from typing import TYPE_CHECKING ++from typing import TYPE_CHECKING, Any + +-from qtpy.QtCore import Qt ++from qtpy.QtCore import QTimer ++from qtpy.QtGui import QCloseEvent, QOpenGLContext + from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget + ++from loggerplus import RobustLogger ++from pykotor.common.indoormap import IndoorMap ++from pykotor.common.tilekit import TileKit ++from pykotor.gl.scene.scene import Scene ++from pykotor.tools.tilekit_preview import ( ++ populate_scene_from_area_designer_v01, ++ populate_scene_tile_grid_floor_preview, ++ upload_tile_kit_assets, ++) ++from pykotor.tools.tilemap_compile import TileLayout ++from toolset.gui.widgets.renderer.base import OpenGLSceneRenderer ++from toolset.gui.widgets.settings.widgets.module_designer import get_renderer_loop_interval_ms ++ + if TYPE_CHECKING: +- from qtpy.QtWidgets import QSplitter ++ from toolset.data.installation import HTInstallation + + + def indoor_builder_3d_enabled() -> bool: +@@ -24,62 +38,177 @@ def indoor_builder_3d_enabled() -> bool: + ) + + +-class _MinimalTileGridGL(QWidget): +- """Clears a color buffer; placeholder for a full tile-mesh + grid renderer.""" ++class IndoorTileGridRenderer(OpenGLSceneRenderer): ++ """OpenGL preview: AreaDesigner JSON and/or PyKotor ``tile_layout`` on the current map.""" + +- def __init__(self, parent: QWidget | None = None) -> None: +- super().__init__(parent) +- from qtpy.QtWidgets import QOpenGLWidget # noqa: PLC0415 ++ def __init__(self, parent: QWidget) -> None: ++ super().__init__(parent, loop_interval_ms=get_renderer_loop_interval_ms()) ++ self._installation: HTInstallation | None = None ++ self._last_map_id: int | None = None ++ self._kits_signature: tuple[str, ...] = () ++ self._uploaded_for_kits: set[str] = set() ++ self._map_ref: IndoorMap | None = None ++ self._tile_kits_ref: list[TileKit] = [] + +- self._gl: object | None = None +- try: +- from OpenGL.GL import ( # noqa: PLC0415 +- GL_COLOR_BUFFER_BIT, +- GL_DEPTH_BUFFER_BIT, +- glClear, +- glClearColor, +- ) ++ self.loop_timer.timeout.disconnect() ++ self.loop_timer.timeout.connect(self._on_loop_timer_timeout) + +- class _V(QOpenGLWidget): +- def initializeGL(self) -> None: +- glClearColor(0.12, 0.12, 0.14, 1.0) ++ def _on_loop_timer_timeout(self) -> None: ++ if self.isVisible(): ++ self.update() + +- def paintGL(self) -> None: +- glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) ++ def set_installation(self, installation: HTInstallation | None) -> None: ++ self._installation = installation ++ self._uploaded_for_kits.clear() + +- self._gl = _V(self) +- layout = QVBoxLayout(self) +- layout.setContentsMargins(0, 0, 0, 0) +- layout.addWidget(self._gl) +- except Exception: +- lab = QLabel(self) +- lab.setAlignment(Qt.AlignmentFlag.AlignCenter) +- lab.setText( +- "3D view: PyOpenGL not available. Use 2D map or install PyOpenGL.", ++ def shutdown_renderer(self) -> None: ++ super().shutdown_renderer() ++ self._uploaded_for_kits.clear() ++ ++ def closeEvent(self, event: QCloseEvent) -> None: # pyright: ignore[reportIncompatibleMethodOverride] ++ self.shutdown_renderer() ++ super().closeEvent(event) ++ ++ def initializeGL(self) -> None: ++ self.makeCurrent() ++ try: ++ from pykotor.gl.compat import HAS_PYOPENGL # noqa: PLC0415 ++ except ImportError: ++ HAS_PYOPENGL = False # noqa: N806 ++ if not HAS_PYOPENGL: ++ RobustLogger().warning("IndoorTileGridRenderer: PyOpenGL missing; 3D preview disabled.") ++ return ++ ++ self.scene = Scene(installation=self._installation) ++ self.scene.enable_frustum_culling = False ++ self.scene.camera.distance = 25.0 ++ self.scene.camera.x = 0.0 ++ self.scene.camera.y = 0.0 ++ self.scene.camera.z = 12.0 ++ self.scene.show_focus_point_gizmo = False ++ self.scene.show_cursor = False ++ self._sync_camera_drawable_size() ++ self.loop_timer.start() ++ ++ def resizeGL(self, width: int, height: int) -> None: # noqa: ARG002 ++ self._sync_camera_drawable_size() ++ ++ def refresh_from_map(self, indoor_map: IndoorMap, tile_kits: list[TileKit]) -> None: ++ """Rebuild GPU scene from map state (call after map / kits change).""" ++ self._map_ref = indoor_map ++ self._tile_kits_ref = tile_kits ++ self.update() ++ ++ def paintGL(self) -> None: ++ if self.scene is None: ++ return ++ ctx: QOpenGLContext | None = self.context() ++ if ctx is None or not ctx.isValid(): ++ return ++ self.makeCurrent() ++ self._sync_camera_drawable_size() ++ ++ indoor_map = self._map_ref ++ tile_kits = self._tile_kits_ref ++ if indoor_map is None: ++ try: ++ self.scene.render() ++ except Exception: # noqa: BLE001 ++ RobustLogger().exception("IndoorTileGridRenderer.render failed.") ++ return ++ ++ kits_by_id: dict[str, TileKit] = {tk.kit_id: tk for tk in tile_kits} ++ sig = tuple(sorted(kits_by_id.keys())) ++ mid = id(indoor_map) ++ if sig != self._kits_signature or mid != self._last_map_id: ++ self._uploaded_for_kits.clear() ++ self._kits_signature = sig ++ self._last_map_id = mid ++ ++ for kid, tk in kits_by_id.items(): ++ if kid not in self._uploaded_for_kits: ++ upload_tile_kit_assets(self.scene, tk) ++ self._uploaded_for_kits.add(kid) ++ ++ area_payload = getattr(indoor_map, "area_designer_v01", None) ++ if isinstance(area_payload, dict) and area_payload.get("format") == "0.1": ++ populate_scene_from_area_designer_v01( ++ self.scene, ++ area_payload, ++ kits_by_id, ++ show_walls=True, ++ show_doors=True, ++ show_corners=True, ++ show_ceilings=False, + ) +- layout = QVBoxLayout(self) +- layout.addWidget(lab) ++ else: ++ tl = getattr(indoor_map, "tile_layout", None) ++ if isinstance(tl, dict) and tl.get("kit_id"): ++ kit_id = str(tl["kit_id"]) ++ tk = kits_by_id.get(kit_id) ++ if tk is not None: ++ layout = TileLayout( ++ format_version=int(tl.get("format_version", 1)), ++ kit_id=kit_id, ++ cell_size=float(tl.get("cell_size", 4.0)), ++ grid_w=int(tl.get("grid_w", 0)), ++ grid_h=int(tl.get("grid_h", 0)), ++ floor_cells=list(tl.get("floor_cells") or []), ++ ) ++ populate_scene_tile_grid_floor_preview(self.scene, tk, layout) ++ else: ++ self.scene.objects.clear() ++ self.scene.invalidate_render_cache() ++ else: ++ self.scene.objects.clear() ++ self.scene.invalidate_render_cache() ++ ++ try: ++ self.scene.render() ++ except Exception: # noqa: BLE001 ++ RobustLogger().exception("IndoorTileGridRenderer.render failed.") + + + def setup_indoor_builder_tile_3d( + *, +- main_splitter: QSplitter, ++ main_splitter: Any, + host: QWidget, + host_layout: QVBoxLayout, + fallback_label: QLabel, +-) -> None: +- """Replace the fallback label with a minimal GL widget when 3D is allowed.""" ++ installation: HTInstallation | None = None, ++) -> IndoorTileGridRenderer | None: ++ """Replace the fallback label with the GL renderer when 3D is allowed.""" + if not indoor_builder_3d_enabled(): + fallback_label.setText("3D tile view disabled (INDOOR_BUILDER_DISABLE_3D).") + try: + main_splitter.setSizes([480, 0]) + except Exception: + pass +- return ++ return None ++ ++ try: ++ from pykotor.gl.compat import HAS_PYOPENGL # noqa: PLC0415 ++ except ImportError: ++ HAS_PYOPENGL = False # noqa: N806 ++ ++ if not HAS_PYOPENGL: ++ fallback_label.setText( ++ "3D view: PyOpenGL not available. Use 2D map or install PyOpenGL.", ++ ) ++ try: ++ main_splitter.setSizes([480, 0]) ++ except Exception: ++ pass ++ return None ++ + host_layout.removeWidget(fallback_label) + fallback_label.hide() +- host_layout.addWidget(_MinimalTileGridGL(host)) ++ gl_widget = IndoorTileGridRenderer(host) ++ gl_widget.set_installation(installation) ++ host_layout.addWidget(gl_widget) + try: + main_splitter.setSizes([400, 200]) + except Exception: + pass ++ QTimer.singleShot(0, gl_widget.update) ++ return gl_widget diff --git a/pyproject.toml b/pyproject.toml index ada3b3ccc..1ba10cd2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ name = "pykotor-workspace" authors = [{ name = "Nick Hugi" }, { name = "th3w1zard1" }] maintainers = [{ name = "th3w1zard1", email = "boden.crouch@gmail.com" }] dependencies = [ - "pykotor[render]>=2.3.12", + "pykotor[render]>=2.3.13", "holocrontoolset>=4.0.0b31", "holopatcher>=2.0.0a3", "holopazaak>=2.0.0", @@ -83,7 +83,7 @@ keywords = ["editor", "holocron", "holopatcher", "kotor", "library", "pykotor", license = {text = "LGPL-3.0-or-later License"} readme = {content-type = "text/markdown", file = "README.md"} requires-python = ">=3.8" -version = "2.3.12" +version = "2.3.13" # Re-export every optional-dependency set from Libraries/PyKotor/pyproject.toml so # that anything previously installable as `pykotor[]` is also reachable via @@ -91,17 +91,17 @@ version = "2.3.12" # corresponding pykotor extra; the actual dependency specifiers live in the # PyKotor sub-project so we never drift from its pinning logic. [project.optional-dependencies] -all = ["pykotor[encodings,exp,extra,font,gl,updater,render]>=2.3.12"] -encodings = ["pykotor[encodings]>=2.3.12"] -exp = ["pykotor[exp]>=2.3.12"] -extra = ["pykotor[extra]>=2.3.12"] -font = ["pykotor[font]>=2.3.12"] -gl = ["pykotor[gl]>=2.3.12"] -gl_exp = ["pykotor[gl_exp]>=2.3.12"] -updater = ["pykotor[updater]>=2.3.12"] -render = ["pykotor[render]>=2.3.12"] +all = ["pykotor[encodings,exp,extra,font,gl,updater,render]>=2.3.13"] +encodings = ["pykotor[encodings]>=2.3.13"] +exp = ["pykotor[exp]>=2.3.13"] +extra = ["pykotor[extra]>=2.3.13"] +font = ["pykotor[font]>=2.3.13"] +gl = ["pykotor[gl]>=2.3.13"] +gl_exp = ["pykotor[gl_exp]>=2.3.13"] +updater = ["pykotor[updater]>=2.3.13"] +render = ["pykotor[render]>=2.3.13"] # Surface pykotor's own dev extra too, for parity with the sub-project. -dev = ["pykotor[dev]>=2.3.12"] +dev = ["pykotor[dev]>=2.3.13"] [tool.setuptools] packages = [] @@ -267,7 +267,7 @@ keywords = ["editor", "holocron", "holopatcher", "kotor", "library", "pykotor", [tool.poetry.dependencies] python = "^3.8" -pykotor = "^2.3.12" +pykotor = "^2.3.13" holocrontoolset = ">=4.0.0b31" holopatcher = ">=2.0.0a3" holopazaak = ">=2.0.0" diff --git a/tool_metadata.py b/tool_metadata.py new file mode 100644 index 000000000..ccc6f1129 --- /dev/null +++ b/tool_metadata.py @@ -0,0 +1,162 @@ +"""Repository tool discovery and PyKotor library path constants (CI + compile scripts).""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomli as tomllib + except ImportError: + tomllib = None # type: ignore[assignment, misc] + +LIBRARY_SOURCE_PATHS: list[str] = [ + "Libraries/PyKotor/src", + "Libraries/bioware-kaitai-formats", +] + + +@dataclass +class ToolInfo: + """Metadata for a package under `Tools/`.""" + + directory: str + name: str + build_name: str + display_name: str + path: str + src_path: str + module_name: str + requires_qt: bool + is_cli: bool + tests_path: str | None = field(default=None) + + @property + def relative_path(self) -> str: + return f"Tools/{self.directory}" + + def to_dict(self) -> dict[str, object | str | bool | None]: + return { + "directory": self.directory, + "name": self.name, + "build_name": self.build_name, + "display_name": self.display_name, + "path": self.path, + "src_path": self.src_path, + "module_name": self.module_name, + "requires_qt": self.requires_qt, + "is_cli": self.is_cli, + "tests_path": self.tests_path, + } + + +def _read_pyproject_data(tool_dir: Path) -> dict[str, object]: + pyproject = tool_dir / "pyproject.toml" + if not pyproject.is_file() or tomllib is None: + return {} + try: + with pyproject.open("rb") as f: + return tomllib.load(f) # type: ignore[no-untyped-call] + except (OSError, TypeError, ValueError, UnicodeError): + return {} + + +def _script_module(data: dict[str, object]) -> str: + project = data.get("project") + if not isinstance(project, dict): + return "" + scripts = project.get("scripts") + if not isinstance(scripts, dict) or not scripts: + return "" + for v in scripts.values(): + if isinstance(v, str) and ":" in v: + return v.split(":", 1)[0].strip() + return "" + + +def _infer_requires_qt(text: str) -> bool: + low = text.lower() + return "pyqt" in low or "pyside" in low or "qt5" in low or "qt6" in low + + +def _one_tool(repo_root: Path, tool_dir: Path) -> ToolInfo | None: + if not (tool_dir / "pyproject.toml").is_file(): + return None + directory = tool_dir.name + data = _read_pyproject_data(tool_dir) + project = data.get("project") if isinstance(data.get("project"), dict) else {} + proj_name = str(project.get("name", directory)) + build_name = re.sub(r"[^0-9a-zA-Z]+", "-", proj_name).lower().strip("-") + if not build_name: + build_name = directory.lower() + toml_text = (tool_dir / "pyproject.toml").read_text(encoding="utf-8", errors="replace") + requires_qt = _infer_requires_qt(toml_text) + is_cli = not requires_qt + if (tool_dir / "src").is_dir(): + src = tool_dir / "src" + else: + src = tool_dir + src_path = str(src.relative_to(repo_root)) + tests: Path | None = None + for tname in ("tests", "test"): + tpath = tool_dir / tname + if tpath.is_dir(): + tests = tpath + break + tests_path = str(tests.relative_to(repo_root)) if tests is not None else None + mod = _script_module(data) + if not mod: + if (src / "toolset").is_dir(): + mod = "toolset" + elif (src / "pykotor").is_dir(): + mod = "pykotor" + else: + mod = directory + return ToolInfo( + directory=directory, + name=build_name, + build_name=build_name, + display_name=proj_name, + path=f"Tools/{directory}", + src_path=src_path, + module_name=mod, + requires_qt=requires_qt, + is_cli=is_cli, + tests_path=tests_path, + ) + + +def discover_tools(repo_root: Path | str) -> list[ToolInfo]: + """List each `Tools/*/` that contains a `pyproject.toml`.""" + root = Path(repo_root).resolve() + tools_base = root / "Tools" + if not tools_base.is_dir(): + return [] + out: list[ToolInfo] = [] + for child in sorted(tools_base.iterdir(), key=lambda p: p.name.lower()): + if not child.is_dir() or child.name.startswith("."): + continue + t = _one_tool(root, child) + if t is not None: + out.append(t) + return out + + +def resolve_tool(name: str, repo_root: Path | str) -> ToolInfo: + """Return tool metadata; *name* matches directory, build name, or project name (casefold).""" + n = (name or "").strip().casefold() + for tool in discover_tools(repo_root): + if n in { + tool.directory.casefold(), + tool.build_name.casefold(), + tool.name.casefold(), + tool.display_name.casefold(), + }: + return tool + msg = f"Unknown tool: {name!r}" + raise KeyError(msg) diff --git a/uv.lock b/uv.lock index f3545dd7d..1e1403a35 100644 --- a/uv.lock +++ b/uv.lock @@ -2903,7 +2903,7 @@ wheels = [ [[package]] name = "pykotor" -version = "2.3.12" +version = "2.3.13" source = { editable = "Libraries/PyKotor" } dependencies = [ { name = "bioware-kaitai-formats" }, @@ -3093,7 +3093,7 @@ provides-extras = ["all", "dev", "encodings", "exp", "extra", "font", "gl", "gl- [[package]] name = "pykotor-workspace" -version = "2.3.12" +version = "2.3.13" source = { editable = "." } dependencies = [ { name = "holocrontoolset" },