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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/OpenApparatus.Studio/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,13 @@ partial void OnEditModeChanged(EditModeKind value)
public bool IsObjectsMode => EditMode == EditModeKind.Object;
public bool IsLayoutMode => EditMode == EditModeKind.Layout;

/// <summary>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.</summary>
[ObservableProperty] bool _showItemInfo;

partial void OnShowItemInfoChanged(bool value) => EditVersion++;

/// <summary>Whether the keyboard-shortcut help overlay is showing. F1
/// toggles, Esc dismisses (handled in MainWindow code-behind).</summary>
[ObservableProperty] bool _isShortcutOverlayVisible;
Expand Down Expand Up @@ -573,6 +580,28 @@ public void OnEditedObjectType()
EditVersion++;
}

/// <summary>Next default <see cref="RoomObject.GlobalId"/> — the largest
/// numeric GlobalId already in use plus one (0 when none parse). Non-numeric
/// user-edited ids are ignored; uniqueness is not enforced.</summary>
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;
}

/// <summary>Next default <see cref="RoomObject.TypeId"/> for objects of
/// <paramref name="slot"/> — the largest numeric TypeId among same-slot
/// objects plus one (0 when none parse).</summary>
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;
}

/// <summary>Sub-cell side length in metres at the current subdivision.</summary>
public float SubCellSize => TileSize / System.Math.Max(1, GridSubdivision);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<int, int>();
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.
Expand Down
75 changes: 73 additions & 2 deletions src/OpenApparatus.Studio/Views/GridEditorView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, int>? 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];
Expand Down Expand Up @@ -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));
}
}
}

/// <summary>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.</summary>
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;
}
}

Expand Down
16 changes: 16 additions & 0 deletions src/OpenApparatus.Studio/Views/ObjectTypesPanel.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 49 additions & 0 deletions src/OpenApparatus.Studio/Views/RoomEditorPanel.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -568,6 +578,45 @@ void MutateSelectedObject(System.Action<RoomObject> change)
_vm!.OnEditedSelectedObject();
}

/// <summary>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.</summary>
Control BuildObjectTextRow(string label, string value, System.Action<string> 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.
Expand Down