EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / AI / Quantity Takeoff from SketchUp Models with Ruby Scripts
AI

Quantity Takeoff from SketchUp Models with Ruby Scripts

by Ton-Or ·3 Oct 2026·9 min read
Quantity Takeoff from SketchUp Models with Ruby Scripts

After finishing a model, I still found myself measuring each piece to fill in a quantity takeoff, even though every number was already in the file. The problem was not the software. It was that I had not named the groups so a script could read them while I was modeling.

I took on a renovation job for a small residential building and finished modeling the design revisions for the client. When it was time to submit the quantity takeoff, I opened the model and started measuring each piece. About twenty pieces in, I realized I was doing it the wrong way, because every number the takeoff needed was already in the file: bounding box dimensions, face areas, and material names. So I stopped measuring and wrote a script to read them out instead. By the end of that job, one command produced a complete table for every category.

I have already covered drafting conventions about which member sits on top of which in Thai Steel Framing Drawing Conventions. This article covers just one thing: extracting numbers from a finished model.

The path of the numbersName groupsWhile modeling->grep namesGroup/Face->Convert unitsinches -> m->Sum by category->A4 table

Name groups while modeling, not when starting the takeoff

How easy a quantity takeoff script is to write comes down to one thing: how I named the groups while modeling. If the whole file is full of groups named Group#12 and Group#13, there is no way to write a script that distinguishes walls from louvers. I would end up clicking through them one by one anyway.

My convention is group name = category-type-sequence number, with the prefix matching the category that goes straight into the quantity takeoff, because the script selects mainly with start_with?, rather than long regular expressions.

Name them like this

The script can read them

  • Bath-ผนังก่อกลาง · Bath-ผนังเบาพลาสวูด-1
  • บานเกล็ด-ผนังหน้า containing a child group named เสา-1
  • มุ้งลวด-หน้าต่างห้องขวา
  • Prefixes can repeat; suffixes identify the location
These names waste time

Back to clicking manually

  • Group#12 · Group#13 · Copy of Group#12
  • Names contain only job numbers, without a type
  • Masonry and lightweight walls share a prefix and cannot be distinguished
  • Unnamed child groups make it impossible to count louver blades and posts separately

I am willing to spend an extra thirty seconds per piece typing a group name while modeling to save hours of measuring at the end. More importantly, when the second and third design revisions come around, I can rerun the same script without measuring everything again.

Internal units are always inches: remember these four multipliers

SketchUp stores everything internally in inches, even when the display units are set to millimeters. Values extracted from bounds and Face#area are all in inches and square inches. So I always define unit conversion lambdas at the top of the file and prohibit multiplying by raw conversion factors anywhere else.

# encoding: UTF-8
m = Sketchup.active_model
e = m.entities

q  = 25.4
mm = ->(v) { (v.to_f * q).round }                 # นิ้ว -> มิลลิเมตร
m2 = ->(a) { (a * 0.00064516).round(2) }          # ตารางนิ้ว -> ตารางเมตร
lm = ->(v) { (v.to_f * 0.0254).round(2) }         # นิ้ว -> เมตร
m3 = ->(v) { (v * 1.6387e-5).round(3) }           # ลูกบาศก์นิ้ว -> ลูกบาศก์เมตร
25.4Inches to millimeters
0.0254Inches to meters
0.00064516Square inches to square meters
1.6387e-5Cubic inches to cubic meters

The value 0.00064516 is exactly 0.0254 squared. I write it out in full so nobody changes it to 0.000645 and throws off the decimal places in the total area. Once the area reaches hundreds of square meters, the difference really shows.

First category: walls, with masonry and lightweight walls kept separate

This job had two types of walls in the same model: full-height masonry walls 150 millimeters thick, and lightweight aluminum-framed walls clad with Plaswood on both sides, 70 millimeters thick and raised 150 millimeters off the floor to retain the existing drainage channel. These two types are priced differently. Masonry is measured as the plastered surface area on both sides in square meters, plus volume, while lightweight walls need two separate lines: the area of Plaswood panels and the length of aluminum framing in meters.

