Issue - Ruby::Set allows duplicated Points 3D objects

I built a set from Ruby::Set to collect 3D points.
Though Ruby::Set does not allow duplicates, it turns out that my set collected a few points with the same coordinates.

I think it is because the Geom:: Point 3D object uses a hash function that is not based on the x,y,z coordinates of the point.

Could anyone suggest how to make a set that can distinguish the points based on their x,y,z,? Should I make a customized wrapper class for 3D points and override the hash function?

Uploading: image.png…Uploading: image.png…

It’s not quite the same thing, but you could collect the points in an Array and use #uniq! to remove duplicates.

According to the doc:

‘…The equality of elements is determined according to Object#eql? and Object#hash. …’

E.g.:

obj1 = Geom::Point3d.new(0,120,0)
obj2 = Geom::Point3d.new(0,120,0)
obj1.equal?(obj2) # => false

shows that the two object above is not equal, even they are a same 3DPoint.

So, you can expect this:

s1 = Set[Geom::Point3d.new(0,120,0)]  #=> <Set: {Point3d(0, 120, 0)}>
s1.add Geom::Point3d.new(0,120,0) #=> #<Set: {Point3d(0, 120, 0), Point3d(0, 120, 0)}>

You need to use the
Geom::Point3d #== instance_method to compare two points for equality.

Something like this:

s1 = Set[Geom::Point3d.new(0,120,0)] 
newpoint = Geom::Point3d.new(0,120,0)
s1.add newpoint unless s1.to_a.any?{|point| point == newpoint}

Sorry, but that does not work:

ary = [Geom::Point3d.new(0,120,0),Geom::Point3d.new(0,120,0)]
=> [Point3d(0, 120, 0), Point3d(0, 120, 0)]
ary.uniq!
=> nil
ary
=> [Point3d(0, 120, 0), Point3d(0, 120, 0)]

but e.g.:

def unique_point_array(ary)
  newary = []
  ary.size.times{
    pt = ary.shift
    newary<<pt unless ary.any?{|p| p == pt}
  }
  newary
end
unique_point_array(ary)
=> [Point3d(0, 120, 0)]
1 Like

Huh! On my phone now so couldn’t test code. I thought surely uniq! used #== to check, but I guess not !

https://ruby-doc.org/core-2.7.2/Array.html#method-i-uniq-21

It compares values using their hash and eql? methods for efficiency.

To compare two points for equality you have to use Geom::Point3d #== method, according to SU Ruby API…:wink:

I’ve covered this recently (the beginning of August last) … See:

2 Likes

Thanks Dezmo for writing up the #unique_point_array method. Though Dan’s #uniq! + proc solution is more elegant, I appreciate the details shown in your code.

2 Likes

Note that will have be O(n^2) - it’ll quickly become very slow.

As of SU2021.1 you could potentially use Geom::PolygonMesh to collect the point in O(log(n)) time. That will collapse the points within SketchUp’s tolerances.

A quick and dirty alternative would be to create a Point3d subclass (or mix-in) with its own hash and eql function:

points1 = [
  Geom::Point3d.new(1, 2, 3),
  Geom::Point3d.new(4, 5, 6),
  Geom::Point3d.new(1, 2, 3),
  Geom::Point3d.new(3, 2, 1),
]
puts "Geom::Point3d"
p Set.new(points1).to_a

module Point3dEx
  def hash
    to_a.hash
  end
  def eql?(other)
    self == other
  end
end
puts "Point3dEx"
points1.each { |pt| pt.extend(Point3dEx) }
p Set.new(points1).to_a

points2 = [
  Geom::Point3d.new(1, 2, 3),
  Geom::Point3d.new(4, 5, 6),
  Geom::Point3d.new(1, 2.00001, 3),
  Geom::Point3d.new(3, 2, 1),
]
puts "Point3dEx (tolerance test)"
points1.each { |pt| pt.extend(Point3dEx) }
p Set.new(points1).to_a

nil

Output:

Geom::Point3d
[Point3d(1, 2, 3), Point3d(4, 5, 6), Point3d(1, 2, 3), Point3d(3, 2, 1)]

