EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / AI / SketchUp Drawing Sheets with Automatic Labels and Dimensions
AI

SketchUp Drawing Sheets with Automatic Labels and Dimensions

by Ton-Or ·2 Oct 2026·10 min read
SketchUp Drawing Sheets with Automatic Labels and Dimensions

The most overlooked step in computer drafting is the last one, when the entire 3D model has to fit onto a readable A4 sheet, with all the labels, dimension lines that do not overlap, and a stated scale.

I had just finished a factory site model when the client asked a question I could not answer right away: "So how do we submit it?" What the agency accepts is a set of paper documents with an overall plan of the entire property, a number for every building and a list of what each one is used for, dimensions, a north arrow, a scale, a header, and a signature block.

I could draw each dimension line manually in the program, but by the client's third layout revision, I gave up. I switched to writing a script that regenerates the entire set of drawing sheets from the current model every time I run it.

The series I wrote earlier was about building models—from setting up the machine and connecting the MCP plugin to generating a steel frame and running FEA. This article covers the other end: turning a finished design into drawing sheets for submission to the authorities. It is a different job with different tools.

First, extract the geometry from the model as numbers

I do not have the script draw directly from the model. I extract the data as numbers first, then process those numbers in Python. This makes reruns fast and changes easy, and I do not have to keep SketchUp open the whole time I am arranging the pages.

Manually drawn models are often all edges, with no faces, because the person drawing them stops after outlining the buildings. I have to create temporary faces before I can query their areas and bounds.

m = Sketchup.active_model
e = m.entities
q = 25.4                                  # inch -> mm
hide = /Terrain|Map |3D Bldgs|Footprints/ # เลเยอร์ภาพถ่ายดาวเทียม ไม่เอามาคิด

m.start_operation('tmp faces', true)
e.grep(Sketchup::Edge).each { |ed| ed.find_faces if ed.valid? }
e.grep(Sketchup::Group).each { |g|
  next if g.layer.name =~ hide
  g.entities.grep(Sketchup::Edge).each { |ed| ed.find_faces if ed.valid? }
}

out = []
e.grep(Sketchup::Face).each do |f|
  next if f.layer.name =~ hide
  b = f.bounds
  a = f.area * 0.00064516                 # ตร.นิ้ว -> ตร.ม.
  next if a < 3                           # ตัดเศษเส้นเล็ก ๆ ทิ้ง
  out << [(b.center.x.to_f * q).round, (b.center.y.to_f * q).round, a.round,
          ((b.max.x.to_f - b.min.x.to_f) * q).round,
          ((b.max.y.to_f - b.min.y.to_f) * q).round]
end
m.abort_operation                          # ทิ้งหน้าชั่วคราวทั้งหมด โมเดลกลับเป็นเหมือนเดิม
"N=#{out.size} :: " + out.map { |a| a.join(',') }.join(' ; ')

There are two things to watch out for. First, 0.00064516 is the multiplier that converts square inches to square meters. SketchUp uses inches internally for everything, regardless of the display units. Second, abort_operation at the end removes all the temporary faces, leaving no clutter in the client's file.

Do not forget moved groups

The bounds of a face inside a group use that group's local coordinates. If the group has been moved or rotated, apply b.center.transform(g.transformation) before using them. Otherwise, the building will appear in the wrong place on the drawing sheet, even though everything is positioned correctly in the model.

Rotated rectangles: the bounding box lies

Real property layouts rarely align with north, so buildings are rotated to follow the fence line. The problem is that bounds always returns an axis-aligned box. A building 20 wide and 30 long, rotated 30 degrees, gives a box of roughly 32 x 36, which is not the size to put on the drawing.

But I have two things: the bounding box and the actual area. Those are enough to work backward to the true side lengths, provided I know it is a rectangle rotated inside that box.

Building rotated 30 degreesTrue sides 20 x 30Axis-aligned bounds, about 32 x 36Bounding boxAbout 32 x 36: too bigFace areaFace#areaSolve for true sides: 20 x 30Round to .0 or .5, then label the drawing
The bounds of a rotated building are always larger than its true sides. They cannot go straight onto the drawing; use the bounds and area reported by the model to solve for the true side lengths.
import math


