// ============================================================
//  Parametric MOLLE / PALS Panel Generator  —  SVG for laser cutting
//  (c) Ryan Mitchell — CC BY 4.0 (use/adapt freely with credit)
//  https://github.com/ryanemitchell/rmm-parametrics
// ============================================================
//
//  QUICK START: pick a Size Preset and a Style Preset up top, press F5
//  to preview (dimensions shown in mm + inches), then
//  File > Export > Export as SVG. The SVG is 1:1 millimetres — import
//  straight into XCS / LightBurn and cut.
//
//  The two preset dropdowns combine freely (seat_back x tech_panel).
//  Set either to "none" to control that half manually in the Advanced
//  sections below. Your own presets go in user_presets.scad.
//
//  Background: PALS is the actual grid standard (1" / 25.4 mm vertical
//  row pitch, 1.5" / 38.1 mm horizontal attachment pitch). "MOLLE" is
//  the gear system that rides on it. There is NO official standard for
//  rigid / laser-cut slot sizes — only the pitch is fixed.
//
// ============================================================

/* [Quick Controls] */
// Units for the SIZE inputs (panel width/height and quad dimensions). Everything else (slot geometry, pitches, margins, holes) stays mm - PALS is defined metrically. Presets are unaffected.
units = "mm"; // [mm:Millimetres, inch:Inches]
// WHAT SIZE - physical panel size/shape for common uses. The grid auto-fits whatever style you pick. "none" = use the Advanced size sections.
size_preset = "none"; // [none:None - manual size below, seat_back:Seat back 290x370mm, headrest:Headrest 170x136mm, wall_600x400:Wall panel 600x400mm, pack_insert:Pack insert 240x230mm, case_insert:Case insert 155x145mm, overland_drawer:Overland drawer face 450x150mm, square_21x21:Square 21x21in (533x533mm), panel_30x20:Panel 30x20in (762x508mm), panel_24x8:Panel 24x8in (610x203mm), van_window:Van window trapezoid 750/600 x 380/320mm, seat_back_taper:Tapered seat back 320/260 x 420mm, wedge_door:Wedge door 500mm - top slants 260 to 200mm]
// HOW IT'S CUT - slot geometry plus a matching feature set. "none" = use the Advanced style sections.
style_preset = "none"; // [none:None - manual style below, rigid_basic:Rigid basic - 32x10mm slots + corner mount holes + rounded corners, softgoods_weave:Softgoods weave - slim staggered slits - no holes, kydex_minimal:Kydex minimal - 27x4mm slits - no holes, heavy_bars:Heavy bars - 33x14mm slots for thick steel + mount holes, tactical_square:Tactical square - square slots on 2in rows + chamfered corners + zip-tie perimeter, tech_panel:Tech panel - square slots + webbing channels between rows + corner slits + hole-slit perimeter, lightweight:Lightweight - 32x10mm slots + lightening holes + spaced mounts]
// Mounting/perimeter hole diameter, mm (6 = M6 clearance-ish; 4-5 for zip ties)
mount_hole_d = 6;
// Beam kerf, mm. Grows the outline and shrinks slots/holes by kerf/2 each side. Leave 0 if your laser software (e.g. XCS) compensates for you. Fiber on thin steel/alu: 0.05-0.2.
kerf = 0;

/* [User Presets (defined in user_presets.scad)] */
// Type the name of a preset from user_presets.scad to use it instead of the size_preset dropdown
custom_size_preset = "";
// Type the name of a preset from user_presets.scad to use it instead of the style_preset dropdown
custom_style_preset = "";

/* [Advanced - Manual Size (size_preset = none)] */
// "rect" uses the sizes below. "quad" builds a four-sided shape from the Quad Outline section and auto-fills it with every slot that fits.
outline_shape = "rect"; // [rect:Rectangle, quad:Four-sided shape (trapezoid / skewed)]
// "grid": you choose columns x rows, panel size is computed. "panel": you choose panel size, max grid is fitted inside.
size_mode = "grid"; // [grid:Set grid (cols x rows), panel:Set panel size (fit grid)]
// Number of slot columns (grid mode)
columns = 6;
// Number of slot rows (grid mode)
rows = 8;
// Target panel width (panel mode) - in the units chosen above
panel_width = 300;
// Target panel height (panel mode) - in the units chosen above
panel_height = 250;

/* [Advanced - Quad Outline (outline_shape = quad)] */
// All five values are in the units chosen in Quick Controls.
// Bottom edge length
quad_bottom_width = 400;
// Top edge horizontal extent
quad_top_width = 300;
// Height of the top-LEFT corner above the bottom edge
quad_left_height = 250;
// Height of the top-RIGHT corner above the bottom edge (different from left = slanted top)
quad_right_height = 250;
// Horizontal position of the top-left corner relative to the bottom-left corner (shifts/skews the top edge)
quad_top_shift = 50;

