Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
151 commits
Select commit Hold shift + click to select a range
ac360bf
feat: preview a material on the context mesh slot in the Material Editor
raighen Oct 1, 2026
cbb2140
feat: turn a material made in Blender into a game material on push
raighen Oct 1, 2026
6ae4d23
fix: stop the Material Editor throwing while its XAML loads
raighen Oct 1, 2026
1a67f78
fix: live-test the Blender material push and close what it found
raighen Oct 1, 2026
d563d98
fix: store large textures the way the game does, and stop Build losin…
raighen Oct 2, 2026
e214dbb
fix: give a created material the two fields the stock ones carry
raighen Oct 2, 2026
6dc713b
fix: rename a probe local that clashed with one further down
raighen Oct 2, 2026
326d7e1
feat: carry a Blender material's normal and specular maps into the game
raighen Oct 2, 2026
ecf8219
style: dotnet format the preview viewport's mesh builder
raighen Oct 2, 2026
32fde0e
feat(mcp): tools that drive the running map editor
raighen Oct 2, 2026
719bdfc
feat(mcp): blender_push, and scene_select with no names clears the se…
raighen Oct 2, 2026
aded881
fix(bridge): refuse a pushed mesh over 65535 vertices instead of writ…
raighen Oct 2, 2026
8ef1cad
feat: bring a light into a district that has none — actor import acro…
raighen Oct 2, 2026
553670a
Keep a light's inverse matrix and clip box in step with where it stands
raighen Oct 2, 2026
c6c56f8
Show and edit a mesh's draw distance in the property panel
raighen Oct 2, 2026
e69f1ad
fix: touch a light's row only when the light has moved
raighen Oct 2, 2026
382eccc
docs: the editor tools, the Blender material push and their limits
raighen Oct 2, 2026
306aa3d
feat: carry a Blender material's alpha into the game - cut-out and tr…
raighen Oct 2, 2026
feb66ba
feat(viewport): draw alpha-blended materials blended
raighen Oct 2, 2026
86637d7
feat: mirror a district's edits into its winter archive
raighen Oct 2, 2026
d159df1
feat: carry objects from one archive into another
raighen Oct 2, 2026
1f98071
feat: a Props tab to drag stock objects into a district
raighen Oct 2, 2026
4282d4b
feat: simplified hulls for imported scenery, and a props column
raighen Oct 2, 2026
9229e38
feat: an imported object's collision moves and is deleted with it
raighen Oct 2, 2026
745476e
feat: a duplicate of an imported object gets copies of its collision
raighen Oct 3, 2026
ea85311
feat: save drops geometry nothing draws; a re-import shares geometry
raighen Oct 3, 2026
fac7b32
feat(mcp): drive the resource editor; read and set car tuning
raighen Oct 3, 2026
b65132d
feat(cars): clone a car under a new name; a Build no longer drops XML
raighen Oct 3, 2026
5c637e1
feat(mcp): archive_build packs one archive's working copy
raighen Oct 3, 2026
549a223
fix: a rebuilt name table keeps the order it was loaded in
raighen Oct 3, 2026
04c0443
feat(cars): a clone gets buffers and a title of its own
raighen Oct 3, 2026
e99a0d2
Packer keeps shipped memory requirements; export a car as an M2O reso…
raighen Oct 3, 2026
ed6863f
feat(cars): build a car under another car's name
raighen Oct 3, 2026
34bf765
feat(cars): the M2O car export follows the map export's layout; docs
raighen Oct 3, 2026
8cdb1b5
fix(viewport): a mesh without normals is lit instead of drawn black
raighen Oct 3, 2026
1e012f5
fix: an imported object brings textures its own archive does not hold
raighen Oct 3, 2026
a4d1eaa
fix(viewport): crash props are drawn with their textures, not black
raighen Oct 3, 2026
c6fc323
fix: an imported object brings textures its own archive does not hold
raighen Oct 3, 2026
141f47e
feat: hide triangles of a stock mesh without rebuilding it
raighen Oct 3, 2026
a7f2a4e
feat(mcp): scene_find finds a collision placement by its hull
raighen Oct 3, 2026
4771cbb
perf(bridge): read a level's index width without decoding the mesh
raighen Oct 5, 2026
f12155b
fix(mcp): editor tools say no when the editor does, and find crash co…
raighen Oct 5, 2026
bc9d5b1
Merge branch 'pr/editor-tools' into pr/alpha-winter
raighen Oct 5, 2026
3696630
fix(mirror): the winter mirror decides against the pair as shipped, n…
raighen Oct 5, 2026
4b7e474
Merge branch 'pr/blender-material-push' into pr/editor-tools
raighen Oct 5, 2026
838c69a
fix(bridge): a material push that can be undone, refused cleanly and …
raighen Oct 5, 2026
f1c6508
Merge branch 'pr/editor-tools' into pr/alpha-winter
raighen Oct 5, 2026
a3b5f73
fix: the material preview blends what the scene blends; an alpha mask…
raighen Oct 5, 2026
f9ee859
Merge branch 'pr/alpha-winter' into pr/object-transplant
raighen Oct 5, 2026
79cd70f
Merge branch 'pr/object-transplant' into pr/cars
raighen Oct 5, 2026
b955fa6
test: the scripted editor answers the resource status the tools now a…
raighen Oct 5, 2026
33a95c5
Merge branch 'pr/cars' into pr/import-textures
raighen Oct 5, 2026
c4b33d4
fix(cars): a clone or a substitution that fails takes itself back; re…
raighen Oct 5, 2026
888c83e
Merge branch 'pr/object-transplant' into pr/cars
raighen Oct 5, 2026
56f5f8d
fix(import): a failed import leaves nothing behind; redo gets its sha…
raighen Oct 5, 2026
f36a23f
Merge branch 'pr/cars' into pr/import-textures
raighen Oct 5, 2026
d9a8055
Merge branch 'pr/object-transplant' into pr/cars
raighen Oct 5, 2026
e958337
test(import): the carried-again check asks for the textures, not for …
raighen Oct 5, 2026
d8ff05b
Merge branch 'pr/cars' into pr/import-textures
raighen Oct 5, 2026
f1dbae0
Merge branch 'pr/object-transplant' into pr/cars
raighen Oct 5, 2026
45236da
fix(import): the lesser findings of the self-review - carry, links, n…
raighen Oct 5, 2026
64b4e9b
fix(cars): the lesser findings of the self-review - target, notices, …
raighen Oct 5, 2026
b447370
Merge branch 'pr/cars' into pr/import-textures
raighen Oct 5, 2026
91722f3
fix(import): the texture index keeps up with the mirror
raighen Oct 5, 2026
1e49f44
fix(import): review of #6 - links by place, blocks shared only when a…
raighen Oct 5, 2026
9dfe25f
Merge branch 'pr/object-transplant' into pr/cars
raighen Oct 5, 2026
1fc6c8c
fix(cars): review of #7 - the scratch clone takes itself back, manife…
raighen Oct 5, 2026
82db24d
Merge branch 'pr/cars' into pr/import-textures
raighen Oct 5, 2026
da89b7d
fix(import): review of #9 - the carry tries every copy the index knows
raighen Oct 5, 2026
d91030d
fix(props): the picture cache of an earlier build is left alone
raighen Oct 5, 2026
b75e1aa
Merge branch 'pr/object-transplant' into pr/cars
raighen Oct 5, 2026
da11e6e
Merge branch 'pr/cars' into pr/import-textures
raighen Oct 5, 2026
8debb06
Merge branch 'pr/blender-material-push' into pr/editor-tools
raighen Oct 5, 2026
4034b0f
fix(bridge): what a second look at 838c69a found
raighen Oct 5, 2026
0ceb4ed
Merge branch 'pr/editor-tools' into pr/alpha-winter
raighen Oct 5, 2026
01973ba
fix(mcp): a layer is not switched off over unsaved edits; moves and i…
raighen Oct 5, 2026
7750e77
fix(mirror): a second look at 3696630 and a3b5f73
raighen Oct 5, 2026
1787070
Merge branch 'pr/alpha-winter' into pr/object-transplant
raighen Oct 5, 2026
d880b76
fix(import): a third pass over the collision links, the sweep and the…
raighen Oct 5, 2026
dc0844f
Merge branch 'pr/object-transplant' into pr/cars
raighen Oct 5, 2026
c236fc5
fix(cars): what a failed pack leaves, and a second look at the checks…
raighen Oct 5, 2026
7e2b3d7
Merge branch 'pr/cars' into pr/import-textures
raighen Oct 5, 2026
902583d
fix(bridge): the pre-pass refuses a material it cannot make; no empty…
raighen Oct 5, 2026
dca8b0a
Merge branch 'pr/blender-material-push' into pr/editor-tools
raighen Oct 5, 2026
e4a17f8
fix(mcp): the layer guard is about the crash layer only
raighen Oct 5, 2026
6478045
Merge branch 'pr/editor-tools' into pr/alpha-winter
raighen Oct 5, 2026
f11a72d
Merge branch 'pr/alpha-winter' into pr/object-transplant
raighen Oct 5, 2026
b2f3b79
fix(mirror): wearers that disagree, names that differ in case, an ent…
raighen Oct 5, 2026
c5ec22a
fix(import): the collision links once more - typed and pushed moves o…
raighen Oct 5, 2026
54e6c77
Merge branch 'pr/object-transplant' into pr/cars
raighen Oct 5, 2026
4006700
fix(cars): the export's check of an existing list asks each car for i…
raighen Oct 5, 2026
704e784
Merge branch 'pr/cars' into pr/import-textures
raighen Oct 5, 2026
1dec8c7
Merge branch 'pr/alpha-winter' into pr/object-transplant
raighen Oct 5, 2026
12d8193
fix(mirror): the same bytes are not written again for an entry that c…
raighen Oct 5, 2026
c576dfe
fix(import): a rename moves the collision record by whose it is, not …
raighen Oct 5, 2026
c323dd5
Merge branch 'pr/object-transplant' into pr/cars
raighen Oct 5, 2026
d5d8b24
Merge branch 'pr/cars' into pr/import-textures
raighen Oct 5, 2026
b0a8984
feat(bridge): a skinned mesh can gain geometry; two levels of one fra…
raighen Oct 6, 2026
23fba4d
fix(bridge): a person's mesh is not turned twice in Blender
raighen Oct 6, 2026
8026257
feat(browser): pictures of archives in the content browser
raighen Oct 6, 2026
c872409
feat(mcp): collision_unused_hulls and crash_placements
raighen Oct 6, 2026
8b1536f
Merge branch 'feat/object-transplant' into release/raighen
raighen Oct 6, 2026
49ef29e
docs: the README says what this release line is and names what it adds
raighen Oct 6, 2026
4a31c23
feat(mcp): load zones (zones_at, zones_map, zone_move_face) and mesh_…
raighen Oct 7, 2026
1b2d232
feat(ui): a Tools menu over the jobs only the MCP server could do
raighen Oct 7, 2026
fb36350
feat(ui): the Build window lists what goes into the game
raighen Oct 7, 2026
a6b57ad
docs: the README covers the Tools menu, the load-zone tools and the B…
raighen Oct 7, 2026
ca0a785
feat(viewport): loading zones are picked by a click and edited with t…
raighen Oct 7, 2026
1d92f66
fix(build): a deleted resource is listed, and a failed comparison end…
raighen Oct 7, 2026
0fea705
fix: hide triangles, slot re-pointing and the car tools after a review
raighen Oct 7, 2026
07b777e
docs: the README covers zone editing in the viewport
raighen Oct 7, 2026
10df3f5
docs: the README of the proposed branch drops the fork's own note
raighen Oct 7, 2026
8f4c914
merge: main, after #3 and #8 went in as single commits
raighen Oct 7, 2026
a44c40c
merge: main, after #3 and #8 went in as single commits
raighen Oct 7, 2026
73b49f8
merge: main, after #3 and #8 went in as single commits
raighen Oct 7, 2026
72f55ba
merge: main, after #3 and #8 went in as single commits
raighen Oct 7, 2026
312f620
merge: the stack below, now on main
raighen Oct 7, 2026
027de7d
merge: main, after #6 went in as a single commit
raighen Oct 7, 2026
c64319d
merge: main, after #6 went in as a single commit
raighen Oct 7, 2026
04fcdae
merge: main, after #6 went in as a single commit
raighen Oct 7, 2026
001e550
merge: main, after #7 and #9 went in as single commits
raighen Oct 7, 2026
f1a4b39
fix: zone writes, the Build list and slot re-pointing after a second …
raighen Oct 7, 2026
ebfe45e
Merge branch 'release/raighen' into pr/tools-and-zones
raighen Oct 7, 2026
d0234e1
test: the hide-triangles probe names the frames that share a material…
raighen Oct 7, 2026
01aad5f
Merge branch 'release/raighen' into pr/tools-and-zones
raighen Oct 7, 2026
61bc4a1
fix(viewport): a resize while a district loads no longer ends the pro…
raighen Oct 8, 2026
ed1d964
feat(zones): a new loading zone can be made, and taken out again
raighen Oct 8, 2026
f737a2f
feat(viewport): a button that makes a loading zone, and a zone layer …
raighen Oct 8, 2026
9f40060
docs: the README covers making a loading zone and the new look of the…
raighen Oct 8, 2026
0c99606
fix(zones): what decides loading is the zone's name; review of zone c…
raighen Oct 8, 2026
276a81c
Merge branch 'release/raighen' into pr/tools-and-zones
raighen Oct 8, 2026
77389d7
Merge remote-tracking branch 'origin/main' into pr/tools-and-zones
raighen Oct 8, 2026
d8bbcf9
feat(tools): Hide triangles picks them by a click, and cuts a mesh th…
raighen Oct 8, 2026
8318111
feat(zones): added zones are marked, zone_create is an undo step, zon…
raighen Oct 8, 2026
40b5d7e
Merge branch 'release/raighen' into pr/tools-and-zones
raighen Oct 8, 2026
d5b5dab
fix(tools): a click that misses the mesh says why; tidy the probe runner
raighen Oct 8, 2026
21ba90d
Merge branch 'release/raighen' into pr/tools-and-zones
raighen Oct 8, 2026
01f0594
fix(tools): hiding triangles on shared and instanced meshes, after a …
raighen Oct 8, 2026
5586563
Merge branch 'release/raighen' into pr/tools-and-zones
raighen Oct 8, 2026
3ce3f83
style: the coarser-level test of FindPicked as a helper of its own
raighen Oct 8, 2026
984f2f0
Merge branch 'release/raighen' into pr/tools-and-zones
raighen Oct 8, 2026
946a2eb
Merge remote-tracking branch 'origin/main' into HEAD
raighen Oct 9, 2026
f717972
fix(materials): a copied material shares no arrays with its original
raighen Oct 9, 2026
2ac4d69
feat(cars): the light list of a car's prefab, and bones added to a rig
raighen Oct 9, 2026
a6491b5
feat(materials): what an archive needs registered, and a library of i…
raighen Oct 9, 2026
ee57178
feat(world): the places the game stands its interiors at (cityshops.bin)
raighen Oct 9, 2026
c1d484a
feat(import): scenery can arrive as the child of a frame
raighen Oct 9, 2026
f068591
feat(mcp): tools for interior places, archive materials and parented …
raighen Oct 9, 2026
0106cb3
fix(import): interiors open with their rooms shown; a heading turns a…
raighen Oct 9, 2026
4febeea
docs: the README lists the interior and archive material tools
raighen Oct 9, 2026
dccd221
Merge upstream main (#10 squash-merged; its content is already here)
raighen Oct 24, 2026
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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ only, no authorization - with its live status in the launcher's status bar. Poin
with `claude mcp add --transport http illusion http://127.0.0.1:2010/mcp`; change the port with
`McpPort` in settings.

It serves 86 tools. The file tools all read through the same format layer the editor uses, so what
It serves 92 tools. The file tools all read through the same format layer the editor uses, so what
a model is told about a file is what the toolkit itself sees; the editor tools drive the running
map editor itself.

Expand All @@ -306,14 +306,15 @@ map editor itself.
| **Archives** | `list_sds_files`, `open_sds_file`, `get_sds_header`, `list_resources`, `get_resource_info`, `search_resources`, `extract_resource`, `get_sds_stats`, `close_sds_file` |
| **Decoding** | `decode_resource` (extract + decode in one call), `decode_actors`, `decode_frame_resource`, `decode_itemdesc`, `decode_collisions` |
| **Scripts** | `decompile_script_resource`, `decompile_lua` - the game's compiled Lua back to source |
| **Materials** | `open_mtl_file`, `list_mtl_files`, `get_material_info`, `search_materials` |
| **Materials** | `open_mtl_file`, `list_mtl_files`, `get_material_info`, `search_materials`, `archive_materials` |
| **Textures** | `list_sds_textures`, `inspect_sds_texture`, `inspect_dds_file`, `inspect_dds_bytes` |
| **Tables** | `list_tables`, `dump_rows`, `lookup_by_row` |
| **Stream map** | `parse_stream_map`, `edit_stream_map` |
| **Effects** | `parse_effects_file`, `parse_effects_from_bytes` |
| **Utility** | `hash_fnv32`, `hash_fnv64`, `hash_batch`, `convert_number`, `detect_file_format`, `detect_format_from_bytes`, `list_game_files`, `get_configured_games`, `ping` |
| **Editor** | `editor_status`, `editor_list_areas`, `editor_open_area`, `editor_save`, `editor_build`, `editor_mirror_winter`, `editor_undo`, `editor_redo`, `editor_notices` |
| **Scene** | `scene_find`, `scene_select`, `scene_delete_selected`, `scene_duplicate_selected`, `object_move`, `object_properties`, `object_set_property`, `actor_import`, `object_import`, `mesh_hide_triangles`, `mesh_materials`, `collision_unused_hulls`, `crash_placements` |
| **Interiors** | `shop_places`, `shop_place_add`, `shop_place_delete`, `shop_create`, `shop_delete` |
| **Resource editor** | `editor_target`, `resource_list`, `resource_open`, `resource_status`, `car_tuning`, `car_tuning_set` |
| **Cars** | `car_clone`, `car_substitute`, `car_export_m2o`, `archive_build` |
| **Loading zones** | `zones_at`, `zones_map`, `zone_move_face`, `zone_create`, `zone_delete` |
Expand Down Expand Up @@ -350,6 +351,20 @@ district in the table or two - and inside one named `AREA902_FOOXBAR` he does no
editor open the new zone is a step of its history; `zone_delete` takes an added zone out again (a
zone the game ships with is refused). Zones are made in the base game's `city_univers` only.

**Interiors.** The game stands a shop, a diner or a flat at a place through three things: a marker
frame inside the interior's archive under `shops\`, a pair of box volumes in `city_univers` that
load it and let it go, and rows of `missions\SHOPS\cityshops.bin`. `shop_places` lists the
interiors and the places each stands at, `shop_place_add` stands one at another place and
`shop_place_delete` takes a place out. `shop_create` makes a new interior as a copy of an existing
one under its own name with its own row, and `shop_delete` removes it. A save is all or nothing.
`object_import` takes a parent frame and, like `actor_import`, works on the archive open in the
resource editor, which is how an interior is furnished and lit.

`archive_materials` tells each material an archive is drawn with as the game's own, changed or
added, with its definition in full and which of its textures the archive holds. The game's own are
an embedded list of the materials it ships with, so the answer is the same on any install.
`libraryTo` writes the added and changed ones as a material library of their own.

**Cars.** `car_clone` makes a new car out of an existing one for single player: a copy of its
archive (and the winter `_z` twin) with the root frame, name table, prefab entry, entity data and
geometry buffers filed under the new model name, registered in the vehicle, paint, cover-point and
Expand Down
151 changes: 143 additions & 8 deletions src/Illusion.Assets/Frames/FrameTransplant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ public sealed class TransplantedObject
internal readonly Dictionary<FrameObjectBase, FrameObjectBase> Copies = new(); // source → copy
internal FrameHeaderScene? Anchor;

/// <summary>The frame of the receiving scene the copy's root was hung under, when it was asked to be
/// someone's child; null for a root that stands in the scene by itself.</summary>
public FrameObjectBase? Under { get; internal set; }

/// <summary>The copy's root — what an actor places, or the scenery object itself.</summary>
public FrameObjectBase Root { get; internal set; } = null!;

Expand Down Expand Up @@ -206,7 +210,18 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) =>
/// </summary>
public static TransplantedObject? TryTransplant(ISceneDocument document, FrameResource source,
FrameObjectBase root, string name, Standing standing, Matrix4x4 world, ImportGeometry? shared,
out string? skipReason)
out string? skipReason) =>
TryTransplant(document, source, root, name, standing, world, shared, under: null, out skipReason);

