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.