Add generated beam cross-section figure to concrete-beam

Add reinforcement inputs (cover, stirrup size/spacing, longitudinal and
top bar size/count) and emit the derived bar geometry from calc.py.
beam_section.py renders a native Typst drawing of the section to
assets/beam-section.typ, which concrete-beam.typ imports as a figure.
Effective depth d remains authoritative for the tension-steel position.
This commit is contained in:
smillmorel 2026-09-21 14:25:16 -04:00
commit 25f66bb4da
8 changed files with 973 additions and 568 deletions

View file

@ -0,0 +1,35 @@
// Generated by beam_section.py from input.yaml - do not edit by hand.
#let beam-section() = {
box(width: 302pt, height: 246pt)[
#place(dx: 80pt, dy: 34pt, rect(width: 112pt, height: 168pt, fill: rgb("#f2f2f2"), stroke: 1pt + black))
#place(dx: 103.6pt, dy: 57.62pt, rect(width: 64.75pt, height: 120.8pt, radius: 1.5pt, stroke: 5.25pt + black))
#place(dx: 106.2pt, dy: 162.6pt, circle(radius: 4.375pt, fill: black))
#place(dx: 157pt, dy: 162.6pt, circle(radius: 4.375pt, fill: black))
#place(dx: 106.2pt, dy: 60.25pt, circle(radius: 3.5pt, fill: black))
#place(dx: 158.8pt, dy: 60.25pt, circle(radius: 3.5pt, fill: black))
#place(line(start: (80pt, 218pt), end: (192pt, 218pt), stroke: 0.6pt + black))
#place(line(start: (80pt, 214pt), end: (80pt, 222pt), stroke: 0.6pt + black))
#place(line(start: (192pt, 214pt), end: (192pt, 222pt), stroke: 0.6pt + black))
#place(dx: 80pt, dy: 221pt, box(width: 112pt, align(center)[$b_w = 8 " in"$]))
#place(line(start: (68pt, 34pt), end: (68pt, 202pt), stroke: 0.6pt + black))
#place(line(start: (64pt, 34pt), end: (72pt, 34pt), stroke: 0.6pt + black))
#place(line(start: (64pt, 202pt), end: (72pt, 202pt), stroke: 0.6pt + black))
#place(dx: 2pt, dy: 113pt, text(size: 8pt)[$h = 12 " in"$])
#place(line(start: (50pt, 34pt), end: (50pt, 167pt), stroke: 0.6pt + black))
#place(line(start: (46pt, 34pt), end: (54pt, 34pt), stroke: 0.6pt + black))
#place(line(start: (46pt, 167pt), end: (54pt, 167pt), stroke: 0.6pt + black))
#place(dx: 2pt, dy: 95.5pt, text(size: 8pt)[$d = 9.5 " in"$])
#place(line(start: (162.2pt, 63.75pt), end: (204pt, 52pt), stroke: 0.5pt + rgb("#626b73")))
#place(dx: 160.8pt, dy: 62.25pt, circle(radius: 1.5pt, fill: black))
#place(dx: 208pt, dy: 46pt, text(size: 7.5pt)[2 No. 4 hanger \ bars])
#place(line(start: (168.4pt, 118pt), end: (204pt, 114pt), stroke: 0.5pt + rgb("#626b73")))
#place(dx: 166.9pt, dy: 116.5pt, circle(radius: 1.5pt, fill: black))
#place(dx: 208pt, dy: 108pt, text(size: 7.5pt)[No. 3 stirrup \ at 6 in o.c.])
#place(line(start: (161.4pt, 167pt), end: (204pt, 184pt), stroke: 0.5pt + rgb("#626b73")))
#place(dx: 159.9pt, dy: 165.5pt, circle(radius: 1.5pt, fill: black))
#place(dx: 208pt, dy: 178pt, text(size: 7.5pt)[2 No. 5 bottom \ $A_s = 0.62 " in"^2$])
#place(line(start: (103.6pt, 178.4pt), end: (40pt, 228pt), stroke: 0.5pt + rgb("#626b73")))
#place(dx: 102.1pt, dy: 176.9pt, circle(radius: 1.5pt, fill: black))
#place(dx: 2pt, dy: 232pt, text(size: 7.5pt)[cover = 1.5 in])
]
}