/// <summary>
/// The same, with the copy's root hung under <paramref name="under"/> — a frame of the receiving scene —
/// instead of standing in the scene by itself: the shape an interior's furniture ships in, every piece a child
/// of the frame that carries the interior to its place. <paramref name="world"/> is still where the copy
/// stands in the world; its own matrix is worked out against the parent's. Scenery only.
/// </summary>
public static TransplantedObject? TryTransplant(ISceneDocument document, FrameResource source,
FrameObjectBase root, string name, Standing standing, Matrix4x4 world, ImportGeometry? shared,
FrameObjectBase? under, out string? skipReason)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(root);
Expand All @@ -223,6 +238,25 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) =>
return null;
}
if (!CanTransplant(root, out skipReason)) return null;
Matrix4x4 underInverse = Matrix4x4.Identity;
if (under != null)
{
if (standing != Standing.Scenery)
{
skipReason = "only scenery can be hung under a frame — an actor places its own object";
return null;
}
if (!resource.FrameObjects.TryGetValue(under.RefID, out object? held) || !ReferenceEquals(held, under))
{
skipReason = "the parent is not a frame of the receiving scene";
return null;
}
if (!Matrix4x4.Invert(under.WorldTransform, out underInverse))
{
skipReason = $"'{under.Name}' has a matrix that cannot be undone — nothing can be placed under it";
return null;
}
}
if (string.IsNullOrWhiteSpace(name) || resource.FrameObjects.Values.OfType<FrameObjectBase>()
.Any(o => string.Equals(o.Name.String, name, StringComparison.OrdinalIgnoreCase)))
{
Expand All @@ -244,7 +278,17 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) =>
var subtree = new List<FrameObjectBase>();
Collect(root, subtree, new HashSet<FrameObjectBase>());

