require 'sketchup.rb'
###########################################################
#
#    Copyright (C) 2008 Uli Tessel (utessel@gmx.de)
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
###########################################################
#
#    Outliner
#
#    Outliner creates (groups of) Paths for faces:
#    The paths will have a configurable distance to the contour (or 
#    outline) of the face.
#    One group is generated for each face, containing all "loops": So 
#    all holes of that face are found in
#    the same group
#
#    One purpose of this script is to create a milling
#    path for existing faces:
#    A (very simple) kind of CAM (Computer Aided Manufacturing)
#
###########################################################
#
#    Options:
#
#    - Distance: The distance where the path is created.
#        0 is possible (should be)
#        For milling: use half of the diameter of your 
#        milling tool
#      
#    - Material: Allows to filter the faces: Only create
#        the paths for faces with the selected Material,
#        or use "all" to get all faces
#
#    - Sort outside:
#       "no" will create the outline path just around
#       its face.
#       "yes" will rotate and move the result, sorts them
#       by width and place them one after another starting 
#       at the origin: So you will get your paths placed
#       flat outside of your model
#
#    - Wrap sort at:
#       only used when sort is yes: The results will be
#       placed into several columns. A column is full at
#       "Wrap sort at". 0 can be used for infinite, so 
#      only one column is generated.
#
#    - Noses:
#       For concave edges this will create a short additional 
#       edge, required for milling so the result will fit
#
###########################################################

