Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a0d04a0
Refactor IEveSpaceObjectChild interface into a base class
filipppavlov Jul 17, 2026
cf19ad9
Add the important files
filipppavlov Jul 20, 2026
ce89584
Format files
filipppavlov Jul 20, 2026
28e6890
Use property to access the part tag
filipppavlov Jul 20, 2026
3173b93
Potential fix for pull request finding
filipppavlov Jul 20, 2026
9384303
Refactor IEveSpaceObjectChild interface into a base class
filipppavlov Jul 17, 2026
98f1653
Add the important files
filipppavlov Jul 20, 2026
d125679
Format files
filipppavlov Jul 20, 2026
6fc4d0e
Use property to access the part tag
filipppavlov Jul 20, 2026
5d1cfe0
Potential fix for pull request finding
filipppavlov Jul 20, 2026
8d7e70e
Rebase to main
filipppavlov Jul 22, 2026
8d7558c
Merge branch 'space-object-child-base-class' of https://github.com/ca…
filipppavlov Jul 22, 2026
af391b2
Modular object building wip
filipppavlov Jul 24, 2026
dad3a52
Transform locators
filipppavlov Jul 24, 2026
b09f06f
Merge remote-tracking branch 'origin/main' into PLAT-11607-modular-sh…
Ikreb1 Aug 5, 2026
2ea14a2
Merge remote-tracking branch 'origin/main' into PLAT-11607-modular-sh…
Ikreb1 Aug 5, 2026
7b37392
unregister child assert allows get parent nullptr as well
Ikreb1 Aug 6, 2026
5c7fd6c
add invalid part tag for error messages
Ikreb1 Aug 7, 2026
6137ec7
keep locator order on remove
Ikreb1 Aug 12, 2026
8588595
build shapeEllipsoid on modifier deletion like how boundingSphere was…
Ikreb1 Aug 12, 2026
4aa827e
apply boundingsphere transform correctly on SetTransform
Ikreb1 Aug 12, 2026
ed89d23
Set the part tag for the child when adding children
Ikreb1 Aug 13, 2026
7f52c36
Fix issue where boundingsphere that fully encapsulates a smaller boun…
Ikreb1 Aug 13, 2026
d8b67a4
Merge branch 'main' into PLAT-11607-modular-ship-assembly
Ikreb1 Aug 14, 2026
6f2ab51
handle layout part tag range support and fix issue with damage locato…
Ikreb1 Aug 18, 2026
42e4e49
apply feedback from copilot review
Ikreb1 Aug 18, 2026
38f59ae
Update trinity/Eve/SpaceObject/Utils/EveLocatorSets.cpp
Ikreb1 Aug 18, 2026
d4aef48
expland blue class macro to fix clang formatting
Ikreb1 Aug 18, 2026
423bc8f
remove range based part tag for modular
Ikreb1 Aug 19, 2026
7043aa3
Add documentation to modular ship assembly
Ikreb1 Aug 20, 2026
9c0e870
Update trinity/Eve/SpaceObject/Children/EveChildPartData_Blue.cpp
Ikreb1 Aug 20, 2026
4651fb4
Add more documentation to modular ship assembly and separate EveModul…
Ikreb1 Aug 20, 2026
50e8f0b
fixup comment
Ikreb1 Aug 20, 2026
b4c4c4d
Merge remote-tracking branch 'origin/main' into PLAT-11607-modular-sh…
Ikreb1 Aug 21, 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
108 changes: 108 additions & 0 deletions doc/source/eve/modular-assembly.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
:orphan:

Modular ship assembly
=====================

A modular space object is a single ``EveSpaceObject2`` assembled at runtime from multiple SOF
hulls ("parts"), created via ``CreateModularObject`` and edited through the transient
``EveModularObjectModifier`` session object. Persistent per-part state lives in
``EveChildPartData``, an effect child on the object itself, so a saved or handed-off object
carries everything needed to reopen an edit session with ``ModifyModularObject``.