def side_lengths(w_mm, h_mm, area_m2):
    """คืนด้านกว้าง-ยาวจริงของสี่เหลี่ยมที่เอียง จากกรอบ bbox + พื้นที่"""
    W, H, A = w_mm / 1000.0, h_mm / 1000.0, float(area_m2)
    q = 4 * A
    p = -(W * W + H * H)
    r = W * H - A
    disc = p * p - 4 * q * r
    if A <= 0 or disc < 0 or q == 0:
        return W, H                       # ไม่เอียง หรือแก้ไม่ได้ -> ใช้กรอบไปเลย
    k = max(0.0, min(0.5, (-p - math.sqrt(disc)) / (2 * q)))
    s2 = W * W + H * H - 4 * A * k
    su = math.sqrt(max(s2 + 2 * A, 1.0))
    d = math.sqrt(su * su - 4 * A) if su * su > 4 * A else 0.0
    b1, b2 = (su + d) / 2.0, (su - d) / 2.0
    return max(b1, b2), min(b1, b2)
Use bbox directly

Put incorrect numbers on the drawing

Every rotated building gets dimensions larger than its actual size, and the closer the rotation is to 45 degrees, the greater the error. Anyone using the drawing will estimate the area incorrectly from the start.

Work backward from area

Get both true side lengths

Use the bounds and area reported by the model to solve for the true sides, then round to .0 or .5, following conventions that tradespeople can read and use in practice.

For display, I always round dimensions to end in zero or five, because nobody measures to the centimeter on an overall site plan, and numbers with two decimal places are unreadable on a 1:1250 plan anyway.

def r5(v):
    """ปัดระยะให้ลงท้าย .0 หรือ .5"""
    return round(v * 2.0) / 2.0

Render the plan as black lines on white

The image placed on the paper needs to be a line drawing, with no satellite imagery, ground, or shadows. A common sticking point is that satellite imagery is not controlled by RenderMode. It is attached to groups and layers with Terrain names, so those layers have to be turned off.

m  = Sketchup.active_model
ro = m.rendering_options
v  = m.active_view
c  = v.camera
q  = 25.4

# เก็บสภาพเดิมไว้ในตัวแปร global ก่อนแตะอะไรทั้งนั้น
$wsk ||= {
  'lay' => m.layers.map { |l| [l.name, l.visible?] },
  'rm'  => ro['RenderMode'], 'bg' => ro['BackgroundColor'],
  'gr'  => ro['DrawGround'], 'ho' => ro['DrawHorizon'],
  'ax'  => ro['DisplaySketchAxes'],
  'cam' => [c.eye.to_a, c.target.to_a, c.up.to_a, c.perspective?,
            c.perspective? ? c.fov : c.height.to_f]
}

m.layers.each { |l| l.visible = false if l.name =~ /Terrain|Map |3D Bldgs|Footprints/ }
ro['RenderMode'] = 1
ro['DrawGround'] = false
ro['DrawHorizon'] = false
ro['DisplaySketchAxes'] = false
ro['BackgroundColor'] = Sketchup::Color.new(255, 255, 255)

# กล้อง top ortho: up = [1,0,0] ทำให้แกน X ของโมเดลชี้ขึ้นบนของภาพ
c.set([CX / q, CY / q, 2_000_000 / q], [CX / q, CY / q, 0], [1, 0, 0])
c.perspective = false
c.height = FRAME_H / q                     # ความสูงกรอบภาพ หน่วยเดียวกับโมเดล
v.refresh
v.write_image({ :filename => OUT, :width => PW, :height => PH,
                :antialias => true, :transparent => false })
Drawing sheet pipelineModelSketchUp->dump numbersruby -> json->render top orthopng black lines->Place labels + dimensionspython->HTML A4/A3All มม.->PDFPrint

Thick lines on a model made only of edges

When a plan is reduced to a little over ten centimeters wide on paper, thin lines become so faint that they are barely visible in print. The usual fix is to enable DrawSilhouettes and increase SilhouetteWidth, but that does nothing if the model has no faces, because a silhouette is the outline of a face, not an edge.