class Outliner

  #--------------------------------------------------------------------------
  def initialize( distance, material, sort, wrapAtY, noses )
    @distance = distance
    @material = material
    @sort = sort
    @noses = noses
    
    @wrapAtY = wrapAtY
    @posX = 0.0
    @posY = 0.0
    @maxx = 0.0
    @maxy = 0.0
  end
  
  #--------------------------------------------------------------------------   
  def MakePlanar( group, n )
       
     # rotate group, so that its normal vector is upwards:  
     # this is simple:
     # target is the vector I want:
     target = Geom::Vector3d.new( 0, 0, 1 )     
     
     # angle is the rotation of the current normal:
     angle = n.angle_between target

     # orth is an orthogonal vector to both (cross product)    
     orth = target * n
     # which is the perfect rotation axis
         
     if (angle!=0) and (orth.length!=0)
          
       # the point does not matter as the result is moved later
       point = Geom::Point3d.new( 0,0,0)       
	   t = Geom::Transformation.rotation( point, orth, -angle )
	   group.transform! t
     end     
	 group.explode
  end
  
  #--------------------------------------------------------------------------
  def PlaceIt( group )
     bb = group.bounds
     point = bb.min
     dest = Geom::Point3d.new( @posX, @posY, 0 )
     move = dest - point
     group.transform! Geom::Transformation.translation( move )
     # next pos:     
     @posY = @posY + bb.max.y - bb.min.y
     # keep track of max width in this column
     maxx = @posX + bb.max.x - bb.min.x         
     if (maxx>@maxx)
       @maxx = maxx
     end
 
     # wrap: start new column
     if (@wrapAtY>0) and (@posY>@wrapAtY)
       @posY = 0;
       @posX = @maxx
     end
  end
  
  #--------------------------------------------------------------------------
  def SortGroup()  
  
    list = []    
    @newGroup.entities.each { |g| list<<g }
    
    # sort groups by their height
    list.sort! { |a,b| b.bounds.width<=>a.bounds.width }
                
    # then Place them one after another      
    list.each { |g| PlaceIt(g) }
  end
  
 
  #--------------------------------------------------------------------------
  def combineTransformation( t1, t2 )
      if (t1)
        if (t2)
          return t1 * t2
        else
          return t1
        end
      else
        return t2
      end
  end

  #--------------------------------------------------------------------------
  def IntersectLight( line )
    l1 = [ @lastLine[0], @lastLine[1] ]
    l2 = [ line[0], line[1] ]
    p = Geom.intersect_line_line( l1, l2 )
    @lastLine = line
    
    if (p)    
      if (@prevPoint)
        @resultLoopLength += (p-@prevPoint).length
      end
      @prevPoint = p;
    end
  end
  #--------------------------------------------------------------------------
  
  def Intersect( line, n )
    l1 = [ @lastLine[0], @lastLine[1] ]
    l2 = [ line[0], line[1] ]
    p = Geom.intersect_line_line( l1, l2 )

    if p then

      if (line[3])
        # original is curve

        if (not @lastLine[3])

          if @pts.length > 1
            # prev wasn't a curve: so add the pts as edges
            @faceGroup.entities.add_edges( @pts )
          end

          # and start a curve at last point:
          last = @pts[ @pts.length-1 ]
          @pts = [last];
        end

        # original was part of a curve: always use the intersection
        @pts << p

      else
      
        if (@distance==0)
            firstPoint = p
            secondPoint = nil
            thirdPoint = nil        
        else

          # maybe that intersection is to far:
          # Then use another line to connect the two
		
          # create that line:
		
          # vectors for the two original lines:
          vl1 = l1[0] - l1[1]
          vl2 = l2[1] - l2[0]
		
          # vector from original point to intersection
          v1 = p - line[2]
		
          # orthogonal to that: will be the direction of our connect-line
          v2 = v1 * n
		
          # p1 is a point on our connect-line in @distance from original point
          v1.length = @distance
          p1 = line[2] + v1
		
          # with the orthognal we get the connect-line:
          l3 = [ p1, v2 ]
		
          # that line has two intersections with our original lines
          p3 = Geom.intersect_line_line( l1, l3 )
          p4 = Geom.intersect_line_line( l2, l3 )
		
          # now what is shorter? an edge to the connect-line or to
          # the intersection?
		
          test1 = p - @lastLine[0]
          test2 = p3 - @lastLine[0]
          
          if (test1.length < test2.length) 
            firstPoint = p
            
            if @noses
              secondPoint = line[2] + v1
              thirdPoint = p                  
            else                   
              secondPoint = nil
              thirdPoint = nil
            end
            
          else
            firstPoint = p3
            secondPoint = p4
            thirdPoint = nil
          end
          end
		
          if (@lastLine[3])
            # prev was part of a curve, new point is not:
		
            # so close that curve
            @pts << firstPoint
		
            if @pts.length>1
              @faceGroup.entities.add_curve( @pts )
            end
		
            @pts = []
          end
          
          @pts << firstPoint
          @pts << secondPoint if secondPoint
          @pts << thirdPoint if thirdPoint
      end
    end
    @lastLine = line

    if (@firstPoint == nil)
      @firstPoint = @pts[0]
    end
  end

  #--------------------------------------------------------------------------
  def DoEdge( vertex1, vertex2, n )

    p1 = vertex1.position
    p2 = vertex2.position

    if (@currentTransformation)
      p1.transform! @currentTransformation
      p2.transform! @currentTransformation
    end

    # vector in direction of line
    linev = p2-p1
    
    @originalLoopLength += linev.length
    
    linev.normalize!

    # cross product of both: orthogonal to line
    movev = linev * n

    # with the required distance
    movev.length = @distance

    # defines two points of a moved line
    np1 = p1 + movev
    np2 = p2 + movev

    # also store the "original" point and if that was part of a curve
    return [ np1, np2, p1, vertex1.curve_interior? ]
  end

  #--------------------------------------------------------------------------
  def DoVertex( vertex, n )
    @lines << DoEdge( @lastVertex, vertex, n )
    @lastVertex = vertex
  end
  
  #--------------------------------------------------------------------------
  def DoLoop( loop, n )
    
    tries = 0
    
    outer = loop.outer?
        
    while (tries<2)
    
      @originalLoopLength = 0.0
      @resultLoopLength = 0.0
      vertices = loop.vertices
 
      @lines = Array.new
      @lastVertex = vertices.last
      vertices.each { |vertex| DoVertex( vertex, n ) }      

      @lastLine =  @lines.last
      @lastPoint = nil
      @prevPoint = nil
      @lines.each {|line| IntersectLight( line ) }
      IntersectLight( @lines.first )
      
      # puts @resultLoopLength.to_mm.to_s+" "+@originalLoopLength.to_mm.to_s
      
      if (outer)
        break if @resultLoopLength>@originalLoopLength
      else
        break if @resultLoopLength<@originalLoopLength
      end
      
      # puts "reversing"
      
      n.reverse!      
      tries += 1
    end
    
    @pts = Array.new
    @lastLine =  @lines.last
    @firstPoint = nil
    @lines.each {|line| Intersect( line, n ) }

    if (@firstPoint)
      @pts << @firstPoint
    end

    if (@pts.length>1)
      if (@lastLine[3])
        @faceGroup.entities.add_curve( @pts )
      else
        @faceGroup.entities.add_edges( @pts )
      end
    end
  end
  #--------------------------------------------------------------------------
  
  #--------------------------------------------------------------------------
  def OutlineFace( face, trans )
  
	  if (@material)
		return if (@material != face.material) and (@material != face.back_material)
	  end

	  @currentTransformation = trans

	  n = face.normal
	  
	  if (@currentTransformation)
	    n.transform! @currentTransformation 
	  end
	  n.normalize!
	  
	  if (@sort)
	    outergroup = @newGroup.entities.add_group
        outergroup.name = @currentName+"_"+face.entityID.to_s
	    @faceGroup = outergroup.entities.add_group
	  else
        @faceGroup = @newGroup.entities.add_group
        @faceGroup.name = @currentName+"_"+face.entityID.to_s
      end
      
  	  face.loops.each { |loop| DoLoop( loop, n ) }
	  
	  if (@sort)
	    MakePlanar( @faceGroup, n )
	  end
  end

  #--------------------------------------------------------------------------
  def DoOutline( entity, trans )

    return if !entity.visible?    
    return if entity == @newGroup

    #----------- FACE -----------------
    if entity.is_a? Sketchup::Face then
      OutlineFace( entity, trans )

    #----------- GROUP -----------------
    elsif entity.is_a? Sketchup::Group
      subtrans = combineTransformation( trans, entity.transformation )
      DoOutlines( entity.entities, subtrans )

    #----------- COMPONENT -----------------
    elsif entity.is_a? Sketchup::ComponentInstance
      was = @currentName
      
      @currentName = @currentName+"_"+entity.definition.name
      subtrans = combineTransformation( trans, entity.transformation )
      DoOutlines( entity.definition.entities, subtrans )
      @currentName = was
    end

  end

  #--------------------------------------------------------------------------
  def DoOutlines( entities, trans )
    entities.each{ |entity| DoOutline( entity, trans ) }
  end

  #--------------------------------------------------------------------------
  def Work(what)
    model = Sketchup.active_model

    model.start_operation("Outliner")

    @newGroup = model.entities.add_group

    begin
      @currentName = "Outline"
      DoOutlines( what, nil )
      
      if (@sort)
        SortGroup()
      end

      model.selection.clear
      model.selection.add @newGroup

      model.commit_operation

    rescue => bang
      model.abort_operation
      UI.messagebox "Error: " + bang
    end
  end