var result = new TransplantedObject { Resource = resource, Adapter = adapter, Anchor = anchor };
var result = new TransplantedObject { Resource = resource, Adapter = adapter, Anchor = anchor, Under = under };
// What a child of `under` records in its second slot: the top of the parent's chain hangs off it — an
// object, or the scene folder that holds that top; nothing when the chain ends at a rootless frame.
FrameEntry? underAnchor = null;
if (under != null)
{
FrameObjectBase top = under;
var climbed = new HashSet<FrameObjectBase> { top };
while (top.Parent is { } next && climbed.Add(next)) top = next;
underAnchor = top.Root as FrameEntry ?? resource.FrameScenes.Values.FirstOrDefault(folder => folder.Children.Contains(top));
}
string unique = Guid.NewGuid().ToString("N")[..8];
var reused = new HashSet<ulong>(); // source buffers this copy draws from without copying them
if (!CopyBuffers(source, resource, subtree, name, unique, result, shared, reused,
Expand Down Expand Up @@ -295,20 +339,38 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) =>
FrameObjectBase copy = result.Copies[original];
if (ReferenceEquals(original, root))
{
result.Parents[copy] = (null, anchor);
// Under a frame: the parent in the first slot and, in the second, what the parent's own chain
// hangs off — the way a shipped interior's pieces name both the frame that carries them and
// the scene that frame stands in.
result.Parents[copy] = under != null ? (under, underAnchor) : (null, anchor);
continue;
}
// Inside the subtree a link follows its target's copy. A second-slot link that pointed OUTSIDE it
// named what the whole chain hangs off — the scene, or the top of the chain — and that is now
// whatever the root hangs off, or the root itself when it hangs off nothing.
FrameEntry? parent1 = Mapped(original.Parent, result.Copies);
FrameEntry? parent2 = original.Refs.ContainsKey(FrameEntryRefTypes.Parent2)
? Mapped(original.Root, result.Copies) ?? (FrameEntry?)anchor ?? result.Root
? Mapped(original.Root, result.Copies) ?? (under != null ? underAnchor : anchor) ?? result.Root
: null;
result.Parents[copy] = (parent1, parent2);
}