/* [Advanced - Slot Style (style_preset = none)] */
// Slot geometry. "custom" uses the values below. square_rigid also sets v_pitch to 50.8mm (2in rows - 1in slot + 1in bar, fabric-PALS analog).
slot_style = "rigid_metal"; // [custom:Custom - use my slot values below, rigid_metal:Rigid metal panel - 32x10mm slots (vehicle/wall panels - straps and MALICE clips), softgoods_slim:Softgoods slim - 32x5.5mm slits (thin sheet <=2mm - tight weave), heavy_duty:Heavy duty - 33x14mm slots (thick steel - gloved-hand threading), kydex_thin:Kydex/thin sheet - 27x4mm slits (minimal look), square_rigid:Square rigid - 32x25.4mm openings on 2in row pitch (GMT-style panels)]
// Slot width, mm (must clear a 1" / 25.4mm strap; keep <= h_pitch - 5 for metal)
slot_width = 32;
// Slot height, mm
slot_height = 10;
// Slot corner radius, mm (slot_height/2 = stadium / pill slots)
slot_corner_radius = 3;
// Slot shape word (applies to any slot style): rect, pill, hex, oval, diamond, octagon
slot_shape = "rect";
// Horizontal centre-to-centre pitch, mm (PALS = 38.1)
h_pitch = 38.1;
// Vertical centre-to-centre pitch, mm (PALS = 25.4; square_rigid overrides to 50.8)
v_pitch = 25.4;
// Stagger alternate rows by half a column (half-position mounting)
stagger = false;

/* [Advanced - Border & Corners] */
// Minimum border between slots and panel edge, mm
edge_margin = 14;
// Panel corner style
corner_style = "round"; // [round:Rounded, chamfer:Chamfered 45 degrees]
// Corner radius / chamfer size, mm (0 = sharp corners)
panel_corner_radius = 8;

/* [Advanced - Mounting & Perimeter] */
mount_holes = true;
// Hole centre inset from panel edge, mm
mount_hole_inset = 7;
// Add intermediate holes along edges when corner holes are further apart than this, mm (0 = corners only). Small values give a dense zip-tie perimeter.
mount_hole_max_spacing = 200;
// Short slits along the border, midway between hole positions (the alternating hole-slit perimeter on commercial panels)
edge_slits = false;
// Edge slit length, mm
edge_slit_length = 20;
// Edge slit width, mm (3-4 = zip tie, 6+ = 1in strap on edge)
edge_slit_width = 3;

/* [Advanced - Channels & Extras] */
// Long thin slits in the bars between slot rows - pass 1in webbing, velcro or hose clamps mid-panel (BuiltRight-style). Best with square_rigid on 50.8mm rows.
inter_slits = false;
// Channel slit height, mm
inter_slit_h = 5;
// Slot columns spanned per channel segment
inter_slit_span = 3;
// Bridge between channel segments, mm
inter_slit_bridge = 8;
// Diagonal slits just inside chamfered corners (needs corner_style = chamfer; replaces the 4 corner mounting holes)
corner_slits = false;
// Corner slit length, mm (0 = auto: same as the chamfer edge)
corner_slit_length = 0;
// Corner slit width, mm
corner_slit_width = 4;
// Perpendicular distance from the chamfer edge, mm
corner_slit_inset = 7;
// Extra holes in the bars between rows (weight reduction; weakens strap bearing - skip for heavy loads)
lightening = false;
lightening_style = "circle"; // [circle:Circles, hex:Hexagons]
// Lightening hole size (diameter / hex across-flats), mm
lightening_size = 8;

/* [Advanced - Engrave Layer] */
// Label text to engrave on the panel. Export it as a SECOND SVG with output_layer=engrave and add it as an engrave operation in XCS. Empty = none.
engrave_text = "";
// Text height, mm
engrave_text_size = 12;
// Text centre position relative to panel centre, mm
engrave_text_x = 0;
engrave_text_y = 0;
// Which geometry this export contains. "cut" = the panel (text shown blue in preview for positioning); "engrave" = the text only.
output_layer = "cut"; // [cut:Cut layer (the panel), engrave:Engrave layer (text only)]

/* [Advanced - Nesting (several panels in one SVG)] */
// Copies of the panel side by side in the exported SVG (one cut job)
nest_x = 1;
// Copies stacked vertically
nest_y = 1;
// Gap between copies, mm (leave room for the kerf and part removal)
nest_gap = 10;

/* [Preview] */
// Show overall width/height in the preview window (mm and inches), updating live. Preview-only: automatically excluded from the exported SVG.
show_dimensions = true;
// Material for the weight estimate shown in the preview and console (uses the thickness below)
material = "steel"; // [steel:Steel (7.85 g/cc), aluminum:Aluminum (2.70 g/cc), hdpe:HDPE (0.95 g/cc), kydex:Kydex (1.35 g/cc), none:No weight estimate]
// Material thickness, mm (used for the 3D preview and the weight estimate)
material_thickness = 3;
// 3D preview only - MUST be false when exporting SVG
preview_3d = false;