end
#--------------------------------------------------------------------------

#-----------------------------------------------------------------------------
def DoOutliner( )

  what = Sketchup.active_model.selection
  if (what.count==0) then
    UI.messagebox "Nothing selected"
    return;
  end

  model = Sketchup.active_model
  materials = model.materials
  names = materials.collect {|m| m.name}
  displaynames = materials.collect {|m| m.display_name}
  
  displaynames << "all"

  prompts = ["Distance", "Material", "Sort outside", "Wrap sort at", "Noses" ]
  
  if $outlinerLastTimeValues
    values = $outlinerLastTimeValues
  else
    values = [10.mm, displaynames[0], "no", 10000.mm, "no" ]
  end
  enums = [nil, displaynames.join("|"), "yes|no", nil, "yes|no" ]
    
  results = UI.inputbox prompts, values, enums, "Outline creation"
  return if not results
  
  $outlinerLastTimeValues = results
  distance = results[0]
     
  if (results[1]=="all")
    material = nil 
  else  
    index = displaynames.index(results[1])
    materialname = index ? names[index] : nil
    material = materialname ? materials[materialname] : nil
    if (material==nil)      
       UI.messagebox( "Material "+matname+" unknown" )
       return
    end    
  end

  sort = results[2]=="yes"  
  wrapAtY = results[3]
  noses = results[4]=="yes"
    
  outliner = Outliner.new(distance, material, sort, wrapAtY, noses)
  outliner.Work(what)
end
#--------------------------------------------------------------------------

#$outlinerLastTimeValues = nil

#--------------------------------------------------------------------------
# Register within Sketchup
if(file_loaded("outliner.rb"))
 	menu = UI.menu("Plugins");
	menu.add_item("Outliner") { DoOutliner() }
end

#--------------------------------------------------------------------------
file_loaded("outliner.rb")

