# Name :          2dBoolean VER 1.3.1 beta
# Description :   Intersects groups or comps on to face and removes "the spill" + extrusion

# Author :       copyright (c) Joel Gustafsson (Jolran) 

#                This is a spinoff from Hatchfaces, not everyone needs hatches and tiling.
#                This feature might get included Hatchfaces as well.
#                
#                TIG has been very helpful and involved, especially at the start.
#                
#                More info about that in here:
#                 
#                http://forums.sketchucation.com/viewtopic.php?f=323&t=38637
#
#    and here:   http://forums.sketchucation.com/viewtopic.php?f=323&t=39661&start=75

# Date :          20.dec.2o13

#-----------------------------------------------------------------------------

#   Permission to use, copy, modify and distribute this software 
#	as long as above copyright notice is included.

# THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.

#ver 1.3.beta   COMPLETE REWRITE.

#				*New Features:

#				-BOOLEAN SUBRACT. Removes everything on face. With respect to holes. The inverse of standard boolean.

#				-EXTRUSION: Pushpulls or translate faces based on material names.

#				*FIXES and IMPROVEMENTS
#				  ver 1.2 In order to take care of holes after intersections better
#				  intersection was made twice, considerably slowing things down.
#                 However it appears that was not solving the issue which was probably
#                 more a result from not having enough parameters in the faceclassify method.
#                 To add more "tests" in there would slow things down, so in this version
#                 original face centers are collected and used in comparsion for what to delete.

#               * A Dialog is introduced. Material names are sent in dynamically.
#                 Pushpull or Translation is made on faces based on material names.
#                 Pushpull is always made second in order since it affects face id's.
#                 One could introduce recollecting of faces to make a choice of ordering
#                 but then a better dialog ( webdialog ) will be required. 
#               
#               * The original boolean script is moved into it's own module.
#                 There are quite some changes in that script as well.

#               * This is now an extension.

#               * Plugin is now located in the PLUGIN menu, where it belongs.

#				* NEW Manual.

#               * NEW icons.

#               * NEW small helpfile

#               * NEW directlink to 2dboolean plugin page

#               * Lot of error errortrapping.
 
#               * slight change in licensing ( above )

#				* This plugin is meant to be a 2D boolean, without any sort of tiling mechanism

#				* Since this version is a complete rewrite with lots of new stuff I suspect
#                 some things may be broken. Also I noticed as dialog came along the optionlist
#                 became quite nested, so maybe turn this into class/object oriented style of code..

#
#ver 1.2.beta   There was a problem with faces in holes not getting erased, still are!
#               But now, should be a lot better. Also major speed improvement. Fewer and better iterations
#               TIG helped making the code more compact. There are things to deal with more, but next version....
#               I haven't removed face.clone caused I had some issues with intersection, but it should be possible to do so=speed improvement.
#
#ver 1.1.beta   Small update, but will improve the workflow. You now only have to select a component to run.
#               For groups you will still have to select a face.
#
#ver 1.0.beta   Issues:
#               At some obscure angles the cutting behavior don't work.
#               It has to do with vector not being 100% accurate after the last transformations.
#               Should be improved........
#               Faces in holes don't always get erased(improved in 1.2)
#               If copying a component, the glue-to-face behavior get's lost
#               Therefore the plugin don't recognize wich face to work with IF not face selected.




require 'sketchup.rb'

