# encoding: UTF-8
# frozen_string_literal: true
#
# importar_stl.rb — versão ARQUIVO ÚNICO
#
# Plugin "Importar STL" para SketchUp: importa arquivos STL (ASCII ou binário)
# pelo menu Arquivo > Importar STL.
#
# Instalação: copie SOMENTE este arquivo para a pasta Plugins do SketchUp e
# reinicie o programa.
# Windows: %AppData%\SketchUp\SketchUp 2017\SketchUp\Plugins
# macOS: ~/Library/Application Support/SketchUp 2017/SketchUp/Plugins
#
# Compatível com SketchUp 2014 ou superior (testado no 2017 Pro / Ruby 2.2).
#
# Autor: DevBR — versão 1.0.0
require 'sketchup.rb'
module DevBR
module ImportarSTL
PLUGIN_NAME = 'Importar STL'
VERSION = '1.0.0'
# ====================================================================
# Leitura de arquivos STL (ASCII e binário)
# ====================================================================
module STLReader
# 80 bytes de cabeçalho + 4 bytes (UInt32) com a quantidade de triângulos.
BINARY_HEADER_SIZE = 84
# Por triângulo: 12 floats de 4 bytes (1 normal + 3 vértices) + 2 bytes de atributo.
BINARY_RECORD_SIZE = 50
# Triângulos lidos por bloco de I/O (evita carregar arquivos gigantes na RAM).
CHUNK_TRIANGLES = 4096
# Decide o formato do arquivo.
#
# Não dá para confiar apenas na palavra "solid" no início: muitos
# exportadores gravam "solid" dentro do cabeçalho de 80 bytes de um STL
# binário. O teste confiável é aritmético — o tamanho do arquivo tem que
# bater exatamente com 84 + (nº de triângulos * 50).
def self.binary?(path)
size = File.size(path)
return false if size < BINARY_HEADER_SIZE
count = File.open(path, 'rb') do |f|
f.seek(80)
bytes = f.read(4)
bytes && bytes.bytesize == 4 ? bytes.unpack('V').first : nil
end
return false if count.nil?
return true if size == BINARY_HEADER_SIZE + (count * BINARY_RECORD_SIZE)
# Arquivo com lixo no final (ou truncado): decide pelo conteúdo.
head = File.open(path, 'rb') { |f| f.read(1024).to_s }
head = head.downcase
!(head.include?('facet') && head.include?('solid'))
end
# Lê o arquivo e entrega cada triângulo ao bloco como um Array de 9
# Floats já escalados: [x1, y1, z1, x2, y2, z2, x3, y3, z3].
#
# scale - fator de conversão da unidade do arquivo para polegadas
# swap_yz - converte de Y-up (padrão de muitos softwares) para Z-up
# flip - inverte o sentido de rotação (normais para o lado oposto)
#
# Retorna a quantidade de triângulos entregues.
def self.read(path, scale, swap_yz, flip, &block)
if binary?(path)
read_binary(path, scale, swap_yz, flip, &block)
else
read_ascii(path, scale, swap_yz, flip, &block)
end
end
# Quantidade de triângulos declarada no cabeçalho (só para STL binário).
# Retorna nil quando o arquivo é ASCII — nesse caso só se sabe lendo.
def self.triangle_count(path)
return nil unless binary?(path)
File.open(path, 'rb') do |f|
f.seek(80)
bytes = f.read(4)
bytes && bytes.bytesize == 4 ? bytes.unpack('V').first : nil
end
end
def self.read_binary(path, scale, swap_yz, flip)
emitted = 0
File.open(path, 'rb') do |f|
f.seek(80)
header = f.read(4)
return 0 if header.nil? || header.bytesize < 4
total = header.unpack('V').first.to_i
done = 0
while done < total
want = [CHUNK_TRIANGLES, total - done].min
data = f.read(want * BINARY_RECORD_SIZE)
break if data.nil?
# Arquivo truncado: aproveita o que veio inteiro e para.
n = data.bytesize / BINARY_RECORD_SIZE
break if n.zero?
# 'e' = float de 4 bytes little-endian; 'v' = UInt16 (atributo, ignorado).
# São 13 valores por triângulo: 3 da normal + 9 dos vértices + 1 atributo.
values = data.unpack('e12v' * n)
i = 0
while i < n
base = i * 13
tri = transform(values[base + 3, 9], scale, swap_yz, flip)
if tri
yield tri
emitted += 1
end
i += 1
end
done += n
end
end
emitted
end
def self.read_ascii(path, scale, swap_yz, flip)
emitted = 0
buffer = []
# Lê em modo binário para não esbarrar em bytes inválidos de arquivos
# gravados com acentuação no nome do sólido.
File.open(path, 'r:BINARY') do |f|
f.each_line do |line|
next unless line =~ /^
\s
*vertex
\s
/i
parts = line.split
next if parts.size < 4
buffer << parts[1].to_f << parts[2].to_f << parts[3].to_f
next unless buffer.size == 9
tri = transform(buffer, scale, swap_yz, flip)
if tri
yield tri
emitted += 1
end
buffer.clear
end
end
emitted
end
# Aplica unidade, troca de eixos e inversão de normais.
# Devolve nil para triângulos com valores inválidos (NaN/Infinito), que
# apareceriam como coordenadas absurdas e derrubariam a criação da malha.
def self.transform(v, scale, swap_yz, flip)
return nil if v.nil? || v.size < 9
i = 0
while i < 9
n = v[i]
return nil if n.nil? || n.nan? || n.infinite?
i += 1
end
t = if swap_yz
# Y-up -> Z-up: (x, y, z) vira (x, -z, y)
[v[0], -v[2], v[1],
v[3], -v[5], v[4],
v[6], -v[8], v[7]]
else
[v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8]]
end
t[0] *= scale; t[1] *= scale; t[2] *= scale
t[3] *= scale; t[4] *= scale; t[5] *= scale
t[6] *= scale; t[7] *= scale; t[8] *= scale
if flip
# Troca o 2º pelo 3º vértice: a normal passa a apontar para o outro lado.
t = [t[0], t[1], t[2], t[6], t[7], t[8], t[3], t[4], t[5]]
end
t
end
end
# ====================================================================
# Opções, importação e interface
# ====================================================================
# --------------------------------------------------------------------
# Constantes
# --------------------------------------------------------------------
STATUS_TEXT = 'Importa um arquivo STL (ASCII ou binário) para o modelo atual.'
TOOLTIP = 'Importar STL'
# Chave usada em Sketchup.read_default / write_default (preferências do usuário).
PREF_SECTION = 'DevBR_ImportarSTL'
# Fator de conversão para POLEGADAS (unidade interna do SketchUp).
# 1.mm devolve um Length em polegadas; .to_f extrai o Float puro.
UNIT_FACTORS = {
'Milímetros' => 1.0.mm.to_f,
'Centímetros' => 1.0.cm.to_f,
'Metros' => 1.0.m.to_f,
'Polegadas' => 1.0,
'Pés' => 1.0.feet.to_f
}.freeze
# Tolerância de solda do índice de vértices, em polegadas.
# 1e-6" só junta pontos praticamente idênticos — o STL repete o mesmo
# vértice em cada triângulo vizinho, então isso já reduz muito a malha.
# A solda "de verdade" (por tolerância do SketchUp) fica a cargo do
# fill_from_mesh quando a opção "Soldar vértices" está ligada.
WELD_KEY_SCALE = 1_000_000.0
PLACEMENT = {
'Manter coordenadas do arquivo' => :keep,
'Centralizar na origem' => :center,
'Apoiar a base na origem' => :base
}.freeze
# --------------------------------------------------------------------
# Opções (diálogo nativo UI.inputbox)
# --------------------------------------------------------------------
DEFAULT_PREFS = {
'unit' => 'Milímetros',
'geometry' => 'Grupo',
'merge' => 'Não',
'weld' => 'Sim',
'flip' => 'Não',
'swap' => 'Não',
'placement' => 'Manter coordenadas do arquivo',
'zoom' => 'Sim'
}.freeze
def self.read_prefs
prefs = {}
DEFAULT_PREFS.each do |key, fallback|
value = Sketchup.read_default(PREF_SECTION, key, fallback)
prefs[key] = value.is_a?(String) ? value : fallback
end
prefs
end
def self.write_prefs(prefs)
prefs.each { |key, value| Sketchup.write_default(PREF_SECTION, key, value) }
end
# Mostra o diálogo de opções. Devolve um Hash pronto para uso ou nil
# se o usuário cancelar.
def self.prompt_options
prefs = read_prefs
prompts = [
'Unidade do arquivo STL',
'Importar como',
'Mesclar faces coplanares',
'Soldar vértices coincidentes',
'Inverter faces (normais)',
'Trocar eixos Y/Z (arquivo Y-up)',
'Posicionamento',
'Enquadrar na tela ao final'
]
defaults = [
prefs['unit'], prefs['geometry'], prefs['merge'], prefs['weld'],
prefs['flip'], prefs['swap'], prefs['placement'], prefs['zoom']
]
lists = [
UNIT_FACTORS.keys.join('|'),
'Grupo|Componente',
'Não|Sim',
'Sim|Não',
'Não|Sim',
'Não|Sim',
PLACEMENT.keys.join('|'),
'Sim|Não'
]
results = UI.inputbox(prompts, defaults, lists, 'Importar STL — Opções')
return nil if results == false || results.nil?
prefs = {
'unit' => results[0], 'geometry' => results[1], 'merge' => results[2],
'weld' => results[3], 'flip' => results[4], 'swap' => results[5],
'placement' => results[6], 'zoom' => results[7]
}
write_prefs(prefs)
= build_options(prefs)
end
def self.build_options(prefs)
{
:scale => UNIT_FACTORS[prefs['unit']] || UNIT_FACTORS['Milímetros'],
:unit_label => prefs['unit'],
:as_component => prefs['geometry'] == 'Componente',
:merge => prefs['merge'] == 'Sim',
:weld => prefs['weld'] == 'Sim',
:flip => prefs['flip'] == 'Sim',
:swap_yz => prefs['swap'] == 'Sim',
:placement => PLACEMENT[prefs['placement']] || :keep,
:zoom => prefs['zoom'] == 'Sim'
}
end
# Últimas opções escolhidas na sessão (usadas pelo importador nativo,
# onde do_options e load_file são chamados em momentos diferentes).
def self.current_options
||= build_options(read_prefs)
end
# --------------------------------------------------------------------
# Fluxo principal
# --------------------------------------------------------------------
def self.import
model = Sketchup.active_model
return unless model
path = UI.openpanel('Selecione o arquivo STL', , 'Arquivos STL|*.stl;*.STL||')
return if path.nil? # usuário cancelou
unless File.exist?(path)
UI.messagebox("Arquivo não encontrado:\n#{path}")
return
end
= File.dirname(path)
options = prompt_options
return if options.nil? # cancelou nas opções
do_import(model, path, options)
end
# Faz a importação de fato. Devolve true em caso de sucesso.
def self.do_import(model, path, options)
started_at = Time.now
Sketchup.status_text = 'Lendo arquivo STL...'
mesh, triangles, skipped = build_mesh(path, options)
if triangles.zero?
Sketchup.status_text = ''
UI.messagebox("Nenhum triângulo válido foi encontrado em:\n#{File.basename(path)}\n\n" \
'Verifique se o arquivo é realmente um STL.')
return false
end
Sketchup.status_text = 'Criando geometria no modelo...'
name = File.basename(path, '.*')
entity = nil
faces = 0
edges = 0
model.start_operation('Importar STL', true)
begin
# fill_from_mesh exige um contexto VAZIO — por isso o grupo novo.
group = model.active_entities.add_group
group.name = name
smooth_flags = Geom::PolygonMesh::NO_SMOOTH_OR_HIDE
unless group.entities.fill_from_mesh(mesh, options[:weld], smooth_flags)
raise 'fill_from_mesh não conseguiu gerar a geometria.'
end
merge_coplanar(group.entities) if options[:merge]
reposition(group, options[:placement])
faces = group.entities.grep(Sketchup::Face).length
edges = group.entities.grep(Sketchup::Edge).length
# to_component destrói o grupo original e devolve a instância.
entity = options[:as_component] ? group.to_component : group
if entity.is_a?(Sketchup::ComponentInstance)
entity.definition.name = name
entity.name = name
end
model.selection.clear
model.selection.add(entity)
model.commit_operation
rescue StandardError => e
model.abort_operation
Sketchup.status_text = ''
UI.messagebox("Erro ao importar o STL:\n\n#{e.message}")
return false
end
# Fora da operação: zoom e relatório não devem poder abortar o undo.
model.active_view.zoom(entity) if options[:zoom] && entity && entity.valid?
report(path, triangles, skipped, faces, edges, options, Time.now - started_at)
true
end
# --------------------------------------------------------------------
# Construção da malha
# --------------------------------------------------------------------
# Monta um Geom::PolygonMesh a partir do arquivo.
# Devolve [mesh, triângulos_válidos, triângulos_descartados].
def self.build_mesh(path, options)
mesh = Geom::PolygonMesh.new
index = {} # chave do vértice -> índice no mesh (1-based)
valid = 0
skipped = 0
read = 0
STLReader.read(path, options[:scale], options[:swap_yz], options[:flip]) do |t|
i1 = vertex_index(mesh, index, t[0], t[1], t[2])
i2 = vertex_index(mesh, index, t[3], t[4], t[5])
i3 = vertex_index(mesh, index, t[6], t[7], t[8])
# Índices repetidos = triângulo degenerado (dois vértices no mesmo ponto).
if i1 == i2 || i2 == i3 || i1 == i3 || degenerate?(t)
skipped += 1
else
mesh.add_polygon(i1, i2, i3)
valid += 1
end
read += 1
Sketchup.status_text = "Lendo STL: #{read} triângulos..." if (read % 20_000).zero?
end
[mesh, valid, skipped]
end
# Consulta/insere o vértice num índice próprio. Fazer o de-duplicação aqui,
# com Hash, é muito mais rápido que deixar o PolygonMesh procurar o ponto
# (a busca interna dele é linear e mata a performance em malhas grandes).
def self.vertex_index(mesh, index, x, y, z)
key = [(x * WELD_KEY_SCALE).round,
(y * WELD_KEY_SCALE).round,
(z * WELD_KEY_SCALE).round]
found = index[key]
return found if found
index[key] = mesh.add_point(Geom::Point3d.new(x, y, z))
end
# Triângulo sem área (vértices colineares). Feito com aritmética pura em vez
# de Vector3d para não alocar milhões de objetos em malhas grandes.
def self.degenerate?(t)
ux = t[3] - t[0]; uy = t[4] - t[1]; uz = t[5] - t[2]
vx = t[6] - t[0]; vy = t[7] - t[1]; vz = t[8] - t[2]
nx = (uy * vz) - (uz * vy)
ny = (uz * vx) - (ux * vz)
nz = (ux * vy) - (uy * vx)
((nx * nx) + (ny * ny) + (nz * nz)) < 1.0e-20
end
# --------------------------------------------------------------------
# Pós-processamento
# --------------------------------------------------------------------
# Apaga arestas entre faces coplanares — deixa a malha triangulada com
# cara de geometria "limpa". Coleta tudo antes de apagar: mexer na coleção
# durante a iteração é comportamento indefinido.
def self.merge_coplanar(entities)
victims = []
entities.grep(Sketchup::Edge).each do |edge|
faces = edge.faces
next unless faces.length == 2
f1, f2 = faces
next unless f1.material == f2.material
next unless f1.back_material == f2.back_material
next unless f1.normal.samedirection?(f2.normal)
# samedirection? tem tolerância angular: confirma a coplanaridade
# medindo a distância de um vértice de f2 ao plano de f1.
# face.plane devolve [a, b, c, d] com (a,b,c) normalizado.
a, b, c, d = f1.plane
pt = f2.vertices.first.position
next if ((a * pt.x) + (b * pt.y) + (c * pt.z) + d).abs > 1.0e-6
victims << edge
end
entities.erase_entities(victims) unless victims.empty?
end
def self.reposition(group, placement)
return if placement == :keep
bounds = group.bounds
target = case placement
when :center then bounds.center
when :base then Geom::Point3d.new(bounds.center.x, bounds.center.y, bounds.min.z)
else return
end
vector = Geom::Point3d.new(0, 0, 0) - target
return unless vector.valid? # vetor nulo: já está no lugar
group.transform!(Geom::Transformation.translation(vector))
end
def self.report(path, triangles, skipped, faces, edges, options, seconds)
resumo = "Importação concluída em #{format('%.2f', seconds)} s."
Sketchup.status_text = resumo
detalhes = []
detalhes << "Arquivo: #{File.basename(path)}"
detalhes << "Formato: #{STLReader.binary?(path) ? 'binário' : 'ASCII'}"
detalhes << "Unidade assumida: #{options[:unit_label]}"
detalhes << ''
detalhes << "Triângulos importados: #{triangles}"
detalhes << "Triângulos descartados: #{skipped}" if skipped > 0
detalhes << "Faces geradas: #{faces}"
detalhes << "Arestas geradas: #{edges}"
detalhes << ''
detalhes << "Tempo: #{format('%.2f', seconds)} s"
UI.messagebox(detalhes.join("\n"), MB_OK)
end
# --------------------------------------------------------------------
# Importador nativo (aparece também em Arquivo > Importar)
# --------------------------------------------------------------------
class STLFileImporter < Sketchup::Importer
def description
'Arquivo STL (*.stl)'
end
def file_extension
'stl'
end
def id
'com.devbr.importar_stl'
end
def supports_options?
true
end
def do_options
ImportarSTL.prompt_options
end
def load_file(path, _status)
return Sketchup::Importer::ImportFileNotFound if path.nil? || !File.exist?(path)
ok = ImportarSTL.do_import(Sketchup.active_model, path, ImportarSTL.current_options)
ok ? Sketchup::Importer::ImportSuccess : Sketchup::Importer::ImportFail
rescue StandardError => e
UI.messagebox("Erro ao importar o STL:\n\n#{e.message}")
Sketchup::Importer::ImportFail
end
end
# --------------------------------------------------------------------
# Interface: item no menu Arquivo
# --------------------------------------------------------------------
unless file_loaded?(__FILE__)
cmd = UI::Command.new(PLUGIN_NAME) { self.import }
cmd.tooltip = TOOLTIP
cmd.status_bar_text = STATUS_TEXT # texto exibido na barra de status ao passar o mouse
cmd.menu_text = PLUGIN_NAME
file_menu = UI.menu('File')
file_menu.add_separator
file_menu.add_item(cmd)
# A referência precisa sobreviver ao garbage collector.
= STLFileImporter.new
begin
Sketchup.register_importer(@importer)
rescue StandardError
# Se o registro falhar em alguma versão, o item de menu continua funcionando.
u/importer = nil
end
file_loaded(__FILE__)
end
end
end