View file

@ -0,0 +1,201 @@
from __future__ import annotations
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
SCALE = 14.0 # points per inch
LEFT = 80.0
RIGHT = 110.0
TOP = 34.0
BOTTOM = 44.0
MUTED = 'rgb("#626b73")'
def _f(value: float) -> str:
return f"{value:.4g}"
def _centers(size: float, number: int, bw: float, cover: float, stirrup_db: float) -> list[float]:
half = size / 2.0
left = cover + stirrup_db + half
right = bw - left
if number == 1:
return [bw / 2.0]
return [left + index * (right - left) / (number - 1) for index in range(number)]
def render(values: dict) -> str:
s = SCALE
bw = float(values["bw"])
h = float(values["h"])
d = float(values["d"])
cover = float(values["cover"])
stirrup_size = int(values["stirrup_size"])
stirrup_db = float(values["stirrup_db"])
stirrup_spacing = float(values["stirrup_spacing"])
bar_size = int(values["bar_size"])
bar_db = float(values["bar_db"])
n_bars = int(values["n_bars"])
top_bar_size = int(values["top_bar_size"])
top_bar_db = float(values["top_bar_db"])
n_top_bars = int(values["n_top_bars"])
As = float(values["As"])
w_sec = bw * s
h_sec = h * s
width = LEFT + w_sec + RIGHT
height = TOP + h_sec + BOTTOM
ox = LEFT
oy = TOP
def sx(x_in: float) -> float:
return ox + x_in * s
def sy(y_in: float) -> float:
return oy + (h - y_in) * s
inset = cover + stirrup_db / 2.0
y_bottom = h - d
bottom = _centers(bar_db, n_bars, bw, cover, stirrup_db)
top_y = h - (cover + stirrup_db + top_bar_db / 2.0)
top = _centers(top_bar_db, n_top_bars, bw, cover, stirrup_db)
lines: list[str] = []
add = lines.append
add("// Generated by beam_section.py from input.yaml - do not edit by hand.")
add("#let beam-section() = {")
add(f" box(width: {_f(width)}pt, height: {_f(height)}pt)[")
add(
f" #place(dx: {_f(ox)}pt, dy: {_f(oy)}pt, "
f'rect(width: {_f(w_sec)}pt, height: {_f(h_sec)}pt, fill: rgb("#f2f2f2"), stroke: 1pt + black))'
)
add(
f" #place(dx: {_f(sx(inset))}pt, dy: {_f(sy(h - inset))}pt, "
f"rect(width: {_f((bw - 2.0 * inset) * s)}pt, height: {_f((h - 2.0 * inset) * s)}pt, "
f"radius: 1.5pt, stroke: {_f(stirrup_db * s)}pt + black))"
)
for x_in in bottom:
r = bar_db / 2.0 * s
add(
f" #place(dx: {_f(sx(x_in) - r)}pt, dy: {_f(sy(y_bottom) - r)}pt, "
f"circle(radius: {_f(r)}pt, fill: black))"
)
for x_in in top:
r = top_bar_db / 2.0 * s
add(
f" #place(dx: {_f(sx(x_in) - r)}pt, dy: {_f(sy(top_y) - r)}pt, "
f"circle(radius: {_f(r)}pt, fill: black))"
)
dim_y = oy + h_sec + 16.0
add(
f" #place(line(start: ({_f(ox)}pt, {_f(dim_y)}pt), end: ({_f(ox + w_sec)}pt, {_f(dim_y)}pt), "
f"stroke: 0.6pt + black))"
)
for tick in (ox, ox + w_sec):
add(
f" #place(line(start: ({_f(tick)}pt, {_f(dim_y - 4)}pt), end: ({_f(tick)}pt, {_f(dim_y + 4)}pt), "
f"stroke: 0.6pt + black))"
)
add(
f" #place(dx: {_f(ox)}pt, dy: {_f(dim_y + 3)}pt, "
f'box(width: {_f(w_sec)}pt, align(center)[$b_w = {_f(bw)} " in"$]))'
)
x_h = ox - 12.0
add(
f" #place(line(start: ({_f(x_h)}pt, {_f(oy)}pt), end: ({_f(x_h)}pt, {_f(oy + h_sec)}pt), "
f"stroke: 0.6pt + black))"
)
for tick in (oy, oy + h_sec):
add(
f" #place(line(start: ({_f(x_h - 4)}pt, {_f(tick)}pt), end: ({_f(x_h + 4)}pt, {_f(tick)}pt), "
f"stroke: 0.6pt + black))"
)
add(
f" #place(dx: 2pt, dy: {_f(oy + h_sec / 2.0 - 5)}pt, "
f'text(size: 8pt)[$h = {_f(h)} " in"$])'
)
x_d = ox - 30.0
d_end = oy + d * s
add(
f" #place(line(start: ({_f(x_d)}pt, {_f(oy)}pt), end: ({_f(x_d)}pt, {_f(d_end)}pt), "
f"stroke: 0.6pt + black))"
)
for tick in (oy, d_end):
add(
f" #place(line(start: ({_f(x_d - 4)}pt, {_f(tick)}pt), end: ({_f(x_d + 4)}pt, {_f(tick)}pt), "
f"stroke: 0.6pt + black))"
)
add(
f" #place(dx: 2pt, dy: {_f((oy + d_end) / 2.0 - 5)}pt, "
f'text(size: 8pt)[$d = {_f(d)} " in"$])'
)
label_x = ox + w_sec + 16.0
lead_x = ox + w_sec + 12.0
def leader(fx: float, fy: float, lx: float, ly: float) -> None:
add(
f" #place(line(start: ({_f(fx)}pt, {_f(fy)}pt), end: ({_f(lx)}pt, {_f(ly)}pt), "
f"stroke: 0.5pt + {MUTED}))"
)
add(f" #place(dx: {_f(fx - 1.5)}pt, dy: {_f(fy - 1.5)}pt, circle(radius: 1.5pt, fill: black))")
leader(sx(top[-1]), sy(top_y), lead_x, oy + 18.0)
add(
f" #place(dx: {_f(label_x)}pt, dy: {_f(oy + 12)}pt, "
f"text(size: 7.5pt)[{n_top_bars} No. {top_bar_size} hanger \\ bars])"
)
leader(sx(bw - inset), sy(h / 2.0), lead_x, oy + h_sec / 2.0 - 4.0)
add(
f" #place(dx: {_f(label_x)}pt, dy: {_f(oy + h_sec / 2.0 - 10)}pt, "
f"text(size: 7.5pt)[No. {stirrup_size} stirrup \\ at {_f(stirrup_spacing)} in o.c.])"
)
leader(sx(bottom[-1]), sy(y_bottom), lead_x, oy + h_sec - 18.0)
add(
f" #place(dx: {_f(label_x)}pt, dy: {_f(oy + h_sec - 24)}pt, "
f'text(size: 7.5pt)[{n_bars} No. {bar_size} bottom \\ $A_s = {_f(As)} " in"^2$])'
)
leader(sx(inset), sy(inset), 40.0, oy + h_sec + 26.0)
add(
f" #place(dx: 2pt, dy: {_f(oy + h_sec + 30)}pt, "
f'text(size: 7.5pt)[cover = {_f(cover)} in])'
)
add(" ]")
add("}")
return "\n".join(lines) + "\n"
def write_fragment(values: dict, path: Path) -> None:
path.write_text(render(values), encoding="utf-8")
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
input_path = Path(argv[0]) if argv else HERE / "input.yaml"
output_path = Path(argv[1]) if len(argv) > 1 else HERE / "assets" / "beam-section.typ"
import yaml
import calc
with input_path.open(encoding="utf-8") as handle:
result = calc.compute(yaml.safe_load(handle))
write_fragment(result["values"], output_path)
print(output_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -36,6 +36,23 @@ def quantity(value, unit: str, name: str) -> float:
return magnitude
def count(value, name: str, minimum: int = 1) -> int:
try:
number = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{name}: expected an integer, got {value!r}") from exc
if number < minimum:
raise ValueError(f"{name} must be >= {minimum}")
return number
def bar_number(value, name: str) -> int:
number = count(value, name, minimum=3)
if number > 18:
raise ValueError(f"{name} must be between 3 and 18")
return number
def compute(inp: dict) -> dict:
span = quantity(inp["span"], "ft", "span")
tributary = quantity(inp["tributary"], "ft", "tributary")
@ -48,6 +65,13 @@ def compute(inp: dict) -> dict:
fy = quantity(inp["fy"], "ksi", "fy")
As = quantity(inp["As"], "in**2", "As")
concrete_density = quantity(inp["concrete_density"], "pcf", "concrete_density")
cover = quantity(inp["cover"], "in", "cover")
stirrup_size = bar_number(inp["stirrup_size"], "stirrup_size")
stirrup_spacing = quantity(inp["stirrup_spacing"], "in", "stirrup_spacing")
bar_size = bar_number(inp["bar_size"], "bar_size")
n_bars = count(inp["n_bars"], "n_bars")
top_bar_size = bar_number(inp["top_bar_size"], "top_bar_size")
n_top_bars = count(inp["n_top_bars"], "n_top_bars")
self_weight = (bw * h / 144.0) * concrete_density / 1000.0
wD = D * tributary / 1000.0 + self_weight
@ -73,6 +97,19 @@ def compute(inp: dict) -> dict:
Vc = 2.0 * math.sqrt(fc * 1000.0) * bw * d / 1000.0
phiVc = 0.75 * Vc
stirrup_db = stirrup_size / 8.0
bar_db = bar_size / 8.0
top_bar_db = top_bar_size / 8.0
As_bars = n_bars * math.pi * bar_db**2 / 4.0
bottom_centroid = h - d
min_bottom_centroid = cover + stirrup_db + bar_db / 2.0
if bottom_centroid < min_bottom_centroid:
raise ValueError("effective depth d is too large for the specified cover and bars")
inner_width = bw - 2.0 * (cover + stirrup_db)
if n_bars * bar_db > inner_width:
raise ValueError("bottom bars do not fit within the stirrup width")
def q(value: float) -> float:
return round(value, 6)
@ -106,6 +143,17 @@ def compute(inp: dict) -> dict:
"As_min": q(As_min),
"Vc": q(Vc),
"phiVc": q(phiVc),
"cover": q(cover),
"stirrup_size": stirrup_size,
"stirrup_spacing": q(stirrup_spacing),
"stirrup_db": q(stirrup_db),
"bar_size": bar_size,
"n_bars": n_bars,
"bar_db": q(bar_db),
"top_bar_size": top_bar_size,
"n_top_bars": n_top_bars,
"top_bar_db": q(top_bar_db),
"As_bars": q(As_bars),
},
"checks": {
"flexure": {
@ -131,6 +179,17 @@ def write_results(result: dict, path: Path) -> None:
path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
def _load_section_module():
import importlib.util
spec = importlib.util.spec_from_file_location("beam_section", HERE / "beam_section.py")
if spec is None or spec.loader is None:
raise SystemExit("Could not load beam_section.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def main() -> int:
input_path = Path(sys.argv[1]) if len(sys.argv) > 1 else HERE / "input.yaml"
output_path = Path(sys.argv[2]) if len(sys.argv) > 2 else input_path.with_name("results.json")
@ -139,6 +198,9 @@ def main() -> int:
result = compute(inp)
write_results(result, output_path)
print(output_path)
section_path = HERE / "assets" / "beam-section.typ"
_load_section_module().write_fragment(result["values"], section_path)
print(section_path)
return 0

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
#import "assets/sheet.typ": calcline, calcsheet, check
#import "assets/beam-section.typ": beam-section
#let data = json("results.json")
#let n = data.values
@ -37,6 +38,16 @@ Simple-span rectangular beam under uniform gravity load. Numbers come from `calc
caption: [#n.span ft simply supported beam, #n.bw in × #n.h in rectangular section.],
)
== Section and Reinforcement
#figure(
beam-section(),
caption: [
Beam cross-section: #n.bw in × #n.h in, No. #n.stirrup_size stirrups at #n.stirrup_spacing in
on center, and #n.n_bars No. #n.bar_size longitudinal bars (A_s = #n.As in²).
],
)
== Loads and Beam Demand
#calcline([$L = #n.span " ft"$], [Simple span])

View file

@ -13,3 +13,11 @@ fy: "60 ksi"
As: "0.62 in**2"
concrete_density: "150 pcf"
load_combination: "1.2D + 1.6L"
cover: "1.5 in"
stirrup_size: 3
stirrup_spacing: "6 in"
bar_size: 5
n_bars: 2
top_bar_size: 4
n_top_bars: 2

View file

@ -27,7 +27,18 @@
"phiMn": 23.961176,
"As_min": 0.253333,
"Vc": 8.325383,
"phiVc": 6.244037
"phiVc": 6.244037,
"cover": 1.5,
"stirrup_size": 3,
"stirrup_spacing": 6.0,
"stirrup_db": 0.375,
"bar_size": 5,
"n_bars": 2,
"bar_db": 0.625,
"top_bar_size": 4,
"n_top_bars": 2,
"top_bar_db": 0.5,
"As_bars": 0.613592
},
"checks": {
"flexure": {

View file

@ -1,5 +1,6 @@
from pathlib import Path
import importlib.util
import math
import pytest
@ -13,6 +14,14 @@ spec.loader.exec_module(calc_module)
compute = calc_module.compute
def _load(name: str, filename: str):
spec = importlib.util.spec_from_file_location(name, HERE / filename)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def result():
import yaml
@ -44,3 +53,35 @@ def test_example_min_steel_and_shear(result):
assert v["phiVc"] == pytest.approx(6.244016, rel=1e-4)
assert result["checks"]["minimum_steel"]["ok"] is True
assert result["checks"]["shear"]["ok"] is True
def test_reinforcement_geometry(result):
v = result["values"]
assert v["cover"] == pytest.approx(1.5)
assert v["stirrup_size"] == 3
assert v["stirrup_db"] == pytest.approx(0.375)
assert v["stirrup_spacing"] == pytest.approx(6.0)
assert v["bar_size"] == 5
assert v["n_bars"] == 2
assert v["bar_db"] == pytest.approx(0.625)
assert v["top_bar_size"] == 4
assert v["n_top_bars"] == 2
assert v["top_bar_db"] == pytest.approx(0.5)
assert v["As_bars"] == pytest.approx(2 * math.pi * 0.625**2 / 4.0, rel=1e-6)
def test_rejects_depth_without_room_for_bars():
import yaml
with (HERE / "input.yaml").open(encoding="utf-8") as handle:
inp = yaml.safe_load(handle)
inp["d"] = "11.9 in"
with pytest.raises(ValueError):
compute(inp)
def test_section_fragment_is_current(result):
module = _load("beam_section", "beam_section.py")
expected = module.render(result["values"])
actual = (HERE / "assets" / "beam-section.typ").read_text(encoding="utf-8")
assert actual == expected