/* [Hidden] */
$fn = 48;

// preset tables live in separate files: built-ins in presets.scad,
// yours in user_presets.scad (user entries shadow built-ins by name)
include <presets.scad>
include <user_presets.scad>
_size_table  = concat(user_size_presets,  _size_presets_builtin);
_style_table = concat(user_style_presets, _style_presets_builtin);
_slot_table  = concat(user_slot_styles,   _slot_styles_builtin);

// ---------- SIZE preset resolution (physical size & shape) ----------
_size_name = (custom_size_preset != "") ? custom_size_preset : size_preset;
_si = search([_size_name], _size_table)[0];
assert(_size_name == "none" || _si != undef,
       str("Unknown size preset '", _size_name, "' - not in the dropdown list or user_presets.scad"));
_sz = (_size_name == "none") ? undef : _size_table[_si];

// size presets are always panel-fit: physical size fixed, grid auto-fits the chosen style
// _u converts the manual size inputs to mm when units = "inch" (preset tables are always mm)
_u = (units == "inch") ? 25.4 : 1;
e_size_mode = is_undef(_sz) ? size_mode              : "panel";
e_panel_w   = is_undef(_sz) ? panel_width * _u       : _sz[1];
e_panel_h   = is_undef(_sz) ? panel_height * _u      : _sz[2];
e_margin    = is_undef(_sz) ? edge_margin            : _sz[3];
e_corner_r  = is_undef(_sz) ? panel_corner_radius    : _sz[4];
e_shape     = is_undef(_sz) ? outline_shape          : _sz[5];
e_qbw       = is_undef(_sz) ? quad_bottom_width * _u : _sz[6];
e_qtw       = is_undef(_sz) ? quad_top_width * _u    : _sz[7];
e_qlh       = is_undef(_sz) ? quad_left_height * _u  : _sz[8];
e_qrh       = is_undef(_sz) ? quad_right_height * _u : _sz[9];
e_qshift    = is_undef(_sz) ? quad_top_shift * _u    : _sz[10];
e_columns   = columns;   // used only in manual grid mode
e_rows      = rows;

// ---------- STYLE preset resolution (slot geometry & features) ----------
_style_name = (custom_style_preset != "") ? custom_style_preset : style_preset;
_ti = search([_style_name], _style_table)[0];
assert(_style_name == "none" || _ti != undef,
       str("Unknown style preset '", _style_name, "' - not in the dropdown list or user_presets.scad"));
_st = (_style_name == "none") ? undef : _style_table[_ti];

e_preset     = is_undef(_st) ? slot_style             : _st[1];
e_stagger    = is_undef(_st) ? stagger                : _st[4];
e_mount      = is_undef(_st) ? mount_holes            : _st[5];
e_max_spc    = is_undef(_st) ? mount_hole_max_spacing : _st[6];
e_edge_slits = is_undef(_st) ? edge_slits             : _st[7];
e_inter      = is_undef(_st) ? inter_slits            : _st[8];
e_cslits     = is_undef(_st) ? corner_slits           : _st[9];
e_corner_sty = is_undef(_st) ? corner_style           : _st[10];
e_lightening = is_undef(_st) ? lightening             : _st[11];

// ---------- slot-geometry preset resolution ----------
_pi   = search([e_preset], _slot_table)[0];
assert(e_preset == "custom" || _pi != undef,
       str("Unknown slot style '", e_preset, "' - not built-in or in user_presets.scad"));
sw    = (e_preset == "custom") ? slot_width         : _slot_table[_pi][1];
sh    = (e_preset == "custom") ? slot_height        : _slot_table[_pi][2];
_scr_raw = (e_preset == "custom") ? slot_corner_radius : _slot_table[_pi][3];
scr   = min(_scr_raw, sh/2, sw/2);   // auto-clamped, never fatal
_rec_vp = (e_preset == "custom") ? 0 : _slot_table[_pi][4];

// pitch priority: style preset > slot-style recommendation > user parameter
e_h_pitch = (!is_undef(_st) && _st[2] > 0) ? _st[2] : h_pitch;
e_v_pitch = (!is_undef(_st) && _st[3] > 0) ? _st[3]
            : (_rec_vp > 0 ? _rec_vp : v_pitch);

// ---------- quad outline geometry ----------
_q_raw = [[0, 0], [e_qbw, 0],
          [e_qshift + e_qtw, e_qrh], [e_qshift, e_qlh]];      // BL BR TR TL (CCW)
_qxs   = [for (p = _q_raw) p[0]];
_qys   = [for (p = _q_raw) p[1]];
_qc    = [(min(_qxs) + max(_qxs))/2, (min(_qys) + max(_qys))/2];
q_pts  = [for (p = _q_raw) [p[0] - _qc[0], p[1] - _qc[1]]];   // centred on origin
q_w    = max(_qxs) - min(_qxs);
q_h    = max(_qys) - min(_qys);

