diff --git a/src/OpenApparatus.Studio/ViewModels/MainWindowViewModel.cs b/src/OpenApparatus.Studio/ViewModels/MainWindowViewModel.cs index c0797c4..72efec8 100644 --- a/src/OpenApparatus.Studio/ViewModels/MainWindowViewModel.cs +++ b/src/OpenApparatus.Studio/ViewModels/MainWindowViewModel.cs @@ -333,6 +333,13 @@ partial void OnEditModeChanged(EditModeKind value) public bool IsObjectsMode => EditMode == EditModeKind.Object; public bool IsLayoutMode => EditMode == EditModeKind.Layout; + /// When true (Object mode only), the editor draws each object's + /// identity fields — GlobalID, TypeID, CustomID, Name — as small text + /// beside its icon. Toggled from the Object-types panel. + [ObservableProperty] bool _showItemInfo; + + partial void OnShowItemInfoChanged(bool value) => EditVersion++; + /// Whether the keyboard-shortcut help overlay is showing. F1 /// toggles, Esc dismisses (handled in MainWindow code-behind). [ObservableProperty] bool _isShortcutOverlayVisible; @@ -573,6 +580,28 @@ public void OnEditedObjectType() EditVersion++; } + /// Next default — the largest + /// numeric GlobalId already in use plus one (0 when none parse). Non-numeric + /// user-edited ids are ignored; uniqueness is not enforced. + int NextGlobalId() + { + int next = 0; + foreach (var o in _objects) + if (int.TryParse(o.GlobalId, out int v) && v >= next) next = v + 1; + return next; + } + + /// Next default for objects of + /// — the largest numeric TypeId among same-slot + /// objects plus one (0 when none parse). + int NextTypeId(int slot) + { + int next = 0; + foreach (var o in _objects) + if (o.Slot == slot && int.TryParse(o.TypeId, out int v) && v >= next) next = v + 1; + return next; + } + /// Sub-cell side length in metres at the current subdivision. public float SubCellSize => TileSize / System.Math.Max(1, GridSubdivision); @@ -628,12 +657,18 @@ public void PlaceObjectAtSelectedSubCell(int slot) } } PushUndo(); + string globalId = NextGlobalId().ToString(); + string typeId = NextTypeId(slot).ToString(); var obj = new RoomObject { OwningRoomId = roomId, Slot = slot, Position = new System.Numerics.Vector3(center2.X, DefaultObjectY, center2.Y), Rotation = 0f, + GlobalId = globalId, + TypeId = typeId, + CustomId = $"{globalId}_{typeId}", + Name = $"{typeForPlacement.Name}_{globalId}_{typeId}", }; _objects.Add(obj); SelectedObjectIndex = _objects.Count - 1; @@ -1719,6 +1754,10 @@ public ProjectFile ToProjectFile() OwningRoomId = o.OwningRoomId, X = o.Position.X, Y = o.Position.Y, Z = o.Position.Z, Rotation = o.Rotation, + GlobalId = o.GlobalId, + TypeId = o.TypeId, + CustomId = o.CustomId, + Name = o.Name, }).ToList(); return f; @@ -1825,14 +1864,41 @@ public void RestoreFromProjectFile(ProjectFile f) _objects.Clear(); if (f.Objects is not null) { + // Identity fields were added after the first releases. Files + // saved before then carry null/empty ids — backfill sequential + // defaults so older apparatus stay usable and consistent. + int backfillGlobal = 0; + var backfillType = new Dictionary(); foreach (var o in f.Objects) + { + string globalId = o.GlobalId ?? ""; + string typeId = o.TypeId ?? ""; + string customId = o.CustomId ?? ""; + string name = o.Name ?? ""; + if (globalId.Length == 0 && typeId.Length == 0 && + customId.Length == 0 && name.Length == 0) + { + int gid = backfillGlobal++; + backfillType.TryGetValue(o.Slot, out int tid); + backfillType[o.Slot] = tid + 1; + globalId = gid.ToString(); + typeId = tid.ToString(); + customId = $"{globalId}_{typeId}"; + var t = GetObjectType(o.Slot); + name = $"{t?.Name ?? "Object"}_{globalId}_{typeId}"; + } _objects.Add(new RoomObject { Slot = o.Slot, OwningRoomId = o.OwningRoomId, Position = new System.Numerics.Vector3(o.X, o.Y, o.Z), Rotation = o.Rotation, + GlobalId = globalId, + TypeId = typeId, + CustomId = customId, + Name = name, }); + } } // Camera. diff --git a/src/OpenApparatus.Studio/Views/GridEditorView.cs b/src/OpenApparatus.Studio/Views/GridEditorView.cs index 3ffc4d7..07ac852 100644 --- a/src/OpenApparatus.Studio/Views/GridEditorView.cs +++ b/src/OpenApparatus.Studio/Views/GridEditorView.cs @@ -1153,7 +1153,7 @@ void DrawSide(OpenApparatus.Topology.Adjacency a, bool isRoomA, // Object icons — drawn last so they sit on top of room labels in // Objects mode. In Floor / Ceiling mode they still render, dimmed, // so the user always sees what's been placed. - DrawObjects(ctx, vm, origin, tileSize); + DrawObjects(ctx, vm, origin, tileSize, typeface); // Constraint zones (door annular wedges, object exclusion discs) and // violator rings — drawn before the measurement labels so the labels @@ -2002,10 +2002,31 @@ public static void DrawAngleLabel( ctx.DrawText(fmt, new Point(rect.X + padX, rect.Y + padY)); } - static void DrawObjects(DrawingContext ctx, MainWindowViewModel vm, Point origin, double tileSize) + static void DrawObjects(DrawingContext ctx, MainWindowViewModel vm, Point origin, double tileSize, Typeface typeface) { if (vm.Objects.Count == 0) return; bool dim = !vm.IsObjectsMode; + bool showInfo = vm.ShowItemInfo && vm.IsObjectsMode; + + // Overlap detection for the info labels: a value is "overlapping" when + // it appears on more than one object. TypeID is scoped per type (the + // 0,1,2… sequence restarts for each object type); the rest are global. + Dictionary? globalIdCounts = null, customIdCounts = null, nameCounts = null; + Dictionary<(int, string), int>? typeIdCounts = null; + if (showInfo) + { + globalIdCounts = new(); customIdCounts = new(); nameCounts = new(); + typeIdCounts = new(); + foreach (var o in vm.Objects) + { + globalIdCounts[o.GlobalId] = globalIdCounts.GetValueOrDefault(o.GlobalId) + 1; + customIdCounts[o.CustomId] = customIdCounts.GetValueOrDefault(o.CustomId) + 1; + nameCounts[o.Name] = nameCounts.GetValueOrDefault(o.Name) + 1; + var tk = (o.Slot, o.TypeId); + typeIdCounts[tk] = typeIdCounts.GetValueOrDefault(tk) + 1; + } + } + for (int i = 0; i < vm.Objects.Count; i++) { var o = vm.Objects[i]; @@ -2036,6 +2057,56 @@ static void DrawObjects(DrawingContext ctx, MainWindowViewModel vm, Point origin }, screen, iconR + 5, iconR + 5); } + + if (showInfo) + { + var lines = new (string Text, bool Overlap)[] + { + ($"G: {o.GlobalId}", globalIdCounts![o.GlobalId] > 1), + ($"T: {o.TypeId}", typeIdCounts![(o.Slot, o.TypeId)] > 1), + (o.CustomId, customIdCounts![o.CustomId] > 1), + (o.Name, nameCounts![o.Name] > 1), + }; + DrawObjectInfoLabel(ctx, typeface, lines, + new Point(screen.X + iconR + 4, screen.Y)); + } + } + } + + /// Renders an object's identity fields as a small stacked label. + /// Each line is green by default, red when its value overlaps another + /// object. A faint backing panel keeps the text legible over the grid. + static void DrawObjectInfoLabel( + DrawingContext ctx, Typeface typeface, (string Text, bool Overlap)[] lines, Point anchor) + { + const double fontSize = 9.0; + var ok = new SolidColorBrush(Color.FromRgb(0x2E, 0x7D, 0x32)); + var bad = new SolidColorBrush(Color.FromRgb(0xC8, 0x28, 0x28)); + + var fmts = new FormattedText[lines.Length]; + double maxW = 0, totalH = 0; + for (int i = 0; i < lines.Length; i++) + { + fmts[i] = new FormattedText(lines[i].Text, + System.Globalization.CultureInfo.InvariantCulture, FlowDirection.LeftToRight, + typeface, fontSize, lines[i].Overlap ? bad : ok); + maxW = System.Math.Max(maxW, fmts[i].Width); + totalH += fmts[i].Height; + } + const double padX = 3.0, padY = 2.0; + // Centre the panel vertically on the object's icon. + var panel = new Rect( + anchor.X, anchor.Y - totalH * 0.5 - padY, + maxW + padX * 2, totalH + padY * 2); + ctx.DrawRectangle( + new SolidColorBrush(Color.FromArgb(225, 250, 250, 250)), + new Pen(new SolidColorBrush(Color.FromArgb(225, 200, 200, 205)), 0.8), + panel, 2, 2); + double y = panel.Y + padY; + for (int i = 0; i < fmts.Length; i++) + { + ctx.DrawText(fmts[i], new Point(panel.X + padX, y)); + y += fmts[i].Height; } } diff --git a/src/OpenApparatus.Studio/Views/ObjectTypesPanel.axaml.cs b/src/OpenApparatus.Studio/Views/ObjectTypesPanel.axaml.cs index d98bd01..7cb1406 100644 --- a/src/OpenApparatus.Studio/Views/ObjectTypesPanel.axaml.cs +++ b/src/OpenApparatus.Studio/Views/ObjectTypesPanel.axaml.cs @@ -75,6 +75,22 @@ void Rebuild() for (int i = 0; i < _vm.ObjectTypes.Count; i++) Body.Children.Add(TypeRow(i, _vm.ObjectTypes[i])); + // Item-info toggle — draws each object's GlobalID / TypeID / CustomID / + // Name beside its icon on the canvas. Overlapping values render red. + var infoToggle = new CheckBox + { + Content = "Show item info", + IsChecked = _vm.ShowItemInfo, + Margin = new Thickness(0, 10, 0, 0), + }; + ToolTip.SetTip(infoToggle, + "Display each object's IDs and name beside it. Overlapping values are red."); + infoToggle.IsCheckedChanged += (_, _) => + { + if (_vm is not null) _vm.ShowItemInfo = infoToggle.IsChecked == true; + }; + Body.Children.Add(infoToggle); + var addBtn = new Button { Content = "+ Add type", diff --git a/src/OpenApparatus.Studio/Views/RoomEditorPanel.axaml.cs b/src/OpenApparatus.Studio/Views/RoomEditorPanel.axaml.cs index 6963a01..aa9582d 100644 --- a/src/OpenApparatus.Studio/Views/RoomEditorPanel.axaml.cs +++ b/src/OpenApparatus.Studio/Views/RoomEditorPanel.axaml.cs @@ -511,6 +511,16 @@ void AddObjectEditorControls(StackPanel host, int idx) Margin = new Thickness(0, 0, 0, 6), }); + // Identity fields — all user-editable, none uniqueness-enforced. + host.Children.Add(BuildObjectTextRow("Global ID", sel.GlobalId, + v => MutateSelectedObject(o => o.GlobalId = v))); + host.Children.Add(BuildObjectTextRow("Type ID", sel.TypeId, + v => MutateSelectedObject(o => o.TypeId = v))); + host.Children.Add(BuildObjectTextRow("Custom ID", sel.CustomId, + v => MutateSelectedObject(o => o.CustomId = v))); + host.Children.Add(BuildObjectTextRow("Name", sel.Name, + v => MutateSelectedObject(o => o.Name = v))); + // Position — single horizontal row with X/Y/Z side-by-side, each // labelled in its axis colour (Unity / Blender / Houdini convention: // red X, green Y, blue Z). Reads as one transform unit instead of @@ -568,6 +578,45 @@ void MutateSelectedObject(System.Action change) _vm!.OnEditedSelectedObject(); } + /// A labelled single-line text editor for an object identity + /// field. Commits on LostFocus / Enter only — committing per keystroke + /// would bump EditVersion and rebuild the panel mid-edit. + Control BuildObjectTextRow(string label, string value, System.Action commit) + { + var holder = new StackPanel { Margin = new Thickness(0, 0, 0, 6) }; + holder.Children.Add(new TextBlock + { + Text = label, + FontSize = 11, + Foreground = TextSecondary, + Margin = new Thickness(0, 0, 0, 2), + }); + var box = new TextBox + { + Text = value, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + string lastCommitted = value; + void Commit() + { + var t = box.Text ?? string.Empty; + if (t == lastCommitted) return; + lastCommitted = t; + commit(t); + } + box.LostFocus += (_, _) => Commit(); + box.KeyDown += (_, e) => + { + if (e.Key == Avalonia.Input.Key.Enter) + { + Commit(); + e.Handled = true; + } + }; + holder.Children.Add(box); + return holder; + } + Control TypeRow(int index, ObjectType type) { // 30×30 swatch button — clicking opens the shape/color picker.