if (standing == Standing.Scenery)
if (under != null)
{
// A child is found through its parent: not on the spawn list, and a matrix of its own that is told
// against the parent's. The anchored-mesh bit as the shipped files have it on a child: set when the
// second slot names an object (a door's leaf under its frame), clear when it names the scene (an
// interior's furniture under its holder) or nothing.
result.Root.IsOnFrameTable = false;
if (result.Root is FrameObjectSingleMesh childMesh)
{
childMesh.SingleMeshFlags = underAnchor is FrameObjectBase
? childMesh.SingleMeshFlags | SingleMeshFlags.ParentIndex2_Flag
: childMesh.SingleMeshFlags & ~SingleMeshFlags.ParentIndex2_Flag;
}
result.Root.LocalTransform = world * underInverse;
}
else if (standing == Standing.Scenery)
{
// The stock drawable shape, as the bridge's object factory builds it: anchored through the
// second slot, on the spawn list, normal-season flags, and the anchored-mesh bit a mesh in that
Expand Down Expand Up @@ -356,16 +418,89 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) =>
/// shipped ones stand on it. Null when nothing in the subtree decodes.
/// </para>
/// </summary>
public static Vector3? BaseOf(FrameObjectBase root) =>
BoundsOf(root) is { } bounds
public static Vector3? BaseOf(FrameObjectBase root) => BaseOf(root, Quaternion.Identity);