# ผนังก่อ: อ่านจากกล่องครอบของกลุ่มตรงๆ
e.grep(Sketchup::Group).select { |g| g.name.to_s.start_with?("Bath-ผนัง") }.each do |g|
  b = g.bounds
  w, d, h = b.width.to_f, b.height.to_f, b.depth.to_f
  face = [w, d].max * h                       # ด้านกว้างคูณความสูง
  puts "PART|#{g.name}|#{mm.(w)}x#{mm.(d)}x#{mm.(h)}|face=#{m2.(face)}|vol=#{m3.(w * d * h)}"
end

For lightweight walls, I have to descend into the child groups because the Plaswood panels and aluminum framing members sit inside the same group. I distinguish them by their child group names: if the name starts with พลาสวูด, it is a panel, so I calculate area; the rest are framing members, so I calculate length.

%w[1 2].each do |k|
  g = e.grep(Sketchup::Group).find { |x| x.name.to_s == "Bath-ผนังเบาพลาสวูด-#{k}" }
  next unless g
  al = 0.0
  pw = 0.0
  g.entities.grep(Sketchup::Group).each do |s|
    dims = [s.bounds.width.to_f, s.bounds.height.to_f, s.bounds.depth.to_f].sort
    if s.name.to_s.start_with?("พลาสวูด")
      pw += dims[1] * dims[2]      # สองมิติที่ใหญ่ที่สุด = หน้าแผ่น
    else
      al += dims[2]                # มิติที่ยาวที่สุด = ความยาวเส้นโครง
    end
  end
  puts "LIGHTWALL|#{k}|plaswood=#{m2.(pw)}|alu_len=#{lm.(al)}"
end

In the actual job, these two sets of calculations gave a total partition area of 10.44 square meters: 3.60 for masonry and two lightweight wall panels at 3.42 each. The materials to order were 13.68 square meters of Plaswood and 21.90 meters of aluminum framing. These numbers could go straight into a purchase order without a single manual measurement.

First trap: bounding boxes of rotated groups

This was what cost me the most time. SketchUp's bounds.width does not mean the width of the piece; it is only the bounding box dimension along the world x axis. If a piece is placed at an angle or has been rotated, width height depth return the dimensions of the box surrounding it, not its actual cross section.

Do not take width height depth literally as width, length, and thickness

A 50 by 25 aluminum framing member placed along the x axis returns its length as width, but place the same member along the y axis and width immediately becomes 50. If the script adds up width for the whole set, the total length will be wrong with no obvious warning, because the number still looks reasonable. The only safe approach is to always sort the three dimensions first, then read them by position: element 0 is thickness, element 1 is width, and element 2 is length.

Along the x axiswidth = member lengthTrue length, correct only hereAlong the y axiswidth = 5050 instead of lengthSort dimensions first, then read: [0] thickness [1] width [2] length
For the same framing member with a 50 by 25 cross section, width is its length when placed along the x axis, but immediately becomes 50 when placed along the y axis. The dimensions cannot be read literally as width, length, and thickness.
# ผิด: ได้ค่าถูกเฉพาะตอนชิ้นงานวางตามแกน x
al += s.bounds.width.to_f

# ถูก: เรียงสามมิติก่อน แล้วอ่านตามตำแหน่ง
dims = [s.bounds.width.to_f, s.bounds.height.to_f, s.bounds.depth.to_f].sort
thick, wide, long = dims[0], dims[1], dims[2]
al += long

The limitation to know is that sorting works only for straight bars or flat panels. If a piece is actually tilted at an angle in the model, such as a louver blade angled to shed water, its bounding box will be larger than the actual piece in all three dimensions. In that case, never use bounds; extract face areas instead, which leads to the second trap.

Second trap: face area is always counted twice

Face#area returns the actual face area in square inches, far more accurately than a bounding box. But there is a catch that everyone misses the first time: a steel sheet 1 millimeter thick has two faces, a top and a bottom. Running sum(&:area) over the whole group immediately produces twice the actual sheet area, even before counting the four edge faces, which are so thin they barely affect the number.