// signed distance from point p to edge a->b (positive = inside for CCW polygon)
function sdist(p, a, b) =
    let (dx = b[0]-a[0], dy = b[1]-a[1])
    (dx*(p[1]-a[1]) - dy*(p[0]-a[0])) / sqrt(dx*dx + dy*dy);
// point inside quad with clearance m from every edge
function inset_ok(p, m) =
    min([for (i = [0:3]) sdist(p, q_pts[i], q_pts[(i+1)%4])]) >= m;
// whole slot (bounding box corners) inside quad with edge_margin clearance
function slot_fits(cx, cy) =
    min([for (c = [[cx-sw/2, cy-sh/2], [cx+sw/2, cy-sh/2],
                   [cx+sw/2, cy+sh/2], [cx-sw/2, cy+sh/2]],
              i = [0:3]) sdist(c, q_pts[i], q_pts[(i+1)%4])]) >= e_margin;

function _crossp(o, a, b) = (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0]);
assert(e_shape != "quad" ||
       min([for (i = [0:3]) _crossp(q_pts[i], q_pts[(i+1)%4], q_pts[(i+2)%4])]) > 0,
       "Quad must be convex - check widths/heights/top_shift");

// quad candidate grid (centred on bounding-box centre, filtered by slot_fits)
qn_cols = max(1, floor(q_w / e_h_pitch) + 1);
qn_rows = max(1, floor(q_h / e_v_pitch) + 1);
function qcol_x(i) = -((qn_cols-1) * e_h_pitch)/2 + i * e_h_pitch;
function qrow_y(j) = -((qn_rows-1) * e_v_pitch)/2 + j * e_v_pitch;
q_slot_count = len([for (j = [0:qn_rows-1], i = [0:qn_cols-1])
    if (slot_fits(qcol_x(i) + ((e_stagger && j%2==1) ? e_h_pitch/2 : 0), qrow_y(j))) 1]);

// ---------- rect grid / panel dimensions ----------
fit_cols = max(1, floor((e_panel_w - 2*e_margin - sw) / e_h_pitch) + 1);
fit_rows = max(1, floor((e_panel_h - 2*e_margin - sh) / e_v_pitch) + 1);
n_cols   = (e_size_mode == "panel") ? fit_cols : e_columns;
n_rows   = (e_size_mode == "panel") ? fit_rows : e_rows;

grid_w  = (n_cols - 1) * e_h_pitch + sw;
grid_h  = (n_rows - 1) * e_v_pitch + sh;
p_w     = (e_shape == "quad") ? q_w : ((e_size_mode == "panel") ? e_panel_w : grid_w + 2*e_margin);
p_h     = (e_shape == "quad") ? q_h : ((e_size_mode == "panel") ? e_panel_h : grid_h + 2*e_margin);

h_bridge = e_h_pitch - sw;      // material between slots in a row
v_bar    = e_v_pitch - sh;      // material bar between rows (what straps/clips wrap)

// millimetres -> inches, rounded to 2 decimals
function fmt_in(v) = round(v / 25.4 * 100) / 100;

// ---------- sanity checks ----------
assert(sw >= 26, str("Slot width ", sw, "mm won't clear a 25.4mm MOLLE strap"));
assert(h_bridge >= 3, str("Only ", h_bridge, "mm of material between slots in a row - widen h_pitch or narrow slots"));
assert(v_bar >= 6, str("Only ", v_bar, "mm bar between rows - reduce slot_height or increase v_pitch"));
assert(e_shape == "quad" ||
       (p_w >= grid_w + 2*e_margin - 0.01 && p_h >= grid_h + 2*e_margin - 0.01),
       "Panel too small for grid + margins");
assert(e_shape != "quad" || q_slot_count > 0,
       "Quad shape too small - no slots fit inside it with this margin");
assert(!e_inter || v_bar - inter_slit_h >= 4,
       str("Inter-row slits leave only ", (v_bar - inter_slit_h)/2,
           "mm webs in the ", v_bar, "mm bars - reduce inter_slit_h or use a taller v_pitch (50.8 recommended)"));
if (e_cslits && !(e_corner_sty == "chamfer" && e_shape == "rect"))
    echo("NOTE: corner_slits need corner_style=chamfer and a rect outline - skipped");
if (e_inter && e_lightening)
    echo("NOTE: inter_slits and lightening both cut the bars between rows - they will overlap");

// ---------- slot count + weight estimate ----------
function shoelace(pts) =
    abs(sum_list([for (i = [0 : len(pts)-1])
        pts[i][0]*pts[(i+1)%len(pts)][1] - pts[(i+1)%len(pts)][0]*pts[i][1]])) / 2;
function sum_list(v) = len(v) == 0 ? 0 : v[0] + sum_list([for (i=[1:1:len(v)-1]) v[i]]);