This is the sequence that works for me.

  1. First, check whether the model really is all edges by comparing grep(Edge).length with grep(Face).length.
  2. Create temporary faces in every closed loop with find_faces.
  3. Create a fully transparent material with alpha = 0.0 and assign it to both material and back_material. The interiors remain transparent, so I can still see through them, but the boundaries now have silhouettes that can be drawn thick.
  4. Enable DrawSilhouettes and set SilhouetteWidth to suit the image size. Larger images need proportionally higher values.
  5. Render, then delete only the temporary faces, keeping all the original edges.
m.start_operation('bold outline', true)
made = []
m.entities.grep(Sketchup::Edge).each { |e| e.find_faces if e.valid? }
mt = m.materials.add('tmp_clear')
mt.alpha = 0.0                                   # โปร่งสนิท ไม่ใช่แค่จาง
m.entities.grep(Sketchup::Face).each { |f|
  next unless f.valid?
  made << f
  f.material = mt
  f.back_material = mt                           # ต้องใส่ทั้งสองด้าน ไม่งั้นทึบข้างหนึ่ง
}
ro['DrawSilhouettes'] = true
ro['SilhouetteWidth'] = 8                        # ภาพ 2200 px ใช้ราว 5-14
v.refresh
v.write_image({ :filename => OUT, :width => PW, :height => PH, :antialias => true })
m.entities.erase_entities(made.select(&:valid?)) # เก็บกวาด เหลือแต่เส้นเดิม
m.commit_operation
Always restore the file owner's display settings

I have been caught by this before: while the script was rendering sheets one at a time, the client had the model open and was switching views, making the resulting sheets inconsistent. The fix is to enforce the required state before every render and restore every original setting afterward: the layers that were turned off, RenderMode, the background color, and the camera. Ruby global variables persist across separate calls, so I can store the original state there.

s = $wsk
if s
  h = {}
  s['lay'].each { |n, vis| h[n] = vis }
  m.layers.each { |l| l.visible = h[l.name] if h.key?(l.name) }
  ro['RenderMode'] = s['rm']
  ro['BackgroundColor'] = s['bg']
  ro['DrawGround'] = s['gr']
  ro['DrawHorizon'] = s['ho']
  ro['DisplaySketchAxes'] = s['ax']
  eye, tgt, up, persp, val = s['cam']
  c.set(Geom::Point3d.new(*eye), Geom::Point3d.new(*tgt), Geom::Vector3d.new(*up))
  c.perspective = persp
  persp ? (c.fov = val) : (c.height = val)
  v.refresh
  "restored rendermode=#{ro['RenderMode']}"
end

Another thing to know is to separate creation and rendering into different commands. Putting find_faces and write_image in the same block can freeze SketchUp, and with large images, the command sometimes returns no data even though the file is still being written. Poll the file size until it stops changing before reading it.

Place number labels automatically without overlaps

Building numbers need to sit in the middle of the shape if it is large enough. For small shapes, they need to move to the side with a leader line pointing back in, and they must not overlap other numbers or shapes or extend beyond the paper.

My method is straightforward: sort the shapes from largest to smallest, give the first ones priority, then keep trying positions around them.

LW, LH, PAD = 5.0, 3.4, 0.5          # ขนาดป้ายเลข (มม.) และระยะกันชน
occupied = []


def hit(cx, cy, w, h, items, pad=0.0):
    for (ox, oy, ow, oh) in items:
        if abs(cx - ox) < (w + ow) / 2.0 + pad and abs(cy - oy) < (h + oh) / 2.0 + pad:
            return True
    return False