e.grep(Sketchup::Group).select { |g| g.name.to_s.start_with?("บานเกล็ด") }.each do |g|
  blades = g.entities.grep(Sketchup::Group).reject { |s| s.name.to_s.start_with?("เสา") }
  posts  = g.entities.grep(Sketchup::Group).select { |s| s.name.to_s.start_with?("เสา") }

  len   = blades.sum { |s| b = s.bounds; [b.width.to_f, b.height.to_f].max }
  sheet = blades.sum { |s| s.entities.grep(Sketchup::Face).sum(&:area) } / 2.0   # หาร 2
  pl    = posts.sum { |s| s.bounds.depth.to_f }

  bb   = g.bounds
  elev = [bb.width.to_f, bb.height.to_f].max * bb.depth.to_f    # พื้นที่หน้าบานตามรูปด้าน
  puts "LOUVER|#{g.name}|n=#{blades.size}|len=#{lm.(len)}|sheet=#{m2.(sheet)}|" \
       "elev=#{m2.(elev)}|posts=#{posts.size}/#{lm.(pl)}"
end

Notice that I deliberately extract two different values. sheet is the unfolded area of the metal sheet, used for ordering materials, while elev is the louver panel area in elevation, used to calculate installation labor and check against the drawings. These values differ substantially because the louver blades are angled and overlap. This actual job had 23.41 square meters of unfolded sheet but only 15.59 square meters of panel area. Using one value for both would throw off either the materials or the labor.

64Louver blades in the job
77.00 mTotal blade length
15.59Panel area, sq. m
23.41Unfolded sheet, sq. m
8Louver support posts
19.9 mTotal post length

Separate mesh areas by material name and face orientation

The expanded metal mesh in this job was existing material that would stay in place. I needed its quantities to identify the retained sections in the drawings. The problem was that it was not grouped separately by wall; it consisted of faces with a mesh material applied, scattered throughout the model. So I changed the selection method from group names to material names, then categorized the faces by normal direction and plane coordinates.

mesh = Hash.new(0.0)
e.grep(Sketchup::Face).each do |f|
  nm = f.material ? f.material.name : nil
  next unless nm && nm.include?("Fencing")
  b = f.bounds
  key = if    f.normal.y.abs > 0.9 && (b.min.y.to_f * q).round.abs < 3100 then "หน้าทางเดิน"
        elsif f.normal.x.abs > 0.9 && (b.min.x.to_f * q) < 11000          then "ข้างซ้าย"
        elsif f.normal.x.abs > 0.9 && (b.min.x.to_f * q) > 19000          then "ข้างขวา"
        else "ระเบียงและอื่นๆ"
        end
  mesh[key] += f.area
end
mesh.each { |k, v| puts "MESH|#{k}|#{m2.(v)}" }

There is a reason for checking normal.y.abs > 0.9 instead of > 0.99. A face exactly perpendicular to the axis gives 1.0, but one created by push/pulling from a slightly angled edge might give 0.97 and silently fail the condition. I use 0.9 as the threshold for "categorizing" but 0.99 for "calculating paint area," where precision matters, because the two tasks need different levels of strictness. The mesh category in this job totaled 50.48 square meters: 26.40 for the corridor front wall, 4.80 for the left side wall, 7.64 for the right side wall, and another 11.64 for the balcony.

Deduct openings by matching face coordinates, not by subtracting afterward

This job required four new bathroom door openings, each 800 by 2000 millimeters. The usual method is to calculate the full wall area and subtract 1.60 square meters four times afterward. That works if the opening count is correct, but if a design revision removes one opening, or I accidentally cut two places in the model but remember only one, the numbers will be silently wrong. So I have the script count openings from the actual model by matching door frame faces whose coordinates correspond to the cut locations.

dn = 0
[[14376, 14526], [15726, 15876]].each do |xa, xb|
  e.grep(Sketchup::Face).select { |f|
    b = f.bounds
    f.normal.y.abs > 0.99 &&
      (b.max.x.to_f * q - xb).abs < 3 &&      # ขอบขวาของช่อง คลาด 3 มม.
      (b.min.x.to_f * q - xa).abs < 3 &&      # ขอบซ้ายของช่อง
      b.min.z.to_f.abs < 0.01 &&              # เริ่มที่ระดับพื้น
      (b.max.z.to_f * q - 2000).abs < 2 &&    # สูง 2000 พอดี
      (b.max.y.to_f - b.min.y.to_f) * q > 700 # กว้างเกิน 700 = ไม่ใช่เศษหน้าเล็กๆ
  }.each { dn += 1 }
end
puts "BATHDOOR|jamb_faces=#{dn}"