n_slots_total = (e_shape == "quad") ? q_slot_count
                : n_rows * n_cols - (e_stagger ? floor(n_rows/2) : 0);
_corner_def   = (e_corner_sty == "chamfer") ? 2*e_corner_r*e_corner_r
                                            : (4-PI)*e_corner_r*e_corner_r;
_outline_area = (e_shape == "quad" ? shoelace(q_pts) : p_w*p_h) - _corner_def;
_slot_area    = sw*sh - (4-PI)*scr*scr;
_ix = p_w/2 - mount_hole_inset;  _iy = p_h/2 - mount_hole_inset;
_n_mh = !e_mount ? 0
        : 4 + 2*((e_max_spc <= 0) ? 0 : max(0, ceil(2*_ix/e_max_spc) - 1))
            + 2*((e_max_spc <= 0) ? 0 : max(0, ceil(2*_iy/e_max_spc) - 1));
_n_light = !e_lightening ? 0 : max(0, (n_slots_total - n_cols));   // rough
_inter_area = !e_inter || e_shape == "quad" ? 0
        : (n_rows-1) * (grid_w - ceil(n_cols/inter_slit_span)*inter_slit_bridge) * inter_slit_h;
_cut_area = _outline_area
          - n_slots_total * _slot_area
          - _n_mh * PI * mount_hole_d*mount_hole_d/4
          - _n_light * PI * lightening_size*lightening_size/4
          - _inter_area;
_density  = material == "steel" ? 7.85 : material == "aluminum" ? 2.70
          : material == "hdpe" ? 0.95 : material == "kydex" ? 1.35 : 0;
weight_g  = _cut_area * material_thickness * _density / 1000;
weight_str = material == "none" ? ""
    : str("~", weight_g >= 1000 ? str(round(weight_g/10)/100, " kg") : str(round(weight_g), " g"),
          " / ~", round(weight_g * 0.03527 * 10)/10, " oz  (", material_thickness, "mm ", material, ")");

echo(str("=== ", n_slots_total, " slots",
         material == "none" ? "" : str(", est. weight ", weight_str), " ==="));

// ---------- advisory warnings (non-fatal; shown in red in the preview) ----------
_shape_known = is_num(search([slot_shape],
    [["rect"],["pill"],["stadium"],["hex"],["hexagon"],["oval"],["ellipse"],
     ["round"],["diamond"],["rhombus"],["octagon"],["oct"]])[0]);
warn_msgs = concat(
    !_shape_known
        ? [str("unknown slot_shape '", slot_shape, "' - using rect (try hex, oval, diamond, octagon, pill)")] : [],
    (slot_shape == "diamond" || slot_shape == "rhombus")
        ? ["diamond slots bear straps on a point - decorative use only"] : [],
    (_scr_raw > scr + 0.001)
        ? [str("slot corner radius clamped to ", scr, " mm")] : [],
    (h_bridge < 5)
        ? [str("bridge ", h_bridge, " mm between slots - thin for metal, ok for plastics")] : [],
    (v_bar < 8)
        ? [str("bar ", v_bar, " mm between rows - avoid heavy pouches")] : [],
    (kerf > 0.5)
        ? [str("kerf ", kerf, " mm is unusually large - double-check")] : []
);
for (w = warn_msgs) echo(str("WARNING: ", w));

echo(str("=== MOLLE panel",
         _size_name  == "none" ? "" : str(" [size:",  _size_name,  "]"),
         _style_name == "none" ? "" : str(" [style:", _style_name, "]"),
         e_shape == "quad"
           ? str(" QUAD bbox ", q_w, " x ", q_h, " mm (", fmt_in(q_w), " x ", fmt_in(q_h),
                 " in), ", q_slot_count, " slots")
           : str(": ", p_w, " x ", p_h, " mm (", fmt_in(p_w), " x ", fmt_in(p_h),
                 " in), ", n_cols, " cols x ", n_rows, " rows"),
         ", slots ", sw, " x ", sh, " mm (pitch ", e_h_pitch, " x ", e_v_pitch,
         "), bar between rows ", v_bar, " mm, bridge between columns ", h_bridge, " mm",
         kerf > 0 ? str(", kerf comp ", kerf, " mm") : "", " ==="));

// ---------- 2D primitives ----------
module rounded_rect(w, h, r) {
    if (r <= 0) square([w, h], center=true);
    else hull()
        for (x = [-1, 1], y = [-1, 1])
            translate([x*(w/2 - r), y*(h/2 - r)]) circle(r=r);
}

module chamfered_rect(w, h, c) {
    if (c <= 0) square([w, h], center=true);
    else polygon([
        [-w/2+c, -h/2], [w/2-c, -h/2], [w/2, -h/2+c], [w/2, h/2-c],
        [w/2-c,  h/2], [-w/2+c,  h/2], [-w/2, h/2-c], [-w/2, -h/2+c]]);
}