This page only describes the cross-cutting flow that no single file shows. API contracts live
with the API: the headers (``EveModularObjectModifier.h``, ``EveChildPartData.h``, ``EveSOF.h``) and the python docstrings on
``trinity.CreateModularObject`` and the modifier methods. Beyond the usage example below,
values and signatures are deliberately not repeated here.

Python example
--------------

Condensed from ``packages/trinity/tests/test_modular.py``, which exercises the full API and is
the authoritative reference for behavior. The modifier edits the object immediately, but
culling bounds are only committed by ``ApplyBounds``, or by dropping the last reference to the
modifier, which the example relies on.

.. code-block:: python

import trinity

IDENTITY_ROT = (0, 0, 0, 1)
UNIT_SCALE = (1, 1, 1)

sof = trinity.EveSOF()
sof.dataMgr.LoadData('res:/dx9/model/spaceobjectfactory/data.red')

# Create an empty modular object. The faction/race arguments seed the
# defaults used when AddHull is passed empty strings.
ship, modifier = trinity.CreateModularObject(sof, 'somefaction', 'somerace')

core = modifier.AddHull('some_hull', '', '', (0, 0, 0), IDENTITY_ROT, UNIT_SCALE)
wing = modifier.AddHull('other_hull', 'somefaction', 'somerace',
(30, 0, 0), IDENTITY_ROT, UNIT_SCALE)
if wing == trinity.GetInvalidPartTag():
raise RuntimeError('hull failed to build')

# Non-SOF parts come from a space object child resource.
beacon = modifier.AddChild('res:/model/somechild.red', (0, 50, 0), IDENTITY_ROT, UNIT_SCALE)

modifier.SetTransform(wing, (-30, 0, 0), IDENTITY_ROT, UNIT_SCALE)
modifier.Remove(beacon) # KeyError on an unknown or already-removed tag

del modifier # last reference dropped: bounding sphere and shape ellipsoid commit here

# Part tags stay valid across sessions: EveChildPartData persists them on the
# object, so a saved/reloaded object reopens the same way.
modifier = trinity.ModifyModularObject(ship, sof)
assert modifier.GetPosition(wing) == (-30, 0, 0)
del modifier

Part-tag propagation
--------------------

A part tag (``EveSpaceObjectChild::PartTag``, sentinels documented in ``EveSpaceObjectChild.h``
and ``EveModularObjectModifier.h``) identifies everything belonging to one part. It flows:

1. **Allocation**: ``EveModularObjectModifier::AllocatePartId`` (``EveModularObjectModifier.cpp``) takes
the max over ``EveChildPartData::GetUnusedPartID`` and the tags of existing effect children.
2. **SOF build**: ``EveSOF::BuildChild`` (``EveSOF.cpp``) stamps the tag on every container and
child it creates; nested layout placements flow it through ``EveSOF::SetupLayout`` /
``EveSOF::CreatePlacement``.
3. **Locators**: ``EveSOF::SetupLocatorSets`` stamps ``partTag`` on each generated locator
(``EveSOFDataMgr::LocatorDirectionData`` converts to ``Locator`` preserving it), then merges
into the object via ``EveSpaceObject2::MergeToLocatorSet``. The merged view built by
``EveSpaceObject2::EnsureChildLocatorMerged`` preserves per-locator tags.
4. **Mesh instances**: instanced meshes are shared across parts, so the tag is per *instance*,
not per child. Each ``EveChildInstancedMeshes::Mesh`` carries a ``partTags`` vector parallel
to the instance data (written in ``AddMesh``, consumed by ``RemoveInstancesByPartTag``). The
child's own ``m_partTag`` is meaningless for instanced meshes.
5. **Effect children**: ``EveSpaceObjectChild::SetPartTag`` propagates through container
overrides (``EveChildContainer::SetPartTag`` etc.), and ``EveSpaceObjectChild::RegisterChild``
copies the parent's tag onto newly attached children.

Locator lifecycle during editing
--------------------------------

