How do you reliably get a group/component instances volume?

Hi,

This is something that I have been struggling with for quite a while. I’ve seen a few posts that relate to this problem but nothing that I’ve found to be conclusive, complete or clear to implement.

Problem: The SketchUp API doesn’t seem to have a reliable method to get the volume of a group/component Instance when it is contained within another group/component instance and transformations are involved. ‘[someGroupOrComponentInstance].volume’ doesn’t reliably give the correct value if transformations have been applied to a group/component Instance or any of its container(s).

I’ve tried all sorts of things, like combining all the transformations, and read may posts here and elsewhere. One way or another nothing seems to be reliable, at least the way I’ve interpreted it, either giving wrong values or crashing Sketchup.

Does anyone have and would like to share code that will do the equivalent of [someGroupOrComponentInstance].volume but works on any group or component instance no matter what group/component container(s) it is nested within or what transformations have been applied to them?

e.g.

Vol = reliableVolume([someGroupOrComponentInstance])

Def get_reliable_volume([someGroupOrComponentInstance])
…magic code…
End

This isn’t my day job, I’m just a humble hobbyist, so apologies if the answer is obvious. Perhaps there is no easy solution and, if so, that is worth knowing too so I can get on with the rest of my life!

Can you give an example of a skp file exhibiting the issue and trial code that doesn’t work? When I tried this with simple examples, I get the correct answer to within normal computer arithmetic limitations.

Unless…what you are after is the theoretical volume that an unscaled instance would have if placed in the model? The simplest way to get that might be to actually place an instance, get its volume, and then erase the instance.

Looking at this, it surprises me that volume calculation is done for an instance, not definition or an InstancePath. An instance of its own doesn’t really have a defined volume; you also need to know the scaling of all parent instances, which SketchUp seems to ignore.

To make it more confusing, the volume returned changes depending on whether a scaled parent instance is currently opened for editing, as SketchUp temporarily transforms coordinates to global space for the currently active drawing context.

It seems this API was added to cater for Dynamic Components (which have their scale reset and gets redrawn whenever you scale them), not to handle the general case. We should consider redesigning this API.

To get the actual volume from an InstancePath, I think you currently need to get the combined transformation for the instance path, except for the last transformation (as SketchUp already takes that specific one into account), and then multiply the determinant of that transformation with the volume SketchUp returns for the instance.

1 Like

Wrote up this snippet that in my quick testing seems to provide the expected results.

The error handling is still missing for non-solid groups/components. The transformation helper stuff is best kept in a separate transformation helper module.

# Calculate the volume for a Group or Component, taking the scaling of any
# parent instances into account.
#
# @param instance_path [Sketchup::InstancePath]
#
# @return [Float] Volume in cubic inches.
def self.calculate_volume(instance_path)
  leaf_instance = instance_path.to_a.last
  parents = instance_path.to_a[0..-2]
  # We exclude the transformation of the leaf instance itself,
  # as SketchUp already takes it into account in Instance#volume/Group#volume.
  parent_transformation = parents.map(&:transformation).inject(IDENTITY, :*)

  # TODO: Add error handling if not manifold.
  leaf_instance.volume * determinant(parent_transformation)
end

# (Generic Transformation helper methods taken from
# https://github.com/Eneroth3/sketchup-community-lib)

# Calculate determinant of 3X3 matrix (ignore translation).
#
# @param transformation [Geom::Transformation]
#
# @return [Float]
def self.determinant(transformation)
  xaxis(transformation) % (yaxis(transformation) * zaxis(transformation))
end

# Get the X axis vector of a transformation.
#
# Unlike native +Transformation#xaxis+ the length of this axis isn't normalized
# but resamples the scaling along the axis.
#
# @param transformation [Geom::Transformation]
#
# @return [Geom::Vector3d]
def self.xaxis(transformation)
  v = Geom::Vector3d.new(transformation.to_a.values_at(0..2))
  v.length /= transformation.to_a[15]

  v
end

# Get the Y axis vector of a transformation.
#
# Unlike native +Transformation#yaxis+ the length of this axis isn't normalized
# but resamples the scaling along the axis.
#
# @param transformation [Geom::Transformation]
#
# @return [Geom::Vector3d]
def self.yaxis(transformation)
  v = Geom::Vector3d.new(transformation.to_a.values_at(4..6))
  v.length /= transformation.to_a[15]

  v
end

# Get the Z axis vector of a transformation.
#
# Unlike native +Transformation#zaxis+ the length of this axis isn't normalized
# but resamples the scaling along the axis.
# @param transformation [Geom::Transformation]
#
# @return [Geom::Vector3d]
def self.zaxis(transformation)
  v = Geom::Vector3d.new(transformation.to_a.values_at(8..10))
  v.length /= transformation.to_a[15]

  v
end

Logged API limitation as GitHub #692

2 Likes

I wonder if instance.volume was modelled after face.area - but missed the critical part of allowing a transformation to be passed in.

Class: Sketchup::Face — SketchUp Ruby API Documentation

1 Like

It is not a case of purposefully ignoring scaling. It is just that given any random instance reference (separate from a particular instance path) the instance itself cannot know which of many instance paths that it can be in, … each of which could have different cumulative scaling depending upon the scales of upstream ancestors.
So, although not that helpful that in this scenario instance#volume returns the untransformed volume, it is understandable.

For SU2020.0 and higher where the active_path can be changed, we can walk the model tree and get proper scaled volumes without messing with transformations.

