diff --git a/lua/acf_devtools/events/cfw_contraption_funcs.lua b/lua/acf_devtools/events/cfw_contraption_funcs.lua index a2a5f34..6e2fb84 100644 --- a/lua/acf_devtools/events/cfw_contraption_funcs.lua +++ b/lua/acf_devtools/events/cfw_contraption_funcs.lua @@ -2,10 +2,10 @@ local ACF_DevTools = ACF_DevTools local EventViewer = ACF_DevTools.EventViewer do - local Created = EventViewer.DefineEvent("CFW.Contraption.Created") - Created.Icon = "icon16/add.png" + local Init = EventViewer.DefineEvent("CFW.Contraption.Init") + Init.Icon = "icon16/add.png" - function Created.BuildNode(Node) + function Init.BuildNode() end end @@ -70,7 +70,7 @@ do local Removed = EventViewer.DefineEvent("CFW.Contraption.Removed") Removed.Icon = "icon16/cancel.png" - function Removed.BuildNode(Node) + function Removed.BuildNode() end end \ No newline at end of file diff --git a/lua/acf_devtools/events/cfw_family_funcs.lua b/lua/acf_devtools/events/cfw_family_funcs.lua new file mode 100644 index 0000000..75ffe35 --- /dev/null +++ b/lua/acf_devtools/events/cfw_family_funcs.lua @@ -0,0 +1,121 @@ +local ACF_DevTools = ACF_DevTools +local EventViewer = ACF_DevTools.EventViewer + +local function RenderEntities3D(...) + for i = 1, select("#", ...) do + local Entity = select(i, ...) + if not IsValid(Entity) then continue end + + local pulse = Lerp((math.sin(CurTime() * 7) + 1) / 2, 0.33, 1) + + render.SuppressEngineLighting(true) + render.ModelMaterialOverride(Material("models/debug/debugwhite")) + render.SetColorModulation(pulse, pulse, pulse) + + render.DepthRange(0, 0) + Entity:DrawModel() + render.DepthRange(0, 1) + + render.SetColorModulation(1, 1, 1) + render.ModelMaterialOverride(nil) + render.SuppressEngineLighting(false) + end +end + +do + local Init = EventViewer.DefineEvent("CFW.Family.Init") + Init.Icon = "icon16/group_add.png" + + function Init.BuildNode() + + end +end + +do + local EntityAdded = EventViewer.DefineEvent("CFW.Family.EntityAdded") + EntityAdded.Icon = "icon16/basket_put.png" + + function EntityAdded.BuildNode(Node, Entity) + EventViewer.AddKeyValueNode(Node, "Entity", Entity, "icon16/brick.png") + end + EntityAdded.Render3D = RenderEntities3D +end + +do + local EntityRemoved = EventViewer.DefineEvent("CFW.Family.EntityRemoved") + EntityRemoved.Icon = "icon16/basket_remove.png" + + function EntityRemoved.BuildNode(Node, Entity) + EventViewer.AddKeyValueNode(Node, "Entity", Entity, "icon16/brick.png") + end + EntityRemoved.Render3D = RenderEntities3D +end + +do + local Merged = EventViewer.DefineEvent("CFW.Family.Merged") + Merged.Icon = "icon16/arrow_merge.png" + + function Merged.BuildNode(Node, Other) + EventViewer.AddKeyValueNode(Node, "Merged Into", Other, "icon16/group.png") + end +end + +do + local Split = EventViewer.DefineEvent("CFW.Family.Split") + Split.Icon = "icon16/arrow_divide.png" + + function Split.BuildNode(Node, Other, Ancestor) + EventViewer.AddKeyValueNode(Node, "Split Into", Other, "icon16/group.png") + EventViewer.AddKeyValueNode(Node, "New Ancestor", Ancestor, "icon16/brick.png") + end + Split.Render3D = RenderEntities3D +end + +do + local AncestorRemoved = EventViewer.DefineEvent("CFW.Family.AncestorRemoved") + AncestorRemoved.Icon = "icon16/arrow_up.png" + + function AncestorRemoved.BuildNode(Node, Old, New) + EventViewer.AddKeyValueNode(Node, "Old Ancestor", Old, "icon16/brick.png") + EventViewer.AddKeyValueNode(Node, "New Ancestor", New, "icon16/brick.png") + end + AncestorRemoved.Render3D = RenderEntities3D +end + +do + local AncestorInserted = EventViewer.DefineEvent("CFW.Family.AncestorInserted") + AncestorInserted.Icon = "icon16/arrow_down.png" + + function AncestorInserted.BuildNode(Node, Old, New) + EventViewer.AddKeyValueNode(Node, "Old Ancestor", Old, "icon16/brick.png") + EventViewer.AddKeyValueNode(Node, "New Ancestor", New, "icon16/brick.png") + end + AncestorInserted.Render3D = RenderEntities3D +end + +do + local BecameRoot = EventViewer.DefineEvent("CFW.Family.BecameRoot") + BecameRoot.Icon = "icon16/shield.png" + + function BecameRoot.BuildNode() + + end +end + +do + local BecameSubFamily = EventViewer.DefineEvent("CFW.Family.BecameSubFamily") + BecameSubFamily.Icon = "icon16/shield_go.png" + + function BecameSubFamily.BuildNode() + + end +end + +do + local Removed = EventViewer.DefineEvent("CFW.Family.Removed") + Removed.Icon = "icon16/cancel.png" + + function Removed.BuildNode() + + end +end diff --git a/lua/cfw/classes/base_sv.lua b/lua/cfw/classes/base_sv.lua new file mode 100644 index 0000000..920c7f9 --- /dev/null +++ b/lua/cfw/classes/base_sv.lua @@ -0,0 +1,53 @@ +-- Base class shared by Contraption and Family. +-- Both track entities in an ents table, an entsbyclass map, and a count +-- The extensions also add mass tracking and other features + +CFW.Classes.EntityCollection = {} + +do + local BASE = CFW.Classes.EntityCollection + + BASE.__index = BASE + + -- Returns the set of tracked entities of the given class, or an empty table. + function BASE:EntitiesByClass(className) + return self.entsbyclass[className] or {} + end + + -- Returns true if at least one entity of the given class is tracked. + function BASE:ContainsClass(className) + local tracked = self.entsbyclass[className] + return tracked and next(tracked) ~= nil or false + end + + -- Registers entity in entsbyclass. + function BASE:AddByClass(entity) + local className = entity:GetClass() + self.entsbyclass[className] = self.entsbyclass[className] or {} + self.entsbyclass[className][entity] = true + end + + -- Removes entity from entsbyclass. + function BASE:RemoveByClass(entity) + local className = entity:GetClass() + local byClass = self.entsbyclass[className] + + if byClass then + byClass[entity] = nil + if not next(byClass) then + self.entsbyclass[className] = nil + end + end + end + + -- Bulk-inserts all entities from another collection's entsbyclass into this one. + function BASE:MergeByClass(other) + for className, ents in pairs(other.entsbyclass) do + self.entsbyclass[className] = self.entsbyclass[className] or {} + + for ent in pairs(ents) do + self.entsbyclass[className][ent] = true + end + end + end +end diff --git a/lua/cfw/classes/contraption_sv.lua b/lua/cfw/classes/contraption_sv.lua index fb9bfc4..22a48c5 100644 --- a/lua/cfw/classes/contraption_sv.lua +++ b/lua/cfw/classes/contraption_sv.lua @@ -1,132 +1,293 @@ --- Contraptions are an object to refer to a collection of connected entities - -CFW.Contraptions = {} +CFW.Contraptions = setmetatable({}, {__mode = 'k'}) CFW.Classes.Contraption = {} +local BASE = CFW.Classes.EntityCollection +local CLASS = CFW.Classes.Contraption + +setmetatable(CLASS, { __index = BASE }) +CLASS.__index = CLASS + function CFW.createContraption() local con = { - ents = {}, - entsbyclass = {}, - families = {}, - count = 0, - color = ColorRand(50, 255), - created = CurTime(), + ents = {}, -- All entities in this contraption + entsbyclass = {}, -- All entities by class (for fast class lookups) + count = 0, -- Number of entities in this contraption + color = ColorRand(50, 255), -- Random color for debug rendering + families = setmetatable({}, {__mode = 'k'}), -- All families in this contraption + physical = {}, -- Entities not parented to anything (family ancestors of root families, or non-family entities) } setmetatable(con, CFW.Classes.Contraption) - con:Init() + CFW.Contraptions[con] = true return con end -do -- Contraption getters and setters - local ENT = FindMetaTable("Entity") - local Entity_GetTable = ENT.GetTable - - -- MARCH 4/16/2026 - -- We're going to deprecate this function and remove it sometime within the next 3-4 months probably. - -- Appropriate announcement will be given out to potential consumers of the API soonish, and then I'll make this ErrorNoHaltWithStack - -- for a week or so, then remove it entirely. Use CFW_GetContraption as its replacement which is properly namespaced. - -- This function has caused headaches in other codebases (wiremod's cam controllers for example) and should've always been namedspaced... - +do -- MARK: External API + local ENT = FindMetaTable("Entity") function ENT:CFW_GetContraption() - local SelfTbl = Entity_GetTable(self) - if not SelfTbl then return nil end - return SelfTbl._contraption + return self._contraption end - - ENT.GetContraption = ENT.CFW_GetContraption end -do -- Class def - local CLASS = CFW.Classes.Contraption - - CLASS.__index = CLASS +-- MARK: Constrained pair +function CLASS:AddConstrainedPair(a, b) + local link = a._links and a._links[b] - function CLASS:Init() - CFW.Contraptions[self] = true - hook.Run("cfw.contraption.created", self) - end + if link then + if link.isParent then + -- Transforming from a parent to constraint - function CLASS:Merge(other) - for ent in pairs(other.ents) do - self:Add(ent) + link.isParent = false + else + link:Add() end + else + CFW.createLink(a, b, false) - other:Remove(self) + if not self.ents[a] then self:Add(a) end + if not self.ents[b] then self:Add(b) end end +end - function CLASS:Add(ent) - ent._contraption = self - self.ents[ent] = true - self.count = self.count + 1 - local className = ent:GetClass() - self.entsbyclass[className] = self.entsbyclass[className] or {} - self.entsbyclass[className][ent] = true +do -- MARK: Parented pair + local function createFamilyWithAncestor(contraption, ancestor) + local fam = CFW.createFamily(contraption, ancestor) + + hook.Run("cfw.family.init", fam) + + -- Add ancestor + fam.count = 1 + fam.ents[ancestor] = true + ancestor._family = fam + + -- Blocking entities can create new families on top of existing families + -- Theyre not physical tho, they're parented to something + if IsValid(ancestor:GetParent()) then + contraption.physical[ancestor] = nil + end + + fam:AddByClass(ancestor) - hook.Run("cfw.contraption.entityAdded", self, ent) + hook.Run("cfw.family.added", fam, ancestor) + + return fam end - function CLASS:Sub(ent) - ent._contraption = nil - self.ents[ent] = nil - self.count = self.count - 1 + -- Adds a parented pair to this contraption + function CLASS:AddParentedPair(child, parent) + local link = child._links and child._links[parent] + + if link then + if not link.isParent then + link.isParent = true + -- Fix up entA/entB to match the parent-link convention (entA=child, entB=parent). + -- The original constraint link may have entA/entB in arbitrary order. If we don't + -- fix this, Link:Remove will operate on the wrong entity: it will Sub the ancestor + -- out of the family instead of the child, and the child never gets its physical + -- status restored. + if link.entA ~= child or link.entB ~= parent then + link.entA, link.entB = child, parent + end + else + link:Add() + end + else + CFW.createLink(child, parent, true) + + if not self.ents[child] then self:Add(child) end + if not self.ents[parent] then self:Add(parent) end + end - local className = ent:GetClass() - local entsByClass = self.entsbyclass[className] + -- Blockers (e.g. turrets) root their own family instead of joining the parent's: + -- still parented, but they impede family propagation as a sub-family of the parent. + if CFW.isBlocker(child:GetClass()) then + local existingFamily = child._family + local childFamily = existingFamily or createFamilyWithAncestor(self, child) + + -- Attach the blocker's family as a SUB-family of the parent's + -- If it has none, wrap it in a single-member one + -- this is the only place a non-blocker size-1 family is legitimately created + -- + -- A parented blocker can't be a root family (physical): It's parented! + -- The AABB/split/networking traversals walk family -> subFamilies from physical roots + -- a sub-family hung off a bare with no families entity would never be reached + local parentFamily = parent._family + + if not parentFamily then + parentFamily = createFamilyWithAncestor(self, parent) + end - if entsByClass then - entsByClass[ent] = nil + childFamily:AttachTo(parentFamily) - if not next(entsByClass) then - self.entsbyclass[className] = nil + -- Pre-existing root family: its ancestor was physical, so demote its mass + -- physical -> parented + -- A freshly created family already counted the ancestor as parented, so don't demote it again + if existingFamily then + hook.Run("cfw.family.becameSubFamily", childFamily) end + + -- The blocker is parented now, so it leaves the physical set + -- New-family path: createFamilyWithAncestor already cleared it (we're just doing it again) + -- Existing-family path: it's still flagged physical from its root-family days -- clear it here + self.physical[child] = nil + + return end - hook.Run("cfw.contraption.entityRemoved", self, ent) + -- Family management (normal case) + local parentFamily = parent._family - if not next(self.ents) then - self:Remove() + if parentFamily then -- If the parent has a family + if child._family then -- And the child has one too + parentFamily:Merge(child) -- Merge that family + else + parentFamily:Add(child) -- Otherwise just add the child to the parent family + end + elseif child._family then + -- InsertAncestor: parent becomes the new ancestor of child's existing family + child._family:InsertAncestor(parent) + else + local fam = createFamilyWithAncestor(self, parent) + fam:Add(child) end end +end - function CLASS:Remove(mergedInto) - self._removed = true +do -- MARK: Splitting + local function moveEntity(ent, fromContraption, toContraption) + ent._contraption = toContraption - if mergedInto then - hook.Run("cfw.contraption.merged", self, mergedInto) - else - hook.Run("cfw.contraption.removed", self) + toContraption.ents[ent] = true + fromContraption.ents[ent] = nil + + toContraption:AddByClass(ent) + fromContraption:RemoveByClass(ent) + + -- Transfer physical tracking (only if entity was physical) + if fromContraption.physical[ent] then + toContraption.physical[ent] = true + fromContraption.physical[ent] = nil end - CFW.Contraptions[self] = nil + toContraption.count = toContraption.count + 1 + fromContraption.count = fromContraption.count - 1 end - function CLASS:Defuse() - for ent in pairs(self.ents) do - if IsValid(ent) then - self:Sub(ent) + -- Splits the contraption, moving flooded entities to a new contraption + -- Returns the new child contraption + function CLASS:Split(flooded, floodedCount) + -- TODO: The entity count comparison is incorrect, particularly if the contraption is parent-heavy + -- This count is purely the physical entities + + -- Determine which set is smaller for optimal transfer + local moveFlooded = self.count - floodedCount > floodedCount + local childContraption = CFW.createContraption() + + -- self.physical contains every constraint-reachable entity (family ancestors + bare entities). + -- For each one in the target set: if it heads a family, migrate the whole family (including any + -- nested sub-families) via MoveToContraption; otherwise move the bare entity directly. + for ent in pairs(self.physical) do + if (flooded[ent] ~= nil) == moveFlooded then + local family = ent._family + + if family then + family:MoveToContraption(self, childContraption) + else + moveEntity(ent, self, childContraption) + end end end - if not self._removed then self:Remove() end + hook.Run("cfw.contraption.init", childContraption) + hook.Run("cfw.contraption.split", self, childContraption) + + return childContraption end +end + + +function CLASS:Merge(other) -- MARK: Merging + -- Always absorb the smaller contraption into the larger one + if other.count > self.count then + return other:Merge(self) + end + + self.count = self.count + other.count + + -- Bulk move entities + for ent in pairs(other.ents) do + ent._contraption = self + self.ents[ent] = true + end + + self:MergeByClass(other) + + -- Bulk merge physical entities + for ent in pairs(other.physical) do + self.physical[ent] = true + end + + -- Move families by reference + for family in pairs(other.families) do + family.contraption = self + self.families[family] = true + end + + other.count = 0 -- This must be set before Remove + + other:Remove(true) + hook.Run("cfw.contraption.merged", other, self) - local Empty = {} - function CLASS:EntitiesByClass(ClassName) - local Tracked = self.entsbyclass[ClassName] - if not Tracked then return Empty end + return self +end + + +function CLASS:Add(ent) -- MARK: Add leaf entity + ent._contraption = self + self.ents[ent] = true + self.count = self.count + 1 + self.physical[ent] = true -- Newly added entities are physical until families resolve + + self:AddByClass(ent) + + hook.Run("cfw.contraption.entityAdded", self, ent) +end + + +function CLASS:Sub(ent) -- MARK: Remove leaf entity + local wasPhysical = self.physical[ent] ~= nil -- Using GetParent here instead causes incorrect classification - return Tracked + ent._contraption = nil + self.ents[ent] = nil + self.count = self.count - 1 + self.physical[ent] = nil + + self:RemoveByClass(ent) + + hook.Run("cfw.contraption.entityRemoved", self, ent, wasPhysical) +end + +function CLASS:Remove(noHook) -- MARK: Remove contraption + if self.count > 0 then + ErrorNoHalt("[CFW] Contraption:Remove called with " .. self.count .. " entities remaining\n") + --[[ + for ent in pairs(self.ents) do + ErrorNoHalt("[CFW] leftover entity: " .. tostring(ent) .. "\n") + end + ]] end - function CLASS:ContainsClass(ClassName) - local Tracked = self.entsbyclass[ClassName] - if not Tracked then return false end + CFW.Contraptions[self] = nil + + if not noHook then + for family in pairs(self.families) do + hook.Run("cfw.family.removed", family) + end - return next(Tracked) ~= nil -- I don't *THINK* we would ever get NULL here... + hook.Run("cfw.contraption.removed", self) end end \ No newline at end of file diff --git a/lua/cfw/classes/family_sv.lua b/lua/cfw/classes/family_sv.lua index 88917bf..6a267a7 100644 --- a/lua/cfw/classes/family_sv.lua +++ b/lua/cfw/classes/family_sv.lua @@ -1,134 +1,333 @@ -- Families are collections of parented entities, you know, parents... children... +-- Families are components of Contraptions - parent trees within a contraption +-- Families are created and managed exclusively by Contraptions CFW.Classes.Family = {} -CFW.Families = {} -function CFW.Classes.Family.create(ancestor) +function CFW.createFamily(contraption, ancestor) local fam = { count = 0, ents = {}, entsbyclass = {}, ancestor = ancestor, children = {}, + contraption = contraption, color = ColorRand(), - created = CurTime(), + -- Sub-family hierarchy: families can be nested when family roots are parented to other families + parentFamily = nil, -- The family this family's ancestor is parented into (nil if root family) + subFamilies = {}, -- Families attached to this family as children (key = sub-family, value = true) } setmetatable(fam, CFW.Classes.Family) - fam:Init() - fam:Add(ancestor, true) + contraption.families[fam] = true return fam end do -- Class def + local BASE = CFW.Classes.EntityCollection local CLASS = CFW.Classes.Family + setmetatable(CLASS, { __index = BASE }) CLASS.__index = CLASS - function CLASS:Init() - CFW.Families[self] = true - - local con = self.ancestor:CFW_GetContraption() - if con then con.families[self] = true end + function CLASS:GetRoot() + return self.ancestor + end - hook.Run("cfw.family.created", self) + function CLASS:CFW_GetContraption() + return self.contraption end - function CLASS:GetRoot() - return self.ancestor + + function CLASS:Remove(noHook) -- MARK: Remove + -- Remove from parent family's subFamilies + if self.parentFamily then + self.parentFamily.subFamilies[self] = nil + self.parentFamily = nil + end + + -- Orphan any sub-families (they become root families) + for subFamily in pairs(self.subFamilies) do + subFamily.parentFamily = nil + end + + self.subFamilies = {} + + -- A family must be fully drained (count == 0) before Remove is called. + -- Each entity must be individually Sub'd via Family:Sub, which clears + -- _family, decrements count, and restores physical tracking. + if self.count > 0 then + ErrorNoHalt("[CFW] Family:Remove called with " .. self.count .. " entities remaining\n") + end + + self.contraption.families[self] = nil + + if not noHook then + hook.Run("cfw.family.removed", self) + end end - function CLASS:Delete() - self:Sub(self.ancestor, true) + -- Attaches this family as a sub-family of the given parent family + function CLASS:AttachTo(parentFamily) -- MARK: Attach sub-family + self:Detach() -- Detach from any existing parent family - local con = self.ancestor:CFW_GetContraption() - if con then con.families[self] = nil end + self.parentFamily = parentFamily - hook.Run("cfw.family.deleted", self) + parentFamily.subFamilies[self] = true + end + + -- Detaches this family from its parent family (becomes a root family) + function CLASS:Detach() -- MARK: Detach sub-family + -- Already a root family: nothing to detach (e.g. AttachTo's pre-detach + -- on a family that has no parent yet). + if not self.parentFamily then return end - CFW.Families[self] = nil + self.parentFamily.subFamilies[self] = nil + self.parentFamily = nil end - function CLASS:Add(entity, isAncestor) - self.count = self.count + 1 - self.ents[entity] = true + -- Returns true if this family has no parent (is a root family) + function CLASS:IsRoot() -- MARK: Is root + return self.parentFamily == nil + end + + function CLASS:Add(entity) -- MARK: Add -- Add entity + self.count = self.count + 1 + self.ents[entity] = true + self.children[entity] = true entity._family = self - local className = entity:GetClass() - self.entsbyclass[className] = self.entsbyclass[className] or {} - self.entsbyclass[className][entity] = true + -- Entity is now parented, no longer physical + self.contraption.physical[entity] = nil + + self:AddByClass(entity) hook.Run("cfw.family.added", self, entity) + end + + function CLASS:Sub(entity) -- MARK: Sub -- Remove entity + -- Capture the tracked physical state before restoring it. A normal child is + -- parented (wasPhysical == false) and becomes physical here; but a dissolving + -- family's ancestor is already physical (wasPhysical == true), so its mass must + -- not be moved between tables. + local wasPhysical = self.contraption.physical[entity] ~= nil + + self.count = self.count - 1 + self.ents[entity] = nil + self.children[entity] = nil + + entity._family = nil + + -- Entity is no longer parented into this family; restore it as a physical entity + self.contraption.physical[entity] = true + + self:RemoveByClass(entity) - if not isAncestor then - self.children[entity] = true + hook.Run("cfw.family.subbed", self, entity, wasPhysical) + end + + function CLASS:Merge(entity) -- MARK: Merge + local oldFamily = entity._family + + for ent in pairs(oldFamily.ents) do + ent._family = self + + self.count = self.count + 1 + self.ents[ent] = true + self.children[ent] = true -- all old-family members (including the old ancestor) become children + + self:AddByClass(ent) end - for k, v in pairs(entity:GetChildren()) do - local child = isnumber(k) and v or k - if child == entity then continue end - if not IsValid(child) then continue end - if child.CFW_NO_FAMILY_TRAVERSAL then continue end + oldFamily.ents = {} + oldFamily.children = {} + oldFamily.entsbyclass = {} + oldFamily.count = 0 - self:Add(child) + -- The merged entity (old ancestor) is now a child, no longer physical + self.contraption.physical[entity] = nil + + -- Transfer sub-families from old family to this family + for subFamily in pairs(oldFamily.subFamilies) do + subFamily.parentFamily = self + self.subFamilies[subFamily] = true end + + oldFamily.subFamilies = {} + + oldFamily:Remove(true) + + hook.Run("cfw.family.merged", self, oldFamily) end - function CLASS:Sub(entity, isAncestor) - self.count = self.count - 1 - self.ents[entity] = nil + -- Removes the current ancestor from the family entirely; an existing child (newAncestor) + -- is promoted to be the new root. The family shrinks by one. Inverse of InsertAncestor. + function CLASS:RemoveAncestor(oldAncestor, newAncestor) -- MARK: Remove ancestor + oldAncestor._family = nil + self.ents[oldAncestor] = nil + self.count = self.count - 1 + self.children[newAncestor] = nil + self.children[oldAncestor] = nil - entity._family = nil + self:RemoveByClass(oldAncestor) + + self.ancestor = newAncestor + + -- New ancestor becomes physical (old ancestor will be removed from contraption via Sub) + self.contraption.physical[newAncestor] = true + + hook.Run("cfw.family.ancestorRemoved", self, oldAncestor, newAncestor) + end - local entValid = IsValid(entity) - local className = entValid and entity:GetClass() or "" - local entsByClass = self.entsbyclass[className] + -- Inserts a new entity as the family's ancestor, keeping the old ancestor as a child. + -- The family grows by one. Fires when an entity with its own family is parented to an + -- entity with no family — the parent becomes the new root, the old ancestor a child. + function CLASS:InsertAncestor(newAncestor) -- MARK: Insert ancestor + local oldAncestor = self.ancestor + local contraption = self.contraption + + self.ancestor = newAncestor + self.ents[newAncestor] = true + self.count = self.count + 1 + self.children[oldAncestor] = true -- old ancestor becomes a child + newAncestor._family = self + + self:AddByClass(newAncestor) + + -- Old ancestor is now parented, no longer physical + contraption.physical[oldAncestor] = nil + -- newAncestor was already added to the contraption via Contraption:Add, which set physical = true + + hook.Run("cfw.family.ancestorInserted", self, oldAncestor, newAncestor) + end - if entsByClass then - entsByClass[entity] = nil + do -- MARK: Splitting + local function moveToNewContraption(oldFamily, newFamily, newContraption, ent, isAncestor) + local oldContraption = oldFamily.contraption - if not next(entsByClass) then - self.entsbyclass[className] = nil + -- Remove from old family + oldFamily.ents[ent] = nil + oldFamily.count = oldFamily.count - 1 + + oldFamily:RemoveByClass(ent) + + if not isAncestor then + oldFamily.children[ent] = nil end - end - hook.Run("cfw.family.subbed", self, entity) + -- Remove from old contraption (physical tracking not needed - was either ancestor or child) + oldContraption.ents[ent] = nil + oldContraption.count = oldContraption.count - 1 + oldContraption.physical[ent] = nil - if isAncestor then return end + oldContraption:RemoveByClass(ent) - self.children[entity] = nil + -- Add to new family + ent._family = newFamily + + newFamily.ents[ent] = true + newFamily.count = newFamily.count + 1 + newFamily:AddByClass(ent) - if not entValid then return end + if not isAncestor then + newFamily.children[ent] = true + end + + -- Add to new contraption + ent._contraption = newContraption + + newContraption.ents[ent] = true + newContraption.count = newContraption.count + 1 - for k, v in pairs(entity:GetChildren()) do - local child = isnumber(k) and v or k - if child == entity then continue end - if child.CFW_NO_FAMILY_TRAVERSAL then continue end + newContraption:AddByClass(ent) + + -- Only ancestor is physical in new contraption + if isAncestor then + newContraption.physical[ent] = true + end - self:Sub(child) + -- Recurse to children (normal family members only - not family roots) + local children = ent._children + if not children then return end + + for child in pairs(children) do + if child and IsValid(child) and child ~= ent then + -- Check if child is a family root (has its own family as a sub-family) + local childFamily = child._family + + if childFamily then + local isSubFamily = childFamily ~= oldFamily + + if isSubFamily then + -- Child is a family root - transfer its sub-family relationship to newFamily + -- and migrate all of its entities and nested sub-families to the new contraption + + oldFamily.subFamilies[childFamily] = nil + newFamily.subFamilies[childFamily] = true + + childFamily.parentFamily = newFamily + childFamily:MoveToContraption(oldContraption, newContraption) + else + -- Normal child - recurse + moveToNewContraption(oldFamily, newFamily, newContraption, child, false) + end + end + end + end end - end - local Empty = {} - function CLASS:EntitiesByClass(ClassName) - local Tracked = self.entsbyclass[ClassName] - if not Tracked then return Empty end + -- Moves this family and all its sub-families to a different contraption. + -- Transfers entity tracking, contraption pointers, and physical entries. + function CLASS:MoveToContraption(oldContraption, newContraption) + oldContraption.families[self] = nil + newContraption.families[self] = true + self.contraption = newContraption - return Tracked - end + for ent in pairs(self.ents) do + ent._contraption = newContraption + + oldContraption.ents[ent] = nil + oldContraption.count = oldContraption.count - 1 + oldContraption:RemoveByClass(ent) + + newContraption.ents[ent] = true + newContraption.count = newContraption.count + 1 + newContraption:AddByClass(ent) - function CLASS:ContainsClass(ClassName) - local Tracked = self.entsbyclass[ClassName] - if not Tracked then return false end + if oldContraption.physical[ent] then + oldContraption.physical[ent] = nil + newContraption.physical[ent] = true + end + end - return next(Tracked) ~= nil -- I don't *THINK* we would ever get NULL here... + for subFamily in pairs(self.subFamilies) do + subFamily:MoveToContraption(oldContraption, newContraption) + end + end + + function CLASS:Split(child) + local oldContraption = self.contraption + local newContraption = CFW.createContraption() + local newFamily = CFW.createFamily(newContraption, child) + + moveToNewContraption(self, newFamily, newContraption, child, true) + + hook.Run("cfw.contraption.init", newContraption) + hook.Run("cfw.family.init", newFamily) + hook.Run("cfw.family.split", self, newFamily, child) + hook.Run("cfw.contraption.split", oldContraption, newContraption) + -- child was a parented member of the old family/contraption and is now the + -- physical ancestor of the new one: apply the parented -> physical transition + -- after the split totals have been computed treating it as parented. + hook.Run("cfw.family.becameRoot", newFamily) + end end end -do +do -- MARK: External API local ENT = FindMetaTable("Entity") function ENT:GetFamily() @@ -140,26 +339,8 @@ do return Family and Family.ancestor or self end - function ENT:SetFamily(newFamily) - local oldFamily = self._family - - if oldFamily then - oldFamily:Sub(self) - - if oldFamily.count <= 1 then oldFamily:Delete() end - end - - if newFamily then - newFamily:Add(self) - end - - if not newFamily and not self.CFW_REMOVING and next(self:GetChildren()) then - CFW.Classes.Family.create(self) - end - end - function ENT:GetFamilyChildren() local Family = self._family return Family and Family.children or self:GetChildren() end -end \ No newline at end of file +end diff --git a/lua/cfw/classes/link_sv.lua b/lua/cfw/classes/link_sv.lua index ac38675..3a205c7 100644 --- a/lua/cfw/classes/link_sv.lua +++ b/lua/cfw/classes/link_sv.lua @@ -4,95 +4,290 @@ CFW.Classes.Link = {} -function CFW.createLink(a, b) - local indexA, indexB = a:EntIndex(), b:EntIndex() +local CLASS = CFW.Classes.Link +CLASS.__index = CLASS + +function CFW.createLink(a, b, isParent) local link = { - entA = a, - entB = b, - indexA = indexA, - indexB = indexB, - count = 1, - color = ColorRand(), - created = CurTime(), + entA = a, + entB = b, + count = 1, + isParent = isParent or false, + color = ColorRand() } a._links = a._links or {} b._links = b._links or {} - a._links[indexB] = link - b._links[indexA] = link + a._links[b] = link + b._links[a] = link setmetatable(link, CFW.Classes.Link) - return link:Init() + return link end -do -- Class def - local CLASS = CFW.Classes.Link +-- Simple incrementing of counter whenever another constraint is added between two entities already constrained together +-- In the case of parents, there will only be 1 connection between two entities +function CLASS:Add() + self.count = self.count + 1 +end -- MARK: Add - CLASS.__index = CLASS -- why? +-- Decrements the connection count; removes the link when count reaches zero +function CLASS:Sub() -- MARK: Sub + self.count = self.count - 1 - function CLASS:Init() - return link + if self.count == 0 then + self:Remove() end +end + +do -- MARK: Remove + -- Flood-fill over constraint edges only (parent edges are skipped) + -- Parent links form trees — an entity has exactly one parent, so + -- removing a parent link is guaranteed to separate that subtree + -- Returns (true, visitedEntities) if sink is reachable from source + -- (false, visitedEntities, visitedCount) if it is not + local function floodFill(source, sink) + local closed = {[source] = true} + local closedCount = 1 + local open = {} + + -- Initialize open set from source's constraint links (skip parent links) + for neighbor, link in pairs(source._links) do + if not link.isParent then + open[neighbor] = true + end + end + + -- Flood outwards until we find the target + while next(open) do + local ent = next(open) + + open[ent] = nil + closed[ent] = true + + closedCount = closedCount + 1 - function CLASS:Add() - self.count = self.count + 1 + if ent == sink then return true, closed end + + for neighbor, link in pairs(ent._links) do + if not closed[neighbor] and not link.isParent then + open[neighbor] = true + end + end + end + + return false, closed, closedCount end - function CLASS:Sub() - self.count = self.count - 1 + -- Post-detach cleanup for the parent side of a just-removed blocker parent link + -- + -- When a blocker (e.g. a turret) is parented CFW gives the parent a family + -- purely so the blocker's family has something to attach to as a sub-family + -- + -- The caller has just detached the blocker's family, so that wrapper family may now be pointless + -- + -- parentFamily : parent's family captured before the link was removed (may be nil) + -- parent : the entity the blocker had been parented to + -- contraption : the child's contraption, captured at the top of Link:Remove + -- bIsLeaf : true if parent has no remaining links of its own + local function dissolveDetachedParent(parentFamily, parent, contraption, bIsLeaf) + -- Has parentFamily become a single-member family with no subFamilies? + -- * parentFamily is a root family (parentFamily.parentFamily == nil) + -- * count == 1 -- the ancestor is its sole member + -- * no subFamilies -- no OTHER blocker still hangs off it (e.g. a second turret + -- sharing the same baseplate), which would keep the family meaningful + -- Together these mean the family existed only to host the now-departed blocker/turret + -- dissolve it and let the ancestor revert to a bare physical entity + if parentFamily and parentFamily.parentFamily == nil and parentFamily.count == 1 and not next(parentFamily.subFamilies) then + parentFamily:Sub(parentFamily.ancestor) + parentFamily:Remove() - if self.count == 0 then return self:Remove() end + -- The ancestor (parent) is now a bare physical entity. + -- Only remove it from the contraption if it now has no third party connection to the contraption + if bIsLeaf then + contraption:Sub(parent) + end + end - return true + if contraption.count == 0 then contraption:Remove() end end function CLASS:Remove() - local contraptionPopped = false - local entA, entB = self.entA, self.entB - local indexA, indexB = self.indexA, self.indexB + local entA, entB = self.entA, self.entB + local contraption = entA._contraption + + -- Remove the link reference from both entities + entA._links[entB] = nil + entB._links[entA] = nil + + -- Are they leafs? (an entity with no connections to other entities) + local aIsLeaf = not next(entA._links) + local bIsLeaf = not next(entB._links) - if IsValid(entA) then - entA._links[indexB] = nil + -- Handle family changes for parent links + -- For parent links: entA is child, entB is parent + if self.isParent then + local child, parent = entA, entB + local childFamily = child._family + local parentFamily = parent._family - if not next(entA._links) then - -- It's important that the entity is removed from the family first, then the contraption in that order - entA:SetFamily(nil) - entA:CFW_GetContraption():Sub(entA) + -- Check if child is in its own family (family root / sub-family) rather than parent's family + -- Family roots (e.g., turrets) have their own family and are not part of parent's family + -- In this case, detach the sub-family relationship and let contraption split handle the rest + if childFamily and childFamily ~= parentFamily then + -- Child is a family root - detach from parent family (becomes a root family) + childFamily:Detach() - contraptionPopped = true + if aIsLeaf then + if childFamily.count == 1 then + childFamily:Sub(child) + childFamily:Remove() + -- Child is now bare and isolated; Sub it from the contraption, + -- and drain the parent if it is also bare and leaf + contraption:Sub(child) + -- After detaching child's family, dissolve a now-zombie parent family + dissolveDetachedParent(parentFamily, parent, contraption, bIsLeaf) + return + else + -- Family root is a constraint-leaf but has parented children + -- Split the entire family off into its own new contraption + local newContraption = CFW.createContraption() + childFamily:MoveToContraption(contraption, newContraption) + newContraption.physical[child] = true -- ancestor is now a physical root + + hook.Run("cfw.contraption.init", newContraption) + hook.Run("cfw.contraption.split", contraption, newContraption) + hook.Run("cfw.family.becameRoot", childFamily) + + -- After detaching child's family, dissolve a now-zombie parent family + dissolveDetachedParent(parentFamily, parent, contraption, bIsLeaf) + return + end + else + -- Non-leaf family root: the child still has links (parent or constraint) + -- The flood-fill path below is skipped for parent links, so we must + -- explicitly move the sub-family to its own contraption here + local newContraption = CFW.createContraption() + childFamily:MoveToContraption(contraption, newContraption) + newContraption.physical[child] = true -- ancestor is now a physical root + + hook.Run("cfw.contraption.init", newContraption) + hook.Run("cfw.contraption.split", contraption, newContraption) + hook.Run("cfw.family.becameRoot", childFamily) + + -- After detaching child's family, dissolve a now-zombie parent family + dissolveDetachedParent(parentFamily, parent, contraption, bIsLeaf) + return + end + elseif parentFamily then + -- Typical case: child is in parent's family + if parentFamily.ancestor == parent and not aIsLeaf and bIsLeaf then + -- RemoveAncestor: parent is ancestor, child has parented children, parent has no other links + -- Since constraints and parents are mutually exclusive, not aIsLeaf guarantees + -- child has parented children. + parentFamily:RemoveAncestor(parent, child) + + if parentFamily.parentFamily == nil and parentFamily.count == 1 and not next(parentFamily.subFamilies) then + parentFamily:Sub(parentFamily.ancestor) + parentFamily:Remove() + -- The new ancestor (child) is now bare (no family). + -- If it is also a leaf with no remaining links, remove it + -- from the contraption so it doesn't become a zombie + if not next(child._links) then + contraption:Sub(child) + end + end + + -- Old ancestor (parent) has no links and is no longer in any family: + -- remove it from the contraption. The generic "one leaf" path below + -- is skipped for parent links, so we must handle it explicitly here + contraption:Sub(parent) + elseif not aIsLeaf then + -- Child heads its own sub-tree (all remaining links are parent links, + -- since constraints and parents are mutually exclusive). + -- Split the sub-tree into a new contraption. + parentFamily:Split(child) + + if parentFamily.parentFamily == nil and parentFamily.count == 1 and not next(parentFamily.subFamilies) then + parentFamily:Sub(parentFamily.ancestor) + parentFamily:Remove() + end + + return + else + -- Child is a leaf, just remove it from the family + parentFamily:Sub(child) + + if parentFamily.parentFamily == nil and parentFamily.count == 1 and not next(parentFamily.subFamilies) then + -- Family dissolves. If the parent is also now a leaf, + -- both are leaves: the "both leaves" path below will + -- Sub both and Remove. Otherwise the child must be + -- Sub'd from the contraption individually. + parentFamily:Sub(parentFamily.ancestor) + parentFamily:Remove() + if not bIsLeaf then + contraption:Sub(child) + end + else + -- Child is now isolated (no family, no links): remove from contraption. + -- The generic "one leaf → Contraption:Sub" path below is skipped for + -- parent links, so we must handle it explicitly here + contraption:Sub(child) + end + end end - else - contraptionPopped = true end - if IsValid(entB) then - entB._links[indexA] = nil + -- Parent links: the isParent block above handles family restructuring and + -- leaf removal when a family persists. The generic "one leaf" paths + -- below are skipped for parent links (they would undo physical tracking that Family:Sub just set) - if not next(entB._links) then - entB:SetFamily(nil) - entB:CFW_GetContraption():Sub(entB) + -- Both entities are now isolated. Sub each from the contraption so + -- the internal tables are naturally empty before Remove. + if aIsLeaf and bIsLeaf then + contraption:Sub(entA) + contraption:Sub(entB) + contraption:Remove() + return + end - contraptionPopped = true - end - else - contraptionPopped = true + -- One entity is a leaf being trimmed. + -- For parent links, a leaf entity was already Sub'd from its family and + -- marked physical — do NOT trim it from the contraption. + if aIsLeaf and not self.isParent then + contraption:Sub(entA) + return + end + + if bIsLeaf and not self.isParent then + contraption:Sub(entB) + return end - return contraptionPopped + -- Parent-link removal is fully handled by the isParent block above + if self.isParent then return end + + -- Only constraint linsk get this far + -- Both entities still have other links - check for an indirect connection by flood filling + local indirectlyConnected, flooded, floodedCount = floodFill(entA, entB) + + if not indirectlyConnected then + contraption:Split(flooded, floodedCount) + end end end -do +do -- MARK: Entity API local ENT = FindMetaTable("Entity") - function ENT:GetCFWLink(other) -- Returns the link object between this and other - return self._links and self._links[other:EntIndex()] or nil + function ENT:GetCFWLink(other) + return self._links and self._links[other] or nil end - function ENT:GetCFWLinks() -- Creates a shallow copy of the links table + function ENT:GetCFWLinks() local links = self._links local out = {} diff --git a/lua/cfw/core/connectivity_sv.lua b/lua/cfw/core/connectivity_sv.lua index 353a3f4..410dbb3 100644 --- a/lua/cfw/core/connectivity_sv.lua +++ b/lua/cfw/core/connectivity_sv.lua @@ -1,100 +1,39 @@ -local function floodFill(source, sinkIndex) - local closed = {[source:EntIndex()] = true} - local closedCount = 0 - local open = source:GetCFWLinks() - - while next(open) do - local entIndex = next(open) -- entIndex, entLink - - open[entIndex] = nil - closed[entIndex] = true - - closedCount = closedCount + 1 - - if entIndex == sinkIndex then return true, closed end - - for neighborIndex in pairs(Entity(entIndex)._links) do -- neighborIndex, neighborLink - if not closed[neighborIndex] then - open[neighborIndex] = true - end - end - end - - return false, closed, closedCount -end - -function CFW.connect(a, b) +function CFW.connect(a, b, isParent) -- Called when a connection is made between two entities -- If a link already exists, add to the link counter - -- If not, create a new link between the two entities and resolve their contraptions - - if a == b then return end -- Should not happen normally, but ragdolls allow you to constrain to other bones on the same ragdoll, and it is the same entity. We'll head it off here since we don't want to track links that don't actually link anything - - local link = a:GetCFWLink(b) + -- If not, create a new link between the two entities and resolve their contraption states - if link then - link:Add() - else -- No existing connection - -- Create a new link - CFW.createLink(a, b) + -- Resolve which contraption will own this pair + local ac, bc = a._contraption, b._contraption + local contraption - -- Resolve contraption states - local ac, bc = a:CFW_GetContraption(), b:CFW_GetContraption() - - if ac and bc then - if ac ~= bc then -- Two DIFFERENT contraptions - if ac.count > bc.count then - ac:Merge(bc) - else - bc:Merge(ac) - end - end - elseif ac then -- Only contraption A - ac:Add(b) - elseif bc then -- Only contraption B - bc:Add(a) - else -- No contraption - local newContraption = CFW.createContraption() - - newContraption:Add(a) - newContraption:Add(b) + if ac and bc then + if ac ~= bc then -- Two different contraptions + contraption = ac:Merge(bc) + else -- The same contraption, we'll just use ac + contraption = ac end + elseif ac then -- Entity A is part of a contraption + contraption = ac + elseif bc then -- Entity B is part of a contraption + contraption = bc + else -- Neither entity was connected to anyhing before this + contraption = CFW.createContraption() + hook.Run("cfw.contraption.init", contraption) end -end -function CFW.disconnect(entA, indexB) - if entA:EntIndex() == indexB then return end -- Should not happen normally, but ragdolls allow you to constrain to other bones on the same ragdoll, and it is the same entity - - -- Don't soft error because if _links isn't present then it's a deeper CFW issue, nothing without a _links table should be able to reach this point at all - local links = entA._links - if not links then ErrorNoHaltWithStack("Contraption Framework Error: Entity had no links. This error generally indicates a deeper problem with CFW.") end - - local link = links[indexB] - - if not link then return end -- There's nothing to disconnect here - - local contraptionPopped = link:Sub() - - if contraptionPopped then return end - - local indirectlyConnected, floodedIndecii, floodedCount = floodFill(entA, indexB) - - if indirectlyConnected then return end - - -- At this point the contraption has been split - -- Create a new contraption and move the cut-off ents to it - -- The child contraption will always be the smaller of the two - - local parentContraption, childContraption = entA:CFW_GetContraption(), CFW.createContraption() - - if parentContraption.count < floodedCount then parentContraption, childContraption = childContraption, parentContraption end - - for entIndex in pairs(floodedIndecii) do - local ent = Entity(entIndex) - - parentContraption:Sub(ent) - childContraption:Add(ent) + -- Now that we've figured out which contraption we're using, add the entities to it + if isParent then + contraption:AddParentedPair(a, b) + else + contraption:AddConstrainedPair(a, b) end +end - hook.Run("cfw.contraption.split", parentContraption, childContraption) +function CFW.disconnect(entA, entB) + local link = entA._links and entA._links[entB] + if link then link:Sub() end end + +-- TODO: Dupes are ingesting and saving CFW contraption data, bloating file size significantly. +-- This doesn't need to be saved at all, CFW builds this data when the constraints are made \ No newline at end of file diff --git a/lua/cfw/core/constraints_sv.lua b/lua/cfw/core/constraints_sv.lua index 3ad9125..356c875 100644 --- a/lua/cfw/core/constraints_sv.lua +++ b/lua/cfw/core/constraints_sv.lua @@ -1,22 +1,26 @@ -local connect = CFW.connect -local disconnect = CFW.disconnect -local timerSimple = timer.Simple -local stringExplode = string.Explode -local isConstraint = { +local connect = CFW.connect +local disconnect = CFW.disconnect +local timerSimple = timer.Simple + +-- Constraint types tracked by CFW +-- Note: Some types (weld, ballsocket, adv. ballsocket) are deduplicated by GMod's +-- constraint library before entity creation - duplicates simply won't fire OnEntityCreated +-- Elastics have a race condition when removed where one of the two entities may be removed before the hook fires +-- TODO: Figure out a way to handle elastics. Until then, they have to be ignored or we get stale entries +CFW.isConstraint = { phys_hinge = true, -- axis phys_lengthconstraint = true, -- rope phys_constraint = true, -- weld phys_ballsocket = true, -- ballsocket - phys_spring = true, -- elastic, hydraulics, muscles - phys_pulleyconstraint = true, -- pulley (do people ever use these?) + -- phys_spring = true, -- elastic, hydraulics, muscles -- INTENTIONALLY IGNORED. Introduces race conditions that cannot be handled (yet?) phys_slideconstraint = true, -- sliders phys_ragdollconstraint = true, -- adv. ballsocket } -local function onRemove(con) - local a, b = con.Ent1, con.Ent2 or con.Ent4 +local isConstraint = CFW.isConstraint - if IsValid(a) then disconnect(a, con._cfwEntB) else disconnect(b, con._cfwEntA) end +local function onRemove(con) + disconnect(con.Ent1, con.Ent2) end -- This is a dumb hack necessitated by SetTable being called on constraints immediately after they are created @@ -26,54 +30,33 @@ end hook.Add("OnEntityCreated", "cfw.entityCreated", function(con) if isConstraint[con:GetClass()] then timerSimple(0, function() - if IsValid(con) then - local a, b = con.Ent1, con.Ent2 or con.Ent4 + if not IsValid(con) then return end - if not IsValid(a) or a:IsWorld() then return end - if not IsValid(b) or b:IsWorld() then return end + -- Rotation-only advanced ballsockets (phys_ragdollconstraint with onlyrotation=1) + -- don't constrain position, so we ignore them entirely + -- This mostly applies to setAng steering plates + if con.onlyrotation and con.onlyrotation ~= 0 then return end - con:CallOnRemove("CFW", onRemove) + local a, b = con.Ent1, con.Ent2 - con._cfwEntA = a:EntIndex() - con._cfwEntB = b:EntIndex() + if not IsValid(a) or not IsValid(b) then return end - connect(a, b) - end - end) - end -end) + -- Ignore map stuff + if a:IsWorld() or a:CreatedByMap() then return end + if b:IsWorld() or b:CreatedByMap() then return end --- Short-Circuits the usual CFW behavior to delete all contraptions in a dupe at once --- Circumvents strange behavior with elastics (including hydraulics) -hook.Add("PreUndo", "cfw.undo", function(undo) - if not undo.Entities then return end - if stringExplode(" ", "AdvDupe2")[1] ~= "AdvDupe2" then return end + -- Prevent ragdolls from constraining to themselves + if a == b then return end - -- Find all entities including those not in the original dupe (wire holograms, etc.) by searching their contraptions - -- Disable their CFW behavior and then delete the contraption - local alreadyRemoved = {} + -- Constraints and parenting are mutually exclusive + -- Severing a third-party parent matters: given child -> parent -> grandparent + -- adding constraint child <-> parent, the parent must lose its link to the grandparent (which splits the contraption) + if IsValid(a:GetParent()) then a:SetParent(nil) end + if IsValid(b:GetParent()) then b:SetParent(nil) end - for _, ent in ipairs(undo.Entities) do - local contraption = ent:CFW_GetContraption() + con:CallOnRemove("CFW", onRemove) - if contraption and not alreadyRemoved[contraption] then - for ent in pairs(contraption.ents) do - -- Disable constraint-removal behavior - if ent.Constraints then - for _, con in ipairs(ent.Constraints) do - if IsValid(con) and isConstraint[con:GetClass()] then - con:RemoveCallOnRemove("CFW") - end - end - end - - -- Disable unparenting behavior - ent._cfwRemoved = true - end - - -- Then remove the contraption - alreadyRemoved[contraption] = true - contraption:Remove() - end + connect(a, b) + end) end end) \ No newline at end of file diff --git a/lua/cfw/core/parenting_sv.lua b/lua/cfw/core/parenting_sv.lua index 1a94bf9..8f7f7db 100644 --- a/lua/cfw/core/parenting_sv.lua +++ b/lua/cfw/core/parenting_sv.lua @@ -10,22 +10,75 @@ local specialEngineEnts = { CFW.parentFilter = filter -local detours = {} +local blockers = {} -function CFW.addParentDetour(class, variable) +-- A "transform proxy" pairs a logical OWNER entity (e.g. a turret drive) with an internal PROXY entity (e.g. its rotator) +-- The proxyowns the physical and transform (position and orientation) +-- The owner stays the contraption-visible parent; the proxy never appears in connectivity +local ownerToProxy = {} -- [ownerClass] = field on the owner holding its proxy (owner -> proxy) +local proxyToOwner = {} -- [proxyClass] = field on the proxy holding its owner (proxy -> owner) + +-- Resolves a transform-proxy owner to its proxy (turret -> rotator), or returns it unchanged +local function toProxy(entity) + local field = ownerToProxy[entity:GetClass()] + + if not field then return entity end + + local proxy = entity[field] + return IsValid(proxy) and proxy or entity +end + +-- Registers an entity class that should always be the root of its own family +-- This is useful for entities like turrets that rotate independently of their parent +function CFW.addBlocker(class) if not class then return end - if not variable then return end + blockers[class] = true +end - detours[class] = function(entity) - return entity[variable] - end +function CFW.removeBlocker(class) + if not class then return end + blockers[class] = nil +end + +function CFW.isBlocker(class) + return blockers[class] or false +end + +-- Gets the physical root for an entity (the entity whose transform is used for hull/CoM) +-- For a transform-proxy owner this is its proxy (e.g. a turret's rotator); otherwise the entity +function CFW.getPhysicalRoot(entity) + if not IsValid(entity) then return entity end + + return toProxy(entity) +end + +-- Registers the whole "transform proxy" for an ACF turret entity +-- +-- The owner points at its proxy via ownerField; the proxy points back at the owner via proxyField. +-- From just that pair everything else is derived: +-- * owner is a blocker +-- * proxy is invisible to connectivity (engine-parented only, never a CFW link) +-- * children parented to the owner attach to the proxy in the engine, but link to the OWNER; +-- GetParent / GetChildren resolve through the proxy so it stays hidden either way +-- * hull/CoM math uses the proxy's transform (getPhysicalRoot) +-- +-- CFW.addTransformProxy("acf_turret", "Rotator", "acf_turret_rotator", "Turret") +function CFW.addTransformProxy(ownerClass, ownerField, proxyClass, proxyField) + if not (ownerClass and ownerField and proxyClass and proxyField) then return end + + ownerToProxy[ownerClass] = ownerField -- owner -> proxy (engine parent, children, transform) + proxyToOwner[proxyClass] = proxyField -- proxy -> owner (logical GetParent) + + blockers[ownerClass] = true -- owner rotates independently: roots its own family + filter[proxyClass] = true -- proxy never shows up in CFW end hook.Add("Initialize", "CFW", function() timer.Simple(0, function() - local ENT = FindMetaTable("Entity") - local setParent = ENT.SetParent - local getParent = ENT.GetParent + local ENT = FindMetaTable("Entity") + local setParent = ENT.SetParent + local getParent = ENT.GetParent + local getChildren = ENT.GetChildren --[[ The hooks here are as follows: @@ -50,26 +103,25 @@ hook.Add("Initialize", "CFW", function() local validNewParent = IsValid(newParent) local validOldParent = IsValid(oldParent) + -- Hide the actual parent from CFW if it's a transform proxy + local logicalNewParent = newParent + local logicalOldParent = self:GetParent() + -- Check if the entity is able to be a child to this parent or not. if self.CFW_PreParentedTo and self:CFW_PreParentedTo(oldParent, newParent, newAttach, ...) == false then return end - -- If a valid new parent, get any entity detours that may be present for the new parents class if validNewParent then - local detour = detours[newParent:GetClass()] - -- Store savedParent so we can do CFW_PreParented and CFW_OnParented later on the actual target if newParent.CFW_OnParented or newParent.CFW_PreParented then savedParent = newParent end - -- Set newParent to detour - if detour then - -- march note: shouldn't we be setting validNewParent again here? - -- won't do it for now to avoid breaking anything, but seems like an obvious one - newParent = detour(newParent) or newParent - end + -- Parenting to a transform-proxy owner attaches to its proxy in the engine (e.g. a turret's rotator) + -- The CFW link uses the logical parent (logicalNewParent) + newParent = toProxy(newParent) + validNewParent = IsValid(newParent) end -- Block parenting to self (why doesn't this just happen earlier on?? that case would never be valid!) @@ -102,33 +154,42 @@ hook.Add("Initialize", "CFW", function() self:CFW_OnParentedTo(oldParent, newParent) end - if self._cfwRemoved then return end -- Removed by an undo if oldParent == newParent then return end if (validOldParent and oldParent:IsPlayer()) or (validNewParent and newParent:IsPlayer()) then return end if (validOldParent and oldParent:IsNPC()) or (validNewParent and newParent:IsNPC()) then return end if (validOldParent and oldParent:IsNextBot()) or (validNewParent and newParent:IsNextBot()) then return end local entClass = self:GetClass() - if filter[entClass] then return end + if filter[entClass] or specialEngineEnts[entClass] then return end - -- Handle the edge case where an entity was originally parented in-engine but is being reparented in the same tick before we could even detect the old parent - local isUnlinkedSpecialEngineEnt = validOldParent and specialEngineEnts[entClass] and not self:GetCFWLink(oldParent) + -- Constraints and parenting are mutually exclusive - remove ALL constraints on the child + if validNewParent and self.Constraints then + for i = #self.Constraints, 1, -1 do + local con = self.Constraints[i] - if validOldParent and not isUnlinkedSpecialEngineEnt then disconnect(self, oldParent:EntIndex(), isParent) end - if validNewParent then connect(self, newParent, isParent) end + if IsValid(con) then + local other = con.Ent1 == self and con.Ent2 or con.Ent1 - if self.CFW_NO_FAMILY_TRAVERSAL then return end + con:RemoveCallOnRemove("CFW") - if validNewParent then - local newParentFamily = newParent:GetFamily() + if IsValid(other) and other ~= newParent and CFW.isConstraint[con:GetClass()] then + disconnect(self, other) + end - if newParentFamily then - self:SetFamily(newParentFamily) - else - CFW.Classes.Family.create(newParent) + con:Remove() + end end - else - self:SetFamily(nil) + end + + -- Handle the edge case where an entity was originally parented in-engine but is being reparented in the same tick before we could even detect the old parent + local isUnlinkedSpecialEngineEnt = validOldParent and specialEngineEnts[entClass] and not self:GetCFWLink(logicalOldParent) + + if validOldParent and not isUnlinkedSpecialEngineEnt then + disconnect(self, logicalOldParent) + end + + if IsValid(logicalNewParent) then + connect(self, logicalNewParent, true) end end @@ -136,15 +197,33 @@ hook.Add("Initialize", "CFW", function() local parent = getParent(self) if IsValid(parent) then - local detour = detours[parent:GetClass()] + -- If the engine parent is a transform proxy (e.g. a turret's rotator) + -- report its owner (the turret drive) instead, so the proxy stays hidden + local field = proxyToOwner[parent:GetClass()] - if detour then - parent = detour(parent) or parent + if field then + local owner = parent[field] + + -- Guard against self-referential loops (a proxy whose owner is self) + if IsValid(owner) and owner ~= self then + parent = owner + end end end return parent end + + function ENT:GetChildren() + -- A transform-proxy owner's children actually live on its proxy (e.g. the rotator) + local proxy = toProxy(self) + + if proxy ~= self then + return getChildren(proxy) + end + + return getChildren(self) + end end) hook.Remove("Initialize", "CFW") @@ -160,24 +239,14 @@ hook.Add("OnEntityCreated", "cfw.engineParentedEntityCreated", function(ent) local parent = ent:GetParent() if not IsValid(parent) or ent:GetCFWLink(parent) then return end - connect(ent, parent) - - if ent.CFW_NO_FAMILY_TRAVERSAL then return end - - local parentFamily = parent:GetFamily() - - if parentFamily then - ent:SetFamily(parentFamily) - else - CFW.Classes.Family.create(parent) - end + connect(ent, parent, true) end) end) -- In order to prevent NULL entities flooding the ENT._links table, we'll just get rid of them before they get removed -- This is a fix for a really annoying issue that was showing up in multiple different ways hook.Add("EntityRemoved", "cfw.entityRemoved", function(ent) - if not IsValid(ent) then return end + if not IsValid(ent) or CFW.isConstraint[ent:GetClass()] then return end ent.CFW_REMOVING = true local links = ent:GetCFWLinks() @@ -187,4 +256,4 @@ hook.Add("EntityRemoved", "cfw.entityRemoved", function(ent) for index in pairs(links) do disconnect(ent, index) end -end) +end) \ No newline at end of file diff --git a/lua/cfw/core/verify_sv.lua b/lua/cfw/core/verify_sv.lua new file mode 100644 index 0000000..33fe4d2 --- /dev/null +++ b/lua/cfw/core/verify_sv.lua @@ -0,0 +1,465 @@ +local PHYS = FindMetaTable("PhysObj") +local getPhysics = FindMetaTable("Entity").GetPhysicsObject +local IsValidPhys = PHYS.IsValid + +local MASS_EPSILON = 0.05 + +local function entMass(ent) + if not IsValid(ent) then return 0 end + + local phys = getPhysics(ent) + + if not IsValidPhys(phys) then return 0 end + + return phys:GetMass() +end + +local isConstraint = CFW.isConstraint + +-- Returns the parent entity recorded in the link graph for ent: the entB of ent's parent +-- link (parent links are stored entA = child, entB = parent). nil if ent has no parent link +local function linkParent(ent) + local links = ent._links + + if not links then return nil end + + for other, link in pairs(links) do + if link.isParent and link.entA == ent then return other end + end + + return nil +end + +-- Whether a constraint is a class CFW tracks at all, ignoring its endpoints. +-- Mirrors the filtering in constraints_sv.lua: only specific classes count and rotation-only +-- advanced ballsockets are ignored. +local function isTrackedConstraintClass(c) + return IsValid(c) and isConstraint[c:GetClass()] ~= nil and not (c.onlyrotation and c.onlyrotation ~= 0) +end + +-- Whether a constraint entity is one CFW would actually track as a link between a and b. +-- Adds the endpoint requirements on top of the class check: it must join a and b (in either +-- order) and never an entity to itself. +local function isTrackedConstraint(c, a, b) + if not isTrackedConstraintClass(c) then return false end + + local e1, e2 = c.Ent1, c.Ent2 + + if e1 == e2 then return false end + + return (e1 == a and e2 == b) or (e1 == b and e2 == a) +end + +-- Number of real, CFW-trackable constraints currently between a and b +-- gmod records each constraint on both endpoints Constraints table, so a's is enough +local function realConstraintCount(a, b) + local cons = a.Constraints + + if not cons then return 0 end + + local n = 0 + + for _, c in pairs(cons) do + if isTrackedConstraint(c, a, b) then n = n + 1 end + end + + return n +end + +-- The running list of problems for the contraption currently being verified +-- Reset at the start of every VerifyContraption call +local issues = {} + +local function add(fmt, ...) + local n = select("#", ...) + local args = { ... } + + for i = 1, n do + local v = args[i] + + if type(v) ~= "number" then args[i] = tostring(v) end + end + + issues[#issues + 1] = string.format(fmt, unpack(args, 1, n)) +end + +-- MARK: Members +-- count, entity validity, and the _contraption reference +local function checkMembers(con) + local ents = con.ents + local realCount = 0 + + for ent in pairs(ents) do + realCount = realCount + 1 + + if not IsValid(ent) then + add("NULL entity present in con.ents") + elseif ent._contraption ~= con then + add("ent %s is in con.ents but _contraption points elsewhere", ent) + end + end + + if con.count ~= realCount then + add("con.count=%s but con.ents holds %d entities", con.count, realCount) + end +end + +-- MARK: Entsbyclass +local function checkEntsByClass(con) + local ents = con.ents + local seen = {} + + for cls, set in pairs(con.entsbyclass) do + if not next(set) then add("entsbyclass[%s] is an empty table", cls) end + + for ent in pairs(set) do + seen[ent] = true + + if not ents[ent] then + add("ent %s in entsbyclass[%s] but not in con.ents", ent, cls) + elseif IsValid(ent) and ent:GetClass() ~= cls then + add("ent %s filed under class %s but is actually %s", ent, cls, ent:GetClass()) + end + end + end + + for ent in pairs(ents) do + if IsValid(ent) and not seen[ent] then + add("ent %s in con.ents but missing from entsbyclass", ent) + end + end +end + +-- MARK: Physical status +-- An entity is either "physical" (an unparented root) or "parented". Four independently +-- maintained views must agree on this: con.physical, the link graph, the family structure, and the engine's actual ent:GetParent() +-- Also nothing may be in con.physical without being in con.ents +local function checkPhysicalStatus(con) + local ents = con.ents + local physical = con.physical + + for ent in pairs(ents) do + if IsValid(ent) then + local graphParent = linkParent(ent) -- Does LINK say it's parented? + local isRoot = graphParent == nil + + -- Does con.physical agree on whether it's parented? + if (physical[ent] ~= nil) ~= isRoot then + add("ent %s: con.physical=%s but link graph says root=%s (must match)", ent, physical[ent] ~= nil, isRoot) + end + + -- Family structure must agree: an entity is a root if it has no family + -- OR it is its own family's ancestor and that family is itself a root family (typical case) + local fam = ent._family + local famSaysRoot = not fam or (fam.ancestor == ent and fam.parentFamily == nil) + + if famSaysRoot ~= isRoot then + add("ent %s: family structure implies root=%s but link graph says root=%s", ent, famSaysRoot, isRoot) + end + + -- The link graph must agree with reality + local realParent = ent:GetParent() + + if isRoot then + -- A root cannot be parented to another entity in the contraption + if IsValid(realParent) and ents[realParent] then + add("ent %s: treated as a root but ent:GetParent()=%s is a contraption member", ent, realParent) + end + elseif graphParent ~= realParent then + -- Covers both a wrong parent and a stale link + add("ent %s: parent link points to %s but ent:GetParent()=%s", ent, graphParent, realParent) + end + end + end + + for ent in pairs(physical) do + if not ents[ent] then + add("ent %s in con.physical but not in con.ents", ent) + end + end +end + +-- MARK: Contraption mass +local function checkContraptionMass(con) + if con.totalMass == nil then return end + + local ents = con.ents + local physical = con.physical + + local total, phys, parented = 0, 0, 0 + + for ent in pairs(ents) do + local m = entMass(ent) + + total = total + m + + if physical[ent] then phys = phys + m else parented = parented + m end + end + + -- Total mass + if math.abs(total - con.totalMass) > MASS_EPSILON then + add("con.totalMass=%.3f but actual sum=%.3f", con.totalMass, total) + end + + -- Physical mass + if math.abs(phys - con.physicalMass) > MASS_EPSILON then + add("con.physicalMass=%.3f but physical sum=%.3f", con.physicalMass, phys) + end + + -- Parented mass + if math.abs(parented - con.parentedMass) > MASS_EPSILON then + add("con.parentedMass=%.3f but parented sum=%.3f", con.parentedMass, parented) + end + + -- Does the parented and physical mass add up to the total? + if math.abs((con.physicalMass + con.parentedMass) - con.totalMass) > MASS_EPSILON then + add("physicalMass+parentedMass=%.3f != totalMass=%.3f", con.physicalMass + con.parentedMass, con.totalMass) + end +end + +-- MARK: Families +local function checkFamilyMembers(con, fam, famOf) + local ents = con.ents + local fc = 0 + + for ent in pairs(fam.ents) do + fc = fc + 1 + famOf[ent] = fam + + if ent._family ~= fam then + add("ent %s in family.ents but its _family reference difers", ent) + end + + if not ents[ent] then + add("ent %s in family(anc=%s).ents but not in con.ents", ent, fam.ancestor) + end + + if ent == fam.ancestor then + if fam.children[ent] then + add("ancestor %s wrongly listed in family.children", ent) + end + elseif not fam.children[ent] then + add("child %s missing from family.children", ent) + end + end + + if fam.count ~= fc then + add("family(anc=%s).count=%s but holds %d ents", fam.ancestor, fam.count, fc) + end + + if fam.ancestor and not fam.ents[fam.ancestor] then + add("family ancestor %s not present in its own ents", fam.ancestor) + end +end + + +local function checkFamilyMass(con, fam) + if fam.totalMass == nil then return end + + local physical = con.physical + local total = 0 + + for ent in pairs(fam.ents) do total = total + entMass(ent) end + + -- Total mass + if math.abs(total - fam.totalMass) > MASS_EPSILON then + add("family(anc=%s).totalMass=%.3f but physics sum=%.3f", fam.ancestor, fam.totalMass, total) + end + + -- Physical mass + local expectPhys = (physical[fam.ancestor] and entMass(fam.ancestor)) or 0 + + if math.abs(expectPhys - fam.physicalMass) > MASS_EPSILON then + add("family(anc=%s).physicalMass=%.3f but ancestor-physical implies %.3f", + fam.ancestor, fam.physicalMass, expectPhys) + end + + -- Do they all add up? + if math.abs((fam.physicalMass + fam.parentedMass) - fam.totalMass) > MASS_EPSILON then + add("family(anc=%s) physicalMass+parentedMass=%.3f != totalMass=%.3f", + fam.ancestor, fam.physicalMass + fam.parentedMass, fam.totalMass) + end +end + + +local function checkFamilyHierarchy(con, fam) + for sub in pairs(fam.subFamilies) do + -- Does this subFamily believe this family is its parent? + if sub.parentFamily ~= fam then + add("subFamily(anc=%s).parentFamily does not point back to family(anc=%s)", sub.ancestor, fam.ancestor) + end + + -- Is the subfamily in the family table? + if not con.families[sub] then + add("subFamily(anc=%s) is not registered in con.families", sub.ancestor) + end + end + + -- Is this family in it's parent's subFamily table? + if fam.parentFamily and not fam.parentFamily.subFamilies[fam] then + add("family(anc=%s) has a parentFamily but is absent from its subFamilies", fam.ancestor) + end +end + +local function checkFamilies(con) + local ents = con.ents + local famOf = {} + + for fam in pairs(con.families) do + if fam.contraption ~= con then + add("family(anc=%s).contraption does not point back to this contraption", fam.ancestor) + end + + checkFamilyMembers(con, fam, famOf) + checkFamilyMass(con, fam) + checkFamilyHierarchy(con, fam) + end + + for ent in pairs(ents) do + local fam = ent._family + + if fam and famOf[ent] ~= fam then + add("ent %s._family is not the con.families entry that contains it", ent) + end + end +end + +-- MARK: Links +-- the link table is symmetric and parent links reference their own endpoints +local function checkLinks(con) + local ents = con.ents + + for ent in pairs(ents) do + if not IsValid(ent) then continue end + + for other, link in pairs(ent._links) do + if not (other._links and other._links[ent] == link) then + add("asymmetric link: %s -> %s not mirrored", ent, other) + end + + if link.isParent and link.entA ~= ent and link.entB ~= ent then + add("link on %s does not reference it as entA or entB", ent) + end + end + end +end + +-- MARK: Constraint reality +-- checkLinks only proves the link table is internally symmetric. This compares the constraint +-- links against the actual constraints in the world: every constraint link must be backed by +-- the right number of real constraints (link.count tracks how many), and every real constraint +-- between two members must have a constraint link. +local function checkConstraintReality(con) + local ents = con.ents + + -- Every constraint link must have matching real constraints + -- Visit each link once, from its entA side + for ent in pairs(ents) do + if not IsValid(ent) then continue end + + for other, link in pairs(ent._links) do + if link.isParent then continue end + + if ent == link.entA and IsValid(other) then + local real = realConstraintCount(ent, other) + + if real == 0 then + add("constraint link %s <-> %s (count=%d) has no real constraint backing it", ent, other, link.count) + elseif real ~= link.count then + add("constraint link %s <-> %s: count=%d but %d real constraints exist", ent, other, link.count, real) + end + end + end + end + + -- Every real constraint between two members must have a (non-parent) link + -- Visit each constraint once using EntIndex ordering to avoid duplicate reports + for ent in pairs(ents) do + if not IsValid(ent) then continue end + if not ent.Constraints then continue end + + for _, c in pairs(ent.Constraints) do + if isTrackedConstraintClass(c) then + local e1, e2 = c.Ent1, c.Ent2 + local other + + if e1 == ent then other = e2 elseif e2 == ent then other = e1 end + + if IsValid(other) and other ~= ent and ents[other] and ent:EntIndex() < other:EntIndex() then + local link = ent._links and ent._links[other] + + if not link then + add("real constraint %s <-> %s exists but there is no link between them", + ent, other) + elseif link.isParent then + add("real constraint %s <-> %s exists but their link is marked isParent", + ent, other) + end + end + end + end + end +end + +-- MARK: Connectivity +-- the contraption is a single connected component over its link graph (constraint + parent edges), and no link crosses out of the contraption +local function checkConnectivity(con) + local ents = con.ents + + local start + + -- Pick the first valid entity we find + for ent in pairs(ents) do if IsValid(ent) then start = ent break end end + + if not start then return end + + local closed = { [start] = true } + local open = { start } + + -- Flood through the contraption + while #open > 0 do + local ent = open[#open] + + open[#open] = nil + + for other in pairs(ent._links) do + if closed[other] then continue end + + closed[other] = true + + if not ents[other] then + add("link from %s reaches %s which is NOT in this contraption", ent, other) + else + open[#open + 1] = other + end + end + end + + for ent in pairs(ents) do + if IsValid(ent) and not closed[ent] then + add("ent %s is in con.ents but unreachable via links (disconnected island)", ent) + end + end +end + + +function CFW.VerifyContraption(con) + if type(con) ~= "table" then return false, { "object is not a contraption" } end + + issues = {} + + if not CFW.Contraptions[con] then + add("contraption is not registered in CFW.Contraptions") + end + + checkMembers(con) + checkEntsByClass(con) + checkPhysicalStatus(con) + checkContraptionMass(con) + checkFamilies(con) + checkLinks(con) + checkConstraintReality(con) + checkConnectivity(con) + + return #issues == 0, issues +end diff --git a/lua/cfw/devtools_hooks.lua b/lua/cfw/devtools_hooks.lua index af418c0..633b4ea 100644 --- a/lua/cfw/devtools_hooks.lua +++ b/lua/cfw/devtools_hooks.lua @@ -1,56 +1,47 @@ +local function ContraptionName(Contraption) return "Contraption: " .. tostring(Contraption):sub(8) end +local function FamilyName(Family) return "Family: " .. tostring(Family):sub(8) end + +local Events = { + ["cfw.contraption.init"] = function(con) return ContraptionName(con), "CFW.Contraption.Init" end, + ["cfw.contraption.entityAdded"] = function(con, ent) return ContraptionName(con), "CFW.Contraption.EntityAdded", ent end, + ["cfw.contraption.entityRemoved"] = function(con, ent) return ContraptionName(con), "CFW.Contraption.EntityRemoved", ent end, + ["cfw.contraption.merged"] = function(from, into) return ContraptionName(from), "CFW.Contraption.Merged", ContraptionName(into) end, + ["cfw.contraption.split"] = function(old, new) return ContraptionName(old), "CFW.Contraption.Split", ContraptionName(new) end, + ["cfw.contraption.removed"] = function(con) return ContraptionName(con), "CFW.Contraption.Removed" end, + + ["cfw.family.init"] = function(fam) return FamilyName(fam), "CFW.Family.Init" end, + ["cfw.family.added"] = function(fam, ent) return FamilyName(fam), "CFW.Family.EntityAdded", ent end, + ["cfw.family.subbed"] = function(fam, ent) return FamilyName(fam), "CFW.Family.EntityRemoved", ent end, + ["cfw.family.removed"] = function(fam) return FamilyName(fam), "CFW.Family.Removed" end, + -- merged/split fire on the surviving / original family; the other family goes away + ["cfw.family.merged"] = function(fam, old) return FamilyName(old), "CFW.Family.Merged", FamilyName(fam) end, + ["cfw.family.split"] = function(old, new, ancestor) return FamilyName(old), "CFW.Family.Split", FamilyName(new), ancestor end, + ["cfw.family.ancestorRemoved"] = function(fam, old, new) return FamilyName(fam), "CFW.Family.AncestorRemoved", old, new end, + ["cfw.family.ancestorInserted"] = function(fam, old, new) return FamilyName(fam), "CFW.Family.AncestorInserted", old, new end, + ["cfw.family.becameRoot"] = function(fam) return FamilyName(fam), "CFW.Family.BecameRoot" end, + ["cfw.family.becameSubFamily"] = function(fam) return FamilyName(fam), "CFW.Family.BecameSubFamily" end, +} -local function GetEventViewerName(Contraption) return "Contraption: " .. tostring(Contraption):sub(8) end local function InitializeHooks(Enabled) - if not Enabled then - hook.Remove("cfw.contraption.created", "CFW_DevtoolsHooks") - hook.Remove("cfw.contraption.entityAdded", "CFW_DevtoolsHooks") - hook.Remove("cfw.contraption.entityRemoved", "CFW_DevtoolsHooks") - hook.Remove("cfw.contraption.merged", "CFW_DevtoolsHooks") - hook.Remove("cfw.contraption.split", "CFW_DevtoolsHooks") - hook.Remove("cfw.contraption.removed", "CFW_DevtoolsHooks") - return + for hookName in pairs(Events) do + hook.Remove(hookName, "CFW_DevtoolsHooks") end + if not Enabled then return end + local EventViewer = CFW.EventViewer - hook.Add("cfw.contraption.created", "CFW_DevtoolsHooks", function(self) - if EventViewer.Enabled() then - EventViewer.AppendEvent(GetEventViewerName(self), "CFW.Contraption.Created") - end - end) - - hook.Add("cfw.contraption.entityAdded", "CFW_DevtoolsHooks", function(self, ent) - if EventViewer.Enabled() then - EventViewer.AppendEvent(GetEventViewerName(self), "CFW.Contraption.EntityAdded", ent) - end - end) - - hook.Add("cfw.contraption.entityRemoved", "CFW_DevtoolsHooks", function(self, ent) - if EventViewer.Enabled() then - EventViewer.AppendEvent(GetEventViewerName(self), "CFW.Contraption.EntityRemoved", ent) - end - end) - - hook.Add("cfw.contraption.merged", "CFW_DevtoolsHooks", function(self, mergedInto) - if EventViewer.Enabled() then - EventViewer.AppendEvent(GetEventViewerName(self), "CFW.Contraption.Merged", GetEventViewerName(mergedInto)) - end - end) - - hook.Add("cfw.contraption.split", "CFW_DevtoolsHooks", function(self, mergedInto) - if EventViewer.Enabled() then - EventViewer.AppendEvent(GetEventViewerName(self), "CFW.Contraption.Split", GetEventViewerName(mergedInto)) - end - end) - - hook.Add("cfw.contraption.removed", "CFW_DevtoolsHooks", function(self) - if EventViewer.Enabled() then - EventViewer.AppendEvent(GetEventViewerName(self), "CFW.Contraption.Removed") - end - end) + for hookName, build in pairs(Events) do + hook.Add(hookName, "CFW_DevtoolsHooks", function(...) + if EventViewer.Enabled() then + EventViewer.AppendEvent(build(...)) + end + end) + end end hook.Add("ACF3_DevTools_EnableChanged", "CFW_Hook", InitializeHooks) + if CFW.EventViewer then -- ?????????????????? InitializeHooks(CFW.EventViewer.Enabled()) -end \ No newline at end of file +end diff --git a/lua/cfw/extensions/mass_sv.lua b/lua/cfw/extensions/mass_sv.lua index 31fb876..608dd59 100644 --- a/lua/cfw/extensions/mass_sv.lua +++ b/lua/cfw/extensions/mass_sv.lua @@ -1,62 +1,430 @@ --- Tracks the total mass of a contraption or family +-- Tracks the total mass of contraptions and families +-- Each family and contraption tracks its own entity mass; Contraptions use the aggregate of families to derive a total +-- Also tracks physical vs parented mass +-- Center of (total) mass is tracked as a mass-weighted position sum (divide by totalMass to get CoM) -local PHYS = FindMetaTable("PhysObj") -local setMass = setMass or PHYS.SetMass +local PHYS = FindMetaTable("PhysObj") +local setMass = PHYS.SetMass +local angle_zero = Angle(0, 0, 0) +local getParent = getParent or FindMetaTable("Entity").GetParent +local IsValidPhys = IsValidPhys or PHYS.IsValid +local IsParented = IsParented or function(ent) return IsValid(getParent(ent)) end -function PHYS:SetMass(newMass) - local ent = self:GetEntity() - local oldMass = ent._mass or 0 -- The 'or 0' handles cases of ents connected before they had a physObj - local massDelta = newMass - oldMass - ent._mass = newMass +-- MARK: Helpers +local GetEntCoMWorld, GetEntCoMLocal, GetEntMass, AddCoM, RecalcCoM - setMass(self, newMass) +do + GetEntCoMWorld = function(ent) + local phys = ent:GetPhysicsObject() + if not IsValidPhys(phys) then return ent:GetPos() end + return ent:LocalToWorld(phys:GetMassCenter()) + end + + -- World mass-center of ent expressed in root's local space (root = the family's physical root) + GetEntCoMLocal = function(ent, root) + return WorldToLocal(GetEntCoMWorld(ent), angle_zero, root:GetPos(), root:GetAngles()):Unpack() + end - local con = ent:CFW_GetContraption() + -- Returns cached mass, or reads it from the physics object on first access + GetEntMass = function(ent) + local mass = ent._mass + if mass then return mass end - if con then - con.totalMass = con.totalMass + massDelta + local phys = ent:GetPhysicsObject() + if not IsValidPhys(phys) then return 0 end + + mass = phys:GetMass() + ent._mass = mass + + return mass end - local family = ent.GetFamily and ent:GetFamily() + -- Increments the family's mass-weighted CoM accumulators by one entity's contribution. + -- Accumulators live in the family's physical-root local space (the rotator for turrets), + -- so the cached CoM holds as the root moves; only intra-family relative motion drifts it. + AddCoM = function(family, ent, mass) + local root = CFW.getPhysicalRoot(family.ancestor) + local x, y, z = GetEntCoMLocal(ent, root) - if family then - family.totalMass = family.totalMass + massDelta + family.massWeightedX = family.massWeightedX + x * mass + family.massWeightedY = family.massWeightedY + y * mass + family.massWeightedZ = family.massWeightedZ + z * mass + end + + -- Recomputes the family's mass-weighted CoM accumulators from scratch, in physical-root space + RecalcCoM = function(family) + local root = CFW.getPhysicalRoot(family.ancestor) + local wx, wy, wz = 0, 0, 0 + + for ent in pairs(family.ents) do + local mass = GetEntMass(ent) + local x, y, z = GetEntCoMLocal(ent, root) + + wx = wx + x * mass + wy = wy + y * mass + wz = wz + z * mass + end + + family.massWeightedX = wx + family.massWeightedY = wy + family.massWeightedZ = wz end end -local function InitMass(Class) - Class.totalMass = 0 + +do -- MARK: PhysObj Detour + function PHYS:SetMass(newMass) + local ent = self:GetEntity() + local oldMass = ent._mass or 0 + + -- Apply the mass, then read back what actually stuck: the engine clamps mass + -- to [0, 50000], so the requested value may not be the value in effect. Track + -- the clamped result so CFW's cache never drifts from physics reality + setMass(self, newMass) + newMass = self:GetMass() + + local delta = newMass - oldMass + ent._mass = newMass + + local con = ent._contraption + if not con then return end + + con.totalMass = con.totalMass + delta + + local family = ent._family + if family then + family.totalMass = family.totalMass + delta + + if ent == family.ancestor and not IsParented(ent) then + family.physicalMass = family.physicalMass + delta + else + family.parentedMass = family.parentedMass + delta + end + + AddCoM(family, ent, delta) + end + + if IsParented(ent) then + con.parentedMass = con.parentedMass + delta + else + con.physicalMass = con.physicalMass + delta + end + + hook.Run("cfw.contraption.massChanged", con, ent, newMass) + + if family then + hook.Run("cfw.family.massChanged", family, ent, newMass) + end + end end -hook.Add("cfw.contraption.created", "CFW_Mass", InitMass) -hook.Add("cfw.family.created", "CFW_Mass", InitMass) -local function AddMass(Class, Ent) - if not IsValid(Ent) then return end +do -- MARK: Base class + -- Shared by Family and Contraption (both have totalMass, physicalMass, parentedMass) + local BASE = CFW.Classes.EntityCollection - local PhysObj = Ent:GetPhysicsObject() + function BASE:GetMass() return self.totalMass end + function BASE:GetPhysicalMass() return self.physicalMass end + function BASE:GetParentedMass() return self.parentedMass end +end + + +do -- MARK: Family + local FAMILY = CFW.Classes.Family - if IsValid(PhysObj) then - local Mass = PhysObj:GetMass() + -- Family CoM is stored in physical-root local space; transformed to world on demand + function FAMILY:GetCenterOfMass() + local mass = self.totalMass + local root = CFW.getPhysicalRoot(self.ancestor) - Ent._mass = Mass - Class.totalMass = Class.totalMass + Mass + return root:LocalToWorld(Vector( + self.massWeightedX / mass, + self.massWeightedY / mass, + self.massWeightedZ / mass + )) end end -hook.Add("cfw.contraption.entityAdded", "CFW_Mass", AddMass) -hook.Add("cfw.family.added", "CFW_Mass", AddMass) -local function SubMass(Class, Ent) - if not IsValid(Ent) then return end +do -- MARK: Contraption + local CONTRAPTION = CFW.Classes.Contraption + + -- Contraption CoM aggregates family CoMs and non-family entity positions + function CONTRAPTION:GetCenterOfMass() + local mass = self.totalMass + local wx, wy, wz = 0, 0, 0 + + -- Get the CoM from families + for family in pairs(self.families) do + local familyMass = family.totalMass + local fx, fy, fz = family:GetCenterOfMass():Unpack() + + wx = wx + fx * familyMass + wy = wy + fy * familyMass + wz = wz + fz * familyMass + end - local PhysObj = Ent:GetPhysicsObject() + -- Then from physical entities (skip those with families) + for ent in pairs(self.physical) do + if not ent._family then + local entMass = GetEntMass(ent) + local ex, ey, ez = GetEntCoMWorld(ent):Unpack() - if IsValid(PhysObj) then - Class.totalMass = Class.totalMass - PhysObj:GetMass() + wx = wx + ex * entMass + wy = wy + ey * entMass + wz = wz + ez * entMass + end + end + + return Vector(wx / mass, wy / mass, wz / mass) end end -hook.Add("cfw.contraption.entityRemoved", "CFW_Mass", SubMass) -hook.Add("cfw.family.subbed", "CFW_Mass", SubMass) \ No newline at end of file + +local function initMassTotals(obj) + obj.totalMass = 0 + obj.physicalMass = 0 + obj.parentedMass = 0 +end + + +do -- MARK: Contraption hooks + hook.Add("cfw.contraption.init", "CFW_Mass", initMassTotals) + + hook.Add("cfw.contraption.entityAdded", "CFW_Mass", function(contraption, ent) + local mass = GetEntMass(ent) + + contraption.totalMass = contraption.totalMass + mass + contraption.physicalMass = contraption.physicalMass + mass + end) + + -- wasPhysical is the entity's tracked physical state at removal time (passed by Contraption:Sub) + -- It is the authoritative source - using the engine IsParented state here is racy: The parent state + -- is changed before CFW processes the removal (e.g. constraining an already-parented entity) + + hook.Add("cfw.contraption.entityRemoved", "CFW_Mass", function(contraption, ent, wasPhysical) + local mass = GetEntMass(ent) + + contraption.totalMass = contraption.totalMass - mass + + if wasPhysical then + contraption.physicalMass = contraption.physicalMass - mass + else + contraption.parentedMass = contraption.parentedMass - mass + end + end) + + -- cfw.family.split fires before this, so family masses are already correct + hook.Add("cfw.contraption.split", "CFW_Mass", function(oldContraption, newContraption) + local totalMass = 0 + local physicalMass = 0 + local parentedMass = 0 + + for family in pairs(newContraption.families) do + totalMass = totalMass + family.totalMass + physicalMass = physicalMass + family.physicalMass + parentedMass = parentedMass + family.parentedMass + end + + for ent in pairs(newContraption.ents) do + if not ent._family then + local mass = GetEntMass(ent) + totalMass = totalMass + mass + physicalMass = physicalMass + mass + end + end + + newContraption.totalMass = totalMass + newContraption.physicalMass = physicalMass + newContraption.parentedMass = parentedMass + + oldContraption.totalMass = oldContraption.totalMass - totalMass + oldContraption.physicalMass = oldContraption.physicalMass - physicalMass + oldContraption.parentedMass = oldContraption.parentedMass - parentedMass + end) + + hook.Add("cfw.contraption.merged", "CFW_Mass", function(oldContraption, newContraption) + newContraption.totalMass = newContraption.totalMass + oldContraption.totalMass + newContraption.physicalMass = newContraption.physicalMass + oldContraption.physicalMass + newContraption.parentedMass = newContraption.parentedMass + oldContraption.parentedMass + end) +end + + +do -- MARK: Family hooks + hook.Add("cfw.family.init", "CFW_Mass", function(family) + initMassTotals(family) + family.massWeightedX = 0 -- Sum of (mass * localPos.x) for CoM calculation + family.massWeightedY = 0 + family.massWeightedZ = 0 + end) + + hook.Add("cfw.family.added", "CFW_Mass", function(family, ent) + local mass = GetEntMass(ent) + local contraption = family.contraption + local isPhysical = ent == family.ancestor and not IsParented(ent) + + family.totalMass = family.totalMass + mass + + if isPhysical then + family.physicalMass = family.physicalMass + mass + else + family.parentedMass = family.parentedMass + mass + contraption.physicalMass = contraption.physicalMass - mass + contraption.parentedMass = contraption.parentedMass + mass + end + + AddCoM(family, ent, mass) + end) + + hook.Add("cfw.family.merged", "CFW_Mass", function(family, oldFamily) + local contraption = family.contraption + local physicalMass = oldFamily.physicalMass + + family.totalMass = family.totalMass + oldFamily.totalMass + family.parentedMass = family.parentedMass + oldFamily.totalMass + + contraption.physicalMass = contraption.physicalMass - physicalMass + contraption.parentedMass = contraption.parentedMass + physicalMass + + -- oldFamily.ents is already cleared by the time this hook fires + -- recompute the whole accumulator from the merged family's entity set instead + RecalcCoM(family) + end) + + hook.Add("cfw.family.subbed", "CFW_Mass", function(family, ent, wasPhysical) + local mass = GetEntMass(ent) + local contraption = family.contraption + + family.totalMass = family.totalMass - mass + + AddCoM(family, ent, -mass) + + if wasPhysical then + family.physicalMass = family.physicalMass - mass + return + end + + family.parentedMass = family.parentedMass - mass + + -- Entity transitions from parented to physical in the contraption + -- If it's removed from the contraption then that will happen later. Families are resolved first. + contraption.parentedMass = contraption.parentedMass - mass + contraption.physicalMass = contraption.physicalMass + mass + end) + + hook.Add("cfw.family.ancestorRemoved", "CFW_Mass", function(family, oldAncestor, newAncestor) + local oldMass = GetEntMass(oldAncestor) + local newMass = GetEntMass(newAncestor) + local contraption = family.contraption + local isRootFamily = not family.parentFamily + + family.totalMass = family.totalMass - oldMass + + if isRootFamily then + -- Root: old physical leaves, new parented becomes physical + family.physicalMass = family.physicalMass - oldMass + newMass + family.parentedMass = family.parentedMass - newMass + contraption.parentedMass = contraption.parentedMass - newMass + contraption.physicalMass = contraption.physicalMass + newMass + else + -- Sub-family: old parented leaves, new stays parented + family.parentedMass = family.parentedMass - oldMass + end + + RecalcCoM(family) + end) + + -- InsertAncestor: a NEW ancestor is added in front of the old one, which becomes a + -- parented child. The new ancestor was already added to the contraption as physical via Contraption:Add + hook.Add("cfw.family.ancestorInserted", "CFW_Mass", function(family, oldAncestor, newAncestor) + local oldMass = GetEntMass(oldAncestor) + local newMass = GetEntMass(newAncestor) + local contraption = family.contraption + + -- New ancestor joins the family. + family.totalMass = family.totalMass + newMass + + -- Physical ancestor swaps from old -> new; old ancestor becomes a parented child. + family.physicalMass = family.physicalMass - oldMass + newMass + family.parentedMass = family.parentedMass + oldMass + + contraption.physicalMass = contraption.physicalMass - oldMass + contraption.parentedMass = contraption.parentedMass + oldMass + + RecalcCoM(family) + end) + + hook.Add("cfw.family.split", "CFW_Mass", function(oldFamily, newFamily) + -- The moved members' contributions must be subtracted from oldFamily's accumulator + -- in the SAME frame they were stored in: oldFamily's physical-root local space + local oldRoot = CFW.getPhysicalRoot(oldFamily.ancestor) + local totalMass = 0 + local oldWx, oldWy, oldWz = 0, 0, 0 + + for ent in pairs(newFamily.ents) do + local mass = GetEntMass(ent) + local x, y, z = GetEntCoMLocal(ent, oldRoot) + + totalMass = totalMass + mass + + oldWx = oldWx + x * mass + oldWy = oldWy + y * mass + oldWz = oldWz + z * mass + end + + -- Treat the new family as still fully parented here. Every moved member WAS + -- parented in the old contraption (including newAncestor, which was a child), so + -- the cfw.contraption.split hook that runs next subtracts them all from the old + -- contraption's parented weight correctly. newAncestor's parented -> physical + -- transition is applied afterwards by the cfw.family.becameRoot hook + newFamily.totalMass = totalMass + newFamily.physicalMass = 0 + newFamily.parentedMass = totalMass + + RecalcCoM(newFamily) + + oldFamily.totalMass = oldFamily.totalMass - totalMass + oldFamily.parentedMass = oldFamily.parentedMass - totalMass + oldFamily.massWeightedX = oldFamily.massWeightedX - oldWx + oldFamily.massWeightedY = oldFamily.massWeightedY - oldWy + oldFamily.massWeightedZ = oldFamily.massWeightedZ - oldWz + + end) + + -- A pre-existing root family became a sub-family because its (physical) ancestor got + -- parented. Move the ancestor's mass physical -> parented at both the family and + -- contraption level. This is the mirror of cfw.family.becameRoot and is fired + -- explicitly from the blocker-parenting path only + -- + -- physicalMass for a family is exactly the ancestor's mass (root) or 0 (sub-family) + hook.Add("cfw.family.becameSubFamily", "CFW_Mass", function(family) + local physical = family.physicalMass + if physical == 0 then return end + + local contraption = family.contraption + + family.physicalMass = 0 + family.parentedMass = family.parentedMass + physical + + contraption.physicalMass = contraption.physicalMass - physical + contraption.parentedMass = contraption.parentedMass + physical + end) + + -- A sub-family became a root family: its parent link was removed and the family + -- migrated to its own contraption, so the ancestor transitions parented -> physical. + -- This fires AFTER cfw.contraption.split has rebuilt the new contraption's totals + -- treating the family as fully parented (physicalMass == 0), so we just shift the + -- ancestor's mass to physical on the (new) contraption. + hook.Add("cfw.family.becameRoot", "CFW_Mass", function(family) + local mass = GetEntMass(family.ancestor) + local contraption = family.contraption + + family.physicalMass = family.physicalMass + mass + family.parentedMass = family.parentedMass - mass + + contraption.physicalMass = contraption.physicalMass + mass + contraption.parentedMass = contraption.parentedMass - mass + end) +end \ No newline at end of file diff --git a/lua/cfw/extensions/position_sv.lua b/lua/cfw/extensions/position_sv.lua index 676c70d..4202f6f 100644 --- a/lua/cfw/extensions/position_sv.lua +++ b/lua/cfw/extensions/position_sv.lua @@ -1,61 +1,219 @@ -local CLASS = CFW.Classes.Contraption -local VEC_0 = Vector(0, 0, 0) +-- Position and AABB calculations for contraptions and families +-- Families cache their AABB in ancestor local space; contraption AABB iterates physical entities only -function CLASS:GetPos() - -- TODO: Optimize this +local HUGE = math.huge +local abs = math.abs - local pos = VEC_0 +local function expandAABBWorld(x, y, z, mins, maxs) + if x < mins.x then mins.x = x end + if y < mins.y then mins.y = y end + if z < mins.z then mins.z = z end + if x > maxs.x then maxs.x = x end + if y > maxs.y then maxs.y = y end + if z > maxs.z then maxs.z = z end +end - for ent in pairs(self.ents) do - pos = pos + ent:GetPos() - end +-- Expand AABB with entity's OBB transformed to world space using optimized AABB transformation +local function expandAABBWithEnt(ent, mins, maxs) + local obbMins, obbMaxs = ent:GetCollisionBounds() + local ang = ent:GetAngles() - return pos / self.count -end + local fx, fy, fz = ang:Forward():Unpack() + local rx, ry, rz = ang:Right():Unpack() + local ux, uy, uz = ang:Up():Unpack() + local px, py, pz = ent:GetPos():Unpack() + local mnx, mny, mnz = obbMins:Unpack() + local mxx, mxy, mxz = obbMaxs:Unpack() -do -- AABB - local HUGE = math.huge - local corner = Vector() + local ocx = (mnx + mxx) * 0.5 + local ocy = (mny + mxy) * 0.5 + local ocz = (mnz + mxz) * 0.5 + local ex = (mxx - mnx) * 0.5 + local ey = (mxy - mny) * 0.5 + local ez = (mxz - mnz) * 0.5 - local function expandAABB(ent, x, y, z, mins, maxs) - corner:SetUnpacked(x, y, z) + local cx = px + ocx * fx + ocy * rx + ocz * ux + local cy = py + ocx * fy + ocy * ry + ocz * uy + local cz = pz + ocx * fz + ocy * rz + ocz * uz + + local newEx = ex * abs(fx) + ey * abs(rx) + ez * abs(ux) + local newEy = ex * abs(fy) + ey * abs(ry) + ez * abs(uy) + local newEz = ex * abs(fz) + ey * abs(rz) + ez * abs(uz) + + expandAABBWorld(cx - newEx, cy - newEy, cz - newEz, mins, maxs) + expandAABBWorld(cx + newEx, cy + newEy, cz + newEz, mins, maxs) +end - local worldCorner = ent:LocalToWorld(corner) +do -- MARK: Family + local CLASS = CFW.Classes.Family + local Vector = Vector - if worldCorner.x < mins.x then mins.x = worldCorner.x end - if worldCorner.y < mins.y then mins.y = worldCorner.y end - if worldCorner.z < mins.z then mins.z = worldCorner.z end - if worldCorner.x > maxs.x then maxs.x = worldCorner.x end - if worldCorner.y > maxs.y then maxs.y = worldCorner.y end - if worldCorner.z > maxs.z then maxs.z = worldCorner.z end + -- OBB edge indices (12 edges of a box) + local OBB_EDGES = { + {1, 2}, {2, 4}, {4, 3}, {3, 1}, -- bottom face + {5, 6}, {6, 8}, {8, 7}, {7, 5}, -- top face + {1, 5}, {2, 6}, {3, 7}, {4, 8} -- vertical edges + } + + function CLASS:GetPos() + local physicalRoot = CFW.getPhysicalRoot(self.ancestor) + return physicalRoot:LocalToWorld(self.aabbCenter) end - function CLASS:GetAABB(filter) - local mins, maxs = Vector(HUGE, HUGE, HUGE), -Vector(HUGE, HUGE, HUGE) + -- Recalculate cached OBB in physical root local space (called when membership changes) + function CLASS:RecalculateAABB() + local physicalRoot = CFW.getPhysicalRoot(self.ancestor) + local rootPos = physicalRoot:GetPos() + local rootAng = physicalRoot:GetAngles() + local mins = Vector(HUGE, HUGE, HUGE) + local maxs = Vector(-HUGE, -HUGE, -HUGE) + + -- Pre-extract root basis and origin as scalars + local rfx, rfy, rfz = rootAng:Forward():Unpack() + local rrx, rry, rrz = rootAng:Right():Unpack() + local rux, ruy, ruz = rootAng:Up():Unpack() + local rpx, rpy, rpz = rootPos:Unpack() for ent in pairs(self.ents) do - if filter and not filter(ent) then continue end local obbMins, obbMaxs = ent:GetCollisionBounds() - local minX, minY, minZ = obbMins:Unpack() - local maxX, maxY, maxZ = obbMaxs:Unpack() - - -- Calculate all 8 corners of the entity's OBB in world space - expandAABB(ent, maxX, minY, minZ, mins, maxs) -- Top Left Front - expandAABB(ent, maxX, minY, maxZ, mins, maxs) -- Top Left Back - expandAABB(ent, maxX, maxY, minZ, mins, maxs) -- Top Right Front - expandAABB(ent, maxX, maxY, maxZ, mins, maxs) -- Top Right Back - expandAABB(ent, minX, minY, minZ, mins, maxs) -- Bottom Left Front - expandAABB(ent, minX, minY, maxZ, mins, maxs) -- Bottom Left Back - expandAABB(ent, minX, maxY, minZ, mins, maxs) -- Bottom Right Front - expandAABB(ent, minX, maxY, maxZ, mins, maxs) -- Bottom Right Back + local entAng = ent:GetAngles() + + local fx, fy, fz = entAng:Forward():Unpack() + local rx, ry, rz = entAng:Right():Unpack() + local ux, uy, uz = entAng:Up():Unpack() + local px, py, pz = ent:GetPos():Unpack() + local mnx, mny, mnz = obbMins:Unpack() + local mxx, mxy, mxz = obbMaxs:Unpack() + + -- OBB center and half-extents in entity local space + local ocx = (mnx + mxx) * 0.5 + local ocy = (mny + mxy) * 0.5 + local ocz = (mnz + mxz) * 0.5 + local ex = (mxx - mnx) * 0.5 + local ey = (mxy - mny) * 0.5 + local ez = (mxz - mnz) * 0.5 + + -- Transform OBB center: entity local -> world + local wcx = px + ocx * fx + ocy * rx + ocz * ux + local wcy = py + ocx * fy + ocy * ry + ocz * uy + local wcz = pz + ocx * fz + ocy * rz + ocz * uz + + -- Transform world center -> root local + local dx, dy, dz = wcx - rpx, wcy - rpy, wcz - rpz + local lcx = dx * rfx + dy * rfy + dz * rfz + local lcy = dx * rrx + dy * rry + dz * rrz + local lcz = dx * rux + dy * ruy + dz * ruz + + -- AABB-of-OBB: project entity half-extents onto each root axis + local newEx = ex * abs(fx * rfx + fy * rfy + fz * rfz) + + ey * abs(rx * rfx + ry * rfy + rz * rfz) + + ez * abs(ux * rfx + uy * rfy + uz * rfz) + local newEy = ex * abs(fx * rrx + fy * rry + fz * rrz) + + ey * abs(rx * rrx + ry * rry + rz * rrz) + + ez * abs(ux * rrx + uy * rry + uz * rrz) + local newEz = ex * abs(fx * rux + fy * ruy + fz * ruz) + + ey * abs(rx * rux + ry * ruy + rz * ruz) + + ez * abs(ux * rux + uy * ruy + uz * ruz) + + -- Expand AABB + local lminx, lminy, lminz = lcx - newEx, lcy - newEy, lcz - newEz + local lmaxx, lmaxy, lmaxz = lcx + newEx, lcy + newEy, lcz + newEz + + if lminx < mins.x then mins.x = lminx end + if lminy < mins.y then mins.y = lminy end + if lminz < mins.z then mins.z = lminz end + if lmaxx > maxs.x then maxs.x = lmaxx end + if lmaxy > maxs.y then maxs.y = lmaxy end + if lmaxz > maxs.z then maxs.z = lmaxz end end - local center = (mins + maxs) * 0.5 + self.aabbMins = mins + self.aabbMaxs = maxs + self.aabbCenter = (mins + maxs) * 0.5 + end + + -- Returns 8 world-space OBB corners and 12 edge index pairs + function CLASS:GetOBB() + local physicalRoot = CFW.getPhysicalRoot(self.ancestor) + local mins, maxs = self.aabbMins, self.aabbMaxs + local mx, my, mz = mins.x, mins.y, mins.z + local Mx, My, Mz = maxs.x, maxs.y, maxs.z + + local verts = { + physicalRoot:LocalToWorld(Vector(mx, my, mz)), + physicalRoot:LocalToWorld(Vector(Mx, my, mz)), + physicalRoot:LocalToWorld(Vector(mx, My, mz)), + physicalRoot:LocalToWorld(Vector(Mx, My, mz)), + physicalRoot:LocalToWorld(Vector(mx, my, Mz)), + physicalRoot:LocalToWorld(Vector(Mx, my, Mz)), + physicalRoot:LocalToWorld(Vector(mx, My, Mz)), + physicalRoot:LocalToWorld(Vector(Mx, My, Mz)) + } + + return verts, OBB_EDGES + end +end + + +do -- MARK: Contraption + local CLASS = CFW.Classes.Contraption + local Vector = Vector + + function CLASS:GetPos() + local _, _, center = self:GetAABB() + return center + end + + -- Expands mins/maxs with the OBB of a family and all its sub-families + local function expandAABBWithFamily(family, mins, maxs) + local verts = family:GetOBB() + for _, vert in ipairs(verts) do + expandAABBWorld(vert.x, vert.y, vert.z, mins, maxs) + end + + for subFamily in pairs(family.subFamilies) do + expandAABBWithFamily(subFamily, mins, maxs) + end + end + + -- Iterates physical entities, using cached family OBBs where available + function CLASS:GetAABB() + local mins = Vector(HUGE, HUGE, HUGE) + local maxs = Vector(-HUGE, -HUGE, -HUGE) - -- debugoverlay.Cross(mins, 12, 0.1, Color(255, 0, 0), true) - -- debugoverlay.Cross(maxs, 12, 0.1, Color(0, 255, 0), true) - -- debugoverlay.Box(center, mins - center, maxs - center, 0.1, self.color) + for ent in pairs(self.physical) do + local family = ent._family + if family then + expandAABBWithFamily(family, mins, maxs) + else + expandAABBWithEnt(ent, mins, maxs) + end + end + + local center = (mins + maxs) * 0.5 return mins, maxs, center end -end \ No newline at end of file +end + + +do -- MARK: Family hooks + hook.Add("cfw.family.init", "CFW_Position", function(family) + family.aabbMins = Vector(0, 0, 0) + family.aabbMaxs = Vector(0, 0, 0) + family.aabbCenter = Vector(0, 0, 0) + end) + + local function recalc(family) family:RecalculateAABB() end + + hook.Add("cfw.family.added", "CFW_Position", recalc) + hook.Add("cfw.family.subbed", "CFW_Position", recalc) + hook.Add("cfw.family.merged", "CFW_Position", recalc) + hook.Add("cfw.family.ancestorRemoved", "CFW_Position", recalc) + hook.Add("cfw.family.ancestorInserted", "CFW_Position", recalc) + + hook.Add("cfw.family.split", "CFW_Position", function(oldFamily, newFamily) + oldFamily:RecalculateAABB() + newFamily:RecalculateAABB() + end) +end diff --git a/lua/entities/gmod_wire_expression2/core/custom/cfw.lua b/lua/entities/gmod_wire_expression2/core/custom/cfw.lua deleted file mode 100644 index e4b9a60..0000000 --- a/lua/entities/gmod_wire_expression2/core/custom/cfw.lua +++ /dev/null @@ -1,73 +0,0 @@ -E2Lib.RegisterExtension("contraption", true, "Enables interaction with Contraption Framework") - -local function isValidContraption(c) - return CFW.Contraptions[c] or false -end - -do -- Datatype and operator - registerType("contraption", "xcr", nil, nil, nil, - function(retval) - if retval == nil then return end - if not istable(retval) then error("Return value is neither nil nor a table, but a " .. type(retval) .. "!", 0) end - end, - function(v) - return not istable(v) - end - ) - - e2function number operator_is(contraption cont) - return isValidContraption(cont) and 1 or 0 - end - - e2function number operator==(contraption c1, contraption c2) - return c1 == c2 and 1 or 0 - end -end - -do - hook.Add("cfw.contraption.created", "e2Tables", function(c) - c.e2Table = WireLib.E2Table.New() - end) - - -- TODO: Merge support -end - -__e2setcost(5) - -e2function number contraption:isValid() - return isValidContraption(this) and 1 or 0 -end - -e2function contraption entity:getContraption() - return this:CFW_GetContraption() -end - -e2function number contraption:count() - return isValidContraption(this) and this.count or 0 -end - -e2function number contraption:getMass() - return isValidContraption(this) and this.totalMass or 0 -end - -e2function table contraption:getTable() - return isValidContraption(this) and this.e2Table or WireLib.E2Table.New() -end - -__e2setcost(20) - -e2function array contraption:getEntities() - if not isValidContraption(this) then return {} end - - local output = {} - local count = 0 - - for k in pairs(this.ents) do - count = count + 1 - - output[count] = k - end - - self.prf = self.prf + count * 2 - return output -end \ No newline at end of file diff --git a/lua/entities/gmod_wire_expression2/core/custom/cl_cfw.lua b/lua/entities/gmod_wire_expression2/core/custom/cl_cfw.lua deleted file mode 100644 index 74333bc..0000000 --- a/lua/entities/gmod_wire_expression2/core/custom/cl_cfw.lua +++ /dev/null @@ -1,6 +0,0 @@ -E2Helper.Descriptions["isValid(xcr:)"] = "Returns 1 if the contraption is valid and 0 otherwise." -E2Helper.Descriptions["getContraption(e:)"] = "Returns the contraption constrained to this entity." -E2Helper.Descriptions["count(xcr:)"] = "Returns the number of entities in the contraption." -E2Helper.Descriptions["getMass(xcr:)"] = "Returns the total mass of the contraption in kg." -E2Helper.Descriptions["getTable(xcr:)"] = "Returns the E2 table of the contraption." -E2Helper.Descriptions["getEntities(xcr:)"] = "Returns the entities contained in the contraption." \ No newline at end of file