// The same point with the root's space tilted first — the bottom of the object as it STANDS (see StandAt).
private static Vector3? BaseOf(FrameObjectBase root, Quaternion tilt) =>
BoundsOf(root, Matrix4x4.CreateFromQuaternion(tilt)) is { } bounds
? new Vector3((bounds.Min.X + bounds.Max.X) / 2f, (bounds.Min.Y + bounds.Max.Y) / 2f, bounds.Min.Z)
: null;

/// <summary>
/// A rotation as two: the tilt that stands the object the way it stands, and after it the heading — its
/// turn about the vertical axis. <c>rotation = Quaternion.Concatenate(Tilt, heading)</c>.
/// <para>
/// An object that is upright in its own space has no tilt and its rotation IS its heading. One that is not
/// — the chairs of Francesca's flat are modelled with their height along the mesh's y, 0.56 × 0.91 × 0.50
/// in their own box, and stood up by a quarter turn in the frame's matrix — keeps that quarter turn in the
/// tilt, whichever way it faces.
/// </para>
/// </summary>
public static (Quaternion Tilt, float Heading) SplitHeading(Quaternion rotation)
{
// The part of the rotation that is a turn about z. None can be told for an object standing exactly
// on its head: there every heading is the same rotation about some horizontal axis.
float length = MathF.Sqrt((rotation.Z * rotation.Z) + (rotation.W * rotation.W));
if (length < 1e-6f) return (rotation, 0f);
var twist = new Quaternion(0f, 0f, rotation.Z / length, rotation.W / length);
float heading = 2f * MathF.Atan2(twist.Z, twist.W);
return (Quaternion.Normalize(Quaternion.Concatenate(rotation, Quaternion.Conjugate(twist))), heading);
}

