// Beam Analysis drawings for the geometry entries in docs/ba-web-spec.md // section 4 -- registered on `window.ToolDrawings` so PA's app.js redrawOne // hook (tools/pa_web/static/app.js:697-706) calls us instead of PA's own // renderers for beam blocks. Drawn with the same pen as PA: the primitives // on `window.Draw` exported by tools/pa_web/static/draw.js (svgRoot, label, // dimH, dimV, ftIn, inch, el, clear, safeNum, clamp, mergeRanges, estWidth, // _stagger). // // Everything works off real-unit numbers (ft, in) straight out of the // geometry dict -- no pixels arrive from the server, all layout and scaling // happens in here. Plain ES2017, no modules, no dependencies beyond // window.Draw and window.Units (both loaded first). Never throws: any field // may be absent or non-numeric and that load (or its part of the drawing) is // simply skipped. (function () { 'use strict'; // draw.js exports its primitives on window.Draw (draw.js:730) and app.js // reaches them as D.* (app.js:11). Same here: alias the handful this file // uses into local names so the drawing code below reads like draw.js's // own renderers instead of wearing a `Draw.` prefix on every call. var D = window.Draw; var el = D.el, label = D.label, dimH = D.dimH, dimV = D.dimV; var svgRoot = D.svgRoot, safeNum = D.safeNum, estWidth = D.estWidth; var stagger = D._stagger; var ftIn = D.ftIn, inch = D.inch; // ------------------------------------------------------------- geometry // Which shape a block's geometry entry carries is decided by the KEYS on // the entry, never by page id: `extents.elevation` has the beam outline // plus supports only; `loads.elevation` adds point/line/moments; // `reinforcement.elevation` has the outline plus longitudinal bars and // stirrup zones; `reinforcement.section` is the cross-section. function isElevation(geom) { return geom && (geom.length_ft !== undefined || geom.supports !== undefined); } function isSection(geom) { return geom && (geom.width_in !== undefined || geom.type !== undefined); } function hasBars(geom) { return !!(geom && (geom.bars || geom.transverse)); } function hasLoads(geom) { return !!(geom && (geom.point || geom.line || geom.moments)); } // ---------------------------------------------------------- beam outline // Shared by every elevation: the beam drawn as a horizontal rectangle // (length_ft wide, height_in tall) with the height exaggerated the way PA's // section drawing exaggerates a thin strip, and the support pads under it. // // `topReserve` is the band above the member already claimed by the load // lanes (a small baseline when there are none); the member fills what is // left between that band and the overall-length dimension row at the // bottom, so load glyphs never collide with the outline. function drawBeam(geom, svg, W, H, topReserve) { var lengthFt = safeNum(geom.length_ft); if (lengthFt === null || lengthFt <= 0) lengthFt = null; var heightIn = safeNum(geom.height_in); if (heightIn === null || heightIn <= 0) heightIn = null; var marginL = 60, marginR = 30; var drawW = W - marginL - marginR; var scale = drawW / (lengthFt || 1); var dimY = H - 14; // overall-length dimension row (draw.js dimH tick ~6) var padH = 12; // bearing-pad projection below the member // Member height: exaggerated up to the space available between the load // lanes and the pads, never beyond. var exaggerate = 1, rectH = 0; if (heightIn !== null) { var trueH = (heightIn / 12) * scale; var avail = Math.max(20, dimY - topReserve - padH - 10); exaggerate = Math.max(1, Math.min(8, avail / (trueH || 1))); rectH = Math.min(avail, trueH * exaggerate); } var rectY = topReserve + 4; var rectX = marginL; function xAt(ft) { return rectX + ft * scale; } var out = { xAt: xAt, scale: scale, exaggerate: exaggerate, rectX: rectX, rectY: rectY, rectW: drawW, rectH: rectH, marginL: marginL, marginR: marginR, drawW: drawW, dimY: dimY, padH: padH, lengthFt: lengthFt, heightIn: heightIn, topReserve: topReserve }; // Beam outline -- only when there is a length to place it by. if (lengthFt !== null) { el('rect', { x: rectX, y: rectY, width: drawW, height: rectH, fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.5 }, svg); } // Supports: bearing pads the width of their own length_ft, drawn as a // pad projecting below the beam, with a filled triangle glyph centred on // the pad centreline (the bearing point). Pads that would run past the // beam end are clipped to the beam face. (geom.supports || []).forEach(function (s) { if (!s || typeof s !== 'object') return; var x = safeNum(s.x_ft); if (x === null) return; var len = safeNum(s.length_ft); if (len === null || len < 0) len = 0; var kind = s.kind; var tri = (kind || '').toLowerCase() === 'pin' ? '#1b1e23' : '#475569'; var cx = xAt(x); var padL = xAt(Math.max(0, x - len / 2)); var padR = xAt(Math.min(lengthFt || 0, x + len / 2)); if (padR - padL < 2) { padL = cx - 3; padR = cx + 3; } var padTop = rectY + rectH; var padBot = Math.min(dimY - 8, padTop + padH); if (padBot > padTop) { el('rect', { x: padL, y: padTop, width: padR - padL, height: padBot - padTop, fill: '#e2e8f0', stroke: '#1b1e23', 'stroke-width': 1.2 }, svg); } // Triangle glyph at the centreline, apex at the beam underside (the // support reaction point), base at the pad bottom. With no member // height the apex lands on the drawn member line itself. var apexY = rectH ? padTop : rectY; el('polygon', { points: [cx - 6, padBot, cx + 6, padBot, cx, apexY].join(' '), fill: tri, stroke: tri }, svg); }); // Overall length dimension, below the pads, in feet-inches. if (lengthFt !== null) { dimH(svg, rectX, rectX + drawW, dimY, ftIn(lengthFt)); } return out; } // ----------------------------------------------------------------- load // // Loads are drawn ABOVE the beam in lanes: line loads at the top (their // band + hatch is the tallest glyph), moments below them, and point-load // arrows immediately above the member so their arrowheads land on the beam // top without crossing another lane. // Measure the lanes first (so the beam can be pushed below them), draw // them after. Returns null when the geometry carries no loads; otherwise // { topReserve, lines, points, moments } with every y absolute in canvas. function loadLanes(geom, xAt) { var points = [], lines = [], moments = []; (geom.line || []).forEach(function (ln) { if (!ln || typeof ln !== 'object') return; var x0 = safeNum(ln.x0_ft), x1 = safeNum(ln.x1_ft); if (x0 === null || x1 === null) return; if (x1 < x0) { var t = x0; x0 = x1; x1 = t; } var px0 = xAt(x0), px1 = xAt(x1); if (px1 - px0 >= 2) lines.push({ px0: px0, px1: px1 }); }); (geom.point || []).forEach(function (p) { if (!p || typeof p !== 'object') return; var x = safeNum(p.x_ft); if (x !== null) points.push({ px: xAt(x), id: p.id }); }); (geom.moments || []).forEach(function (m) { if (!m || typeof m !== 'object') return; var x = safeNum(m.x_ft); if (x !== null) moments.push({ px: xAt(x), id: m.id }); }); if (!points.length && !lines.length && !moments.length) return null; // Line loads: one 26px lane each (label above a band + hatch). var lane = 0; lines.forEach(function (ln) { ln.y = 40 + lane * 26; // label row of this lane lane++; }); var laneH = lane * 26; // Moment arcs sit below the line lanes, above the point-load arrows. var momentH = moments.length ? 44 : 0; moments.forEach(function (m, i) { m.y = 40 + laneH + 12 + (moments.length > 1 ? i * 24 : 0); // arc centre row }); // Point-load arrows are drawn lowest so their tips reach the beam top; // their labels are staggered so close loads never print on top of each // other. The reserve must hold the deepest stagger level. var pItems = points.map(function (p) { var w = estWidth(p.id || '?', 10); return { x0: p.px - w / 2, x1: p.px + w / 2 }; }); var pLevels = stagger(pItems, 6); var maxLvl = 0; pLevels.forEach(function (l) { if (l > maxLvl) maxLvl = l; }); var pointH = points.length ? 36 + maxLvl * 16 : 0; var topReserve = 40 + laneH + momentH + pointH; points.forEach(function (p, i) { p.y = topReserve - pointH + 8 + pLevels[i] * 16; // arrow tail row }); return { topReserve: topReserve, lines: lines, points: points, moments: moments }; } // One glyph per load, labelled with the id ONLY -- the case values are too // dense to print on an elevation (the tables carry them). `lanes` holds the // measured positions; `out` is the beam layout whose top the arrows land // on. function drawLoads(geom, lanes, svg, out) { var beamTop = out.rectY; // Line loads: a band along their own extent with hatch below. lanes.lines.forEach(function (ln) { var by = ln.y + 13; // band line sits under the label row el('line', { x1: ln.px0, y1: by, x2: ln.px1, y2: by, stroke: '#0ea5e9', 'stroke-width': 4 }, svg); var ticks = Math.max(2, Math.round((ln.px1 - ln.px0) / 18)); for (var i = 0; i <= ticks; i++) { var tx = ln.px0 + (ln.px1 - ln.px0) * (i / ticks); el('line', { x1: tx, y1: by, x2: tx, y2: by + 8, stroke: '#0ea5e9', 'stroke-width': 1 }, svg); } }); // Line-load ids: draw after the bands so label backgrounds sit on top. var li = 0; (geom.line || []).forEach(function (ln) { if (!ln || typeof ln !== 'object') return; var x0 = safeNum(ln.x0_ft), x1 = safeNum(ln.x1_ft); if (x0 === null || x1 === null) return; if (x1 < x0) { var t = x0; x0 = x1; x1 = t; } if (lanes.lines[li]) { label(svg, (lanes.lines[li].px0 + lanes.lines[li].px1) / 2, lanes.lines[li].y - 2, ln.id || '?', { size: 10, fill: '#0ea5e9' }); } li++; }); // Point loads: a down arrow whose tip reaches the beam top. lanes.points.forEach(function (p, i) { var tipY = beamTop - 2; el('line', { x1: p.px, y1: p.y, x2: p.px, y2: tipY - 10, stroke: '#2f6fed', 'stroke-width': 2 }, svg); el('polygon', { points: [p.px - 4, tipY - 12, p.px + 4, tipY - 12, p.px, tipY - 2].join(' '), fill: '#2f6fed' }, svg); var pid = (geom.point[i] && geom.point[i].id) || '?'; label(svg, p.px, p.y - 4, pid, { size: 10, fill: '#2f6fed' }); }); // Moments: a curved-arrow (rotation) glyph with the id below it. lanes.moments.forEach(function (m, i) { var cy = m.y; var r = 9; var x0 = m.px + r, y0 = cy; var x1 = m.px + r * Math.cos(-2.4), y1 = cy + r * Math.sin(-2.4); el('path', { d: 'M ' + x0 + ' ' + y0 + ' A ' + r + ' ' + r + ' 0 1 0 ' + x1 + ' ' + y1, fill: 'none', stroke: '#d33', 'stroke-width': 1.8 }, svg); var tipX = m.px + r * Math.cos(-0.4), tipY = cy + r * Math.sin(-0.4); var ang = -0.4 - Math.PI / 2; el('polygon', { points: [tipX, tipY, tipX + 6 * Math.cos(ang + 0.5), tipY + 6 * Math.sin(ang + 0.5), tipX + 6 * Math.cos(ang - 0.5), tipY + 6 * Math.sin(ang - 0.5)].join(' '), fill: '#d33' }, svg); var mid = (geom.moments[i] && geom.moments[i].id) || '?'; label(svg, m.px, cy + r + 16, mid, { size: 10, fill: '#d33' }); }); } // ----------------------------------------------------------------- bar // Longitudinal bar rows on the reinforcement elevation: one horizontal line // at the bar's y_in (measured up from the beam soffit), with a hook tick at // the end whose condition is 'hook'. Straight ends get no glyph. A bar can // only be placed once the outline has a height -- with no height entered // there is nowhere to put it, and it is skipped (geometry problems name it). function drawBars(geom, svg, out) { if (!out.heightIn) return; var hPx = out.rectH / out.heightIn; // pixels per inch of depth (already exaggerated) (geom.bars || []).forEach(function (b) { if (!b || typeof b !== 'object') return; var x0 = safeNum(b.x0_ft), x1 = safeNum(b.x1_ft); if (x0 === null || x1 === null) return; if (x1 < x0) { var t = x0; x0 = x1; x1 = t; } var yIn = safeNum(b.y_in); if (yIn === null) return; if (yIn < 0) yIn = 0; if (yIn > out.heightIn) yIn = out.heightIn; // y_in from the BOTTOM of the section; SVG y runs down from the top of // the outline. var y = out.rectY + out.rectH - yIn * hPx; var px0 = out.xAt(x0), px1 = out.xAt(x1); el('line', { x1: px0, y1: y, x2: px1, y2: y, stroke: '#1b1e23', 'stroke-width': 1.6 }, svg); // Hook: a short perpendicular tick turned toward the member interior // (up for a bar in the bottom half, down for one in the top half). var hookUp = yIn < out.heightIn / 2; if ((b.start_cond || '').toLowerCase() === 'hook') { var hy = hookUp ? y - 6 : y + 6; el('line', { x1: px0, y1: y, x2: px0, y2: hy, stroke: '#1b1e23', 'stroke-width': 1.6 }, svg); } if ((b.end_cond || '').toLowerCase() === 'hook') { var hy2 = hookUp ? y - 6 : y + 6; el('line', { x1: px1, y1: y, x2: px1, y2: hy2, stroke: '#1b1e23', 'stroke-width': 1.6 }, svg); } }); } // Transverse ("stirrup") zones on the reinforcement elevation: vertical // ticks over x0..x1 at the true spacing_in pitch -- the first tick at x0 // (the face of the first stirrup) and one every pitch after it, ticks that // would land past x1 dropped. Cap at 60 ticks per zone: a 4" pitch over a // 40' span would otherwise try to draw 120 hairlines the browser then has // to rasterise. The band spans the member depth inside a small inset so it // reads as ties around the bars. `legs > 2` adds a second hairline. function drawTransverse(geom, svg, out) { (geom.transverse || []).forEach(function (z) { if (!z || typeof z !== 'object') return; var x0 = safeNum(z.x0_ft), x1 = safeNum(z.x1_ft); if (x0 === null || x1 === null) return; if (x1 < x0) { var t = x0; x0 = x1; x1 = t; } var spacingIn = safeNum(z.spacing_in); if (spacingIn === null || spacingIn <= 0) return; var yTop = out.rectY, yBot = out.rectY + out.rectH; if (out.heightIn) { // Stirrups wrap the steel inside the cover: draw the band inset from // the outline faces so it reads as ties around the bars, not as a // solid fill of the whole depth. var inset = Math.max(1.5, (Math.min(2, out.heightIn / 4) / out.heightIn) * out.rectH); yTop += inset; yBot -= inset; } if (yBot - yTop < 2) return; var legs = Math.max(1, Math.round(safeNum(z.legs) || 1)); var px0 = out.xAt(x0), px1 = out.xAt(x1); if (px1 - px0 < 2) return; // Step from x0 at spacing_in (converted to feet); each tick is one // pitch on. The cap keeps an extreme zone from overdrawing. var count = Math.min(60, Math.floor((x1 - x0) / (spacingIn / 12)) + 1); for (var i = 0; i < count; i++) { var tx = px0 + i * (spacingIn / 12) * out.scale; if (tx > px1 + 0.5) break; el('line', { x1: tx, y1: yTop, x2: tx, y2: yBot, stroke: '#7c3aed', 'stroke-width': 0.9 }, svg); if (legs > 2) { el('line', { x1: tx + 2.5, y1: yTop, x2: tx + 2.5, y2: yBot, stroke: '#c4b5fd', 'stroke-width': 0.6 }, svg); } } }); } // ---------------------------------------------------------------- hooks // `elevation(inst, state, host, geom)`: the beam outline + supports + // overall length (extents.elevation); load arrows/bands/moments when the // geometry carries loads (loads.elevation); longitudinal bars and stirrup // zones when it carries reinforcement (reinforcement.elevation). One glyph // per load or bar, ids only -- never the case values. Which variant is // drawn is decided by which keys the geometry entry carries, not page id. function elevation(inst, state, host, geom) { geom = geom || {}; var aspect = (inst && inst.block && inst.block.aspect) || 0.24; var root = svgRoot(host, aspect, 1000); var svg = root.svg, W = root.W, H = root.H; if (isSection(geom) && !isElevation(geom)) { // Defensive: a section entry mis-routed to the elevation fn. label(svg, W / 2, H / 2, '(cross-section data -- use the section drawing)', { size: 12, fill: '#6b7280' }); return; } var lengthFt = safeNum(geom.length_ft); if (lengthFt === null || lengthFt <= 0) lengthFt = null; if (lengthFt === null && !hasLoads(geom) && !hasBars(geom)) { label(svg, W / 2, H / 2, 'Enter a beam length to see the elevation.', { size: 12, fill: '#6b7280' }); return; } // Reserve the load lanes ABOVE the member before the beam is laid out, // so the outline never draws over a load glyph. Loads only render for // the loads.elevation entry (the reinforcement elevation carries bars). var scale = (W - 90) / (lengthFt || 1); var lanes = (hasLoads(geom) && !hasBars(geom)) ? loadLanes(geom, function (ft) { return 60 + ft * scale; }) : null; var topReserve = lanes ? lanes.topReserve : 18; var out = drawBeam(geom, svg, W, H, topReserve); if (hasBars(geom)) { // reinforcement.elevation: bars + stirrup zones ON the outline. drawBars(geom, svg, out); drawTransverse(geom, svg, out); } else if (lanes) { // loads.elevation: one glyph per load above the outline. drawLoads(geom, lanes, svg, out); } if (out.heightIn !== null && out.exaggerate > 1.1) { label(svg, out.marginL, 12, '(depth exaggerated ' + out.exaggerate.toFixed(1) + '\u00d7)', { anchor: 'start', size: 10, fill: '#6b7280' }); } } // `section(inst, state, host, geom)`: the cross-section (rectangular / L / // inverted_t), the bars as filled circles at their (x_in, y_in) -- y from // the soffit, converted to SVG's top-down y -- strands as smaller OPEN // circles, width/height dimensions in inches, and a `_stagger`ed label per // distinct bar size. function section(inst, state, host, geom) { geom = geom || {}; var aspect = (inst && inst.block && inst.block.aspect) || 0.28; var root = svgRoot(host, aspect, 1000); var svg = root.svg, W = root.W, H = root.H; var widthIn = safeNum(geom.width_in); var heightIn = safeNum(geom.height_in); if (widthIn === null || widthIn <= 0 || heightIn === null || heightIn <= 0) { label(svg, W / 2, H / 2, 'Enter the section width and height to see the section.', { size: 12, fill: '#6b7280' }); return; } // Margins follow renderReinforcementSection: width dimension below the // section, height dimension to its left, labels in inches. var marginL = 70, marginR = 30, marginTop = 40, marginBottom = 56; var drawW = W - marginL - marginR; var drawH = H - marginTop - marginBottom; var scale = Math.min(drawW / widthIn, drawH / heightIn); var rectX = marginL + (drawW - widthIn * scale) / 2; var rectY = marginTop + (drawH - heightIn * scale) / 2; function xAt(xIn) { return rectX + xIn * scale; } // y_in measured from the bottom of the section; SVG y runs down. function yAt(yIn) { return rectY + (heightIn - yIn) * scale; } // The section outline mirrors `model.Section.rects` // (beamcalc/src/beamcalc/model.py:170-217) exactly -- that is the // canonical shape the engine analyses, so the picture and the numbers // cannot drift. Rects are (x0, y0, w, h) with y measured UP from the // bottom, x from the left. `height_in` is the OVERALL height // (stem_h + flange_t for L / inverted_t -- model.py:196,209); the stem // rectangle is drawn at FULL height and the ledge at the bottom. function addOutline(xIn, yBottomIn, wIn, hIn) { el('rect', { x: xAt(xIn), y: yAt(yBottomIn + hIn), width: Math.max(1, wIn * scale), height: Math.max(1, hIn * scale), fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.2 }, svg); } var type = String(geom.type || 'rectangular').toLowerCase().replace(/[\s_]+/g, '_'); if (type === 'rect' || type.indexOf('rect') === 0) type = 'rectangular'; var stemW = safeNum(geom.stem_width_in); var flangeW = safeNum(geom.flange_width_in); var flangeT = safeNum(geom.flange_thickness_in); var side = String(geom.side || 'left').toLowerCase(); if (type === 'inverted_t' || type === 'inverted_tee' || type === 'it') { // model.py:205-217 -- a full-height centred stem over a bottom flange // that runs out both sides: (flange_w, 0, stem_w, h) plus the two // side ledges (0, 0, flange_w, flange_t) and // (flange_w + stem_w, 0, flange_w, flange_t). if (flangeT !== null) { addOutline(0, 0, widthIn, flangeT); } if (stemW !== null && flangeW !== null) { addOutline(flangeW, 0, stemW, heightIn); } } else if (type === 'l') { // model.py:190-203 -- the stem occupies the full height; the ledge is // at the BOTTOM on one side. side=left: stem starts at x=flange_w and // the ledge fills the left; side=right is the mirror image. if (stemW !== null && flangeW !== null) { var stemX = side === 'right' ? 0 : flangeW; var ledgeX = side === 'right' ? stemW : 0; addOutline(stemX, 0, stemW, heightIn); if (flangeT !== null) addOutline(ledgeX, 0, flangeW, flangeT); } else if (flangeT !== null && flangeW !== null) { // No stem dims (malformed): still show the ledge rather than a blank. addOutline(side === 'right' ? Math.max(0, widthIn - flangeW) : 0, 0, flangeW, flangeT); } } else { // rectangular -- and anything unrecognised falls back to a plain box // rather than drawing nothing. addOutline(0, 0, widthIn, heightIn); } // Bars: filled circles, diameter scaled to the section (capped so a #11 // bar does not cover its neighbour), at their own x_in / y_in. (geom.bars || []).forEach(function (b) { if (!b || typeof b !== 'object') return; var xIn = safeNum(b.x_in), yIn = safeNum(b.y_in); if (xIn === null || yIn === null) return; var diaIn = safeNum(b.dia_in); var r = (diaIn !== null && diaIn > 0) ? Math.max(2, Math.min(6, diaIn * scale * 0.5)) : 3; el('circle', { cx: xAt(xIn), cy: yAt(yIn), r: r, fill: '#1b1e23' }, svg); }); // Strands: smaller OPEN circles (the PA convention for prestressing // steel in section). (geom.strands || []).forEach(function (s) { if (!s || typeof s !== 'object') return; var xIn = safeNum(s.x_in), yIn = safeNum(s.y_in); if (xIn === null || yIn === null) return; el('circle', { cx: xAt(xIn), cy: yAt(yIn), r: 3, fill: '#fff', stroke: '#2f6fed', 'stroke-width': 1.4 }, svg); }); dimH(svg, rectX, rectX + widthIn * scale, rectY + heightIn * scale + 16, inch(widthIn), { below: true }); dimV(svg, rectY, rectY + heightIn * scale, rectX - 14, inch(heightIn), { left: true }); // One `_stagger`ed label per distinct bar size, spread across the width // just above the outline -- a legend that never prints over the concrete. // `_stagger` levels push a label down only when it would otherwise // overlap its neighbour's text. var sizes = []; (geom.bars || []).forEach(function (b) { if (!b || typeof b !== 'object') return; var size = b.size; if (!size || sizes.indexOf(size) !== -1) return; sizes.push(size); }); if (sizes.length) { var gap = 14; var items = sizes.map(function (s, i) { var w = estWidth(s, 11) + gap; // Real x centres: spread the labels evenly across the section width. var cx = rectX + widthIn * scale * (i + 0.5) / sizes.length; return { x0: cx - w / 2, x1: cx + w / 2 }; }); var levels = stagger(items, 6); var lvlY = rectY - 10; sizes.forEach(function (s, i) { var cx = rectX + widthIn * scale * (i + 0.5) / sizes.length; label(svg, cx, lvlY + levels[i] * 13, s, { size: 11, fill: '#1b1e23' }); }); } } window.ToolDrawings = { elevation: elevation, section: section }; })();