From 5bba0e3c4a5d28b88b7196499e1313eaf95d42c5 Mon Sep 17 00:00:00 2001 From: wenil Date: Mon, 25 May 2026 12:44:45 +0300 Subject: [PATCH] holder: expose all 59 scad parameters auto-extracted from the .scad source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled hex_cell.scad has many more knobs than the dozen we used to expose (box clearances, cap/box wall, vertical stacking, busbar template, etc). Maintaining a hand-curated PARAMS list in Python next to a SCAD file that already documents every variable inline was always going to drift. Sync + auto-extract approach: - scad/hex_cell.scad: replace with the upstream master file from Albert Phan's Hex-Cell-Holder fork (adds the BUSBAR TEMPLATE section and the "busbar template" part option). - holder.py: PARAMS now built at import time by _scan_scad(), which regexes top-level `name = literal; // help` lines into Param entries up until the // END OF CONFIGURATION marker. Scan handles bool / number / string literals; derived expressions and helper variables are skipped automatically. - Manual maps stay small and explicit: _SELECT_OPTIONS for the few string-enum params (part, pack_style, box_style, template_outline, template_hole_style, etc.), _GROUP_RULES for UI sectioning, and _NUMBER_HINTS for sensible min/max/step on the most-tweaked numbers. UI: - holder-app.js: extra GROUP_ORDER / GROUP_LABELS for the new sections (cap, box, insulator, bolts, wires, stacking, template, advanced). Group titles are now click-to-collapse; the advanced groups start collapsed so the form isn't a wall of inputs on load. - holder.css: caret marker on the group title, smooth rotate on collapse, hides the body via .collapsed class. Net effect: every variable in the .scad — including the new busbar template knobs — is editable from the page, with helpful comments copied straight from the .scad source. --- holder.py | 168 +++++++++++++++++++++++++++++++--------- scad/hex_cell.scad | 165 +++++++++++++++++++++++++++++++++++---- static/holder.css | 22 ++++++ static/js/holder-app.js | 37 +++++++-- 4 files changed, 337 insertions(+), 55 deletions(-) diff --git a/holder.py b/holder.py index bae3857..c00cdf3 100644 --- a/holder.py +++ b/holder.py @@ -62,46 +62,142 @@ class Param: help: str | None = None -PARAMS: list[Param] = [ - # Pack geometry - Param("part", "Part", "select", "holder", group="part", - options=["holder", "cap", "box lid", "box bottom", "insulator"], - help="Which piece to generate."), - Param("pack_style", "Pack style", "select", "rect", group="part", - options=["rect", "para", "tria"], - help="Cell arrangement."), - Param("wire_style", "Wire style", "select", "strip", group="part", - options=["strip", "bus"], - help="strip = nickel strips between cells; bus = bus wires between rows."), +# ----- automated scan of the bundled .scad ---------------------------------- +# We extract top-level `name = literal; // help` lines into Param entries. +# Lines whose RHS isn't a constant number / bool / string (e.g. derived +# expressions like `box_total_height = get_mock_pack_height() + ...`) are +# skipped — those would still take their default value from the .scad itself. - # Cell - Param("cell_dia", "Cell diameter (mm)", "number", 21.2, group="cell", - min=10, max=40, step=0.1, help="21.2 for 21700, 18.4 for 18650, 26.5 for 26650."), - Param("cell_height", "Cell height (mm)", "number", 70.0, group="cell", - min=30, max=200, step=1), - Param("wall", "Wall thickness (mm)", "number", 0.8, group="cell", - min=0.4, max=3.0, step=0.1, - help="Wall around one cell; spacing between cells is 2× this."), +# Param categories — used both for grouping in the UI and for picking the +# select-typed params (those with a fixed option set). Keep keys in scad +# variable-name space; group order/labels are the rendering hint. +_SELECT_OPTIONS: dict[str, list[str]] = { + "part": ["holder", "cap", "box lid", "box bottom", + "wire clamp", "insulator", + "vertical box section", "busbar template"], + "part_type": ["normal", "mirrored", "both", "assembled"], + "pack_style": ["rect", "para", "tria"], + "wire_style": ["strip", "bus"], + "box_style": ["bolt", "ziptie", "both"], + "template_outline": ["rect", "hull"], + "template_hole_style": ["engrave", "through", "center"], +} - # Pack size - Param("num_rows", "Rows", "number", 6, group="size", min=1, max=40, step=1), - Param("num_cols", "Columns", "number", 12, group="size", min=1, max=40, step=1), - - # Holder - Param("holder_height", "Holder height (mm)", "number", 10.0, group="holder", - min=4, max=30, step=0.5), - Param("slot_height", "Slot height (mm)", "number", 3.0, group="holder", - min=0, max=10, step=0.5, - help="Height of all wire slots (set 0 for no slots)."), - Param("col_slot_width", "Column slot width (mm)", "number", 8.0, group="holder", - min=0, max=20, step=0.5), - Param("row_slot_width", "Row slot width (mm)", "number", 8.0, group="holder", - min=0, max=20, step=0.5), - Param("cell_top_overlap", "Cell top overlap (mm)","number", 3.0, group="holder", - min=0, max=10, step=0.5, - help="Opening dia = cell_dia − 2 × this. Welding-window size."), +# How to group each scad var into UI sections. First match wins; "advanced" +# catches the rest. +_GROUP_RULES: list[tuple[str, list[str]]] = [ + ("part", ["part", "part_type", "pack_style", "wire_style", "box_style"]), + ("cell", ["cell_dia", "cell_height", "wall"]), + ("size", ["num_rows", "num_cols"]), + ("holder", ["holder_height", "slot_height", "col_slot_width", + "row_slot_width", "cell_top_overlap"]), + ("cap", ["cap_wall", "cap_clearance"]), + ("box", ["box_lip", "wire_clamp_add", "box_wall", "box_clearance", + "bms_clearance", "box_bottom_clearance", + "box_wire_side_clearance", "box_nonwire_side_clearance"]), + ("insulator",["insulator_as_support", "support_z_gap", "insulator_tolerance"]), + ("bolts", ["bolt_dia", "bolt_head_dia", "bolt_head_thickness", + "ziptie_width", "ziptie_thickness", "bolt_dia_clearance", + "wire_clamp_bolt_dia"]), + ("wires", ["wire_diameter", "clamp_factor", "wire_hole_width", + "wire_hole_length", "wire_top_wall", "clamp_plate_height"]), + ("stacking", ["stacking_pins", "stacking_pin_dia", "stacking_pin_alt_style", + "stacking_bolts", "stacking_bolt_dia", "num_pack_stacks", + "stacking_pins_tolerance"]), + ("template", ["template_2d", "template_outline", "template_thickness", + "template_margin", "template_mark_dia", "template_hole_style", + "template_line_width", "template_engrave_depth", + "template_center_mark", "template_center_mark_dia"]), ] +# Tighter numeric input hints (min/max/step) for selected params. Anything +# not listed here gets a free-form number input. +_NUMBER_HINTS: dict[str, dict[str, float]] = { + "cell_dia": {"min": 10, "max": 40, "step": 0.1}, + "cell_height": {"min": 30, "max": 200, "step": 1}, + "wall": {"min": 0.2, "max": 3, "step": 0.1}, + "num_rows": {"min": 1, "max": 50, "step": 1}, + "num_cols": {"min": 1, "max": 50, "step": 1}, + "holder_height": {"min": 4, "max": 30, "step": 0.5}, + "slot_height": {"min": 0, "max": 10, "step": 0.5}, + "col_slot_width": {"min": 0, "max": 20, "step": 0.5}, + "row_slot_width": {"min": 0, "max": 20, "step": 0.5}, + "cell_top_overlap": {"min": 0, "max": 10, "step": 0.5}, + "cap_wall": {"min": 0.4, "max": 5, "step": 0.1}, + "box_wall": {"min": 0.4, "max": 10, "step": 0.1}, + "num_pack_stacks": {"min": 1, "max": 10, "step": 1}, +} + +_LITERAL_RE = re.compile( + r"^\s*([a-z_][a-z0-9_]*)\s*=\s*" + r"(?Ptrue|false|\"[^\"]*\"|[-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*;" + r"(?:\s*//\s*(?P.*))?\s*$" +) + + +def _group_for(name: str) -> str: + for grp, names in _GROUP_RULES: + if name in names: + return grp + return "advanced" + + +def _pretty_label(name: str) -> str: + return name.replace("_", " ").capitalize() + + +def _scan_scad(path: Path) -> list[Param]: + out: list[Param] = [] + seen: set[str] = set() + if not path.is_file(): + return out + for raw in path.read_text(encoding="utf-8", errors="replace").splitlines(): + # Stop at the explicit end-of-config marker so we don't pick up + # internal helpers (hextra, spacing, etc.). + if "END OF CONFIGURATION" in raw or "NON-Configurable" in raw: + break + m = _LITERAL_RE.match(raw) + if not m: + continue + name = m.group(1) + if name in seen: + continue + rhs = m.group("rhs") + help_text = (m.group("comment") or "").strip() or None + + if rhs in ("true", "false"): + kind, default = "bool", (rhs == "true") + elif rhs.startswith('"'): + literal = rhs[1:-1] + if name in _SELECT_OPTIONS: + kind, default = "select", literal + else: + # Plain free-text strings aren't worth a form field. + continue + else: + try: + num = float(rhs) + default = int(num) if num.is_integer() else num + kind = "number" + except ValueError: + continue + + p = Param( + name=name, label=_pretty_label(name), kind=kind, + default=default, group=_group_for(name), help=help_text, + ) + if kind == "select": + p.options = _SELECT_OPTIONS[name] + if kind == "number" and name in _NUMBER_HINTS: + h = _NUMBER_HINTS[name] + p.min, p.max, p.step = h.get("min"), h.get("max"), h.get("step") + out.append(p) + seen.add(name) + return out + + +PARAMS: list[Param] = _scan_scad(SCAD_FILE) + def default_params() -> dict[str, Any]: return {p.name: p.default for p in PARAMS} diff --git a/scad/hex_cell.scad b/scad/hex_cell.scad index da29531..f3c9629 100644 --- a/scad/hex_cell.scad +++ b/scad/hex_cell.scad @@ -15,16 +15,16 @@ ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -cell_dia = 21.2; // Cell diameter default = 18.4 for 18650s **PRINT OUT TEST FIT PIECE STL FIRST** +cell_dia = 21.4; // Cell diameter default = 18.4 for 18650s **PRINT OUT TEST FIT PIECE STL FIRST** cell_height = 70; // Cell height default = 65 for 18650s -wall = 0.8; // Wall thickness around a single cell. Make as a multiple of the nozzle diameter. Spacing between cells is twice this amount. default = 1.2 +wall = 0.2; // Wall thickness around a single cell. Make as a multiple of the nozzle diameter. Spacing between cells is twice this amount. default = 1.2 // If using bought injection molded hexes and printing out the boxes, take the distance between the centers of 2 cells and divide by two for the wall thickness (((((pitch - diameter)/2). Add space for the protuding interlocking tabs in the cap or box clearances. -num_rows = 6; -num_cols = 12; +num_rows = 7; +num_cols = 3; holder_height = 10; // Height of cell holder default = 10 (not including slot_height) -slot_height = 3; // Height of all slots default = 3 mm (set to 0 for no slots but that allows you to print without support) +slot_height = 1; // Height of all slots default = 3 mm (set to 0 for no slots but that allows you to print without support) col_slot_width = 8; // Width of slots between rows default = 8 row_slot_width = 8; // Width of slots along rows default = 8 @@ -35,39 +35,40 @@ pack_style = "rect"; // "rect" for rectangular pack, "para" for parallelogram, " wire_style = "strip"; // "strip" to make space to run nickel strips between cells. Default usage // "bus" to make space for bus wires between rows -box_style = "both"; // "bolt" for bolting the box pack together +box_style = "bolt"; // "bolt" for bolting the box pack together // "ziptie" for using zipties to fasten the box together. (ziptie heads will stick out), // "both" default: uses bolts for the 4 corners and zipties inbetween. Useful for mounting the pack to something with zipties but while still using bolts to hold it together part_type = "normal"; // "normal","mirrored", or "both". "assembled" is used for debugging. You'll want a mirrored piece if the tops and bottom are different ( ie. When there are even rows in rectangular style or any number of rows in parallelogram. The Console will tell you if you need a mirrored piece). -part = "box bottom"; // "holder" to generate cell holders, +part = "holder"; // "holder" to generate cell holders, // "cap" to generate pack end caps, // "box lid" to generate box lid // "box bottom" for box bottom // "wire clamp" for strain relief clamp // "insulator" for insulator piece to fit over the nickel strips // "vertical box section" for vertical battery stacking boxes (print 1 section for every additional stacked pack) + // "busbar template" to generate a flat template/drawing of the exact cell positions for making nickel strips / bus bars box_lip = true; // Adds a lip to the box pieces. default = true. wire_clamp_add = true; // Adds a wire exit hole out the side of the box lid. insulator_as_support = true; // Print the insulator as a part of the holder support material. -cap_wall = 1.2; // Cap wall thickness (default = 1.2 recommend to make a multiple of nozzle dia) +cap_wall = 0.8; // Cap wall thickness (default = 1.2 recommend to make a multiple of nozzle dia) cap_clearance = 0.2; // Clearance between holder and caps default = 0.2 -box_wall = 2; // Box wall thickness (default = 2.0 recommend to make at least 4 * multiple of nozzle dia) +box_wall = 1.2; // Box wall thickness (default = 2.0 recommend to make at least 4 * multiple of nozzle dia) box_clearance = 0.2; // Clearance between holder and box default = 0.2 // Box clearances for wires -bms_clearance = 10; // Vertical space for the battery management system (bms) on top of holders, set to 0 for no extra space +bms_clearance = 0; // Vertical space for the battery management system (bms) on top of holders, set to 0 for no extra space box_bottom_clearance = 0; // Vertical space for wires on bottom of box -box_wire_side_clearance = 3; // Horizontal space from right side (side with wire hole opening) to the box wall for wires +box_wire_side_clearance = 15; // Horizontal space from right side (side with wire hole opening) to the box wall for wires box_nonwire_side_clearance = 0; // Horizontal space from left side (opposite of wire hole) to the box wall for wires support_z_gap = 0.3; // Insulator gap to holder. default 0.3 -insulator_tolerance = 1.5; // How much smaller to make the width of the insulator default 1.5 +insulator_tolerance = 0.4; // How much smaller to make the width of the insulator default 1.5 insulator_thickness = (slot_height-support_z_gap); // Thickness of insulator wire_diameter = 5; // Diameter of 1 power wire used in the strain relief clamps default = 5 for 10 awg stranded silicon wire @@ -106,7 +107,7 @@ num_pack_stacks = 1; // How many additional packs you will stack vertically. Aff cell_top_overlap = 3; // How big the opening overlaps the cell default = 3 opening_dia = cell_dia-cell_top_overlap*2; // Circular opening to expose cell -separation = 1; // Separation between cell top and wire slots (aka tab thickness) default = 1 +separation = 0.6; // Separation between cell top and wire slots (aka tab thickness) default = 1 wire_hole_width = 15; // Width of wire hole default = 15 wire_hole_length = 10; // Length of the wireclamp that sticks out default = 10 wire_top_wall = 4; // Thickness of top wire wall default = 4mm @@ -120,6 +121,23 @@ spacer_overhang = box_clearance + 3; // Amount of spacer overhang to hold the flip_holders = false; // Mostly used for taking pngs +/////////////////////////////////////////////////////////////////////////////////// +// BUSBAR / WELDING TEMPLATE OPTIONS (used when part = "busbar template") +/////////////////////////////////////////////////////////////////////////////////// +template_2d = false; // false = printable 3D plate; true = flat 2D drawing for DXF/SVG export (F6 -> Export as DXF / SVG) +template_outline = "rect"; // "rect" = rectangular plate (easy to cut into strips); "hull" = follows the pack outline +template_thickness = 1.5; // Plate thickness for the 3D version +template_margin = 3; // Extra material added around the outermost cells +template_mark_dia = cell_dia; // Diameter of the marked circle at each cell. Set to opening_dia to mark only the exposed weld window +template_hole_style = "engrave";// "engrave" = ring scribed into the top, plate stays solid (best for tracing/marking) + // "through" = holes all the way through (use as a drill / spray-mark jig) + // "center" = only a tiny center hole at each cell (precise punch/drill centers) +template_line_width = 0.8; // Width of the engraved ring line (make a multiple of nozzle dia) +template_engrave_depth = 0.6; // Depth of the engraved ring for "engrave" style +template_center_mark = true; // With "engrave": also add a tiny center hole at each cell for punching exact centers +template_center_mark_dia = 1; // Diameter of the center mark hole + + // cell_tab_width = 5; // Width of tab that keeps the cell in the holder default = 5 // cell_tab_length = 3; // Approx Length of tab that keeps the cell in the holder default = 3 @@ -388,6 +406,11 @@ wire_clamp_nib_dia = 5; { vertical_box_section(num_pack_stacks); } + else if(part == "busbar template") + { + busbar_template(); + echo_cell_coordinates(); + } else if(part == "flipped holder png") { rotate([0,180,0]) @@ -1495,3 +1518,119 @@ function get_pin_list_rect(num_rows,num_cols) ] ]; + + +/////////////////////////////////////////////////////////////////////////// +// BUSBAR / WELDING TEMPLATE +// Generates a flat plate (or 2D drawing) with the exact cell positions so it +// can be printed, cut, and used as a template for nickel strips / bus bars, +// or exported as a dimensioned drawing (DXF/SVG). +/////////////////////////////////////////////////////////////////////////// + +// returns the list of all hex/cell center points for the current pack_style +function get_all_hex_centers() += pack_style == "rect" ? get_hex_center_points_rect(num_rows,num_cols) + : pack_style == "para" ? get_hex_center_points_para(num_rows,num_cols) + : get_hex_center_points_tria(num_rows,num_cols); + +// Echoes every cell center coordinate plus key dimensions to the console. +module echo_cell_coordinates() +{ + centers = get_all_hex_centers(); + echo("===== CELL CENTER COORDINATES (mm) ====="); + echo(str("pack_style = ", pack_style, ", rows = ", num_rows, ", cols = ", num_cols, ", total cells = ", len(centers))); + echo(str("Origin (0,0) = center of first cell. cell_dia = ", cell_dia, ", opening_dia = ", opening_dia)); + echo(str("Pitch X (same row, center-to-center) = ", hex_w)); + echo(str("Row offset X (between rows) = ", hex_w/2)); + echo(str("Pitch Y (row to row, center-to-center)= ", hex_pt*1.5)); + for(i = [0:len(centers)-1]) + echo(str("Cell ", i+1, ": x = ", centers[i].x, " , y = ", centers[i].y)); + echo("========================================"); +} + +// Small flat ring used for engraving cell outlines +module template_ring(outer_d, line_width, h) +{ + difference() + { + cylinder(d = outer_d, h = h); + translate([0,0,-extra]) + cylinder(d = max(outer_d - 2*line_width, 0.1), h = h + 2*extra); + } +} + +// 2D footprint of the template plate +module template_plate_shape(centers) +{ + if(template_outline == "hull") + { + hull() + for(p = centers) + translate([p.x,p.y]) + offset(r = template_margin) + polygon([for(a=[0:5])[hex_pt*sin(a*60),hex_pt*cos(a*60)]]); + } + else // rectangular bounding plate + { + xs = [for(p = centers) p.x]; + ys = [for(p = centers) p.y]; + min_x = min(xs) - cell_radius - template_margin; + max_x = max(xs) + cell_radius + template_margin; + min_y = min(ys) - cell_radius - template_margin; + max_y = max(ys) + cell_radius + template_margin; + translate([min_x, min_y]) + square([max_x - min_x, max_y - min_y]); + } +} + +// Flat 2D drawing (render with F6, then File -> Export -> Export as DXF / SVG) +module busbar_template_2d(centers) +{ + difference() + { + template_plate_shape(centers); + for(p = centers) + translate([p.x,p.y]) + circle(d = template_mark_dia); + } +} + +// Printable 3D plate +module busbar_template_3d(centers) +{ + difference() + { + // Plate + linear_extrude(height = template_thickness) + template_plate_shape(centers); + + // Per-cell markings + for(p = centers) + translate([p.x,p.y,0]) + { + if(template_hole_style == "through") + translate([0,0,-extra]) + cylinder(d = template_mark_dia, h = template_thickness + 2*extra); + else if(template_hole_style == "engrave") + translate([0,0,template_thickness - template_engrave_depth]) + template_ring(template_mark_dia, template_line_width, template_engrave_depth + extra); + else if(template_hole_style == "center") + translate([0,0,-extra]) + cylinder(d = template_center_mark_dia, h = template_thickness + 2*extra); + + // Optional center mark (only useful when material remains, i.e. "engrave") + if(template_center_mark && template_hole_style == "engrave") + translate([0,0,-extra]) + cylinder(d = template_center_mark_dia, h = template_thickness + 2*extra); + } + } +} + +module busbar_template() +{ + centers = get_all_hex_centers(); + if(template_2d) + busbar_template_2d(centers); + else + busbar_template_3d(centers); +} diff --git a/static/holder.css b/static/holder.css index 2757eb9..511e78d 100644 --- a/static/holder.css +++ b/static/holder.css @@ -31,6 +31,28 @@ letter-spacing: 0.05em; margin: 0 0 6px; font-weight: 600; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 6px; +} +.param-group-title::before { + content: "\25BC"; /* ▼ */ + display: inline-block; + font-size: 8px; + transition: transform 0.12s ease; +} +.param-group.collapsed .param-group-title::before { + transform: rotate(-90deg); /* ▶ */ +} +.param-group.collapsed .param-group-body { + display: none; +} +.param-group-body { + display: flex; + flex-direction: column; + gap: 4px; } .param-row { diff --git a/static/js/holder-app.js b/static/js/holder-app.js index 407ad1d..3e35cff 100644 --- a/static/js/holder-app.js +++ b/static/js/holder-app.js @@ -53,13 +53,31 @@ function _whenViewerReady(cb) { // ----- form generation ------------------------------------------------------ -const GROUP_ORDER = ["part", "cell", "size", "holder"]; +const GROUP_ORDER = [ + "part", "cell", "size", "holder", + "cap", "box", "insulator", + "bolts", "wires", + "stacking", "template", "advanced", +]; const GROUP_LABELS = { - part: "Part / pack", - cell: "Cell", - size: "Pack size", - holder: "Holder", + part: "Part / pack", + cell: "Cell", + size: "Pack size", + holder: "Holder", + cap: "Cap", + box: "Box", + insulator: "Insulator", + bolts: "Bolts & zipties", + wires: "Wires & clamp", + stacking: "Vertical stacking", + template: "Busbar template", + advanced: "Advanced", }; +// Groups that start collapsed (user opens with a click). Basics stay open. +const GROUPS_COLLAPSED = new Set([ + "cap", "box", "insulator", "bolts", "wires", + "stacking", "template", "advanced", +]); function _renderForm(schema, defaults) { const root = $("param-form"); @@ -77,11 +95,18 @@ function _renderForm(schema, defaults) { for (const g of groups) { const wrap = document.createElement("div"); wrap.className = "param-group"; + if (GROUPS_COLLAPSED.has(g)) wrap.classList.add("collapsed"); + const title = document.createElement("h3"); title.className = "param-group-title"; title.textContent = GROUP_LABELS[g] || g; + title.addEventListener("click", () => wrap.classList.toggle("collapsed")); wrap.appendChild(title); + const body = document.createElement("div"); + body.className = "param-group-body"; + wrap.appendChild(body); + for (const p of byGroup.get(g)) { const row = document.createElement("div"); row.className = "param-row"; @@ -123,7 +148,7 @@ function _renderForm(schema, defaults) { h.textContent = p.help; row.appendChild(h); } - wrap.appendChild(row); + body.appendChild(row); } root.appendChild(wrap); }