/// <summary>A rotation with its heading replaced and its tilt kept: what an asked heading makes of the
/// rotation an object has in its own archive. For an upright object that is the heading itself.</summary>
public static Quaternion WithHeading(Quaternion rotation, float yawRadians) =>
Quaternion.Concatenate(SplitHeading(rotation).Tilt, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRadians));

/// <summary>
/// The matrix that STANDS a piece of scenery on a point: tilted and scaled as it is in its own archive
/// (<paramref name="sourceWorld"/>), turned about the vertical axis the way it is there or — when a heading
/// is asked for — to that heading, and moved so that the middle of the bottom of its geometry is on
/// <paramref name="at"/>.
/// <para>
/// The heading replaces the object's heading and nothing else. Replacing its whole rotation, as this once
/// did, laid on its side everything that is not upright in its own space (see
/// <see cref="SplitHeading"/>). The bottom is likewise the bottom of the object as it stands — its
/// geometry with the tilt applied — and not of the box of its own space, of which such an object's bottom
/// is a side.
/// </para>
/// </summary>
/// <param name="yawRadians">The heading to give it; null keeps the one it has.</param>
public static Matrix4x4 StandAt(FrameObjectBase root, Matrix4x4 sourceWorld, Vector3 at, float? yawRadians)
{
// A matrix that is not rotation and scale (sheared, flattened) has no tilt to keep: it is taken as it
// is, and an asked heading is then all the rotation the copy gets.
bool split = TransformMath.TryDecompose(sourceWorld, out Vector3 scale, out Quaternion rotation, out _);
(Quaternion tilt, float heading) = split ? SplitHeading(rotation) : (Quaternion.Identity, 0f);

Matrix4x4 world = sourceWorld;
// What follows the tilt: the turn about the vertical axis and the scale.
Matrix4x4 standing = sourceWorld;
if (yawRadians is { } yaw)
{
Quaternion turn = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw);
world = TransformMath.Compose(Quaternion.Concatenate(tilt, turn), scale, Vector3.Zero);
standing = TransformMath.Compose(turn, scale, Vector3.Zero);
}
else if (split)
{
standing = TransformMath.Compose(
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, heading), scale, Vector3.Zero);
}

Vector3 standsOn = BaseOf(root, tilt) ?? Vector3.Zero;
world.Translation = at - Vector3.TransformNormal(standsOn, standing);
return world;
}

/// <summary>The box everything the subtree draws fits in, in its root's own space. Null when nothing decodes.</summary>
public static (Vector3 Min, Vector3 Max)? BoundsOf(FrameObjectBase root)
public static (Vector3 Min, Vector3 Max)? BoundsOf(FrameObjectBase root) => BoundsOf(root, Matrix4x4.Identity);

private static (Vector3 Min, Vector3 Max)? BoundsOf(FrameObjectBase root, Matrix4x4 then)
{
ArgumentNullException.ThrowIfNull(root);
if (!Matrix4x4.Invert(root.WorldTransform, out Matrix4x4 toRoot)) return null;
toRoot *= then;
Vector3 min = new(float.MaxValue), max = new(float.MinValue);
foreach ((Vector3[] positions, _) in TrianglesOf(root))
{
Expand Down
Loading
Loading