That 3-millimeter tolerance matters. An exact equality check would find no faces, because values converted back from inches always carry fractional remainders. But widening the tolerance too far, to 30 millimeters, would pick up other nearby faces as well. I use 3 for coordinates and 2 for height, because height is less likely to coincide with another level. Once the script's count matches the actual openings, I multiply it by 1.60 for the opening area and by 0.24 for the volume of wall demolished.

  1. Calculate the full wall area from actual faces in the model, not room dimensions on paper
  2. Have the script count openings from door frame faces and print the count visibly
  3. Visually compare the count with the drawings once. If they differ, the model and drawings do not match; stop and fix that first
  4. Then multiply the confirmed count by the area per opening and the volume of wall demolished
  5. Put exposed surfaces around openings on a separate line, because plastering the edges is priced differently

Paint area needs both plane and coordinate limits

The easiest category to get wrong is interior paint area, because selecting only "faces perpendicular to the x axis" will pick up walls from adjacent rooms as well. So I apply three levels of constraints: first the face orientation, second the plane containing the face, which must match this room's wall surface, and third the coordinate ranges on the other two axes, which must fall within the room boundary.

[["ห้องซ้าย", 10876, 14376], ["ห้องขวา", 15876, 19376]].each do |nm, x1, x2|
  a = 0.0
  e.grep(Sketchup::Face).each do |f|
    b = f.bounds
    next if b.max.z.to_f * q > 3001                                  # ไม่เอาส่วนเหนือฝ้า
    next unless b.min.y.to_f * q > 5580 && b.max.y.to_f * q < 11085  # อยู่ในช่วงลึกของห้อง
    if f.normal.x.abs > 0.99 &&
       ((b.min.x.to_f * q - x1).abs < 2 || (b.min.x.to_f * q - x2).abs < 2)
      a += f.area
    elsif f.normal.y.abs > 0.99 &&
          ((b.min.y.to_f * q - 5582).abs < 2 || (b.min.y.to_f * q - 11082).abs < 2) &&
          b.min.x.to_f * q > x1 - 2 && b.max.x.to_f * q < x2 + 2
      a += f.area
    end
  end
  puts "PAINT-IN|#{nm}|#{m2.(a)}"
end

The advantage of calculating from actual faces is that door, window, and ventilation openings are deducted automatically, because there are no faces in those spaces to begin with. There is no need to subtract the openings individually afterward. The two residential rooms in this job each came to exactly 38.44 square meters, a result that confirms its own correctness because the rooms mirror each other. If the areas ever differ, it means an opening has not been fully modeled.

Find one self-checking number in every job

I always like to find a pair that should naturally be equal, such as two mirrored rooms or two walls of equal length, and have the script print them side by side. If those two numbers match, I can trust almost all the remaining results. If they do not, something is wrong in the model, and I do not yet need to spend time checking any other numbers.

Finish with a report I can actually deliver

I have the script return pipe-separated lines, starting with an uppercase category name such as PART| LIGHTWALL| LOUVER| MESH| SCREEN| PAINT-IN|. The reason is that this format is readable by eye and can be split straight into an HTML table without writing a parser.

out = []
# ... เก็บทุกหมวดใส่ out ระหว่างทาง ...
out << "SCREEN-TOTAL|net=#{m2.(tn)}|alu=#{lm.(ta)}"
out.join("\n")

I then put the output into a single-page HTML file with @page { size: A4 portrait; margin: 12mm; }, print it to PDF, and send it to the client. Each category has its own table in the file, followed by four fixed notes at the end of the report: all quantities are calculated from actual model surfaces with openings already deducted; bathroom interior surfaces are allocated separately to tiling and excluded from paint areas; which items have not yet been included in the model; and all units are millimeters unless specified otherwise.

Always note what has not been included

A quantity takeoff script is only faithful to the model. Anything not yet modeled will not appear in the report, and the reader has no way to know it is missing. In this job, I had not yet added insect screens to the rear ventilation openings, so I stated plainly at the end of the report that they were not included. That is better than leaving the client to discover it when the materials arrive short.

Personally, I think quantity takeoff is the drafting task that benefits most from scripting, because drawings are always revised, and every revision means redoing the entire takeoff. Measuring by hand means starting from scratch every time, but with a script, I just rerun it. The only condition is being willing to name groups properly from day one. Feel free to adapt the naming scheme and tolerance thresholds to your own work. If I have left anything out or made a typo, I will come back and correct it later.

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first