// slot cut by shape word (unknown words fall back to rect and warn)
_hex_e = min(sh/2, sw/3);
_oct_c = min(sh/3, sw/4);
module slot() {
    s = slot_shape;
    if (s == "hex" || s == "hexagon")
        polygon([[-sw/2,0],[-sw/2+_hex_e,-sh/2],[sw/2-_hex_e,-sh/2],
                 [sw/2,0],[sw/2-_hex_e,sh/2],[-sw/2+_hex_e,sh/2]]);
    else if (s == "oval" || s == "ellipse" || s == "round")
        scale([sw/2, sh/2]) circle(r=1);
    else if (s == "diamond" || s == "rhombus")
        polygon([[0,-sh/2],[sw/2,0],[0,sh/2],[-sw/2,0]]);
    else if (s == "octagon" || s == "oct")
        polygon([[-sw/2+_oct_c,-sh/2],[sw/2-_oct_c,-sh/2],[sw/2,-sh/2+_oct_c],
                 [sw/2,sh/2-_oct_c],[sw/2-_oct_c,sh/2],[-sw/2+_oct_c,sh/2],
                 [-sw/2,sh/2-_oct_c],[-sw/2,-sh/2+_oct_c]]);
    else if (s == "pill" || s == "stadium")
        rounded_rect(sw, sh, sh/2);
    else
        rounded_rect(sw, sh, scr);
}

module hexagon(af) circle(d = af / cos(30), $fn = 6); // af = across flats

// ---------- rect-mode slot grid ----------
function row_y(j)      = -grid_h/2 + sh/2 + j*e_v_pitch;
function row_cols(j)   = (e_stagger && j % 2 == 1) ? n_cols - 1 : n_cols;
function col_x(i, j)   = -((row_cols(j)-1)*e_h_pitch)/2 + i*e_h_pitch;

module slot_grid_rect()
    for (j = [0 : n_rows-1], i = [0 : row_cols(j)-1])
        translate([col_x(i, j), row_y(j)]) slot();

// ---------- quad-mode slot grid (auto-fill) ----------
module slot_grid_quad()
    for (j = [0 : qn_rows-1], i = [0 : qn_cols-1]) {
        x = qcol_x(i) + ((e_stagger && j%2==1) ? e_h_pitch/2 : 0);
        y = qrow_y(j);
        if (slot_fits(x, y)) translate([x, y]) slot();
    }

// ---------- rect-mode perimeter features ----------
function n_between(len) = (e_max_spc <= 0) ? 0
                          : max(0, ceil(len / e_max_spc) - 1);

// corner holes need extra inset when the corner treatment cuts deeper than
// the edge inset (otherwise they notch the chamfer / rounded corner)
c_inset = (e_corner_r <= 0) ? mount_hole_inset
        : (e_corner_sty == "chamfer")
          ? max(mount_hole_inset, (e_corner_r + mount_hole_d/2 + 2) / 2)
          : max(mount_hole_inset,
                e_corner_r - (e_corner_r - mount_hole_d/2 - 2) / sqrt(2));

module mount_hole_set_rect() {
    ix = p_w/2 - mount_hole_inset;
    iy = p_h/2 - mount_hole_inset;
    nx = n_between(2*ix);
    ny = n_between(2*iy);
    // corner holes (skipped when corner slits take their place)
    if (!(e_cslits && e_corner_sty == "chamfer"))
        for (x = [-1, 1], y = [-1, 1])
            translate([x*(p_w/2 - c_inset), y*(p_h/2 - c_inset)])
                circle(d = mount_hole_d);
    if (nx > 0) for (k = [1 : nx], y = [-iy, iy])
        translate([-ix + k * 2*ix/(nx+1), y]) circle(d = mount_hole_d);
    if (ny > 0) for (k = [1 : ny], x = [-ix, ix])
        translate([x, -iy + k * 2*iy/(ny+1)]) circle(d = mount_hole_d);
}

module edge_slit_set_rect() {
    ix = p_w/2 - mount_hole_inset;
    iy = p_h/2 - mount_hole_inset;
    nx = n_between(2*ix);
    ny = n_between(2*iy);
    sx = 2*ix/(nx+1);
    sy = 2*iy/(ny+1);
    lx = min(edge_slit_length, sx - mount_hole_d - 6);  // keep clear of holes
    ly = min(edge_slit_length, sy - mount_hole_d - 6);
    if (lx > 4) for (k = [0 : nx], y = [-iy, iy])
        translate([-ix + (k + 0.5)*sx, y])
            rounded_rect(lx, edge_slit_width, edge_slit_width/2);
    if (ly > 4) for (k = [0 : ny], x = [-ix, ix])
        translate([x, -iy + (k + 0.5)*sy])
            rounded_rect(edge_slit_width, ly, edge_slit_width/2);
}