module JOL

	module Jol2dboolean
	
  # MODULE CONSTANTS
    #
  # Paths..
	PL_PATH = File.dirname(__FILE__) unless defined? ( self::PL_PATH )
	ICONPATH = File.join( PL_PATH, "icons" )
	require File.join( PL_PATH, 'geom2d_intersections.rb' )
  # Constants set..
	NILMATERIAL = "Default".freeze
	PPULL = "Pushpull".freeze
	MMOVE = "Move".freeze

	
  # INIT 
    #
	def self.boolean2D_init( klassify = false, xtrude = false ) #kl true = subtract
		
		model = Sketchup.active_model 
		sel = model.selection
		@is3D  = false
		@mats  = nil #move
		@mats = {} if xtrude 
		nested = false
		comps  = []
		faces  = []
			
	  # Validycheck for selection Don't want to alter geometry before Dialog.
	  # REVIEW: There must be an easier way to find out if nested ? (this does work though...)
	  # TODO: Support nested geometry ? Reccursive exploding ok. But how to collect materials for dialog when deeply nested ?
	    #
		nest = lambda {|ent| ent.grep( Sketchup::ComponentInstance ).length > 0 || ent.grep( Sketchup::Group ).length > 0 }
		gp_verts = []
		
		for ent in sel
			case ent
			when Sketchup::Face
				faces.push( ent )
				next
			when Sketchup::ComponentInstance
				ent_type = ent.definition.entities
				if nest.call( ent_type ) 
					break nested = true
				end
				comps.push( ent )
				faces.push( ent.glued_to ) if ent.glued_to
			when Sketchup::Group
				ent_type = ent.entities
				if nest.call( ent.entities ) 
					break nested = true
				end
				comps.push( ent )
			end
		 
		  # Validy check: CRITICAL! do not perform boolean if 3D!! BUGSPLAT!
		  # Grab points 1st. then check on plane. 
		  # Grab materials for dialog while at it.
		    # 
			ent_type.grep( Sketchup::Face ).each {|face|
				gp_verts.push( *face.vertices.collect{|v| v.position } )
				if xtrude
					if face.material.nil? # fix for Sketchup default material
						@mats[ NILMATERIAL ] = [ NILMATERIAL ] unless @mats.has_key?( NILMATERIAL )
					else
						@mats[ face.material.name ] = [ face.material ] unless @mats.has_key?( face.material.name )
					end
				end
			}
		   # ver 1.3.1 FIX. If running edges only plugin stopped.	
		   #In case there are no face.vertices in component
		    #
			if gp_verts.empty?
				ent_type.grep( Sketchup::Edge ).each { |edge| 
					gp_verts << edge.start.position  
					gp_verts << edge.end.position 
				}
			end
			
		end 
		
	  # Switches! unless selection meets requirements..
	  # Perform cheapest tests first, seperately..
	    #
		if nested
			self.ui_error_report( "No nested Groups or Components are not allowed", "NESTED GEOMETRY" ) { self.cleanup }
			return
		end
		
		if comps.empty? 
			self.ui_error_report( "Unable to retrieve Component or Group in selection.", "SELECTION ERROR" ) { self.cleanup }
			return
		end
		
		if faces.empty?
			str = "Unable to retrieve Face in selection.\n \n" +
				  "NOTE: Face selection is required when using Groups only \n" +
				  "and/or if Components are in use and not glued to any face\n" 
			
			self.ui_error_report( str, "SELECTION ERROR" ) { self.cleanup }
			return
		end
		
	  # Expensive test
	  # Create a plane from 3 vertices. 
	  # REVIEW: Accept that it may out of targetface.plane. flat 2d sufficient ?
	    #
		gp_verts.uniq!
		pln = Geom.fit_plane_to_points( gp_verts[0..2] ) 
		
		gp_verts.each{|pt|
			#Sketchup.active_model.active_entities.add_cpoint pt
			next if pt.on_plane? pln
			break @is3D = true
		}
	  
	  # Tests passed. Continue....
		#
		targetface = faces[0] # should now be existent
		
		if xtrude 
			self.start_dialog( targetface, comps )
		else
			if @is3D
				self.ui_error_report( "2DBoolean Operation is not allowed on 3D geometry", "2D ERROR" ) { self.cleanup }
				return
			else
				klassify = klassify ?  Sketchup::Face::PointInside : Sketchup::Face::PointOutside
				self.create_boolean( model, targetface, comps, klassify )
			end
		end
		
	end #init

  
    
  # Dialog #
    #
	def self.start_dialog( face, gps )
	
	  # Just in case user happends to try extruding components with only edges..
		#
		unless @mats.length > 0 
			str = "Face Material could not be retrived ?\n" +
				  "Geometry must have faces for extrusion"
			self.ui_error_report( str, "MISSING MATERIAL" ) { self.cleanup }
			return
		end
		#
		model = Sketchup.active_model
		stack = []  # Keep names/keys ordered for Hash
		opts1 = PPULL
		opts  = "Pushpull|Move"
		title = "EXTRUDE FACES"
		prompts = ["BooleanType ? "]
		
		if @is3D 
			values = ["None"]
			list = ["None"]
		else
			values = ["Trim"]
			list = ["None|Trim|Subtract"]
		end
		
		for k,v in @mats
			stack.push( k )
		   #Extrusion-mode selection
			prompts.push( k )
			values.push( opts1 )
			list.push( opts )
		   #Distance selection
			prompts.push( "Dist" )
			values.push( 10.to_l ) # default
			list.push( "" )
		end
			
		options = UI.inputbox( prompts, values, list, title )
	
		unless options # QUIT!
			self.cleanup
			return nil 
		end
		
		bool_met = options.shift
	
	#{# INPUTBOX RETURNS 
	  # TODO: validation procedure for illegal characters ?
	  # Inputbox does conversion to length BUT reports errors if for ex "B" instead of "1"
		#
		stack.each{|k| 
			opts = options.slice!( 0, 2 )
			next @mats.delete( k ) if opts[1] == 0 # ver 1.3.1 delete material reference
			arr = @mats[k]
			arr.push( *opts )
		}
	#}#
		case bool_met
		when "None"
		  # Need to make sure right kind of entities are sent to Xtrusion method & explode multiples.
			
			self.begin_operation( "xtrude" )
			#
			if gps.length > 1 
				ent_type = self.merge_src_geometry( model, gps ) # Group 
			else
				ent_type = gps[0] #.is_a?( Sketchup::ComponentInstance ) ? gps[0].definition : gps[0]
			end
		  # if @is3D and pushpulled incorrectly resulting Group won't be gluing to the face..
			self.comp_behave( ent_type, face, true ) # Glue to face & xtrude.
			self.cleanup
			return # DONE QUIT!
			#
			model.commit_operation
			
		when "Trim"  
			self.create_boolean( model, face, gps, Sketchup::Face::PointOutside )
		when "Subtract" 
			self.create_boolean( model, face, gps, Sketchup::Face::PointInside )
		end	
			
	end #dialog
    
	
    # Start creating geometry	
	#
	def self.create_boolean( model, face, gps, klassify )
		self.begin_operation( "boolean" )
		#
		gp = self.merge_src_geometry( model, gps )
		gp = GEOM2d_BOOLEANS.create_boolean( face, gp, klassify )
		self.comp_behave( gp, face, (true if @mats) )
		#
		model.commit_operation
		
	end
	
	# EXPLODING
	# TODO: Support Nesting ?  
	#	
	def self.merge_src_geometry( model, comps ) #Merge into 1 group. 
		
		group = model.active_entities.add_group()
		
		for gp in comps 
			if gp.is_a?( Sketchup::ComponentInstance )
			
				defn = gp.definition
				tran = gp.transformation
				
			elsif gp.is_a?( Sketchup::Group )
			
				defn = gp.entities.parent
				tran = gp.transformation

			end
			inst = group.entities.add_instance( defn, tran ) 
			gp.erase!
			inst.explode #ver 1.3 REVIEW: exploding inside loop
		end
		
		group
	end
	
  # Glue to face & naming.
  # TODO: Better naming algoritm.
    #
	def self.comp_behave( inst, face, xtr = false )
		
		if inst.is_a?( Sketchup::Group ) # directly from dialog for xtrude only
			inst = inst.to_component
		end
		
		defn = inst.definition
		be = defn.behavior
		be.is2d = true
		be.cuts_opening = true
		be.snapto = 0
		inst.glued_to = face

	  # naming ####
		inst.name = "2d boolean"
		defn.name = "2D bool"
		#inst.set_attribute( "2d_boolean", "perf", 1 )
		
		xtr ? self.extrusion( defn ) : self.cleanup # or QUIT!

	end
	
  #{# EXTRUSION 
  # We must ensure translation comes before any pushpulls for best result. (Pushpull alter object ID's)
  # Also acommodate from eventual face deletions from translation.
  # TODO: Support Pushpull before translation ? That would mean recollect all faces after translation = slower.
  # Multiple arguments for sequential transforms on same face ? Would need Webdialog for that.
    #
	def self.extrusion( defn ) 
		
		mv_mode = false
		pp_mode = false

		check = lambda{|val| val.to_s }
		vals = @mats.values.flatten
		
		if check.call( vals ).include?( PPULL )
			ppulls = {}
			pp_mode = true
		end
		if check.call( vals ).include?( MMOVE )
			trans = [ [], [] ] 
			mv_mode = true
		end
		
		fcs = defn.entities.grep( Sketchup::Face ).each{|face|
			mat = face.material.nil? ? NILMATERIAL : face.material.name
			if ( target_mat = @mats[mat] )
				dist = target_mat[2]
				if target_mat[1] == PPULL
					ppulls[face] = dist
				end
				
				if target_mat[1] == MMOVE
					n = face.normal.clone
					n.length = dist
					trans[0].push( face )
					trans[1].push( n ) 
				end
				
			end
		}
		
		mv_mode ? defn.entities.transform_by_vectors( trans[0], trans[1] ) : nil
		
		if pp_mode
		  # Check if face is not deleted after translation is critical.
			for k,v in ppulls
				next if k.deleted? 
				k.pushpull( v )
			end
		end

		@mats = nil
	end
   #}#
 