place = {}
for i in sorted(range(len(S)), key=lambda k: -(boxes[k][2] * boxes[k][3])):
    cxm, cym, wm, hm = boxes[i]
    # รูปใหญ่พอ และตรงกลางยังว่าง -> วางกลางรูปเลย
    if wm >= LW + 0.8 and hm >= LH + 0.8 and not hit(cxm, cym, LW, LH, occupied, PAD):
        place[i] = (cxm, cym, False)
        occupied.append((cxm, cym, LW, LH))
        continue
    best = None
    for r in (0.7, 1.0, 1.4, 1.9, 2.5, 3.2):            # ไล่ออกจากรูปทีละวง
        for ang in (0, 45, 315, 90, 270, 135, 225, 180): # ขวาก่อน แล้วค่อยไล่รอบ
            dx = (wm / 2.0 + LW / 2.0 + 0.7) * r * math.cos(math.radians(ang))
            dy = (hm / 2.0 + LH / 2.0 + 0.7) * r * math.sin(math.radians(ang))
            px, py = cxm + dx, cym - dy
            if px < LW / 2 or px > W - LW / 2 or py < LH / 2 or py > H - LH / 2:
                continue                                 # หลุดขอบกระดาษ
            if hit(px, py, LW, LH, occupied, PAD):
                continue                                 # ทับเลขตัวอื่น
            if hit(px, py, LW * 0.55, LH * 0.55,
                   [b for j, b in enumerate(boxes) if j != i], 0.0):
                continue                                 # ไปนั่งทับรูปอื่น
            best = (px, py, True)
            break
        if best:
            break
    place[i] = best or (cxm + wm / 2.0 + LW / 2.0 + 0.8, cym, True)
    occupied.append((place[i][0], place[i][1], LW, LH))

Once a number has moved farther than half the shape's size, I draw a leader line back to the center. I use a thin div rotated to the calculated angle, without the complication of svg.

Dimension lines: mostly automatic, but with overrides

Automatic side selection works well about eighty percent of the time. The rest are cases where a person can see that a dimension belongs on the other side, but the automatic rules cannot. I added per-item overrides instead of breaking the shared logic.

# (เลขอาคาร, ความยาวด้าน) -> (ทิศ x, ทิศ y, ระยะเยื้องต่ำสุด มม.)
DIM_EDGE_OPT = {
    (9,  15.0): (0,  1, 4.0),    # ด้าน 15 ม. ของอาคารชั่ง -> ดันลงล่าง
    (16, 73.5): (-1, 0, 2.8),    # ด้านยาวของอาคารซ่อมบำรุง -> ไปฝั่งซ้าย
    (16, 26.5): (0,  1, 7.0),    # ด้านกว้าง -> ลงล่างให้พ้นเลข 19
}
DIM_PREF = {18: (1, 1), 4: (-1, 1), 14: (-1, 0), 16: (0, 1)}   # ทิศเส้นระยะรวมของอาคารนั้น
DIM_OFF  = {18: 4.5, 4: 3.2, 9: 6.0, 16: 6.0}                  # ระยะเยื้องขั้นต่ำเฉพาะบางเลข
DIM_IGNORE_BUSY = {9, 5}     # เลขที่ให้วางตามทิศที่สั่ง โดยไม่สนสิ่งกีดขวาง
DIM_ALL4 = {21}              # เลขที่ต้องการระยะครบทั้งสี่ด้าน
NO_DIM   = {6, 10, 15}       # เลขที่ไม่ต้องใส่ระยะเลย

The numbers here are examples. The point is the structure: keep general rules and exceptions in separate places. When the client asks, "Move this dimension to the bottom," I add one line to the table and rerun, without touching code that already works correctly.

I think this is the difference between a script that is useful in practice and one that looks elegant but needs manual corrections every time.

Another issue is moving dimension lines inside groups. If I choose to store dimension lines in the actual model rather than overlaying them in HTML, I run into the familiar SketchUp trap: groups have their own transformations. Insert world coordinates directly, and the line appears somewhere else.

m.start_operation("Re-place dimensions", true)
moves = {                     # ชื่อกลุ่ม => [ข้อความเดิม, จุดเริ่ม, จุดปลาย, ระยะเยื้อง] เมตร
  "A" => ["75.00 m", [10.0, 80.0],  [10.0, 155.0], [-0.8, 0.0]],
  "B" => ["40.00 m", [0.0, -10.0],  [0.0, 30.0],   [-0.8, 0.0]],
}
log = []
moves.each do |name, (txt, a, b, off)|
  g = m.entities.grep(Sketchup::Group).find { |x| x.name == name }
  next log << "#{name}: ไม่พบกลุ่ม" unless g
  ti = g.transformation.inverse                    # โลก -> local ของกลุ่มนี้
  old = g.entities.grep(Sketchup::DimensionLinear).select { |d| d.text.to_s == txt }
  old.each { |d| g.entities.erase_entities(d) if d.valid? }
  p1 = Geom::Point3d.new(a[0].m, a[1].m, 0).transform(ti)
  p2 = Geom::Point3d.new(b[0].m, b[1].m, 0).transform(ti)
  nd = g.entities.add_dimension_linear(p1, p2, [off[0].m, off[1].m, 0])
  log << "#{name}: #{old.size} -> #{nd.text}"