- **AddHull**: locators from every hull merge into the object's sets *by set name*, with no
renaming or prefixing (``EveSpaceObject2::MergeToLocatorSet`` appends to an existing same-named
set). Parts are distinguishable within a set only by ``partTag``.
- **Remove**: locators are stripped from every set by exact ``partTag`` match, mesh instances via
``RemoveInstancesByPartTag``, effect children by tag; accumulated impact damage is cleared.
- **SetTransform**: locators of the part are re-derived in place (position through
inverse-old-transform then new-transform, direction and scale by delta), and the part's stored
bounding sphere is re-transformed the same way. See
``EveModularObjectModifier::SetTransform`` (``EveModularObjectModifier.cpp``).
- **Damage locators / impact overlay**: the impact overlay allocates per-damage-locator slots, so
its count must track the merged ``DAMAGE_LOCATOR_SET_NAME`` locator set. ``UpdateImpactOverlayLocatorCount``
re-syncs it after AddHull/Remove; a stale count would index locators that no longer exist.
- Any structural edit calls ``EveSpaceObject2::InvalidateMergedLocators`` so the merged view is
rebuilt lazily.

Gotchas
-------

- A modular object with zero parts (or before ``ApplyBounds``/modifier destruction ever ran) has
a zero-radius bounding sphere: ``EveSpaceObject2::UpdateVisibility`` skips the mesh-visibility
test and ``EveSpaceObject2::IsVisible`` culls it at any distance, so it never renders.
- Culling volumes are only pushed to the object by ``EveModularObjectModifier::ApplyBounds`` (the
destructor calls it too). Editing without applying leaves the object rendering with stale
bounds.
6 changes: 6 additions & 0 deletions trinity/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,9 @@ set(_SOURCES
Eve/SpaceObject/Children/EveChildMesh.cpp
Eve/SpaceObject/Children/EveChildMesh.h
Eve/SpaceObject/Children/EveChildMesh_Blue.cpp
Eve/SpaceObject/Children/EveChildPartData.cpp
Eve/SpaceObject/Children/EveChildPartData.h
Eve/SpaceObject/Children/EveChildPartData_Blue.cpp
Eve/SpaceObject/Children/EveChildParticleSphere.cpp
Eve/SpaceObject/Children/EveChildParticleSphere.h
Eve/SpaceObject/Children/EveChildParticleSphere_Blue.cpp
Expand All @@ -575,6 +578,9 @@ set(_SOURCES
Eve/SpaceObject/Children/EveCloudEditableVolume.cpp
Eve/SpaceObject/Children/EveCloudEditableVolume.h
Eve/SpaceObject/Children/EveCloudEditableVolume_Blue.cpp
Eve/SpaceObject/Children/EveModularObjectModifier.cpp
Eve/SpaceObject/Children/EveModularObjectModifier.h
Eve/SpaceObject/Children/EveModularObjectModifier_Blue.cpp
Eve/SpaceObject/Children/EveSpaceObjectChild.cpp
Eve/SpaceObject/Children/EveSpaceObjectChild.h
Eve/SpaceObject/Children/EveSpaceObjectChild_Blue.cpp
Expand Down
23 changes: 20 additions & 3 deletions trinity/Eve/EveInstancedMeshManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,32 @@ class EveInstancedMeshManager
DataHandle& operator=( const DataHandle& ) = delete;
DataHandle( DataHandle&& other ) noexcept
{
owner = other.owner;
index = other.index;
if( owner )
{
owner->ReplaceHandle( this, &other );
owner->ReplaceHandle( &other, this );
Comment thread
Ikreb1 marked this conversation as resolved.
}
owner = other.owner;
index = other.index;
other.owner = nullptr;
other.index = InvalidIndex;
}
DataHandle& operator=( DataHandle&& other ) noexcept
{
if( this != &other )
{
CCP_ASSERT( !*this );
owner = other.owner;
index = other.index;
if( owner )
{
owner->ReplaceHandle( &other, this );
}
other.owner = nullptr;
other.index = InvalidIndex;
}
return *this;
}


operator bool() const
{
Expand Down
137 changes: 136 additions & 1 deletion trinity/Eve/SpaceObject/Children/EveChildInstancedMeshes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -399,13 +399,80 @@ void EveChildInstancedMeshes::AddMesh(
const Matrix* instanceTransforms,
size_t count,
const BlueSharedString& sofHullName,
const BlueSharedString& sofLocatorSetName )
const BlueSharedString& sofLocatorSetName,
EveSpaceObjectChild::PartTag partTag )
{
if( areaCount == 0 || count == 0 )
{
return;
}

for( auto& mesh : m_meshes )
{
if( mesh.geometryPath != geometryPath || mesh.meshIndex != meshIndex )
{
continue;
}
if( mesh.flags.GetCastsShadow() != castsShadow || mesh.reflectionMode != reflectionMode )
{
continue;
}
if( mesh.areas.size() != areaCount )
{
continue;
}
bool areasEqual = true;
for( size_t i = 0; i < areaCount; ++i )
{
if( strcmp( mesh.areas[i].effect->GetEffectPathName(), areas[i].effect->GetEffectPathName() ) != 0 || mesh.areas[i].batchType != areas[i].batchType || mesh.areas[i].areaIndex != areas[i].areaIndex || mesh.areas[i].areaCount != areas[i].areaCount )
{
areasEqual = false;
break;
}
if( mesh.areas[i].effectHash != areas[i].effect->GetHashValue() )
{
areasEqual = false;
break;
}
}
if( !areasEqual )
{
continue;
}
if( !( mesh.sofHullName == sofHullName && mesh.sofLocatorSetName == sofLocatorSetName ) )
{
continue;
}
const size_t existingCount = mesh.instances.size();
mesh.instances.reserve( existingCount + count );
mesh.partTags.reserve( mesh.partTags.size() + count );
for( size_t i = 0; i < count; ++i )
{
EveInstancedMeshManager::StaticPerInstanceData instanceData;
auto& mat = instanceTransforms[i];
instanceData.worldTransform[0] = Vector4( mat._11, mat._21, mat._31, mat._41 );
instanceData.worldTransform[1] = Vector4( mat._12, mat._22, mat._32, mat._42 );
instanceData.worldTransform[2] = Vector4( mat._13, mat._23, mat._33, mat._43 );
instanceData.sphereIndex = static_cast<uint32_t>( existingCount + i );
mesh.instances.push_back( instanceData );
mesh.partTags.push_back( partTag );
}
mesh.instanceSpheres.resize( mesh.instances.size() );
if( mesh.sphereHandle )
{
mesh.sphereHandle.owner->RemoveBoundingSphereGroup( mesh.sphereHandle );
}
for( auto& area : mesh.areas )
{
if( area.meshGroupHandle )
{
area.meshGroupHandle.owner->RemoveMeshGroup( area.meshGroupHandle );
}
}
m_allRegistered = false;
return;
}

Mesh& mesh = m_meshes.emplace_back();
mesh.geometryPath = geometryPath;
mesh.reflectionMode = reflectionMode;
Expand All @@ -420,6 +487,7 @@ void EveChildInstancedMeshes::AddMesh(
a.effectHash = a.effect ? a.effect->GetHashValue() : 0;
}
mesh.instances.reserve( count );
mesh.partTags.reserve( count );
for( size_t i = 0; i < count; ++i )
{
EveInstancedMeshManager::StaticPerInstanceData instanceData;
Expand All @@ -429,6 +497,7 @@ void EveChildInstancedMeshes::AddMesh(
instanceData.worldTransform[2] = Vector4( mat._13, mat._23, mat._33, mat._43 );
instanceData.sphereIndex = static_cast<uint32_t>( i );
mesh.instances.push_back( instanceData );
mesh.partTags.push_back( partTag );
}
mesh.instanceSpheres.resize( count );
BeResMan->GetResource( mesh.geometryPath, "", mesh.geometry );
Expand Down Expand Up @@ -458,6 +527,72 @@ void EveChildInstancedMeshes::AddMesh(
m_allRegistered = false;
}

void EveChildInstancedMeshes::RemoveInstancesByPartTag( EveSpaceObjectChild::PartTag partTag )
{
for( size_t i = 0; i < m_meshes.size(); ++i )
{
auto& mesh = m_meshes[i];

auto newEnd = std::remove_if( begin( mesh.instances ), end( mesh.instances ), [&]( const EveInstancedMeshManager::StaticPerInstanceData& instance ) {
return mesh.partTags[&instance - mesh.instances.data()] == partTag;
} );
bool removed = newEnd != end( mesh.instances );
if( !removed )
{
continue;
}
if( newEnd == begin( mesh.instances ) )
{
if( mesh.sphereHandle )
{
mesh.sphereHandle.owner->RemoveBoundingSphereGroup( mesh.sphereHandle );
}
for( auto& area : mesh.areas )
{
if( area.meshGroupHandle )
{
area.meshGroupHandle.owner->RemoveMeshGroup( area.meshGroupHandle );
}
}
TriGeometryResPtr geometry = mesh.geometry;
std::swap( mesh, m_meshes.back() );
auto seenMesh = find_if( begin( m_meshes ), end( m_meshes ) - 1, [&]( const Mesh& m ) { return m.geometry == geometry; } );
if( geometry && seenMesh == end( m_meshes ) - 1 )
{
geometry->RemoveNotifyTarget( this );
}
m_meshes.pop_back();
--i;
continue;
}
mesh.instances.erase( newEnd, end( mesh.instances ) );
auto newTagEnd = std::remove_if( begin( mesh.partTags ), end( mesh.partTags ), [&]( uint32_t tag ) {
return tag == partTag;
} );
mesh.partTags.erase( newTagEnd, end( mesh.partTags ) );
for( auto& instance : mesh.instances )
{
instance.sphereIndex = static_cast<uint32_t>( &instance - mesh.instances.data() );
}
if( removed )
{
m_allRegistered = false;
mesh.instanceSpheres.resize( mesh.instances.size() );
if( mesh.sphereHandle )
{
mesh.sphereHandle.owner->RemoveBoundingSphereGroup( mesh.sphereHandle );
}
for( auto& area : mesh.areas )
{
if( area.meshGroupHandle )
{
area.meshGroupHandle.owner->RemoveMeshGroup( area.meshGroupHandle );
}
}
}
}
}

void EveChildInstancedMeshes::ReleaseCachedData( BlueAsyncRes* p )
{
}
Expand Down
6 changes: 5 additions & 1 deletion trinity/Eve/SpaceObject/Children/EveChildInstancedMeshes.h
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ BLUE_CLASS( EveChildInstancedMeshes ) :
const Matrix* instanceTransforms,
size_t count,
const BlueSharedString& sofHullName,
const BlueSharedString& sofLocatorSetName );
const BlueSharedString& sofLocatorSetName,
EveSpaceObjectChild::PartTag partTag = EveSpaceObjectChild::NO_PART_TAG );

void RemoveInstancesByPartTag( EveSpaceObjectChild::PartTag partTag );

BluePy GetSofSourceLocator( uint32_t areaId ) const;
uint32_t GetMeshCount() const;
Expand Down Expand Up @@ -134,6 +137,7 @@ BLUE_CLASS( EveChildInstancedMeshes ) :

std::vector<EveInstancedMeshManager::StaticPerInstanceData> instances;
std::vector<CcpMath::Sphere> instanceSpheres;
std::vector<uint32_t> partTags;

EveInstancedMeshManager::BoundingSphereHandle sphereHandle;

Expand Down
17 changes: 17 additions & 0 deletions trinity/Eve/SpaceObject/Children/EveChildPartData.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright © 2026 CCP ehf.

#include "StdAfx.h"
#include "EveChildPartData.h"
#include <numeric>


EveChildPartData::EveChildPartData( IRoot* )
{
}

EveSpaceObjectChild::PartTag EveChildPartData::GetUnusedPartID() const
{
return std::accumulate( m_parts.begin(), m_parts.end(), 1u, []( EveSpaceObjectChild::PartTag maxId, const PartData& part ) {
return std::max( maxId, part.partId + 1 );
} );
}
Loading