// 45-degree slits just inside the chamfered corners (rect mode)
module corner_slit_set() {
    c   = e_corner_r;
    len = (corner_slit_length > 0) ? corner_slit_length : c * sqrt(2);
    for (sx = [-1, 1], sy = [-1, 1]) {
        mid = [sx*(p_w/2 - c/2), sy*(p_h/2 - c/2)];   // chamfer edge midpoint
        inw = [-sx, -sy] / sqrt(2);                    // inward along bisector
        translate(mid + inw * corner_slit_inset)
            rotate((sx*sy > 0) ? -45 : 45)
                rounded_rect(len, corner_slit_width, corner_slit_width/2);
    }
}

// long webbing/velcro channel slits in the bars between slot rows (rect mode)
module inter_slit_set_rect() {
    for (j = [0 : n_rows-2]) {
        nc   = min(row_cols(j), row_cols(j+1));
        nseg = ceil(nc / inter_slit_span);
        for (s = [0 : nseg-1]) {
            c0 = s * inter_slit_span;
            c1 = min(c0 + inter_slit_span, nc) - 1;
            x0 = col_x(c0, j) - sw/2 + inter_slit_bridge/2;
            x1 = col_x(c1, j) + sw/2 - inter_slit_bridge/2;
            if (x1 - x0 > inter_slit_h)
                translate([(x0+x1)/2, row_y(j) + e_v_pitch/2])
                    rounded_rect(x1 - x0, inter_slit_h, inter_slit_h/2);
        }
    }
}

// quad version: a channel segment is kept only where slots exist above AND below it
module inter_slit_set_quad() {
    for (j = [0 : qn_rows-2]) {
        nseg = ceil(qn_cols / inter_slit_span);
        for (s = [0 : nseg-1]) {
            c0 = s * inter_slit_span;
            c1 = min(c0 + inter_slit_span, qn_cols) - 1;
            ok = len([for (i = [c0 : c1])
                      if (!(slot_fits(qcol_x(i), qrow_y(j)) &&
                            slot_fits(qcol_x(i), qrow_y(j+1)))) 1]) == 0;
            x0 = qcol_x(c0) - sw/2 + inter_slit_bridge/2;
            x1 = qcol_x(c1) + sw/2 - inter_slit_bridge/2;
            if (ok && x1 - x0 > inter_slit_h)
                translate([(x0+x1)/2, qrow_y(j) + e_v_pitch/2])
                    rounded_rect(x1 - x0, inter_slit_h, inter_slit_h/2);
        }
    }
}

// ---------- quad-mode perimeter features (follow the slanted edges) ----------
module quad_perimeter(do_holes, do_slits) {
    for (i = [0 : 3]) {
        a  = q_pts[i];
        b  = q_pts[(i+1) % 4];
        L  = norm(b - a);
        u  = (b - a) / L;
        nv = [-u[1], u[0]];                       // inward normal (CCW polygon)
        setback = max(mount_hole_inset * 2.5, e_corner_r + mount_hole_d);
        a2 = a + u * setback + nv * mount_hole_inset;
        b2 = b - u * setback + nv * mount_hole_inset;
        L2 = norm(b2 - a2);
        n  = (e_max_spc > 0) ? max(1, ceil(L2 / e_max_spc)) : 1;
        if (L2 > mount_hole_d) {
            if (do_holes)
                for (k = [0 : n])
                    translate(a2 + (b2 - a2) * k / n) circle(d = mount_hole_d);
            if (do_slits) {
                lmax = min(edge_slit_length, L2/n - mount_hole_d - 6);
                if (lmax > 4)
                    for (k = [0 : n-1])
                        translate(a2 + (b2 - a2) * (k + 0.5) / n)
                            rotate(atan2(u[1], u[0]))
                                rounded_rect(lmax, edge_slit_width, edge_slit_width/2);
            }
        }
    }
}

// ---------- lightening holes ----------
module lightening_hole()
    let (ls = min(lightening_size, v_bar - 3))    // keep >=1.5mm web above/below
    if (ls > 2) {
        if (lightening_style == "hex") hexagon(ls);
        else circle(d = ls);
    }

module lightening_set_rect()
    for (j = [0 : n_rows-2], i = [0 : row_cols(j)-1])
        translate([col_x(i, j), row_y(j) + e_v_pitch/2]) lightening_hole();

module lightening_set_quad()
    for (j = [0 : qn_rows-2], i = [0 : qn_cols-1]) {
        x = qcol_x(i) + ((e_stagger && j%2==1) ? e_h_pitch/2 : 0);
        y = qrow_y(j);
        if (slot_fits(x, y) && slot_fits(x, y + e_v_pitch))
            translate([x, y + e_v_pitch/2]) lightening_hole();
    }

// ---------- outlines ----------
module outline_rect()
    if (e_corner_sty == "chamfer") chamfered_rect(p_w, p_h, e_corner_r);
    else rounded_rect(p_w, p_h, e_corner_r);

