diff --git a/lua/primitive/core/construct.lua b/lua/primitive/core/construct.lua index 27b06cc..7105ffa 100644 --- a/lua/primitive/core/construct.lua +++ b/lua/primitive/core/construct.lua @@ -98,6 +98,166 @@ local function util_PointMirror( point, origin, normal ) return point + normal * l * 2 end +-- Two unit axes spanning the plane the normal defines +local function util_PlaneBasis( normal ) + local right + if math_abs( normal.z ) < 0.9 then + right = vec_getnormalized( vec_cross( normal, Vector( 0, 0, 1 ) ) ) + else + right = vec_getnormalized( vec_cross( normal, Vector( 1, 0, 0 ) ) ) + end + + return right, vec_cross( normal, right ) +end + +-- Andrew's monotone chain over parallel coordinate arrays, returning point indices; collinear and +-- duplicate points are popped so the kept set stays minimal across clips. `order` must hold 1..count. +local function util_ConvexHull2D( px, py, count, order ) + table.sort( order, function( a, b ) + if px[a] ~= px[b] then return px[a] < px[b] end + return py[a] < py[b] + end ) + + local hull, n = {}, 0 + + for i = 1, count do + local p = order[i] + while n >= 2 and ( px[hull[n]] - px[hull[n - 1]] ) * ( py[p] - py[hull[n - 1]] ) - ( py[hull[n]] - py[hull[n - 1]] ) * ( px[p] - px[hull[n - 1]] ) <= 0 do + hull[n] = nil + n = n - 1 + end + n = n + 1 + hull[n] = p + end + + local lower = n + 1 + for i = count - 1, 1, -1 do + local p = order[i] + while n >= lower and ( px[hull[n]] - px[hull[n - 1]] ) * ( py[p] - py[hull[n - 1]] ) - ( py[hull[n]] - py[hull[n - 1]] ) * ( px[p] - px[hull[n - 1]] ) <= 0 do + hull[n] = nil + n = n - 1 + end + n = n + 1 + hull[n] = p + end + + hull[n] = nil -- last point repeats the first + + return hull, n - 1 +end + + +------------------------------- +-- Clips a convex point cloud, keeping the normal side; returns nil if nothing survives. +-- Crossing every above/below pair catches every cut edge, then the 2d hull drops the interior +-- chord crossings so the point count stays bounded across clips. +local function clipConvex( points, planePos, planeNormal ) + local count = #points + local above, dist = {}, {} + local kept = {} + + -- Sort the cloud by side, keeping what the normal points toward + for i = 1, count do + dist[i] = vec_dot( planeNormal, points[i] - planePos ) + above[i] = dist[i] >= -1e-6 + if above[i] then kept[#kept + 1] = points[i] end + end + + if #kept == 0 then return nil end -- plane removed the convex + if #kept == count then return points end -- plane missed it, hand back the original + + -- Cross the plane in the plane's 2d frame, as scalars in parallel arrays: pairs number in + -- the tens of thousands on a dense cloud, so no tables are made per crossing and Vectors + -- are only built for the crossings the hull keeps + local right, up = util_PlaneBasis( planeNormal ) + local rx, uy = {}, {} + local below, nbelow = {}, 0 + + for i = 1, count do + rx[i] = vec_dot( right, points[i] ) + uy[i] = vec_dot( up, points[i] ) + + if not above[i] then + nbelow = nbelow + 1 + below[nbelow] = i + end + end + + local cutX, cutY, cutI, cutJ, cutF, order = {}, {}, {}, {}, {}, {} + local ncuts = 0 + + for i = 1, count do + if above[i] then + local da, xa, ya = dist[i], rx[i], uy[i] + + for k = 1, nbelow do + local j = below[k] + + -- da >= 0 > db, so the denominator is positive and frac lands in [0, 1] + local frac = math_clamp( da / ( da - dist[j] ), 0, 1 ) + + ncuts = ncuts + 1 + cutX[ncuts] = xa + ( rx[j] - xa ) * frac + cutY[ncuts] = ya + ( uy[j] - ya ) * frac + cutI[ncuts] = i + cutJ[ncuts] = j + cutF[ncuts] = frac + order[ncuts] = ncuts + end + end + end + + local hull, nhull + if ncuts < 3 then + -- Fewer than three crossings can't bound a face, so there's no hull to take + hull, nhull = order, ncuts + else + hull, nhull = util_ConvexHull2D( cutX, cutY, ncuts, order ) + end + + for i = 1, nhull do + local cut = hull[i] + local a = points[cutI[cut]] + kept[#kept + 1] = a + ( points[cutJ[cut]] - a ) * cutF[cut] + end + + return kept +end + +-- Applies clip planes ( { pos, normal, seal } ) to a construct result, in place. A plane that +-- would erase the geometry is skipped, so a primitive is never clipped out of existence. +local function applyClips( result, clips, physics ) + for _, clip in ipairs( clips ) do + local planePos = clip.pos + local planeNormal = clip.normal + + -- Clipping a multi convex will always result in a concave hole in the physics mesh. + -- We shouldn't introduce a visual filling where there is a physical hole. + local seal = clip.seal and ( not istable( result.convexes ) or #result.convexes <= 1 ) + + if physics and istable( result.convexes ) then + -- Cut every collision hull, dropping the ones the plane wiped out + local clipped = {} + for i = 1, #result.convexes do + local convex = clipConvex( result.convexes[i], planePos, planeNormal ) + if convex then clipped[#clipped + 1] = convex end + end + + -- Only change if a convex got clipped + if clipped[1] then result.convexes = clipped end + end + + if CLIENT and istable( result.verts ) and istable( result.index ) then + -- Cut the render mesh, capping the hole when the clip asks to be sealed + local above = result:Bisect( { pos = planePos, normal = planeNormal }, seal, false ) + if above then -- false when the plane misses the mesh, in either direction + result.verts = above.verts + result.index = above.index + end + end + end +end + ------------------------------- addon.construct = { simpleton = {} } @@ -192,6 +352,11 @@ do return true, errorModel( 4, name ) end + -- Cut with any caller-supplied clip planes before Build triangulates, so cut faces get UVs + if istable( param.clips ) and param.clips[1] then + applyClips( result, param.clips, physics ) + end + if CLIENT then -- Bad vertex table, error model CODE 5 if not istable( result.verts ) or #result.verts < 3 then diff --git a/lua/primitive/entities/base.lua b/lua/primitive/entities/base.lua index c8d19a9..0b6998b 100644 --- a/lua/primitive/entities/base.lua +++ b/lua/primitive/entities/base.lua @@ -54,12 +54,39 @@ function class:SetupDataTables() self:PrimitiveVar( "PrimMESHPOS", "Vector", { global = true, category = "model", title = "offset", panel = "vector", min = Vector( -500, -500, -500 ), max = Vector( 500, 500, 500 ) }, true ) self:PrimitiveVar( "PrimMESHROT", "Angle", { global = true, category = "model", title = "rotate", panel = "angle" }, true ) + -- Tell improved clipping we are responsible for maintaining the mesh and its clips. + self.ImprovedClippingExternalMesh = true + self:PrimitiveSetupDataTables() end function class:PrimitiveGetConstructSimple( name ) local keys = self:PrimitiveGetKeys() + + -- Bake any improved clips in as entity-local planes. + if ImprovedClipping then + local clips = ImprovedClipping.GetClips( self ) + + if clips[1] then + local param = {} + for k, v in pairs( keys ) do param[k] = v end + + local planes = {} + for i = 1, #clips do + local clip = clips[i] + planes[i] = { + pos = clip.Normal * clip.Distance, + normal = clip.Normal, + seal = clip.Seal ~= false, + } + end + param.clips = planes + + return Primitive.construct.get( name, param, CLIENT, param.PrimMESHPHYS ) + end + end + return Primitive.construct.get( name, keys, CLIENT, keys.PrimMESHPHYS ) end @@ -195,6 +222,10 @@ local function rescale( self, scalar ) end function class:PrimitiveRebuildPhysics( result ) + -- Rebuilding a parented primitive would cause issues with the hitbox. Unparent and reparent + local parent = SERVER and self:GetParent() or nil + if IsValid( parent ) then self:SetParent( nil ) end + local props if SERVER then props = self:PrimitiveGetProperties() @@ -254,6 +285,8 @@ function class:PrimitiveRebuildPhysics( result ) self:PrimitiveSetProperties( props ) end + + if IsValid( parent ) then self:SetParent( parent ) end end @@ -623,28 +656,13 @@ do end end) - -- Re-apply any proper_clipping physics clips after each primitive reconstruction. - -- Primitives reinitialize via PhysicsInitMultiConvex on every reconstruction, which wipes the clips - -- Bypass the race condition by preserving existing clips on rebuild - hook.Add("Primitive_PostRebuildPhysics", "Primitive_ProperClipping", function( ent ) - if not ent.Clipped or not istable( ent.ClipData ) then return end -- no clips to reapply - if not ProperClipping or not isfunction( ProperClipping.ClipPhysics ) then return end -- proper_clipping not installed - - -- Aggregate all physics clips - local norms, dists = {}, {} - local count = 0 - for _, clip in ipairs( ent.ClipData ) do - if clip.physics then - count = count + 1 - norms[count] = clip.norm - dists[count] = clip.dist - end + -- Rebuild on clip changes so the new planes get baked in ( see PrimitiveGetConstructSimple ). + -- The hook only exists when Improved Clipping is installed, so neither addon depends on the other. + hook.Add( "ImprovedClipping_ClipsChanged", "Primitive_ImprovedClipping", function( ent ) + if ent.IsPrimitive and istable( ent.primitive ) then + ent:PrimitiveReconstruct() end - - if count == 0 then return end -- no physics clips to reapply - - ProperClipping.ClipPhysics( ent, norms, dists, true ) - end) + end ) end