Point3dEx
[Point3d(1, 2, 3), Point3d(4, 5, 6), Point3d(3, 2, 1)]

Point3dEx (tolerance test)
[Point3d(1, 2, 3), Point3d(4, 5, 6), Point3d(3, 2, 1)]
1 Like

hm… thinking about it… I’m not 100% sure this is working fully with tolerance… need to better understand when hash and eql? is used…

In response to @tt_su,

eql? is only called when two of elements in the Set hash to the same value.

module Point3dEx
  def hash
    puts 'hash called'
    77777 # return a uniform 'hash' number for all elements
  end
  
  def eql?(other)
    puts 'eql? called'
    self == other
  end
end

points1 = [
  Geom::Point3d.new(1, 2, 3),
  Geom::Point3d.new(4, 5, 6),
  Geom::Point3d.new(1, 2.00001, 3),
  Geom::Point3d.new(3, 2, 1),
]
points1.each { |pt| pt.extend(Point3dEx) }

point2 = Geom::Point3d.new( 1, 2.0002, 3 )
point2.extend( Point3dEx )

point3 = Geom::Point3d.new( 1, 2.001022, 3 )
point3.extend( Point3dEx )

puts "Point3dEx (tolerance test)"
# Create set
s1 = Set.new( points1 )
puts
puts s1.to_a
puts

#Set.include? for  points 2 & 3
puts "test Set include? point2"
puts s1.include?(point2)
puts
puts "test Set include? point3"
puts s1.include?(point3)

Then I’m not sure why the points Geom::Point3d.new(1, 2.00001, 3) and Geom::Point3d.new(1, 2, 3) appeared to merge… :thinking:

There was a typo in your test code, The points1 array was reused for the tolerance test.

points2 = [
  Geom::Point3d.new(1, 2, 3),
  Geom::Point3d.new(4, 5, 6),
  Geom::Point3d.new(1, 2.00001, 3),
  Geom::Point3d.new(3, 2, 1),
]
puts "Point3dEx (tolerance test)"
points1.each { |pt| pt.extend(Point3dEx) }
p Set.new(points1).to_a
2 Likes

Duh! :man_facepalming:

Yea, that’s what I actually would have expected, no tolerance in comparison.

He’s an alternative version:

points1 = [
Geom::Point3d.new(1, 2, 3),
Geom::Point3d.new(4, 5, 6),
Geom::Point3d.new(1, 2.00001, 3),
Geom::Point3d.new(3, 2, 1),
]
puts "Geom::Point3d"
p Set.new(points1).to_a

module Point3dEx
  def hash
    to_a.map { |i| (i * 1000).to_i }.hash
  end
  def eql?(other)
    self == other
  end
end
puts "Point3dEx"
points1.each { |pt| pt.extend(Point3dEx) }
p Set.new(points1).to_a

nil
Geom::Point3d
[Point3d(1, 2, 3), Point3d(4, 5, 6), Point3d(1, 2.00001, 3), Point3d(3, 2, 1)]

Point3dEx
[Point3d(1, 2, 3), Point3d(4, 5, 6), Point3d(3, 2, 1)]

Is there any doubt that we’re all puzzle solvers? Here is a test case where all is not well.

points1 = [
Geom::Point3d.new(1, 2, 3),
Geom::Point3d.new(1.0007, 2, 3), # == points1[0]
Geom::Point3d.new(1.0007, 2.0009, 3), # != points1[0]
Geom::Point3d.new(0.9997, 2, 3), # == points1[0]
]

puts
puts "Initial points as a Set"
puts Set.new(points1).to_a
puts

module Point3dEx
  def hash
    to_a.map { |i| (i * 1000).to_i }.hash
  end
  
  def eql?(other)
    self == other
  end
end

puts "Points in the Point3dEx Set"
points1.each { |pt| pt.extend(Point3dEx) }
puts Set.new(points1).to_a

puts
puts "Points1[3] is included in the set yet,\npoints1[0] == points1[3] => #{points1[0] == points1[3]}"

nil

Yea, this approach is not usable. Thinking about it, comparing the components of the points to the tolerance is never going to be the same as comparing the distance between then to the tolerance.