FAIR WARNING: Julia has pointed out that changing the active edit path modifies the model and effects the undo and redo paths. The following example would likely be a good candidate for wrapping within a model operation. The operation could either be aborted, or undone after the volume is calculated.

# Walks the model's entities tree getting the cumulative volume for a target
# manifold component or group. (Pass in the instance or it's definition.)
#
# @param target [Sketchup::ComponentDefinition,Sketchup::ComponentInstance,Sketchup::Group]
#
# @return [Float] Pass this number to {Sketchup::format_volume} to produce
#  a string formatted for display in the current model units.
#
# @since SketchUp 2020.0
def get_cumulative_volume(target)
  total = 0.0
  # Argument validation:
  if target.is_a?(Sketchup::ComponentDefinition)
    target_definition = target
    unless target.count_used_instances.size > 0
      puts "#{__meth__}: definition object has no used instances."
      return total
    end
    unless target.instances[0].manifold?
      fail(ArgumentError,'definition does not define manifold geometry.',caller)
    end
  elsif target.respond_to?(:definition)
    target_definition = target.definition
    unless target.manifold?
      fail(ArgumentError,'instance or group is not manifold.',caller)
    end
  else
    fail(TypeError,'instance or definition object expected.',caller)
  end
  #
  model = target.model
  # Proc for walking an entities context:
  walk = proc { |ents|
    insts = ents.select { |e| e.respond_to?(:definition) }
    targets = insts.find_all { |i| i.definition == target_definition }
    total += targets.sum(&:volume)
    insts = insts - targets
    insts.each do |inst|
      if model.active_path.nil?
        model.active_path= [inst]
      else
        model.active_path= model.active_path << inst
      end
      # Recursive proc call:
      walk.call(inst.definition.entities)
      path = model.active_path
      path.pop # remove last leaf
      model.active_path= path
    end
  }
  #
  prev = model.active_path
  model.active_path= []
  walk.call(model.entities)
  #
  model.active_path= prev
  return total
end
1 Like

OK, so I’ve tried ene_su’s code above and it does seem to work fine on a range of transformed, nested manifold groups/component instances.

To implement it along the lines of my original question I’ve used the code below (paste the code below after ene_su’s code). Sorry if it looks rather amateur and inefficeint to you guys!

Just for interest: I’d tried a similar approach to the code below before and, from each container’s transformation, also built up the combined transformation for the final volume calculation adjustment - but had problems. E.g., if a group or component instance is added to an existing nested group, somehow the order of transformations and therefore overall adjustment is affected. But ene_su’s approach seem to work fine.

Thanks for all your help on this.

#To get the volume of an array of groups/conponent instances, including
#nested ones, using ene_su's code.
#
#@param array [ent1,ent2,ent3,...]
#@return array [[ent1,ent1's volume],[ent2,ent2's volume],[ent3,ent3's volume],...]
def reliableVolume(ent_a)
  path_data_a = []
  ci_volumes=[]

  #Adds all nested Gs/CIs in the selection and records each one's container ID.
  ent_a.each{|ent|
    ent.definition.entities.each{|innerEnt|
      #Only interested in Gs/CIs
      if innerEnt.kind_of? Sketchup::Group or innerEnt.kind_of? Sketchup::ComponentInstance
        #Adds any Gs/CIs contained by this G/CI
        ent_a.push(innerEnt)
        #Records each G's/CI's container ID
        path_data_a.push([innerEnt,ent])
      end
    }
  }

  #Gets the volume of each manifold G/CI based on the
  #instancePath, using ene_su's code.
  ent_a.each{|ci|
    #To check if manifold (if not, returns a minus number)
    if ci.volume > 0
      #Builds the instancePath
      path_a=[ci]
      this_ci=ci
      loop do
        #Gets the container data for this G/CI
        this_ci_data = path_data_a.assoc(this_ci)
        #Exits if no data i.e. reached the end.
        break if this_ci_data == nil
        #The container ID
        this_ci_cont = this_ci_data[1]
        #Adds continer ID to list
        path_a.push(this_ci_cont)
        #moves container - next level up
        this_ci = this_ci_cont
      end
      #Reverses the list to start with the root G/CI
      path_a.reverse!
      #Deletes this record to avoid situations where more than
      #one CI points to the same component definition
      path_data_a.delete(path_data_a.assoc(ci))
      #The instancePath for this G/CI
      path = Sketchup::InstancePath.new(path_a)
      #to get the volume using ene_su's code
      vol = calculate_volume(path)
      ci_volumes.push([ci,vol])
    end
  }
  return ci_volumes
end

#For testing, use the entities currently selected...
sel = Sketchup.active_model.selection
ent_a = []
#Adds selected G/CI entity IDs to an array
sel.each {|ent|
  #Only interested in Gs/CIs
  if ent.kind_of? Sketchup::Group or ent.kind_of? Sketchup::ComponentInstance
    ent_a.push(ent)
  end
}
puts reliableVolume(ent_a)

1 Like

Assuming that we always expect the volume to be positive, isn’t the determinant negative for a coordinate system with a flipped axis? Or maybe that is accounted for in some other way?

1 Like

Good catch! You need to #abs the value too.

Wrapping in a model operation to abort or undo still clears the redo stack. Rather than changing the model (changing active entities) when all you want to do is passively read data from it and then try to figure out a workaround to make it less noticeable, you can just not change the model in the first place.

1 Like