module outline_quad()
    if (e_corner_r <= 0) polygon(q_pts);
    else if (e_corner_sty == "chamfer")
        offset(delta = e_corner_r, chamfer = true) offset(delta = -e_corner_r) polygon(q_pts);
    else
        offset(r = e_corner_r) offset(delta = -e_corner_r) polygon(q_pts);

// ---------- panel ----------
module panel_2d() {
    difference() {
        if (e_shape == "quad") outline_quad(); else outline_rect();
        if (e_shape == "quad") slot_grid_quad(); else slot_grid_rect();
        if (e_mount) { if (e_shape == "quad") quad_perimeter(true, false); else mount_hole_set_rect(); }
        if (e_edge_slits) { if (e_shape == "quad") quad_perimeter(false, true); else edge_slit_set_rect(); }
        if (e_lightening) { if (e_shape == "quad") lightening_set_quad(); else lightening_set_rect(); }
        if (e_inter) { if (e_shape == "quad") inter_slit_set_quad(); else inter_slit_set_rect(); }
        if (e_cslits && e_corner_sty == "chamfer" && e_shape == "rect") corner_slit_set();
    }
}

module panel_final()
    if (kerf > 0) offset(r = kerf/2) panel_2d();
    else panel_2d();

// ---------- live dimension annotations (preview only, never exported) ----------
module dimension_annotations() {
    ts  = max(8, min(22, max(p_w, p_h) * 0.04));   // text size scales with panel
    off = ts * 1.4;                                 // distance from panel edge
    lw  = ts * 0.08;                                // dimension line weight
    color("red") {
        // overall width - above the panel
        translate([0, p_h/2 + off]) {
            square([p_w, lw], center=true);
            for (x = [-p_w/2, p_w/2])
                translate([x, 0]) square([lw, ts*0.7], center=true);
            translate([0, ts*0.35])
                text(str(round(p_w*10)/10, " mm  /  ", fmt_in(p_w), "\""),
                     size=ts, halign="center", valign="baseline");
        }
        // overall height - right of the panel
        translate([p_w/2 + off, 0]) {
            square([lw, p_h], center=true);
            for (y = [-p_h/2, p_h/2])
                translate([0, y]) square([ts*0.7, lw], center=true);
            translate([ts*1.15, 0]) rotate(90)
                text(str(round(p_h*10)/10, " mm  /  ", fmt_in(p_h), "\""),
                     size=ts, halign="center", valign="baseline");
        }
        // slot count + weight - below the panel
        translate([0, -p_h/2 - off])
            text(str(n_slots_total, " slots",
                     material == "none" ? "" : str("   |   ", weight_str)),
                 size=ts*0.9, halign="center", valign="top");
        // advisory warnings - below that
        if (len(warn_msgs) > 0) for (i = [0 : len(warn_msgs)-1])
            translate([0, -p_h/2 - off - (i+1)*ts*1.6])
                text(str("! ", warn_msgs[i]), size=ts*0.85,
                     halign="center", valign="top");
    }
}

// ---------- nesting: tile several panels into one cut job ----------
nx_ = max(1, nest_x);
ny_ = max(1, nest_y);
sheet_w = nx_ * (p_w + kerf) + (nx_ - 1) * nest_gap;
sheet_h = ny_ * (p_h + kerf) + (ny_ - 1) * nest_gap;
if (nx_ * ny_ > 1)
    echo(str("=== nested ", nx_, " x ", ny_, " = ", nx_*ny_, " panels, sheet ",
             sheet_w, " x ", sheet_h, " mm (", fmt_in(sheet_w), " x ",
             fmt_in(sheet_h), " in) ==="));

// ---------- engrave layer ----------
module engrave_2d()
    if (engrave_text != "")
        translate([engrave_text_x, engrave_text_y])
            text(engrave_text, size = engrave_text_size,
                 halign = "center", valign = "center");

assert(output_layer != "engrave" || engrave_text != "",
       "output_layer=engrave but engrave_text is empty - nothing to export");

module nest_positions()
    for (i = [0 : nx_-1], j = [0 : ny_-1])
        translate([i * (p_w + kerf + nest_gap), j * (p_h + kerf + nest_gap)])
            children();

module nested()        nest_positions() panel_final();
module nested_engrave() nest_positions() engrave_2d();

if (output_layer == "engrave") {
    // engrave layer only - same panel positions as the cut layer,
    // so both SVGs import into XCS aligned at 0,0
    if (preview_3d) linear_extrude(0.5) nested_engrave();
    else nested_engrave();
} else if (preview_3d) {
    linear_extrude(material_thickness) nested();
    if ($preview) {
        color("blue") translate([0, 0, material_thickness])
            linear_extrude(0.3) nested_engrave();
        if (show_dimensions) linear_extrude(0.5) dimension_annotations();
    }
} else {
    nested();
    // $preview is false during any export/render, so neither the blue
    // engrave-position guide nor the dimensions can reach the cut SVG
    if ($preview) {
        color("blue") nested_engrave();
        if (show_dimensions) dimension_annotations();
    }
}
