From 6bc922cabf11d31509e4a0535fe6073e142e41cd Mon Sep 17 00:00:00 2001 From: wenil Date: Sun, 24 May 2026 19:27:50 +0300 Subject: [PATCH] Add hex holder designer page (/holder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side OpenSCAD renders STL from bundled hex_cell.scad with parameter overrides via -D. Frontend is a Three.js viewer with auto-form generated from /api/holder/params. 'Design busbars →' button posts the computed cell coordinates to /api/projects and redirects to the busbar editor with the holder cells pre-loaded. - holder.py: openscad subprocess wrapper + compute_cells() (Python mirror of get_hex_center_points_*) - scad/hex_cell.scad: verbatim copy of Addy/Hex-Cell-Holder source - app.py: /holder route + /api/holder/{params,render,cells} - static/holder.html etc: parameter form + Three.js STL viewer - Dockerfile / install.sh: apt install openscad - static/index.html: nav link Holder ↔ Busbars in topbar --- CLAUDE.md | 15 +- Dockerfile | 4 +- README.md | 20 +- app.py | 45 +- deploy/install.sh | 3 +- holder.py | 225 ++++++ scad/hex_cell.scad | 1497 ++++++++++++++++++++++++++++++++++++ static/holder.css | 67 ++ static/holder.html | 67 ++ static/index.html | 5 + static/js/holder-app.js | 280 +++++++ static/js/holder-viewer.js | 131 ++++ static/styles.css | 21 + 13 files changed, 2371 insertions(+), 9 deletions(-) create mode 100644 holder.py create mode 100644 scad/hex_cell.scad create mode 100644 static/holder.css create mode 100644 static/holder.html create mode 100644 static/js/holder-app.js create mode 100644 static/js/holder-viewer.js diff --git a/CLAUDE.md b/CLAUDE.md index d290bd1..3e4ecb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,15 @@ The hard requirement is a working **STEP export**. STEP is generated by OpenCASC ``` Browser (static/) ──fetch──> Flask (app.py) - ├── busbar_export.py → build123d → STEP/DXF/SVG + ├── busbar_export.py → build123d → STEP/DXF/SVG (busbars page) + ├── holder.py → openscad subprocess → STL (holder page) └── storage.py → SQLite (projects/presets/snapshots) ``` +Two pages: +- `/` → busbar designer (cell-import, panel/wire shapes, export STEP) +- `/holder` → hex-holder designer (parameter form, openscad render, STL download, "Design busbars →" hand-off) + - **Frontend** is plain HTML + JS (no build step). State lives in memory but is auto-synced to the server every 1.5 s; the active project ID is in the URL (`?p=42`) so refresh and other devices resume the same state. - **Backend** is a Flask app with three groups of endpoints: - **CAD export**: `POST /api/export/{step,dxf,svg}` — stateless; takes a busbar payload and returns bytes via build123d. @@ -76,10 +81,14 @@ If the user reports coordinates "off by half a hex," check `rect`-vs-`para` and ## File map -- `app.py` — Flask app. Serves `static/`, dispatches export endpoints, exposes REST CRUD for projects/presets/snapshots. Single-file; no blueprints. +- `app.py` — Flask app. Serves `static/`, dispatches export endpoints, exposes REST CRUD for projects/presets/snapshots/holder. Single-file; no blueprints. - `busbar_export.py` — pure functions. Input: payload dict. Output: bytes. No Flask import here. +- `holder.py` — `subprocess.run(["openscad", "-D ...", "hex_cell.scad"])` wrapper that returns STL bytes; also has `compute_cells()` Python-side (mirrors the SCAD formulas) so the busbar hand-off doesn't need a second openscad call. +- `scad/hex_cell.scad` — verbatim copy of Addy777/Hex-Cell-Holder script. Source of truth for hex holder geometry. Update via git (no submodule for now). - `storage.py` — SQLite layer for projects, presets, snapshots. Connection-per-call inside a context manager; no Flask import. -- `static/index.html` — single page; left sidebar (import / params / busbars) + right pane (canvas + live SVG preview) + top bar (project switcher + history + exports) + history modal. +- `static/index.html` — busbar designer page; left sidebar (import / params / busbars) + right pane (canvas + live SVG preview) + top bar (project switcher + history + exports) + history modal. +- `static/holder.html` — hex-holder designer page; parameter form (left) + Three.js viewer (right). +- `static/js/holder-app.js`, `holder-viewer.js`, `holder.css` — UI for `/holder`. holder-viewer is an ES module (Three.js imported via importmap from jsdelivr). - `static/js/app.js` — top-level controller; owns in-memory state, hooks auto-save + URL routing. - `static/js/api.js` — thin fetch-based REST client (Api.listProjects, getProject, ...). - `static/js/importer.js` — paste-text parser + generator. diff --git a/Dockerfile b/Dockerfile index e42eb7d..6b4224c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,9 +20,11 @@ RUN pip install --user -r requirements.txt && \ FROM python:3.12-slim AS runtime -# OCP needs these at runtime. +# OCP runtime libs + openscad (subprocess for /api/holder/render). +# openscad headless renders STL from .scad with parameter overrides via -D. RUN apt-get update && apt-get install -y --no-install-recommends \ libgl1 libglu1-mesa libxrender1 libxext6 libsm6 libgomp1 \ + openscad xvfb \ && rm -rf /var/lib/apt/lists/* \ && useradd --create-home --shell /usr/sbin/nologin app diff --git a/README.md b/README.md index 598c16a..73b453d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,15 @@ -# Busbar Designer +# Busbar Designer + Hex Holder Designer + +Web "combine" for cylindrical-cell battery pack production. Two pages: + +| Page | Purpose | +|-------------|---------------------------------------------------------------------------| +| `/holder` | Parametric **hex cell holder** designer — adjust pack size / cell type, see 3D, download STL for printing. | +| `/` | **Busbar** designer — import cell coordinates, group into busbars (parallel + series), export STEP/DXF/SVG for laser cutting. | + +The holder page has a **Design busbars →** button that auto-creates a busbar project pre-loaded with the exact cell coordinates from the configured holder. + +--- Web tool for designing nickel/copper **busbars** over cylindrical-cell battery packs (21700, 18650, ...) built with hex-shaped cell holders. @@ -11,9 +22,10 @@ Workflow: ## Quick start -Requires **Python 3.10+**. On Windows / PowerShell: +Requires **Python 3.10+** and **OpenSCAD** on the host (only needed for the `/holder` page; the busbar page works without it). ```powershell +# Windows / PowerShell cd busbar-designer python -m venv .venv .\.venv\Scripts\Activate.ps1 @@ -21,7 +33,9 @@ pip install -r requirements.txt python app.py ``` -Then open in your browser. +OpenSCAD install: Windows — `winget install OpenSCAD.OpenSCAD`; Linux — `apt install openscad`. + +Then open for the busbar designer, or for the hex holder designer. > First-time install pulls **build123d** (~ 200 MB; bundles OpenCASCADE via OCP) and may take several minutes. diff --git a/app.py b/app.py index 2e62b42..441fd75 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,7 @@ from flask import Flask, Response, jsonify, request, send_from_directory from busbar_export import WRITERS import storage +import holder APP_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_DIR = os.path.join(APP_DIR, "static") @@ -37,9 +38,51 @@ def index(): return send_from_directory(STATIC_DIR, "index.html") +@app.get("/holder") +def holder_page(): + return send_from_directory(STATIC_DIR, "holder.html") + + @app.get("/api/health") def health(): - return jsonify({"status": "ok"}) + return jsonify({"status": "ok", "openscad": holder.openscad_available()}) + + +# --------------------------------------------------------------------------- +# Hex holder designer (OpenSCAD-backed) +# --------------------------------------------------------------------------- + + +@app.get("/api/holder/params") +def holder_params(): + """Schema for the parameter form + their defaults.""" + return jsonify({"params": holder.schema_dict(), "defaults": holder.default_params()}) + + +@app.post("/api/holder/render") +def holder_render(): + """Render STL for the supplied parameter overrides.""" + body = request.get_json(silent=True) or {} + try: + data = holder.render_stl(body.get("params", {})) + except (FileNotFoundError, RuntimeError) as e: + return jsonify({"error": str(e)}), 500 + return Response( + data, + mimetype="model/stl", + headers={"Content-Disposition": 'attachment; filename="hex_holder.stl"'}, + ) + + +@app.post("/api/holder/cells") +def holder_cells(): + """Cell-center coordinates for the supplied parameters (no OpenSCAD needed).""" + body = request.get_json(silent=True) or {} + try: + cells = holder.compute_cells(body.get("params", {})) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + return jsonify({"cells": cells, "count": len(cells)}) # --------------------------------------------------------------------------- diff --git a/deploy/install.sh b/deploy/install.sh index 683155b..a80805b 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -43,7 +43,8 @@ apt-get update -qq apt-get install -y -qq \ git ca-certificates curl \ python3 python3-venv python3-pip \ - libgl1 libglu1-mesa libxrender1 libxext6 libsm6 libgomp1 + libgl1 libglu1-mesa libxrender1 libxext6 libsm6 libgomp1 \ + openscad xvfb # ---- service user ---------------------------------------------------------- if ! id "$SVC_USER" >/dev/null 2>&1; then diff --git a/holder.py b/holder.py new file mode 100644 index 0000000..d796034 --- /dev/null +++ b/holder.py @@ -0,0 +1,225 @@ +"""Generate hex cell holder STL via OpenSCAD subprocess. + +We embed Addy's hex_cell.scad (in scad/) and shell out to `openscad` with +parameter overrides via `-D var=value`. STL bytes come back from a temp file. + +Cell-center coordinates are derived in *Python* using the same formulas +the .scad file uses (see static/js/importer.js for the JS mirror). That avoids +a second OpenSCAD invocation just to echo coordinates and matches the math +exactly — see `hex_holder_geometry` memory for the formulas. +""" + +from __future__ import annotations + +import json +import math +import os +import shutil +import subprocess +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# --------------------------------------------------------------------------- +# Locations / config +# --------------------------------------------------------------------------- + +APP_DIR = Path(__file__).resolve().parent +SCAD_FILE = APP_DIR / "scad" / "hex_cell.scad" +OPENSCAD_BIN = os.environ.get("OPENSCAD_BIN", "openscad") +RENDER_TIMEOUT = int(os.environ.get("OPENSCAD_TIMEOUT", "60")) + + +def openscad_available() -> bool: + return shutil.which(OPENSCAD_BIN) is not None + + +# --------------------------------------------------------------------------- +# Parameter schema — exposed to the frontend for auto-form generation. +# Only the subset users typically touch; the .scad itself has many more knobs +# that fall back to script defaults. +# --------------------------------------------------------------------------- + + +@dataclass +class Param: + name: str # SCAD variable name + label: str # UI label + kind: str # "number" | "select" | "bool" + default: Any + group: str = "main" + options: list[str] | None = None # for kind="select" + min: float | None = None # for kind="number" + max: float | None = None + step: float | None = None + 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."), + + # 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."), + + # 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."), +] + + +def default_params() -> dict[str, Any]: + return {p.name: p.default for p in PARAMS} + + +def schema_dict() -> list[dict]: + out = [] + for p in PARAMS: + d = {"name": p.name, "label": p.label, "kind": p.kind, + "default": p.default, "group": p.group} + if p.options is not None: d["options"] = p.options + if p.min is not None: d["min"] = p.min + if p.max is not None: d["max"] = p.max + if p.step is not None: d["step"] = p.step + if p.help is not None: d["help"] = p.help + out.append(d) + return out + + +# --------------------------------------------------------------------------- +# Cell-center computation (Python mirror of get_hex_center_points_*). +# Returns list of {id, x, y} in mm. +# --------------------------------------------------------------------------- + + +_COS30 = math.cos(math.radians(30)) + + +def compute_cells(params: dict) -> list[dict]: + cell_dia = float(params.get("cell_dia", 21.2)) + wall = float(params.get("wall", 0.8)) + rows = int(params.get("num_rows", 6)) + cols = int(params.get("num_cols", 12)) + style = str(params.get("pack_style", "rect")) + + hex_w = cell_dia + 2 * wall + hex_pt = (hex_w / 2) / _COS30 + row_y = lambda r: r * 1.5 * hex_pt + + out: list[dict] = [] + cid = 1 + if style == "rect": + for r in range(rows): + for c in range(cols): + x = (0 if r % 2 == 0 else 0.5 * hex_w) + hex_w * c + out.append({"id": cid, "x": x, "y": row_y(r)}) + cid += 1 + elif style == "para": + for r in range(rows): + for c in range(cols): + out.append({"id": cid, "x": r * 0.5 * hex_w + hex_w * c, "y": row_y(r)}) + cid += 1 + elif style == "tria": + for r in range(rows): + for c in range(r + 1): + out.append({"id": cid, "x": r * 0.5 * hex_w - hex_w * c, "y": row_y(r)}) + cid += 1 + else: + raise ValueError(f"unknown pack_style: {style!r}") + return out + + +# --------------------------------------------------------------------------- +# OpenSCAD render +# --------------------------------------------------------------------------- + + +def _to_scad_literal(v: Any) -> str: + """Convert a Python value to the literal text OpenSCAD expects on -D.""" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, float)): + return repr(v) + if isinstance(v, str): + # OpenSCAD strings are double-quoted; escape via json. + return json.dumps(v) + raise TypeError(f"unsupported parameter type: {type(v).__name__}") + + +def _filter_params(params: dict) -> dict: + """Drop None and unknown-name params; coerce types where obvious.""" + known = {p.name: p for p in PARAMS} + out = {} + for k, v in (params or {}).items(): + if k not in known or v is None: + continue + p = known[k] + if p.kind == "number": + v = float(v) if p.step and p.step != int(p.step) else int(float(v)) + # keep ints for integer-step params + if isinstance(p.default, int) and float(v).is_integer(): + v = int(v) + elif p.kind == "bool": + v = bool(v) + elif p.kind == "select": + v = str(v) + out[k] = v + return out + + +def render_stl(params: dict) -> bytes: + """Render the .scad with given parameter overrides; return STL bytes.""" + if not openscad_available(): + raise RuntimeError( + f"`{OPENSCAD_BIN}` not found on PATH. Install OpenSCAD " + "(e.g. `apt install openscad` on Debian/Ubuntu)." + ) + if not SCAD_FILE.is_file(): + raise FileNotFoundError(f"SCAD source not found at {SCAD_FILE}") + + clean = _filter_params(params) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "out.stl" + cmd = [OPENSCAD_BIN, "-o", str(out)] + for k, v in clean.items(): + cmd += ["-D", f"{k}={_to_scad_literal(v)}"] + cmd.append(str(SCAD_FILE)) + try: + r = subprocess.run(cmd, capture_output=True, timeout=RENDER_TIMEOUT) + except subprocess.TimeoutExpired: + raise RuntimeError(f"openscad timed out after {RENDER_TIMEOUT}s") + if r.returncode != 0: + err = (r.stderr or b"").decode(errors="replace").strip() + raise RuntimeError(f"openscad failed (exit {r.returncode}):\n{err[-800:]}") + if not out.exists() or out.stat().st_size == 0: + raise RuntimeError("openscad produced no STL (geometry empty?)") + return out.read_bytes() diff --git a/scad/hex_cell.scad b/scad/hex_cell.scad new file mode 100644 index 0000000..da29531 --- /dev/null +++ b/scad/hex_cell.scad @@ -0,0 +1,1497 @@ +// https://github.com/Addy771/Hex-Cell-Holder +// This script generates models of cell holders and caps for +// building battery packs using cylindrical cells. +// Original concept by ES user "SpinningMagnets" +// More info can be found here: +// https://endless-sphere.com/forums/viewtopic.php?f=3&t=90058 +// +// This file was created by Addy and is released as public domain +// Contributors +// Albert Phan - Added boxes, stacking, and optimizations + + +/////////////////////////////////////////////////////////////////////////////////////////////////////////// +// BASIC CONFIGURATION +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + + +cell_dia = 21.2; // 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 + // 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; + +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) + +col_slot_width = 8; // Width of slots between rows default = 8 +row_slot_width = 8; // Width of slots along rows default = 8 + + +pack_style = "rect"; // "rect" for rectangular pack, "para" for parallelogram, "tria" for triangle shaped pack (number of rows define the amount of rows at the bottom of the triangle. Columns get ignored) + +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 + // "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, + // "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) + +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_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_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 +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_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_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 +wire_clamp_bolt_dia = 3; // Bolt dia used for clamping wire default = 3 for M3 bolt +clamp_factor = 0.7; // Factor of wire diameter to be clamped. Higher number is less clamping force (default=0.7 max=1.0) +bolt_dia = 3; // Actual dia of bolt default = 3 for M3 bolt +bolt_head_dia = 6; // Actual dia of bolt head default = 6 for M3 socket head bolt +bolt_head_thickness = 3; // Thickness (height) of bolt head default = 3 for M3 Socket head +ziptie_width = 8; +ziptie_thickness = 2.5; + +//////////////////////////////////////////////////////////////////////////////////// +// EXPERIMENTAL Vertical Holder Stacking +// Use at own risk - Stacking holders vertically requires more thought into electrical short prevention. +// For best use, col and row slots sizes should be the same so the pin is centered +// Rectangular packs only +// Using stacking pins or bolts require you to cut holes out for the kapton tape/fishpaper for the insulation. +// If you don't use stacking pins or bolts, you can print out insulators (use part = insulator) and add kapton/fishpaper without cutting holes in it on top. +// The fishpaper is a last line of protection to prevent shorts in case the plastic insulation melts due to a malfunction. +/////////////////////////////////////////////////////////////////////////////////// + +stacking_pins = false; // Adds pins and holes for stacking holders vertically. Make sure col and row slots are the same width. You'll have to think about how to insulate the strips properly. Maybe precut kapton/fishpaper? +stacking_pin_dia = 3; // Default 3 mm. Smaller than 3 not recommended. +stacking_pin_alt_style = false; // Alternate style of pins that are longer and go into the holder deeper. (Used when the triangle islands are too small for a hole) +stacking_bolts = false; // Adds holes through the holders to bolt them (if not using box to bolt them together). + // !!!!!!MAKE SURE BOLTS DO NOT SHORT NICKEL STRIPS!!!! + // Don't use with stacking pins. You'll need mirrored pieces. +stacking_bolt_dia = 4.5; // Bolt dia. Make slightly bigger for bolt fit. Watch out for too large bolts that cut too much out of the holder. +num_pack_stacks = 1; // How many additional packs you will stack vertically. Affects part = vertical box section. (set to 1 if you just want to print single sections and glue them together. 1 section for every additional pack stack) + + + +/////////////////////////////////////////////////////////////////////////////////// +// ADVANCED CONFIGURATION for users that need to customize everything +////////////////////////////////////////////////////////////////////////////////// + +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 +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 +clamp_plate_height = 4; // default = 4 +bolt_dia_clearance = 1; // Amount of extra diameter for bolt holes default = 1 +box_lip_height = box_wall * 0.75; // Height of lip default = box_wall * 0.75 +box_lip_width = box_wall * 0.5; // Width of lip default = box_wall * 0.5 +stacking_pins_tolerance = 0.5; // How much larger for the stacking pin hole compared to it's pin diameter +spacer_overhang = box_clearance + 3; // Amount of spacer overhang to hold the holders default = 3 + +flip_holders = false; // Mostly used for taking pngs + + + +// 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 + +////////////////////////////////////////////////////// +// Don't forget to do a test fit print +////////////////////////////////////////////////////// + +// END OF CONFIGURATION +//////////////////////////////////////////////////////////////////////// + +// TODO: +// [x] Vertical Stacking Pins +// [x] Vertical Stacking Bolts +// [x] Add insulators +// [x] rect support +// [x] other styles support +// [x] Fixed boxes spacers +// [x] Add vertical stacking boxes +// [x] Add insulator to bat file +// [] Add some more echo helper messages for mirrored pieces wrt to stacking pins/bolt +// [x] Add insulation to "both" part types +// [x] Don't do mirrored versions for Both parts unless needed. +// [x] Double check box_lip_height usage versus box_wall/2 in some cases with the lid and bottom lips + + +/////////////////////////////////////////////////////////////////////////// +// NON-Configurable helper variables +/////////////////////////////////////////////////////////////////////////// +$fn = 50; // Number of facets for circular parts. +hextra = 0.0001; // enlarge hexes by this to make them overlap +extra = 1; // for proper differences() +spacing = 4; // Spacing between top and bottom pieces +box_total_height = get_mock_pack_height() + 2 * (box_wall + box_clearance) + bms_clearance + box_bottom_clearance; +box_lid_height = (holder_height + slot_height)/2 + (box_clearance + box_wall) + bms_clearance; // box lid to middle of holder +box_bottom_height = box_total_height - box_lid_height; +vertical_box_section_height = get_mock_pack_height(); +hex_w = (cell_dia + 2*wall); // Width of one hex cell +hex_pt = (hex_w/2) / cos(30); // Half the distance of point to point of a hex aka radius +cell_radius = cell_dia/2; +box_clearance_x = box_clearance * cos(30); // Used whenever we are translating in the x direction from the hexes +box_wall_x = box_wall * cos(30); // Used whenever we are translating in the x direction from the hexes + +wire_clamp_support = hex_pt + box_clearance + box_wall - wire_hole_width/2 ; // Place for strain relief clamp to screw into +wire_clamp_nib_dia = 5; + + if (part_type == "mirrored") + { + if (part == "cap") + { + mirror([0,1,0]) + cap(cap_wall,cap_clearance); + } + else if (part == "holder") + { + mirror([0,1,0]) + rotate([0,180,0]) + holders(); + } + } + else if(part_type == "both") + { + if (part == "cap") + { + cap(cap_wall,cap_clearance); + mirror([0,1,0]) + translate([0,2*hex_pt + 2 * (cap_wall + cap_clearance) + spacing,0]) + cap(cap_wall,cap_clearance); + } + else if (part == "holder") + { + + //rotate([0, 180, 0]) //flips around all the holders + { + // First holder + holders(); + // Second holder + if(pack_style == "para") + { + mirror([0,1,0]) + translate([hex_w*0.5,1.5*hex_pt + spacing,0]) + holders(); + } + else if(pack_style == "rect") + { + if(num_rows % 2 == 1) // If odd pack move pack over to nest properly + { + if(stacking_bolts) + { + mirror([1,0,0]) // mirrored for bolt stacking holes + rotate([0,0,180]) + translate([-(hex_w*0.5), (1.5*(hex_pt) + spacing),0]) + holders(); + + } + else // not mirrored for anything but bolt stacking holes + { + translate([-(hex_w*0.5), -(1.5*(hex_pt) * num_rows+ spacing),0]) + holders(); + } + } + else // if even pack + { + mirror([0,1,0]) // mirrored for bolt stacking holes + translate([hex_w*0.5,1.5*hex_pt + spacing,0]) + holders(); + } + } + } + } + else if (part == "box lid" || part == "box bottom") + { + box_bottom(); + translate([0,-(hex_pt * 2 + 2 * (box_wall + box_clearance) + spacing), 0]) + mirror([0,0,1]) + rotate([180,0,0]) + box_lid(); + } + + } + else if(part_type == "assembled") + { + // TESTING difference section analysis + difference() + { + union() + { + mock_pack(); // for debugging for now + + + if(part == "vertical box section") + { + // iterate through packs + for(i = [1:num_pack_stacks]) + { + translate([0,0,get_mock_pack_height()*i]) + { + mock_pack(); + // vertical section box + color("darkgreen", alpha =0.8) + translate([0,0,box_bottom_height -(box_clearance+box_wall)-get_mock_pack_height()]) + vertical_box_section(); + + if(i == num_pack_stacks) + { + // lid + translate([0,0,box_bottom_height + box_lid_height - 2 * (box_wall + box_clearance) - box_bottom_clearance]) + mirror([0,0,1]) + { + color("green", alpha = 0.7) + box_lid(); + if(wire_clamp_add) + { + color("orange", alpha = 0.7) + translate([(get_hex_center_x_length(num_cols + 1) + box_wire_side_clearance + box_wall + box_clearance + wire_hole_length/2),0,box_lid_height-box_wall-box_clearance]) + mirror([0,0,1]) + wire_clamp(); + } + + } + } + } + + } + + } + else + { + //normal non stacking assembly + // lid + translate([0,0,box_bottom_height-(box_wall +box_clearance)*2+box_lid_height]) + mirror([0,0,1]) + { + color("green", alpha = 0.7) + box_lid(); + if(wire_clamp_add) + { + color("orange", alpha = 0.7) + translate([(get_hex_center_x_length(num_cols + 1) + box_wire_side_clearance + box_wall + box_clearance + wire_hole_length/2),0,box_lid_height-box_wall-box_clearance]) + mirror([0,0,1]) + wire_clamp(); + } + + } + } + // bottom + color("lightgreen", alpha = 0.7) + translate([0,0,0]) + box_bottom(); + } + + // // sectional analysis testing cutout + // translate([0,50,50]) + // cube([150,100,2000], center = true); + } + + // Additional assembled packs + // translate([(get_hex_center_x_length(num_rows+2)+ 2*(box_wall + box_clearance) + box_nonwire_side_clearance + box_wire_side_clearance)*2,get_hex_center_y_length(num_cols+2) + 2*(box_wall + box_clearance),0]) + // { + // mock_pack(); // for debugging for now - 2 * (box_wall + box_clearance) - bms_clearance + // translate([0,0,get_mock_pack_height() + bms_clearance]) + // mirror([0,0,1]) + // { + // color("green", alpha = 0.7) + // box_lid(); + // if(wire_clamp_add) + // { + // color("orange", alpha = 0.7) + // translate([(get_hex_center_x_length(num_cols + 1) + box_wire_side_clearance + box_wall + box_clearance + wire_hole_length/2),0,box_lid_height-box_wall-box_clearance]) + // mirror([0,0,1]) + // wire_clamp(); + // } + + // } + + + // } + + // translate([(get_hex_center_x_length(num_rows+2)+ 2*(box_wall + box_clearance) + box_nonwire_side_clearance + box_wire_side_clearance)*4,(get_hex_center_y_length(num_cols+2) + 2*(box_wall + box_clearance)) * 3,0]) + // { + // mock_pack(); // for debugging for now + // color("lightgreen", alpha = 0.7) + // translate([0,0,-(box_bottom_clearance)]) + // box_bottom(); + + // } + + + + } + else if(part_type == "mock pack") + { + mock_pack(); + } + else // if Normal + { + if (part == "cap") + { + cap(cap_wall,cap_clearance); + } + else if (part == "holder") + rotate([0,0,0]) + { + holders(); + } + + + else if (part == "box lid") + { + translate([0,get_hex_center_y_length(num_cols),0]) + mirror([0,0,1]) + rotate([180,0,0]) + box_lid(); + } + else if (part == "box bottom") + { + box_bottom(); + } + else if (part == "wire clamp") + wire_clamp(); + else if(part == "insulator") + { + holder_insulators(); + } + else if(part == "vertical box section") + { + vertical_box_section(num_pack_stacks); + } + else if(part == "flipped holder png") + { + rotate([0,180,0]) + holders(); + } + else if(part == "testing") + { + translate([0,0,0]) + { + box_lid(); + } + // Testing + translate([0,-get_hex_center_y_length(num_rows+2) - (box_wall + box_clearance)*2,0]) + box_bottom(); + translate([0,-get_hex_center_y_length(num_rows+2)*2 - (box_wall + box_clearance)*4,0]) + vertical_box_section(); + } + } + + + + + +///////////////////////////////////////////////////////////////////////////////////////////////////////// +// echos and info +echo(hex_cell_center_to_center_distance = get_hex_center_x_length(2)); +echo(pack_height_holder = get_mock_pack_height()); +echo(box_lid_height = box_lid_height); +echo(box_bottom_height = box_bottom_height); +echo(box_total_height = box_total_height); +echo(box_total_length = get_box_total_length()); +echo(box_total_width = get_box_total_width()); +echo(vertical_box_section_height = vertical_box_section_height * (num_pack_stacks) + box_lip_height); +echo(box_total_height_with_vertical_stacking = box_bottom_height + box_lid_height + vertical_box_section_height * num_pack_stacks - box_lip_height); + +echo(total_width_holder = get_hex_center_y_length(num_rows)+hex_pt*2); +if (pack_style == "rect") +{ + // Rectangular style + echo(total_length_holder=get_holder_rect_length()); + if((num_rows % 2) == 0) // Even? + + echo("\n******************************************************* \n Top and bottom are different. Don't forget to do a mirrored holder\n*******************************************************"); +} +else if(pack_style == "para") +{ + // Parallelogram style + echo(total_length_holder=hex_w*(num_cols+0.5*(num_rows-1))); + echo("\n******************************************************* \n Top and bottom are different. Don't forget to do a mirrored holder\n*******************************************************"); +} +else if(pack_style == "tria") +{ + // Triangle style + echo(total_length_holder=hex_w*(num_rows-1)); + echo("\n******************************************************* \n Top and bottom are not different. But don't forget to print twice ;)\n*******************************************************"); +} + +if (part_type == "mirrored" && (part == "box lid" || part == "box bottom")) + echo("\n******************************************************* \n Please choose Normal for box lid or box bottom as there aren't mirrored versions of them.\n*******************************************************"); + +if (pack_style == "tria" && (part == "box lid" || part == "box bottom" || part == "cap")) + echo("\n******************************************************* \n There are currently no boxes and caps for triangle style\n*******************************************************"); + + + +////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +module cap(cap_wall,cap_clearance,cap_height = holder_height+slot_height,positive_x = 0, negative_x = 0, positive_y = 0, negative_y = 0) +{ + difference() + { + // Positive Hull + cap_positive(cap_wall,cap_clearance,cap_height,positive_x,negative_x,positive_y,negative_y); + // Negative Hull + cap_negative(cap_wall,cap_clearance,cap_height,positive_x,negative_x,positive_y,negative_y); + } +} + +// Generates the rectangular cap positive piece used in caps and boxes. Default height is holder height +// height includes wall thickness and clearance +// Z Origin is -(cap_wall + cap_clearance) +// Positive_x = amount of clearance between the positive x box wall and the holder. +// Negative_x = amount of clearance between the negative x box wall and the holder. +// Same goes for y +module cap_positive(cap_wall,cap_clearance,cap_height = holder_height,positive_x = 0, negative_x = 0, positive_y = 0, negative_y = 0) +{ + if(pack_style=="rect") + { + translate([0,0,-(cap_wall + cap_clearance)]) + hull() + { + // Generate 4 hexes in each corner and hull them together + // [0,0] Bottom left + translate([-negative_x,-negative_y,0]) + hex(cap_height,hex_pt + cap_wall + cap_clearance); + // [1,0] Bottom right + translate([get_hex_center_x_length(num_cols + 0.5)+ positive_x,-(negative_y),0]) + hex(cap_height,hex_pt + cap_wall + cap_clearance); + // [0,1] Top left + translate([-(negative_x),get_hex_center_y_length(num_rows) + positive_y,0]) + hex(cap_height,hex_pt + cap_wall + cap_clearance); + // [1,1] Top right + translate([get_hex_center_x_length(num_cols + 0.5) + positive_x,get_hex_center_y_length(num_rows) + positive_y,0]) + hex(cap_height,hex_pt + cap_wall + cap_clearance); + } + } + else if(pack_style=="para") + { + translate([0,0,-(cap_wall + cap_clearance)]) + hull() + { + // Generate 4 hexes in each corner and hull them together + // [0,0] Bottom left + translate([-negative_x,-negative_y,0]) + hex(cap_height,hex_pt + cap_wall + cap_clearance); + // [1,0] Bottom right + translate([get_hex_center_x_length(num_cols)+ positive_x,-(negative_y),0]) + hex(cap_height,hex_pt + cap_wall + cap_clearance); + // [0,1] Top left + translate([get_hex_center_x_length(num_rows/2 +0.5)-(negative_x),get_hex_center_y_length(num_rows) + positive_y,0]) + hex(cap_height,hex_pt + cap_wall + cap_clearance); + // [1,1] Top right + translate([get_hex_center_x_length(num_cols + num_rows/2-0.5) + positive_x,get_hex_center_y_length(num_rows) + positive_y,0]) + hex(cap_height,hex_pt + cap_wall + cap_clearance); + } + } +} + + +// Generates the cap negative piece (as a positive to be cut out using difference) used in cap and box. This is basically the same as cap_positive but the hexes are smaller by cap_wall and +// z origin = -cap_clearance +module cap_negative(cap_wall,cap_clearance,cap_height = holder_height,positive_x = 0, negative_x = 0, positive_y = 0, negative_y = 0) +{ + translate([0,0,-cap_clearance]) + { + if(pack_style=="rect") + { + hull() + { + // Generate 4 hexes in each corner and hull them together + // [0,0] Bottom left + translate([-negative_x,-negative_y,0]) + hex(cap_height,hex_pt + cap_clearance); + // [1,0] Bottom right + translate([get_hex_center_x_length(num_cols + 0.5)+ positive_x,-(negative_y),0]) + hex(cap_height,hex_pt + cap_clearance); + // [0,1] Top left + translate([-(negative_x),get_hex_center_y_length(num_rows) + positive_y,0]) + hex(cap_height,hex_pt + cap_clearance); + // [1,1] Top right + translate([get_hex_center_x_length(num_cols + 0.5) + positive_x,get_hex_center_y_length(num_rows) + positive_y,0]) + hex(cap_height,hex_pt + cap_clearance); + } + } + else if(pack_style=="para") + { + hull() + { + // Generate 4 hexes in each corner and hull them together + // [0,0] Bottom left + translate([-negative_x,-negative_y,0]) + hex(cap_height,hex_pt + cap_clearance); + // [1,0] Bottom right + translate([get_hex_center_x_length(num_cols)+ positive_x,-(negative_y),0]) + hex(cap_height,hex_pt + cap_clearance); + // [0,1] Top left + translate([get_hex_center_x_length(num_rows/2 +0.5)-(negative_x),get_hex_center_y_length(num_rows) + positive_y,0]) + hex(cap_height,hex_pt + cap_clearance); + // [1,1] Top right + translate([get_hex_center_x_length(num_cols + num_rows/2-0.5) + positive_x,get_hex_center_y_length(num_rows) + positive_y,0]) + hex(cap_height,hex_pt + cap_clearance); + } + } + } +} + +module box_lid() +{ + difference() + { + union() + { + difference() + { + union() + { + // Positive + cap_positive(box_wall,box_clearance,box_lid_height,box_wire_side_clearance, box_nonwire_side_clearance); + // Lip Positive ( lip is added to box_lid and subtracted from box_bottom) + if(box_lip) + { + translate([0,0,box_lid_height-box_lip_width]) + cap_positive(box_lip_width,box_clearance,box_lip_height,box_wire_side_clearance,box_nonwire_side_clearance); + } + // Wire support hole + if(wire_clamp_add) + { + translate([(num_cols * hex_w + box_wire_side_clearance + box_clearance_x + box_wall_x) - (box_wall_x + box_clearance_x + num_cols/2 * hex_w/2 + extra),-(wire_hole_width + 2 * (wire_clamp_support)) /2,-(box_wall + box_clearance)]) + cube([wire_hole_length + box_wall_x + box_clearance_x + num_cols/2 * hex_w/2 + extra,wire_hole_width + 2 * (wire_clamp_support),box_lid_height]); + } + + } + // Negatives + cap_negative(box_wall,box_clearance,box_lid_height*2,box_wire_side_clearance,box_nonwire_side_clearance); + if(wire_clamp_add) + { + // Wire hole cutout + translate([(num_cols)*hex_w+box_clearance_x + box_wall_x +box_wire_side_clearance,0,wire_top_wall - box_wall + box_lid_height/2]) + cube([(wire_hole_length + box_wall_x + box_clearance_x + num_cols/2 * hex_w/2) * 2 + box_wall *3,wire_hole_width,box_lid_height], center = true); + // Strain relief bolt cutouts + translate([(num_cols)*hex_w+box_clearance_x + box_wall_x + box_wire_side_clearance + wire_hole_length/2,(wire_hole_width + wire_clamp_support)/2,0]) + cylinder(d = wire_clamp_bolt_dia * 0.9, h=wire_top_wall + box_lid_height); + translate([(num_cols)*hex_w+box_clearance_x + box_wall_x + box_wire_side_clearance + wire_hole_length/2,-(wire_hole_width + wire_clamp_support)/2,0]) + cylinder(d = wire_clamp_bolt_dia * 0.9, h=wire_top_wall + box_lid_height); + } + + + } + // Lid supports + if(box_lip) + both_box_holder_supports(box_lid_height + box_lip_height, bms_clearance); + else + both_box_holder_supports(box_lid_height, bms_clearance); + } + // Other cutouts of entire box lid + generate_box_lid_holes(lid = true); + } +} + +module box_bottom() +{ + difference() + { + union() + { + cap(box_wall,box_clearance,box_bottom_height,box_wire_side_clearance,box_nonwire_side_clearance); + both_box_holder_supports(box_bottom_height,box_bottom_clearance); + } + + // Other cutouts of entire box bottom + // Lip cutout + if(box_lip) + { + translate([0,0,box_bottom_height-box_lip_width-box_lip_height]) + { + cap_negative(box_wall,box_clearance + box_lip_width,box_lid_height,box_wire_side_clearance,box_nonwire_side_clearance); // z origin -(box_clearance + box_lip_width) + } + + } + generate_box_lid_holes(); + } +} + +// Creates a mock pack for debugging +// Origin is the bottom of the center of the first hex cell +module mock_pack() +{ + + color("blue") holders(); + // add 18650s + if(pack_style == "rect") + { + for(hex_list = get_hex_center_points_rect(num_rows,num_cols)) + { + // Iterate through each hex center and place a cell + translate([hex_list.x,hex_list.y,slot_height + separation]) + color("CornflowerBlue")mock_cell(); + + } + } + else if(pack_style == "para") + { + for(hex_list = get_hex_center_points_para(num_rows,num_cols)) + { + // Iterate through each hex center and place a cell + translate([hex_list.x,hex_list.y,slot_height + separation]) + color("CornflowerBlue")mock_cell(); + } + } + + color("blue") + translate([0,0,slot_height + separation + cell_height + slot_height + separation]) + mirror([0,0,1]) + holders(); +} + +// Creates a mock cell. Origin is bottom of 1st hex cell holder. +module mock_cell() +{ + cylinder(d = cell_dia, h = cell_height); +} + +// Generates the holders +module holders() +{ + if(insulator_as_support) + { + translate([0,0,-holder_height+slot_height]) + holder_insulators(); + } + + translate([0, 0, holder_height]) + { + difference() + { + union() + { + if(pack_style == "rect") + { + for(hex_list = get_hex_center_points_rect(num_rows,num_cols)) + { + // Iterate through each hex center and place a hex cell + translate([hex_list.x,hex_list.y,0]) + pick_hex(); + } + } + else if(pack_style == "para") + { + for(hex_list = get_hex_center_points_para(num_rows,num_cols)) + { + // Iterate through each hex center and place a hex cell + translate([hex_list.x,hex_list.y,0]) + pick_hex(); + } + } + else if(pack_style == "tria") + { + for(hex_list = get_hex_center_points_tria(num_rows,num_cols)) + { + // Iterate through each hex center and place a hex cell + translate([hex_list.x,hex_list.y,0]) + pick_hex(); + } + } + // Other unions + // Stacking Pins here + if(stacking_pins) + { + for(hex_list = get_pin_list_rect(num_rows,num_cols)) + { + // add pins + if(stacking_pin_alt_style) + { + translate([hex_list.x,hex_list.y,-(holder_height + slot_height)]) + { + + // increase triangle island height by one slot height + linear_extrude(height = slot_height) + { + polygon(points=[ + // generate points + for(a = [30,150,270])[(hex_pt-(0.5*col_slot_width)/cos(60))*cos(a),(hex_pt-(0.5*col_slot_width)/cos(60))*sin(a)], + + ]); + } + // add pin + translate([0,0,-slot_height*0.5]) + cylinder(d = stacking_pin_dia, h = slot_height); + } + } + else + { + translate([hex_list.x,hex_list.y,-(holder_height + slot_height*0.5)]) + cylinder(d = stacking_pin_dia, h = slot_height); + } + } + } + } + // Cutouts + + // Stacking Pins + if(stacking_pins) + { + // do pin holes + for(hex_list = get_pin_holes_list_rect(num_rows,num_cols)) + { + if(stacking_pin_alt_style) + { + // delete triangle + translate([hex_list.x,hex_list.y,-(holder_height + extra)]) + linear_extrude(height = slot_height + extra) + { + polygon(points=[ + // generate points + for(a = [90,210,330])[hex_pt*cos(a),hex_pt*sin(a)] + ]); + } + + } + // add hole + translate([hex_list.x,hex_list.y,-holder_height-extra]) + cylinder(d = stacking_pin_dia + stacking_pins_tolerance, h = slot_height *2 + extra); + + } + } + + // Stacking Bolts + if(stacking_bolts) + { + for(hex_list = get_pin_holes_list_rect(num_rows,num_cols)) + { + // delete triangle + translate([hex_list.x,hex_list.y,-(holder_height + extra)]) + linear_extrude(height = slot_height + extra) + { + polygon(points=[ + // generate points + for(a = [90,210,330])[hex_pt*cos(a),hex_pt*sin(a)] + ]); + } + // add hole + translate([hex_list.x,hex_list.y,-(holder_height + extra)]) + cylinder(d = stacking_bolt_dia, h = holder_height*2); + } + } + } + } +} + + +module pick_hex() +{ + if (wire_style == "strip") + strip_hex(); + else if (wire_style == "bus") + { + bus_hex(); + } + else + strip_hex(); +} + + +module strip_hex() +{ + mirror([0,0,1]) + { + difference() + { + union() + { + // Hex block + hex(holder_height, hex_pt + hextra); + } + + + // Top opening + translate([0,0,-1]) + cylinder(h=holder_height+2,d=opening_dia); + + // Cell space + cylinder(h=2 *(holder_height-slot_height-separation) ,d=cell_dia, center=true); + + // 1st column slot + rotate([0,0,60]) + translate([0,0,holder_height + slot_height]) + cube([hex_w+1,col_slot_width,4*slot_height], center=true); + + // 2nd column slot + rotate([0,0,-60]) + translate([0,0,holder_height + slot_height]) + cube([hex_w+1,col_slot_width,4*slot_height], center=true); + + // Row slot + translate([0,0,holder_height + slot_height]) + cube([hex_w+1,row_slot_width,4*slot_height], center=true); + + } + //cell_tabs(); // older style of cell tabs. Not used anymore + } +} + + +module bus_hex() +{ + mirror([0,0,1]) + { + difference() + { + // Hex block + hex(holder_height, hex_pt + hextra); + + // Top opening + translate([0,0,-1]) + cylinder(h=holder_height+2,d=opening_dia); + + // Cell space + cylinder(h=2 *(holder_height-slot_height-separation) ,d=cell_dia, center=true); + + + // 1st column slot + rotate([0,0,60]) + translate([0,0,holder_height]) + cube([hex_w+1,col_slot_width,2*slot_height], center=true); + + // 2nd column slot + rotate([0,0,-60]) + translate([0,0,holder_height]) + cube([hex_w+1,col_slot_width,2*slot_height], center=true); + + // Row slot A + translate([0,(hex_pt*cos(60) + hex_pt)/2,holder_height]) + cube([hex_w + extra,row_slot_width,2*slot_height], center=true); + + // Row slot B + translate([0,-(hex_pt*cos(60) + hex_pt)/2,holder_height]) + cube([hex_w + extra,row_slot_width,2*slot_height], center=true); + } + //cell_tabs(); // older style of cell tabs. Not used anymore + } +} + +// 3 tabs in the holder that keep the cell in. Not used anymore. +module cell_tabs() +{ + // Tabs + if(wire_style == "strip") + { + for(a = [1,3,5]) + { + difference() + { + intersection() + { + translate([ cell_radius*sin(a*60),cell_radius*cos(a*60),holder_height-(separation/2 + slot_height)]) + rotate([0,0,a*30])cube([cell_tab_length * 2 + wall, cell_tab_width, separation],center=true); + hex(holder_height, hex_pt + hextra); + } + // Difference with strip cutouts + union() + { + // 1st column slot + rotate([0,0,60]) + translate([0,0,holder_height/2]) + cube([hex_w+1,col_slot_width,holder_height*2], center=true); + + // 2nd column slot + rotate([0,0,-60]) + translate([0,0,holder_height/2]) + cube([hex_w+1,col_slot_width,holder_height*2], center=true); + + // Row slot + translate([0,0,holder_height/2]) + cube([hex_w+1,row_slot_width,holder_height*2], center=true); + } + + + } + } + } + else if(wire_style == "bus") + { + for(a = [1,3]) + { + + intersection() + { + translate([cell_radius*sin(a*90),cell_radius*cos(a*90),holder_height-(separation/2 + slot_height)]) + rotate([0,0,a*180])cube([cell_tab_length * 2 + wall, cell_tab_width, separation],center=true); + hex(holder_height, hex_pt + hextra); + } + + } + } +} + +// Generates a insulator to fit inside the nickel strip channels, also doubles as a spacer +// TODO: support para too +// Add cut outs for stacking bolts + +module holder_insulators() +{ + // TESTING + // mirror([0,0,1]) + // translate([0,0,-holder_height]) + // holders(); + + // Use difference code from hex cells + // intersection it with hexes + if(pack_style == "rect") + { + single_insulator(get_hex_center_points_rect(num_rows,num_cols)); + } + else if(pack_style == "para") + { + single_insulator(get_hex_center_points_para(num_rows,num_cols)); + } + else if(pack_style == "tria") + { + single_insulator(get_hex_center_points_tria(num_rows,num_cols)); + } + + + +} + +module single_insulator(hex_list) +{ + difference() + { + // sorry + for(hex_list = hex_list) + { + // Iterate through each hex center and place a hex cell + translate([hex_list.x,hex_list.y,0]) + { + intersection() + { + union() + { + // Hex block + hex(holder_height-slot_height + insulator_thickness, hex_pt + hextra); + } + + union() + { + if(wire_style == "strip") + { + // 1st column slot + rotate([0,0,60]) + translate([0,0,holder_height + slot_height]) + cube([hex_w+1,col_slot_width-insulator_tolerance,4*slot_height], center=true); + + // 2nd column slot + rotate([0,0,-60]) + translate([0,0,holder_height + slot_height]) + cube([hex_w+1,col_slot_width-insulator_tolerance,4*slot_height], center=true); + + // Row slot + translate([0,0,holder_height + slot_height]) + cube([hex_w+1,row_slot_width-insulator_tolerance,4*slot_height], center=true); + } + else if(wire_style == "bus") + { + // 1st column slot + rotate([0,0,60]) + translate([0,0,holder_height]) + cube([hex_w+1,col_slot_width-insulator_tolerance,2*slot_height], center=true); + + // 2nd column slot + rotate([0,0,-60]) + translate([0,0,holder_height]) + cube([hex_w+1,col_slot_width-insulator_tolerance,2*slot_height], center=true); + + // Row slot A + translate([0,(hex_pt*cos(60) + hex_pt)/2,holder_height]) + cube([hex_w + extra,row_slot_width-insulator_tolerance,2*slot_height], center=true); + + // Row slot B + translate([0,-(hex_pt*cos(60) + hex_pt)/2,holder_height]) + cube([hex_w + extra,row_slot_width-insulator_tolerance,2*slot_height], center=true); + + // Cell opening + translate([0,0,holder_height-slot_height]) + cylinder(h=slot_height,d=opening_dia-insulator_tolerance); + } + + } + + + } + } + } + + // cut out stacking bolt holes + if(stacking_bolts) + { + for(bolt_holes_list = concat(get_pin_holes_list_rect(num_rows,num_cols), get_pin_list_rect(num_rows,num_cols))) + { + // add hole + translate([bolt_holes_list.x,bolt_holes_list.y,0]) + cylinder(d = stacking_bolt_dia, h = holder_height*2); + } + } + } +} + + +// Generates a box section designed for one addition vertical pack stack. Can make sections larger if printer is able to print higher +module vertical_box_section(num_stacks = 1) +{ + // Create a box section with lips (if enabled) on both sides + difference() + { + union() + { + + // main box + difference() + { + union() + { + translate([0,0,0]) + cap_positive(box_wall,box_clearance,vertical_box_section_height*num_stacks,box_wire_side_clearance,box_nonwire_side_clearance); + // lip addition on bottom + if(box_lip) + { + translate([0,0,-(box_lip_width + box_lip_height)]) + cap_positive(box_lip_width,box_clearance,box_lip_height,box_wire_side_clearance,box_nonwire_side_clearance); + } + } + + + // hollow out + translate([0,0,-vertical_box_section_height*num_stacks]) + cap_negative(box_wall,box_clearance,vertical_box_section_height*num_stacks*100,box_wire_side_clearance,box_nonwire_side_clearance); + } + + if(box_lip) + { + translate([0,0,-box_lip_height]) + both_box_holder_supports(vertical_box_section_height*num_stacks,0); + } + else + { + translate([0,0,0]) + both_box_holder_supports(vertical_box_section_height*num_stacks,0); + } + + + + + //%cylinder(d=50,h = vertical_box_section_height*num_stacks); // helper cylinder + } + + // Other cutouts of entire box bottom + // Lip cutout + if(box_lip) + { + // top lip cut + translate([0,0,vertical_box_section_height*num_stacks]) + cap_negative(box_wall,box_clearance + box_lip_width,box_lid_height,box_wire_side_clearance,box_nonwire_side_clearance); + } + generate_box_lid_holes(make_bolt_head_holes = false); + } +} + +// Generates support for the box for bolts and zipties. spacer parameter addes a spacer incase there is extra space on the boxes for wires. +module both_box_holder_supports(lid_support_height = box_lid_height, spacer = 0) +{ + intersection() + { + union() + { + + // Generate +y side of supports + box_holder_support(lid_support_height,spacer); + + // Generate -y side of supports + // if even, translate over half a hex + if(num_rows % 2 == 0) + { + if(pack_style == "rect") + { + translate([get_hex_center_x_length(num_cols + 0.5),get_hex_center_y_length(num_rows),0]) + rotate([0,0,180]) + box_holder_support(lid_support_height,spacer); + } + else if(pack_style == "para") + { + translate([get_hex_center_x_length(num_cols+num_rows/2 - 0.5),get_hex_center_y_length(num_rows),0]) + rotate([0,0,180]) + box_holder_support(lid_support_height,spacer); + } + } + + else // if odd + { + if(pack_style =="rect") + { + translate([0,get_hex_center_y_length(num_rows),0]) + mirror([0,1,0]) + box_holder_support(lid_support_height,spacer); + } + else if(pack_style == "para") + { + translate([get_hex_center_x_length(num_rows/2+0.5),get_hex_center_y_length(num_rows),0]) + mirror([0,1,0]) + box_holder_support(lid_support_height,spacer); + } + } + } + translate([0,0,-(box_wall/2 + extra)]) + cap_positive(box_wall/2,box_clearance,lid_support_height*2 ,box_wire_side_clearance,box_nonwire_side_clearance); + } + + + + +} + +// Generates the support to hold the holders in the box lid and bottom. +// spacer sets height for bms clearance or bottom_clearance +// mirrored is used for the -y box_holder_support which will have different sizes due to different side clearances +module box_holder_support(lid_support_height = box_lid_height,spacer = 0) +{ + difference() + { + for(col = [1:num_cols]) + { + // iterate on one side + // add support in the shape of a hex inbetween cols + union() + { + translate([get_hex_center_x_length(col+0.5),-get_hex_center_y_length(2)-box_clearance,-(box_wall+box_clearance)]) + hex(lid_support_height); + if(spacer) + { + translate([get_hex_center_x_length(col)+hex_w/2,-(get_hex_center_y_length(2) + box_clearance - spacer_overhang),-box_clearance-box_wall]) + { + intersection() + { + translate([0,0,0]) + cube([hex_w/2,hex_pt * 2 + spacer_overhang,(spacer+box_wall)*3],center = true); + hex(spacer+box_wall); + } + + } + + } + } + } + // Cutouts + + // TESTING: flatten the lid support tips for injection molded holders with tabs + // translate([get_hex_center_x_length(0.5),-(hex_pt- cos(60) * hex_pt + box_clearance),0]) + // cube([1000,5,100]); + } + +} + +module generate_box_lid_holes(lid = false,make_bolt_head_holes = true) +{ + list = get_holes_list(num_rows,num_cols); + // create holes + for(range = list) + { + if(box_style == "bolt") + { + // Bolt hole + translate([range.x,range.y,box_lid_height/2-box_wall-box_clearance]) + { + generate_bolt_hole(lid,make_bolt_head_holes); + } + } + else if(box_style == "ziptie") + { + // Ziptie holes + translate([range.x,range.y,box_bottom_height/2-box_wall-box_clearance]) + { + generate_ziptie_hole(); + } + } + + } + if(box_style == "both") + { + // these indexes set where the bolt holes should go for rect and para styles + last = len(list)-1; + + row_end = (pack_style == "rect") + ? num_cols - 1 + : num_cols - 2; // if para style + + row_start = (pack_style == "rect") + ? num_cols + : num_cols -1; // if para style + + // Bolt holes on ends, zipties in middle + for(i = [list[0],list[row_end],list[row_start],list[last]]) + { + translate([i.x,i.y,box_lid_height/2-box_wall-box_clearance]) + { + generate_bolt_hole(lid,make_bolt_head_holes); + } + } + // iterate on list but not the bolt holes + + for(index = [0:len(list)-1]) + { + // if not where the bolt holes are + if (!(index == 0 || index == row_end || index == row_start || index == last )) + { + // Do zipties + // Ziptie holes + translate([list[index].x,list[index].y,box_bottom_height/2-box_wall-box_clearance]) + { + generate_ziptie_hole(); + } + } + } + } +} + +module generate_bolt_hole(lid = false,make_bolt_head_holes = true) +{ + if(lid) + cylinder(d = bolt_dia, h = box_bottom_height*100,center = true); // Bolt size + else + cylinder(d = bolt_dia*0.9, h = box_bottom_height*100,center = true); // Tap size + translate([0,0,-(box_lid_height/2-box_wall-box_clearance)- (box_wall + box_clearance) - extra]) + { + // Bolt Head Hole + if(make_bolt_head_holes) + cylinder(d = bolt_head_dia, h = bolt_head_thickness + extra); + } +} + +module generate_ziptie_hole() +{ + cube([ziptie_width,ziptie_thickness,box_bottom_height*100],center = true); +} + + +// returns a list of all the positions of the holes for box and lid +function get_holes_list(num_rows,num_cols) += [ + // Iterate through rows/cols + for(row = [0,num_rows],col = [1:pack_style == "rect" ? num_cols: num_cols-1]) + [ // X Component of list member + row == 0 // if bottom row + ? get_hex_center_x_length(pack_style == "rect" ? col + 0.5: col + 0.5 + row/2) + : row % 2 == 0 + ? get_hex_center_x_length(pack_style == "rect" ? col: col + row/2) // if top row even + : get_hex_center_x_length(pack_style == "rect" ? col + 0.5: col + row/2) // else top row odd + , + // Y component of list member + row == 0 // if bottom row + ? -hex_pt + cos(60) * hex_pt - box_clearance - (cos(60) * hex_pt + box_wall)/2 // Messy but spaces the hole half way between the lid support tip and box wall + : get_hex_center_y_length(num_rows) + hex_pt - cos(60) * hex_pt + box_clearance + (cos(60) * hex_pt + box_wall)/2 // else top row + + ] + + ]; + + + + + + +// Part which clamps down the wires for strain relief +// Z origin is top of the mounting plate +module wire_clamp() +{ + translate([0,0,-clamp_plate_height/2]) + { + difference() + { + union() + { + clamp_height = box_lid_height - wire_top_wall - box_clearance - wire_diameter * clamp_factor; + cube([wire_hole_length,wire_hole_width + 2 * wire_clamp_support,clamp_plate_height],center = true); + // To top of plate + translate([0,0,wire_top_wall/2]) + { + translate([0,0,(clamp_height - wire_clamp_nib_dia/2)/2]) + cube([wire_hole_length,wire_hole_width - extra, clamp_height - wire_clamp_nib_dia/2], true); + translate([0,(wire_hole_width - extra)/2,(clamp_height) - wire_clamp_nib_dia/2]) + rotate([90,0,0]) + cylinder(d = wire_clamp_nib_dia, h = wire_hole_width - extra); + } + + } + // Bolt hole cutout + translate([0,(wire_hole_width + wire_clamp_support)/2,-wire_top_wall]) + cylinder(d = wire_clamp_bolt_dia + bolt_dia_clearance, h = wire_top_wall * 2); + translate([0,-(wire_hole_width + wire_clamp_support)/2,-wire_top_wall]) + cylinder(d = wire_clamp_bolt_dia + bolt_dia_clearance, h = wire_top_wall * 2); + } + + + } + +} + +// Generates a hex of cap_height tall and hex_pt radius by default. +module hex(cap_height = holder_height,hex_pt = hex_pt) +{ + linear_extrude(height=cap_height, convexity = 10) + polygon([ for (a=[0:5])[hex_pt*sin(a*60),hex_pt*cos(a*60)]]); + +} + +// returns total box width +function get_box_total_width() += 2 * (box_clearance + box_wall) + get_holder_width(); + +// returns holder width +function get_holder_width() += get_hex_center_y_length(num_rows) + hex_pt * 2; + + + +// returns length of the longest part of box (from side of lid to wireclamp) +// Reminder: Rect box only. +function get_box_total_length() += 2 * (box_clearance + box_wall) + get_holder_rect_length() + box_wire_side_clearance + box_nonwire_side_clearance + wire_hole_length; + +// returns length of rect holders +function get_holder_rect_length() += hex_w*(num_cols+0.5); + + + +// returns height of the mock pack +function get_mock_pack_height() += 2 * (slot_height + separation) + cell_height; +// returns the length of the center of one hex cell on a row to number to hexes passed to function +function get_hex_center_x_length(num_cell) += (num_cell-1) * hex_w; + +// returns the length of the center of vertical(columns) hex cells to number to hexes passed to function +function get_hex_center_y_length(num_cell) += (num_cell-1) * hex_pt*1.5; + +// returns a list of the hex cell centers of a given num of rows and columns for para packs. +function get_hex_center_points_para(num_rows, num_cols) += [ + for(row = [0:num_rows-1],col = [0:num_cols-1]) + [ // X component of list member + row*(0.5 * hex_w) + hex_w * col + , + // Y component of list member + row * 1.5 * (hex_pt) + ] + ]; + +// returns a list of the hex cell centers of a given num of rows and columns for rect packs. +function get_hex_center_points_rect(num_rows, num_cols) += [ + // Iterate through num of rows and cols + for(row = [0:num_rows-1],col = [0:num_cols-1]) + [ // X component of list member + row % 2 == 0 ? // if even + hex_w * col + ://else + 0.5 * hex_w + hex_w * col + , + // Y component of list member + row * 1.5 * (hex_pt) + ] + ]; // Closing function bracket + +// returns a list of the hex cell centers of a given num of rows and columns for rect packs. +function get_hex_center_points_tria(num_rows, num_cols) += [ + // Iterate through num of rows and cols + for(row = [0:num_rows-1],col = [0:row]) + [ // X component of list member + row*(0.5 * hex_w) + hex_w*(-col) + , + // Y component of list member + row * 1.5 * (hex_pt) + ] + ]; // Closing function bracket + +// code from tria for +// else if(pack_style == "tria") +// { + +// for(row = [0:num_rows-1]) +// { +// translate([row*(0.5 * hex_w),1.5*(hex_pt)*row,0]) +// for(col = [0:row]) +// { +// translate([hex_w*(-col),0,0]) +// pick_hex(); +// } +// } +// } + +// returns a list of all the positions of the pin holes for stacking pins +function get_pin_holes_list_rect(num_rows,num_cols) += [ + // Iterate through rows/cols and ignore last row and last column + for(row = [0:num_rows-2],col = [0:num_cols-2]) + [ // X Component of list member + row % 2 == 0? // if even + hex_w * col + hex_pt*cos(30) + : // else odd + 0.5 * hex_w +hex_w * col + hex_pt*cos(30) + , + // Y component of list member + row * 1.5 * hex_pt + hex_pt*sin(30) + ] + + ]; + +// returns a list of all the positions of the pins for stacking pins +function get_pin_list_rect(num_rows,num_cols) += [ + // Iterate through rows/cols and ignore last row and last column + for(row = [0:num_rows-2],col = [1:num_cols-1]) + [ // X Component of list member + row % 2 == 0? // if even + hex_w * col + hex_pt*cos(90) + : // else odd + col == num_cols - 1? // if also last column then just put pin in first column hex (this nicely works out) + 0.5 * hex_w + : // else + 0.5 * hex_w +hex_w * col + hex_pt*cos(90) + , + // Y component of list member + row * 1.5 * hex_pt + hex_pt*sin(90) + ] + + ]; diff --git a/static/holder.css b/static/holder.css new file mode 100644 index 0000000..5353f9d --- /dev/null +++ b/static/holder.css @@ -0,0 +1,67 @@ +/* Holder-designer specific styles; complements styles.css. */ + +.holder-left { width: 340px; } + +.viewer3d { + width: 100%; + height: 100%; + background: #0a0d12; +} + +.right { position: relative; } + +/* Parameter form */ +.param-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.param-group { + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + padding: 8px 10px; +} + +.param-group-title { + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 6px; + font-weight: 600; +} + +.param-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.param-row label { + flex: 1; + font-size: 12px; + color: var(--text); + text-transform: none; + letter-spacing: 0; + flex-direction: row; + align-items: center; +} + +.param-row input[type=number], +.param-row select { + width: 110px; + flex-shrink: 0; + font-size: 12px; + padding: 3px 6px; +} + +.param-row .param-help { + font-size: 10px; + color: var(--muted); + flex-basis: 100%; + margin-top: -2px; +} diff --git a/static/holder.html b/static/holder.html new file mode 100644 index 0000000..0866b1a --- /dev/null +++ b/static/holder.html @@ -0,0 +1,67 @@ + + + + + +Hex Holder Designer + + + + + + +
+

Hex Holder Designer

+ + + + — cells + +
+ + + + +
+
+ +
+ + +
+
+
+
left-drag: rotate · right-drag: pan · wheel: zoom
+
+
+
+ + + + + + diff --git a/static/index.html b/static/index.html index 50d8ce0..6782be5 100644 --- a/static/index.html +++ b/static/index.html @@ -10,6 +10,11 @@

Busbar Designer

+ +