end
m.commit_operation
log.join(" | ")

I use "delete the existing one with matching text, then create a new one" rather than "move the existing one" because this gives the same result however many times I run it. I do not end up with ten overlapping lines while trying different values.

My dimensioning conventions: consistently use either column centerlines or outer edges as references; use a single representative dimension for repeated equal spans rather than cluttering the drawing by labeling every span; and keep units and precision consistent throughout the set.

Assemble A4 and A3 sheets

Here I chose to generate HTML and print it to PDF rather than use LayOut. The reason is that I can control every millimeter from the script and regenerate the entire set with one command whenever the layout changes.

The key is to think in actual millimeters on paper, not pixels, then choose a scale from a standard list by finding the first one that fits the plan inside the frame.

STD = [250, 500, 750, 1000, 1250, 2000, 2500, 4000, 5000]
MAXH, MAXW = 168.0, 178.0          # พื้นที่วางภาพบน A4 แนวตั้ง หลังหักขอบและหัวกระดาษ

def fit(fr):
    for s in STD:
        h, w = fr['hx'] / s, fr['wy'] / s     # fr เป็นขนาดกรอบจริง หน่วย มม.
        if h <= MAXH and w <= MAXW:
            return s, w, h
    s = STD[-1]
    return s, fr['wy'] / s, fr['hx'] / s

# มาตราบรรทัด: เลือกความยาวที่พิมพ์แล้วยาวไม่เกิน 62 มม.
barL = 100
for L in (200, 100, 50, 25, 10):
    if L * 1000.0 / sc <= 62.0:
        barL = L
        break
barmm = barL * 1000.0 / sc
1 : 1250Overall plan scale
178 มม.Maximum width on A4
168 มม.Maximum height on A4
62 มม.Maximum scale bar length

What the agency checks for on every sheet is a header with the applicant's name, address, and registration number; fields for the document number and date; the drawing title and referenced item number; the plan with number labels; a list of numbers and their uses; a north arrow; a scale bar; the stated scale; and a signature block for the authorized signatory.

The north arrow and scale bar matter more than I expected. When a drawing is reduced by photocopying, the printed scale ratio immediately becomes wrong, but the scale bar shrinks with the drawing, so it can still be used for measurement.

Getting HTML to print correctly across multiple pages has its own set of traps. I intend to cover that in a separate article, because actual printable width and headers and footers that repeat on every page have details of their own.

One final point, and one I have got wrong before

Do not say it is finished before inspecting the actual image

For work that produces an image, always open it and compare it with the reference before saying it is finished. I once delivered work even though my own rendered image clearly showed a building floating out of position. The real mistake was not the incorrect placement, but saying it was done after I had already seen that it did not match. If it does not match, state plainly how far off it is. That is better than presenting rough work as finished.

A related trap is assuming that an unexpectedly plain result is a bug. Once, I rendered a plan as clean lines on a gray background, with no satellite imagery. I thought it was broken, so I went through several rounds of fixes to bring the satellite image back. In the end, the client said they did not want a map image, just a clearly drawn plan. What I was about to throw away was already what they wanted.

The right approach is to show the image first and ask one question, rather than guess and waste time over several rounds.

Summary

The whole structure is to extract numbers from the model first, solve for the true sides of rotated shapes using their areas, render top ortho with imagery layers turned off and the view state explicitly enforced, thicken the lines using transparent faces plus SilhouetteWidth, always restore the original state for the file owner, and then let the script place numbers and dimension lines with an override table for cases where human judgment works better.

For me, the most valuable part is being able to regenerate the entire set. When the client requests a sixth layout revision, I run three commands and get a complete new set of drawing sheets in a few minutes. Compared with moving dimension lines one by one in the program, it is a world apart.

Anyone using this for other work can adjust the tables and paper sizes as needed. Each agency has different requirements, so always ask them first. Do not take examples in articles, including mine, as authoritative. 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