Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2734796
generate_panel.py
alufers (Albert Koczy)
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Authored By
alufers
Thu, Jul 2, 12:46 AM
2026-07-02 00:46:35 (UTC+2)
Size
9 KB
Referenced Files
None
Subscribers
None
generate_panel.py
View Options
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "fonttools>=4.50",
# ]
# ///
"""
Generate SVGs for laser cutting a control-panel front plate.
The full plate carries 10 indicator lights. Because the laser bed (or the
acrylic stock) is not big enough for one tall plate, it is split into TWO
panels of 5 rows each that butt together vertically to form the big panel.
Layout (per panel):
* 5 rows x 2 columns of 22 mm holes for indicator lights.
* Holes are spaced 48.55 mm centre-to-centre in both X and Y.
* To the right of each row, an engraved room name.
Tileability:
The vertical gap between the first/last hole row and the panel edge is
exactly HALF the gap between two inner rows. So when the two panels are
stacked, the seam gap (half + half) equals a full inner gap and the
hole pitch stays a perfect 48.55 mm across the join.
Text:
Engraved room names are converted from the Ubuntu font into vector
<path> outlines, so the target/laser machine does not need the font
installed. Each name is vertically centred on its hole row.
Colour convention (LightBurn/Visicut style):
* RED strokes -> CUT
* BLUE fills -> ENGRAVE
All coordinates and sizes are in millimetres (1 SVG user unit == 1 mm).
"""
import
os
import
sys
from
fontTools.pens.boundsPen
import
BoundsPen
from
fontTools.pens.svgPathPen
import
SVGPathPen
from
fontTools.pens.transformPen
import
TransformPen
from
fontTools.ttLib
import
TTFont
# ---------------------------------------------------------------------------
# Parameters
# ---------------------------------------------------------------------------
HOLE_DIAMETER
=
22.0
# mm, indicator-light hole
HOLE_SPACING
=
48.55
# mm, centre-to-centre, both X and Y
N_ROWS_PER_PANEL
=
5
N_COLS
=
2
TEXT_HEIGHT
=
15.0
# mm, engraved cap/text height
TEXT_GAP
=
20.0
# mm, gap between last hole edge and text start
TEXT_WIDTH_EST
=
145.0
# mm, reserved horizontal room for the longest name
MARGIN
=
10.0
# mm, horizontal (left/right) border margin
CUT_COLOR
=
"#FF0000"
# red -> cut
ENGRAVE_COLOR
=
"#0000FF"
# blue -> engrave
CUT_STROKE_W
=
0.1
# mm, hairline cut
# Candidate font files; first one found wins. Ubuntu first to match the
# original design, with a Polish-capable fallback.
FONT_CANDIDATES
=
[
"/usr/share/fonts/ubuntu/Ubuntu-R.ttf"
,
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf"
,
"/usr/share/fonts/TTF/DejaVuSans.ttf"
,
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
,
]
# Room names (Polish), top row first. Taken from temp-at/at2.yaml `rooms`.
# Split into two panels of N_ROWS_PER_PANEL each.
ROOM_NAMES
=
[
"Wejście"
,
"Audytorium"
,
"Korytarz"
,
"Elelab"
,
"Arts & Crafts"
,
"Łazienka"
,
"Magazynek"
,
"Kuźnia"
,
"CNC"
,
"Maszynownia"
,
]
OUTPUT_PREFIX
=
"panel"
# writes panel_1.svg, panel_2.svg, ...
# ---------------------------------------------------------------------------
# Font / text-to-path
# ---------------------------------------------------------------------------
def
_find_font
()
->
str
:
for
path
in
FONT_CANDIDATES
:
if
os
.
path
.
isfile
(
path
):
return
path
raise
FileNotFoundError
(
"No usable font found. Tried:
\n
"
+
"
\n
"
.
join
(
FONT_CANDIDATES
)
)
def
_cap_height
(
font
,
glyph_set
,
cmap
,
units_per_em
:
float
)
->
float
:
"""Cap height in font units. Prefer the OS/2 sCapHeight metric; fall
back to the measured height of an uppercase 'H'."""
os2
=
font
.
get
(
"OS/2"
)
if
os2
is
not
None
and
getattr
(
os2
,
"sCapHeight"
,
0
):
return
float
(
os2
.
sCapHeight
)
h_glyph
=
cmap
.
get
(
ord
(
"H"
))
if
h_glyph
is
not
None
:
bp
=
BoundsPen
(
glyph_set
)
glyph_set
[
h_glyph
]
.
draw
(
bp
)
if
bp
.
bounds
is
not
None
:
return
float
(
bp
.
bounds
[
3
])
return
units_per_em
*
0.7
def
_load_font
(
path
:
str
):
font
=
TTFont
(
path
)
glyph_set
=
font
.
getGlyphSet
()
cmap
=
font
.
getBestCmap
()
units_per_em
=
font
[
"head"
]
.
unitsPerEm
cap_height
=
_cap_height
(
font
,
glyph_set
,
cmap
,
units_per_em
)
return
glyph_set
,
cmap
,
units_per_em
,
cap_height
def
text_to_path
(
text
:
str
,
x
:
float
,
cy
:
float
,
font_size
:
float
,
glyph_set
,
cmap
,
units_per_em
:
float
,
cap_height
:
float
,
)
->
str
:
"""Return an SVG <path> element for `text`, left-anchored at `x` and
vertically centred on `cy`.
Centring uses a FIXED cap-height band (baseline -> cap height), not the
per-string bounding box. That keeps every row's baseline at the same
offset from its hole centre, so diacritics (e.g. the accent on Ś) and
descenders extend beyond the band without shifting the line — exactly
what you want when the names should read on a common baseline grid.
The glyph outlines are emitted in raw font units; a single SVG transform
scales them to `font_size`, flips the (font y-up) axis to SVG's y-down,
positions the baseline start at `x`, and shifts so the cap-height band is
centred on `cy`.
"""
scale
=
font_size
/
units_per_em
# Accumulate outline commands in font units, advancing the pen by each
# glyph's advance width.
path_pen
=
SVGPathPen
(
glyph_set
)
pen_x
=
0.0
for
ch
in
text
:
glyph_name
=
cmap
.
get
(
ord
(
ch
))
if
glyph_name
is
None
:
# Unknown character: advance by a space-ish gap, skip outline.
pen_x
+=
units_per_em
*
0.3
continue
offset
=
(
1
,
0
,
0
,
1
,
pen_x
,
0
)
glyph
=
glyph_set
[
glyph_name
]
glyph
.
draw
(
TransformPen
(
path_pen
,
offset
))
pen_x
+=
glyph
.
width
d
=
path_pen
.
getCommands
()
if
not
d
:
return
""
# point (px, py) -> ( px*scale + tx , -py*scale + ty )
# baseline start at x => tx = x
# cap-band centre maps to cy => ty = cy + (cap_height/2)*scale
tx
=
x
ty
=
cy
+
(
cap_height
/
2.0
)
*
scale
transform
=
(
f
"translate({tx:.4f} {ty:.4f}) scale({scale:.6f} {-scale:.6f})"
)
return
(
f
' <path transform="{transform}" '
f
'fill="{ENGRAVE_COLOR}" stroke="none" d="{d}" />'
)
# ---------------------------------------------------------------------------
# Geometry
# ---------------------------------------------------------------------------
def
build_panel_svg
(
names
,
glyph_set
,
cmap
,
units_per_em
,
cap_height
)
->
str
:
if
len
(
names
)
!=
N_ROWS_PER_PANEL
:
raise
ValueError
(
f
"panel has {len(names)} names but N_ROWS_PER_PANEL is "
f
"{N_ROWS_PER_PANEL}"
)
radius
=
HOLE_DIAMETER
/
2.0
# Horizontal: hole edge sits MARGIN from the left border.
first_cx
=
MARGIN
+
radius
# Vertical: edge-to-first-row gap is half an inner row gap, so panels tile.
first_cy
=
HOLE_SPACING
/
2.0
col_xs
=
[
first_cx
+
c
*
HOLE_SPACING
for
c
in
range
(
N_COLS
)]
row_ys
=
[
first_cy
+
r
*
HOLE_SPACING
for
r
in
range
(
N_ROWS_PER_PANEL
)]
last_col_x
=
col_xs
[
-
1
]
text_x
=
last_col_x
+
radius
+
TEXT_GAP
total_width
=
text_x
+
TEXT_WIDTH_EST
+
MARGIN
# Half-gap top and bottom => total height is an exact multiple of pitch.
total_height
=
N_ROWS_PER_PANEL
*
HOLE_SPACING
# Visible-text height ~= 0.72 of em box for Ubuntu's cap height.
font_size
=
TEXT_HEIGHT
/
0.72
parts
=
[]
parts
.
append
(
f
'<svg xmlns="http://www.w3.org/2000/svg" '
f
'width="{total_width:.3f}mm" height="{total_height:.3f}mm" '
f
'viewBox="0 0 {total_width:.3f} {total_height:.3f}">'
)
# --- Engraved text as paths (blue) ------------------------------------
parts
.
append
(
' <g id="engrave">'
)
for
r
,
name
in
enumerate
(
names
):
cy
=
row_ys
[
r
]
path_el
=
text_to_path
(
name
.
upper
(),
text_x
,
cy
,
font_size
,
glyph_set
,
cmap
,
units_per_em
,
cap_height
,
)
if
path_el
:
parts
.
append
(
path_el
)
parts
.
append
(
' </g>'
)
# --- Cut holes + border (red) -----------------------------------------
parts
.
append
(
f
' <g id="cut" fill="none" stroke="{CUT_COLOR}" '
f
'stroke-width="{CUT_STROKE_W}">'
)
for
cy
in
row_ys
:
for
cx
in
col_xs
:
parts
.
append
(
f
' <circle cx="{cx:.3f}" cy="{cy:.3f}" r="{radius:.3f}" />'
)
parts
.
append
(
f
' <rect x="0" y="0" '
f
'width="{total_width:.3f}" height="{total_height:.3f}" />'
)
parts
.
append
(
' </g>'
)
parts
.
append
(
'</svg>'
)
return
"
\n
"
.
join
(
parts
)
def
main
()
->
None
:
if
len
(
ROOM_NAMES
)
%
N_ROWS_PER_PANEL
!=
0
:
raise
ValueError
(
f
"ROOM_NAMES ({len(ROOM_NAMES)}) is not a multiple of "
f
"N_ROWS_PER_PANEL ({N_ROWS_PER_PANEL})"
)
font_path
=
_find_font
()
glyph_set
,
cmap
,
units_per_em
,
cap_height
=
_load_font
(
font_path
)
print
(
f
"Using font: {font_path}"
,
file
=
sys
.
stderr
)
n_panels
=
len
(
ROOM_NAMES
)
//
N_ROWS_PER_PANEL
for
i
in
range
(
n_panels
):
chunk
=
ROOM_NAMES
[
i
*
N_ROWS_PER_PANEL
:(
i
+
1
)
*
N_ROWS_PER_PANEL
]
svg
=
build_panel_svg
(
chunk
,
glyph_set
,
cmap
,
units_per_em
,
cap_height
)
out
=
f
"{OUTPUT_PREFIX}_{i + 1}.svg"
with
open
(
out
,
"w"
,
encoding
=
"utf-8"
)
as
f
:
f
.
write
(
svg
)
print
(
f
"Wrote {out}"
)
if
__name__
==
"__main__"
:
main
()
File Metadata
Details
Attached
Mime Type
text/x-script.python
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2043584
Default Alt Text
generate_panel.py (9 KB)
Attached To
Mode
Tablica wyjściowa
Attached
Detach File
Event Timeline
Log In to Comment