### ALL GEOMETRY CREATED! ##################################################


	
	
  #{# HELPER METHODS 
	#
	def self.begin_operation( str )
		model = Sketchup.active_model
		Sketchup.version.to_i >= 7 ? model.start_operation( str, true ) : model.start_operation( str )
	end
	 
	def self.ui_error_report( str, title, &block )
		UI.messagebox( str, MB_MULTILINE, title )
		yield
		nil
	end 
	
  #{# DEPRECIATED but kept for ev. improvements.
	# Method for trapping input errors for numbers.
	# It seams the errors are coming from inputbox conversion to length
	# so there is no way to prevent that ?
	#
	def self.input_validate( str ) # 
		return str if str.is_a?( Length )
			str.gsub!(/[^\d\-.,]/, '') #Remove characters 1st
			str.gsub!(/\./, ',').to_f.to_l # , to . & convert to length 
		unless str.is_a?( Length )
			txt = "Illegal nondigit characters where used in Input \n"+
				  "Try using . and not ,"
			self.ui_error_report( txt, "NUMBER CONVERSION ERROR" )
			return 
		end
		str # length
	end

	def self.cleanup
		model = Sketchup.active_model
		@mats = nil
		model.selection.clear
		Sketchup.set_status_text "exiting 2Dboolean...", SB_VCB_LABEL
		model.select_tool( nil )
		return 
	end
  #}#
	
	
  #{# HELPMENU ( Plugins-menu ) # REDUNDANT ? ver 1.3.1 now has PDF MANUAL..
	#
	def self.userhelp
		
	    str = "TRIM 2D GEOMETRY TO FACE: \n" +
			  "Removes geometry outside face and in holes.\n\n" +
			  
			  "SUBTRACT FACE FROM 2D-GEOMETRY: \n" +
			  "Removes geometry on face and keep geometry in holes.\n\n" +

			  "2D-BOOLEAN WITH EXTRUSION: \n" +
			  "Both boolean methods with pushpull or translation\n" +
			  "performed based on face material selection\n\n" +

			  "USAGE:\n" +
			  "Groups or Components can be used in multiples.\n" +
			  "If Groups only, face selection is required\n" +
			  "since targetface is retrieved from gluebehavior.\n" +
			  "That means that at least 1 Component needs to be\n" +
			  "glued to the targetface.\n" +
			  "If dragging Component from browser make sure to drop\n" +
			  "Component directly onto face for gluebehavior.\n\n" +

			  "EXTRUDING FACES: Translation is performed first\n" +
			  "since pushpulls is a destructive behavior which\n" +
			  "changes the Id's of faces.\n" +
			  "Results may vary and depends on how materials.\n" +
			  "are setup and passing holes can be unpredictable.\n" +
			  "(Pushpulls normally gives better result than Move)\n" +
			  "Care also has to be taken when constructing the Components.\n" +
			  "Make sure that they are created on ground with Z-AXIS\n" +
			  "(blue) pointing upwards, and gluebehavior on.\n" +
			  "This can be switched off later by rightclicking -> Unglue.\n\n\n" +
			  "Ver 1.3 2dBoolean (c) Joel Gustafsson.." 
			  
		self.ui_error_report( str, "HELP MANUAL" ) { return nil }
		
	end
  #}#  	
		
	
  #{# MENU & TOOLBAR 
	#
	unless file_loaded?( File.basename(__FILE__) )
				
	  # Trim
		cmd = UI::Command.new( "Trim" ) { self.boolean2D_init() }
		cmd.small_icon = File.join( ICONPATH, "trim_small.png" )
		cmd.large_icon = File.join( ICONPATH, "trim_large.png" )
		cmd.status_bar_text = "Trim"
		cmd.tooltip = "Trim 2D-Geometry to Face"
		cmd_Trim = cmd
	  # Subtract
		cmd = UI::Command.new( "Subtract" ) { self.boolean2D_init( true ) }
		cmd.small_icon = File.join( ICONPATH, "subtract_small.png" )
		cmd.large_icon = File.join( ICONPATH, "subtract_large.png" )
		cmd.status_bar_text = "Subtract"
		cmd.tooltip = "Subtract Face from 2D-Geometry"
		cmd_Subtract = cmd
	  # Extrude
		cmd = UI::Command.new( "Extrude" ) { self.boolean2D_init( false, true ) }
		cmd.small_icon = File.join( ICONPATH, "extrude_small.png" )
		cmd.large_icon = File.join( ICONPATH, "extrude_large.png" )
		cmd.status_bar_text = "Extrude"
		cmd.tooltip = "2D-Boolean with Extrusion"
		cmd_Extrude = cmd
	  # Help
		cmd = UI::Command.new( "Help" ) { self.userhelp }
		cmd.status_bar_text = "Help"
		cmd.tooltip = "Help"
		cmd_Help = cmd
	
	  # Plugin menu 
		bolmenu = UI.menu("Plugins").add_submenu("2d Boolean")
		bolmenu.add_separator
		bolmenu.add_item( cmd_Trim )
		bolmenu.add_item( cmd_Subtract )
		bolmenu.add_separator
		bolmenu.add_item( cmd_Extrude )
		bolmenu.add_separator
		bolmenu.add_item( cmd_Help )
	    bolmenu.add_item("Plugin-Website") { UI.openURL("http://sketchucation.com/forums/viewtopic.php?f=323&t=39661") } #TODO: Add video tutorial
		
	  # Toolbar 
		toolbar = UI::Toolbar.new("2dBoolean")
		toolbar.add_item( cmd_Trim )
		toolbar.add_item( cmd_Subtract )
		toolbar.add_separator
		toolbar.add_item( cmd_Extrude )
		toolbar.show
		
		file_loaded( File.basename(__FILE__) )
		
	end
  #}#
  #
	end # 2dboolean
end # JOL
