master
John Lauer 1.2.0: chip thumbnail baked into every IC symbol by default (real outline or generic chip), chipOutline/chipSize prefs, AI check-and-revise hints, STEP ladder in hints 9a15e91 1mo ago
/*!
 * symgen.js : JavaScript port of adom-symbol's `src/sym_gen.rs` (generate_ic_sym).
 * Used by the wiki widget when WebAssembly is blocked by the frame CSP. Kept
 * byte-for-byte equal to the Rust output; `widget/parity.js` diffs the two on
 * every build. Do not "improve" this file: change sym_gen.rs, then mirror it.
 */
(function (root) {
  const PIN_LENGTH = 2.54, PIN_PITCH = 2.54, GROUP_GAP = 3.81, LABEL_OFFSET = 2.0;
  const BODY_TOP_MARGIN = 2.54, BODY_BOTTOM_MARGIN = 2.54, MIN_BODY_WIDTH = 20.0, BODY_PADDING = 4.0;
  const FS_PIN_NAME = 1.27, FS_GROUP_LABEL = 0.762;
  const NAME_CHAR_W = 1.5, NAME_EXT_OFFSET = 1.016 + 0.6, LABEL_CHAR_W = 0.9, LABEL_INSET = 1.02;
  const DESIGNATOR_INSET = 2.54, MIN_ICON_MM = 15.0, ICON_MARGIN = 1.0;
  // Chip outline (mirrors sym_gen.rs: OUTLINE_BODY_FRAC, chip_size_frac, GENERIC_OUTLINE*, icon_box, bake_chip_outline)
  const OUTLINE_BODY_FRAC = 0.62;
  function chipSizeFrac(size) { return size === 'small' ? OUTLINE_BODY_FRAC * 0.58 : size === 'medium' ? OUTLINE_BODY_FRAC * 0.78 : OUTLINE_BODY_FRAC; }
  const GENERIC_OUTLINE_VIEWBOX = [0.0, 0.0, 320.0, 320.0];
  const GENERIC_OUTLINE_NAME_AT = [160.3, 141.3];
  const GENERIC_OUTLINE_NAME_W = 297.5;
  const GENERIC_OUTLINE = [
    [[123.1, 55.4], [309.1, 162.8], [197.5, 227.2], [11.6, 119.8], [123.1, 55.4]],
    [[11.6, 119.8], [11.6, 156.3], [197.5, 263.7], [197.5, 227.2]],
    [[197.5, 263.7], [309.1, 199.3], [309.1, 162.8]],
    [[19.3, 160.8], [10.0, 166.1], [21.2, 172.6], [30.5, 167.2], [21.2, 172.6], [21.2, 164.0], [10.0, 157.5], [10.0, 166.1]],
    [[45.9, 176.1], [36.6, 181.5], [47.7, 187.9], [57.0, 182.5], [47.7, 187.9], [47.7, 179.3], [36.6, 172.9], [36.6, 181.5]],
    [[72.4, 191.4], [63.1, 196.8], [74.3, 203.2], [83.6, 197.9], [74.3, 203.2], [74.3, 194.7], [63.1, 188.2], [63.1, 196.8]],
    [[99.0, 206.8], [89.7, 212.1], [100.8, 218.6], [110.1, 213.2], [100.8, 218.6], [100.8, 210.0], [89.7, 203.6], [89.7, 212.1]],
    [[125.5, 222.1], [116.2, 227.5], [127.4, 233.9], [136.7, 228.5], [127.4, 233.9], [127.4, 225.3], [116.2, 218.9], [116.2, 227.5]],
    [[152.1, 237.4], [142.8, 242.8], [154.0, 249.2], [163.3, 243.9], [154.0, 249.2], [154.0, 240.7], [142.8, 234.2], [142.8, 242.8]],
    [[178.7, 252.8], [169.4, 258.1], [180.5, 264.6], [189.8, 259.2], [180.5, 264.6], [180.5, 256.0], [169.4, 249.6], [169.4, 258.1]],
    [[300.7, 204.1], [310.0, 209.5], [298.8, 215.9], [289.5, 210.5], [298.8, 215.9], [298.8, 207.3], [310.0, 200.9], [310.0, 209.5]],
    [[272.8, 220.2], [282.1, 225.6], [271.0, 232.0], [261.7, 226.6], [271.0, 232.0], [271.0, 223.4], [282.1, 217.0], [282.1, 225.6]],
    [[244.9, 236.3], [254.2, 241.7], [243.1, 248.1], [233.8, 242.7], [243.1, 248.1], [243.1, 239.5], [254.2, 233.1], [254.2, 241.7]],
    [[217.0, 252.4], [226.3, 257.8], [215.2, 264.2], [205.9, 258.8], [215.2, 264.2], [215.2, 255.6], [226.3, 249.2], [226.3, 257.8]],
    [[59.0, 123.6], [52.5, 125.1], [46.0, 123.6], [43.3, 119.8], [46.0, 116.1], [52.5, 114.5], [59.0, 116.1], [61.7, 119.8], [59.0, 123.6]],
  ];
  function genericOutline(size) {
    return { polylines: GENERIC_OUTLINE.map(pl => pl.map(p => [p[0], p[1]])), viewbox: GENERIC_OUTLINE_VIEWBOX.slice(), name_at: GENERIC_OUTLINE_NAME_AT.slice(), name_width: GENERIC_OUTLINE_NAME_W, size: size || 'large', source: 'generic-iso' };
  }
  function fx3(v) {
    const scaled = v * 1000;
    const rounded = scaled >= 0 ? Math.floor(scaled + 0.5) : Math.ceil(scaled - 0.5);
    let s = (rounded / 1000).toFixed(3);
    return s === '-0.000' ? '0.000' : s;
  }
  function iconBox(body, free0, designators, frac) {
    const bw = body[2] - body[0], bh = body[3] - body[1];
    const free = free0.slice();
    for (const [x, y] of designators) {
      if (x > body[0] && x < body[2] && y > body[1] && y < body[3]) {
        const cy = (free[1] + free[3]) / 2;
        if (y >= cy) free[3] = Math.min(free[3], y - 1.3); else free[1] = Math.max(free[1], y + 1.3);
      }
    }
    const fw = Math.max(free[2] - free[0] - 2 * ICON_MARGIN, 1.0), fh = Math.max(free[3] - free[1] - 2 * ICON_MARGIN, 1.0);
    const w = Math.min(bw * frac, fw), h = Math.min(bh * frac, fh);
    const bcx = (body[0] + body[2]) / 2;
    const cx = (bcx - w / 2 >= free[0] + ICON_MARGIN && bcx + w / 2 <= free[2] - ICON_MARGIN) ? bcx : (free[0] + free[2]) / 2;
    const cy = (free[1] + free[3]) / 2;
    return [cx, cy, w, h];
  }
  function bakeChipOutline(outline, name, body, free, designators) {
    const frac = chipSizeFrac(outline.size);
    const [cx, cy, w, h] = iconBox(body, free, designators, frac);
    const [vx0, vy0, vw, vh] = outline.viewbox;
    const scale = Math.min(w / vw, h / vh);
    const iconMm = scale * Math.max(vw, vh);
    if (!(isFinite(scale) && scale > 0) || iconMm < MIN_ICON_MM * 0.6) return null;
    const scx = vx0 + vw / 2, scy = vy0 + vh / 2;
    const map = (px, py) => [cx + (px - scx) * scale, cy - (py - scy) * scale];
    const stroke = fx3(Math.max(scale * vw / 120, 0.12));
    const lines = [];
    for (const pl of outline.polylines) {
      if (pl.length < 2) continue;
      const pts = pl.map(([px, py]) => { const [x, y] = map(px, py); return `(xy ${fx3(x)} ${fx3(y)})`; });
      lines.push(`      (polyline (pts ${pts.join(' ')}) (stroke (width ${stroke}) (type default)) (fill (type none)))`);
    }
    let lasered = false;
    if (outline.name_at) {
      const [tx, ty] = map(outline.name_at[0], outline.name_at[1]);
      const n = Math.max(chars(name), 1);
      const avail = outline.name_width * scale;
      const size = Math.max(Math.min(avail * 0.7 / (n * 0.8), 1.27), 0.5);
      lines.push(`      (text "${name}" (at ${fx(tx)} ${fx(ty)} 0)`, `        (effects (font (size ${fx(size)} ${fx(size)})))`, '      )');
      lasered = true;
    }
    return [lines, lasered];
  }

  function fx(v) {
    const scaled = v * 100;
    const rounded = scaled >= 0 ? Math.floor(scaled + 0.5) : Math.ceil(scaled - 0.5);
    let s = (rounded / 100).toFixed(2);
    return s === '-0.00' ? '0.00' : s;
  }
  const chars = s => Array.from(s).length;
  const strip = s => chars(s.replace(/~\{/g, '').replace(/\}/g, ''));
  function isGroundName(name) {
    const n = name.toUpperCase();
    return n.startsWith('GND') || n.startsWith('VSS') || n.startsWith('AGND') || n.startsWith('DGND')
      || n.startsWith('PGND') || n.startsWith('OGND') || n === 'EP' || n.startsWith('EP_') || n === 'PAD' || n === 'THERMAL';
  }
  const eqi = (a, b) => a.toLowerCase() === b.toLowerCase();
  const clone = p => ({ name: p.name, number: p.number, ptype: p.ptype, group: p.group == null ? null : p.group, side: p.side == null ? null : p.side });
  function byGroup(pins) {
    const out = [];
    for (const p of pins) {
      const g = p.group == null ? '_ungrouped' : p.group;
      const e = out.find(x => x[0] === g);
      if (e) e[1].push(clone(p)); else out.push([g, [clone(p)]]);
    }
    return out;
  }
  const isPowerType = p => p.ptype === 'power_in' || p.ptype === 'power_out';

  function inferSquareSides(pins, grouped) {
    grouped = grouped && pins.some(p => p.group != null);
    if (!grouped) {
      const n = pins.length, per = Math.max(1, Math.floor((n + 3) / 4));
      const left = [], bottom = [], right = [], top = [];
      pins.forEach((p, i) => {
        const q = clone(p); q.group = null;
        switch (Math.min(3, Math.floor(i / per))) {
          case 0: q.side = 'left'; left.push(q); break;
          case 1: q.side = 'bottom'; bottom.push(q); break;
          case 2: q.side = 'right'; right.push(q); break;
          default: q.side = 'top'; top.push(q);
        }
      });
      right.reverse(); top.reverse();
      return left.concat(bottom, right, top);
    }
    const bg = byGroup(pins);
    const order = bg.map(x => x[0]);
    const pk = order.find(g => eqi(g, 'power'));
    if (pk != null) {
      const idx = bg.findIndex(x => x[0] === pk);
      const gnd = bg[idx][1].filter(p => isGroundName(p.name)), pwr = bg[idx][1].filter(p => !isGroundName(p.name));
      if (gnd.length && pwr.length && !order.some(g => eqi(g, 'ground'))) {
        bg[idx][1] = pwr;
        bg.push(['Ground', gnd.map(p => { const q = clone(p); q.group = 'Ground'; return q; })]);
        order.push('Ground');
      }
    }
    const sides = ['left', 'bottom', 'right', 'top'];
    const load = [0, 0, 0, 0];
    const sideOf = bg.map(() => null);
    bg.forEach(([g, ps], i) => {
      const allGround = ps.every(p => isGroundName(p.name));
      const allPower = ps.every(isPowerType);
      if (eqi(g, 'ground') || (allGround && g !== '_ungrouped')) { sideOf[i] = 'bottom'; load[1] += ps.length; }
      else if (eqi(g, 'power') || (allPower && !allGround && g !== '_ungrouped')) { sideOf[i] = 'top'; load[3] += ps.length; }
    });
    const rest = bg.map((_, i) => i).filter(i => sideOf[i] == null);
    rest.sort((a, b) => bg[b][1].length - bg[a][1].length); // stable, like Rust sort_by
    for (const i of rest) {
      let k = 0; for (let j = 1; j < 4; j++) if (load[j] < load[k]) k = j;
      sideOf[i] = sides[k]; load[k] += bg[i][1].length;
    }
    const out = [];
    for (const side of sides) bg.forEach(([, ps], i) => { if (sideOf[i] === side) for (const p of ps) { const q = clone(p); q.side = side; out.push(q); } });
    return out;
  }

  function inferLrGroupedSides(pins) {
    const bg = byGroup(pins);
    const n = bg.length;
    const isPower = (g, ps) => eqi(g, 'power') || eqi(g, 'ground') || (g !== '_ungrouped' && ps.every(isPowerType));
    const side = bg.map(() => null);
    let ln = 0, rn = 0;
    bg.forEach(([g, ps], i) => { if (isPower(g, ps)) { side[i] = 'right'; rn += ps.length; } });
    const rest = bg.map((_, i) => i).filter(i => side[i] == null);
    rest.sort((a, b) => bg[b][1].length - bg[a][1].length);
    for (const i of rest) {
      const ps = bg[i][1];
      const outputsOnly = ps.every(p => p.ptype === 'output' || p.ptype === 'tri_state' || p.ptype === 'open_collector');
      const toRight = ln === rn ? outputsOnly : rn < ln;
      if (toRight) { side[i] = 'right'; rn += ps.length; } else { side[i] = 'left'; ln += ps.length; }
    }
    const out = [];
    bg.forEach(([, ps], i) => { if (side[i] === 'left') for (const p of ps) { const q = clone(p); q.side = 'left'; out.push(q); } });
    const ro = bg.map((_, i) => i).filter(i => side[i] === 'right');
    ro.sort((a, b) => ((isPower(bg[a][0], bg[a][1]) ? 0 : 1) - (isPower(bg[b][0], bg[b][1]) ? 0 : 1)) || (a - b));
    for (const i of ro) for (const p of bg[i][1]) { const q = clone(p); q.side = 'right'; out.push(q); }
    return out;
  }

  function inferDipSides(pins) {
    const half = Math.floor((pins.length + 1) / 2);
    const left = pins.slice(0, half).map(p => { const q = clone(p); q.side = 'left'; return q; });
    const right = pins.slice(half).map(p => { const q = clone(p); q.side = 'right'; return q; });
    right.reverse();
    return left.concat(right);
  }

  function layoutVertical(sideGroups) {
    const pins = [], labels = []; let y = 0;
    sideGroups.forEach((sg, i) => {
      if (i > 0) y -= GROUP_GAP;
      const labelY = y; y -= LABEL_OFFSET;
      for (const pin of sg.pins) { pins.push({ name: pin.name, number: pin.number, ptype: pin.ptype, x: 0, y }); y -= PIN_PITCH; }
      if (sg.name !== '_ungrouped') labels.push({ name: sg.name, x: 0, y: labelY });
    });
    return [pins, labels, Math.abs(y)];
  }
  function layoutHorizontal(sideGroups, startX) {
    const pins = [], labels = []; let x = startX;
    sideGroups.forEach((sg, i) => {
      if (i > 0) x += GROUP_GAP;
      const labelX = x;
      if (sg.name !== '_ungrouped') x += LABEL_OFFSET;
      for (const pin of sg.pins) { pins.push({ name: pin.name, number: pin.number, ptype: pin.ptype, x, y: 0 }); x += PIN_PITCH; }
      if (sg.name !== '_ungrouped') labels.push({ name: sg.name, x: labelX, y: 0 });
    });
    return [pins, labels, x - startX];
  }

  function designatorAt(pos, bl, br, bt, bb, clr, free, halfW) {
    const cy = (bt + bb) / 2;
    const bcx = (bl + br) / 2;
    const fcx = (bcx - halfW >= free[0] && bcx + halfW <= free[2]) ? bcx : (free[0] + free[2]) / 2;
    switch (pos) {
      case 'center': return [0, cy, ''];
      case 'free-top': return [fcx, free[3] - DESIGNATOR_INSET, ''];
      case 'free-bot': case 'free-bottom': return [fcx, free[1] + DESIGNATOR_INSET, ''];
      case 'corner-top': return [bl + 1.0, clr.top, ' (justify left)'];
      case 'corner-bot': case 'corner-bottom': return [bl + 1.0, clr.bot, ' (justify left)'];
      case 'in-top': return [0, bt - 2.2, ''];
      case 'out-bot': case 'out-bottom': return [0, clr.bot, ''];
      case 'in-bot': case 'in-bottom': return [0, bb + 2.2, ''];
      case 'out-left': return [clr.left, cy, ' (justify right)'];
      case 'in-left': return [bl + 2.0, cy, ' (justify left)'];
      case 'out-right': return [clr.right, cy, ' (justify left)'];
      case 'in-right': return [br - 2.0, cy, ' (justify right)'];
      case 'top-left': return [bl + 2.0, bt - 1.8, ' (justify left)'];
      case 'bottom-left': return [bl + 2.0, bb + 1.8, ' (justify left)'];
      default: return [0, clr.top, ''];
    }
  }

  function generateIcSym(input) {
    const pins0 = input.pins.map(p => ({ name: p.name, number: p.number, ptype: p.ptype != null ? p.ptype : p.type, group: p.group == null ? null : p.group, side: p.side == null ? null : p.side }));
    let pinLayout = input.pin_layout || 'left-right';
    const hasExplicitSide = pins0.some(p => p.side != null);
    let squarePins = null;
    if (pinLayout === 'square' && !hasExplicitSide) squarePins = inferSquareSides(pins0, false);
    else if ((pinLayout === 'square-grouped' || pinLayout === 'all-sides') && !hasExplicitSide) squarePins = inferSquareSides(pins0, true);
    const anyGroup = pins0.some(p => p.group != null);
    let dipPins = null;
    if (squarePins == null && pinLayout === 'left-right' && !hasExplicitSide && pins0.length > 1)
      dipPins = anyGroup ? inferLrGroupedSides(pins0) : inferDipSides(pins0);
    const inputPins = squarePins || dipPins || pins0;
    if (squarePins) pinLayout = 'all-sides';

    const groups = byGroup(inputPins);
    const left = [], right = [], top = [], bottom = [];
    for (const [groupName, groupPins] of groups) {
      for (const side of ['left', 'right', 'top', 'bottom']) {
        const ps = groupPins.filter(p => p.side === side);
        if (!ps.length) continue;
        const sg = { name: groupName, pins: ps };
        ({ left, right, top, bottom })[side].push(sg);
      }
      const rest = groupPins.filter(p => p.side == null);
      if (rest.length) {
        const sg = { name: groupName, pins: rest };
        const hasInputs = rest.some(p => p.ptype === 'input' || p.ptype === 'bidirectional');
        const hasPower = rest.some(isPowerType);
        const hasOutputs = rest.some(p => p.ptype === 'output' || p.ptype === 'tri_state');
        const isGround = rest.every(p => p.ptype === 'power_in' && isGroundName(p.name));
        if (pinLayout === 'all-sides') {
          if (isGround) bottom.push(sg);
          else if (hasPower && !hasInputs && !hasOutputs) top.push(sg);
          else if (hasOutputs && !hasInputs) right.push(sg);
          else left.push(sg);
        } else {
          if (isGround) left.push(sg);
          else if (hasOutputs && !hasInputs) right.push(sg);
          else if (hasPower && !hasInputs) right.push(sg);
          else left.push(sg);
        }
      }
    }

    const [leftPins, leftLabels] = layoutVertical(left);
    const [rightPins, rightLabels] = layoutVertical(right);
    const longestLeft = leftPins.reduce((m, p) => Math.max(m, chars(p.name)), 0);
    const longestRight = rightPins.reduce((m, p) => Math.max(m, chars(p.name)), 0);
    let nameWidth = (longestLeft + longestRight) * 1.0 + BODY_PADDING * 2;
    if (input.chip_name_centered) {
      const nameLen = chars(input.symbol_name);
      nameWidth = Math.max(nameWidth, (longestLeft + longestRight + nameLen) * 1.0 + BODY_PADDING * 2 + 2.0);
    }
    const topPinCount = top.reduce((s, g) => s + g.pins.length, 0);
    const bottomPinCount = bottom.reduce((s, g) => s + g.pins.length, 0);
    const topGroupGaps = Math.max(0, top.length - 1) * GROUP_GAP;
    const bottomGroupGaps = Math.max(0, bottom.length - 1) * GROUP_GAP;
    const topLabelOffsets = top.filter(g => g.name !== '_ungrouped').length * LABEL_OFFSET;
    const bottomLabelOffsets = bottom.filter(g => g.name !== '_ungrouped').length * LABEL_OFFSET;
    const topWidth = topPinCount * PIN_PITCH + topGroupGaps + topLabelOffsets + BODY_PADDING * 2;
    const bottomWidth = bottomPinCount * PIN_PITCH + bottomGroupGaps + bottomLabelOffsets + BODY_PADDING * 2;
    if (top.length || bottom.length) {
      const valueLen = Math.max(chars(input.symbol_name), chars(input.reference_prefix) + 1);
      nameWidth = Math.max(nameWidth, (longestLeft + longestRight + valueLen) * NAME_CHAR_W + 2 * NAME_EXT_OFFSET + 2.0);
    }
    nameWidth = Math.max(nameWidth, (longestLeft + longestRight) * NAME_CHAR_W + 2 * NAME_EXT_OFFSET + MIN_ICON_MM + 2 * ICON_MARGIN);
    const bodyWidth = Math.max(nameWidth, MIN_BODY_WIDTH, topWidth, bottomWidth);
    const halfWidth = bodyWidth / 2;
    const bodyLeft = -Math.ceil(halfWidth / 2.54) * 2.54;
    const bodyRight = Math.ceil(halfWidth / 2.54) * 2.54;

    const topTotal = layoutHorizontal(top, 0)[2];
    const bottomTotal = layoutHorizontal(bottom, 0)[2];
    const [topPins, topLabels] = layoutHorizontal(top, -topTotal / 2);
    const [bottomPins, bottomLabels] = layoutHorizontal(bottom, -bottomTotal / 2);

    const labelYs = leftLabels.map(l => l.y).concat(rightLabels.map(l => l.y));
    const topLabelY = labelYs.length ? Math.max(...labelYs) : 0;
    let topPinClearance = 0, bottomPinClearance = 0;
    if (topPins.length) {
      const ln = topPins.reduce((m, p) => Math.max(m, chars(p.name)), 0);
      const ll = topLabels.reduce((m, l) => Math.max(m, chars(l.name)), 0);
      topPinClearance = Math.max(ln * FS_PIN_NAME * 0.7, ll * FS_GROUP_LABEL * 0.7) + 2.0;
    }
    if (bottomPins.length) {
      const ln = bottomPins.reduce((m, p) => Math.max(m, chars(p.name)), 0);
      const ll = bottomLabels.reduce((m, l) => Math.max(m, chars(l.name)), 0);
      bottomPinClearance = Math.max(ln * FS_PIN_NAME * 0.7, ll * FS_GROUP_LABEL * 0.7) + 2.5;
    }
    let bodyTop = topLabelY + BODY_TOP_MARGIN + topPinClearance;
    const pinYs = leftPins.map(p => p.y).concat(rightPins.map(p => p.y));
    const lowestPinY = pinYs.length ? Math.min(...pinYs) : -10;
    let bodyBottom = lowestPinY - BODY_BOTTOM_MARGIN - bottomPinClearance;

    const extName = n => n * NAME_CHAR_W + NAME_EXT_OFFSET;
    const extLabel = n => n * LABEL_CHAR_W + LABEL_INSET;
    if (topPins.length || bottomPins.length) {
      const topExt = Math.max(0, ...topPins.map(p => extName(strip(p.name))), ...topLabels.map(l => extLabel(chars(l.name))));
      const botExt = Math.max(0, ...bottomPins.map(p => extName(strip(p.name))), ...bottomLabels.map(l => extLabel(chars(l.name))));
      const freeH = (bodyTop - topExt) - (bodyBottom + botExt);
      const needH = 2 * DESIGNATOR_INSET + 2 * FS_PIN_NAME + MIN_ICON_MM + 2 * ICON_MARGIN;
      if (freeH < needH) {
        const grow = Math.ceil((needH - freeH) / 2 / 2.54) * 2.54;
        bodyTop += grow; bodyBottom -= grow;
      }
    }
    if (input.chip_outline && !topPins.length && !bottomPins.length) {
      const needH = MIN_ICON_MM * 0.6 / OUTLINE_BODY_FRAC + 2 * ICON_MARGIN + 0.5;
      const freeH = bodyTop - bodyBottom;
      if (freeH < needH) { const grow = Math.ceil((needH - freeH) / 2 / 1.27) * 1.27; bodyTop += grow; bodyBottom -= grow; }
    }
    let fl = bodyLeft, fr = bodyRight, ft = bodyTop, fb = bodyBottom;
    for (const p of leftPins) fl = Math.max(fl, bodyLeft + extName(strip(p.name)));
    for (const l of leftLabels) fl = Math.max(fl, bodyLeft + extLabel(chars(l.name)));
    for (const p of rightPins) fr = Math.min(fr, bodyRight - extName(strip(p.name)));
    for (const l of rightLabels) fr = Math.min(fr, bodyRight - extLabel(chars(l.name)));
    for (const p of topPins) ft = Math.min(ft, bodyTop - extName(strip(p.name)));
    for (const l of topLabels) ft = Math.min(ft, bodyTop - extLabel(chars(l.name)));
    for (const p of bottomPins) fb = Math.max(fb, bodyBottom + extName(strip(p.name)));
    for (const l of bottomLabels) fb = Math.max(fb, bodyBottom + extLabel(chars(l.name)));
    const free = (fr - fl < 2 || ft - fb < 2) ? [bodyLeft, bodyBottom, bodyRight, bodyTop] : [fl, fb, fr, ft];
    const vertical = topPins.length > 0 || bottomPins.length > 0;
    const hasVerticalPins = vertical && (free[3] - free[1]) >= 2 * DESIGNATOR_INSET + 2 * FS_PIN_NAME;
    const cornerFallback = vertical && !hasVerticalPins;

    const L = [];
    L.push('(kicad_symbol_lib', '  (version 20231120)', '  (generator "adom")', `  (symbol "${input.symbol_name}"`,
      '    (pin_names (offset 1.016))', '    (exclude_from_sim no)', '    (in_bom yes)', '    (on_board yes)');
    const longestLeftNum = leftPins.reduce((m, p) => Math.max(m, chars(p.number)), 0);
    const longestRightNum = rightPins.reduce((m, p) => Math.max(m, chars(p.number)), 0);
    const clr = {
      top: topPins.length ? bodyTop + PIN_LENGTH + 6.0 : bodyTop + 2.54,
      bot: bottomPins.length ? bodyBottom - PIN_LENGTH - 6.0 : bodyBottom - 2.54,
      left: bodyLeft - PIN_LENGTH - longestLeftNum * 0.8 - 2.5,
      right: bodyRight + PIN_LENGTH + longestRightNum * 0.8 + 2.5,
    };
    const designators = [];
    L.push(`    (property "Reference" "${input.reference_prefix}"`);
    {
      const rp = input.ref_pos || '';
      let pos;
      if ((rp === '' || rp === 'top' || rp === 'out-top') && hasVerticalPins) pos = 'free-top';
      else if ((rp === '' || rp === 'top' || rp === 'out-top') && cornerFallback) pos = 'corner-top';
      else if (rp === '') pos = 'out-top';
      else pos = rp;
      const halfW = chars(input.reference_prefix) * NAME_CHAR_W / 2 + NAME_CHAR_W;
      const [rx, ry, just] = designatorAt(pos, bodyLeft, bodyRight, bodyTop, bodyBottom, clr, free, halfW);
      designators.push([rx, ry]);
      L.push(`      (at ${fx(rx)} ${fx(ry)} 0)`, `      (effects (font (size 1.27 1.27))${just})`);
    }
    L.push('    )');
    L.push(`    (property "Value" "${input.symbol_name}"`);
    if (input.value_pos === 'none') {
      const [vx, vy] = designatorAt('out-bot', bodyLeft, bodyRight, bodyTop, bodyBottom, clr, free, 0);
      L.push(`      (at ${fx(vx)} ${fx(vy)} 0)`, '      (effects (font (size 1.27 1.27)) hide)');
    } else {
      const vp = input.value_pos || '';
      let pos;
      if ((vp === '' || vp === 'out-bot' || vp === 'out-bottom') && hasVerticalPins) pos = 'free-bot';
      else if ((vp === '' || vp === 'out-bot' || vp === 'out-bottom') && cornerFallback) pos = 'corner-bot';
      else if (vp === '') pos = 'out-bot';
      else pos = vp;
      const halfW = chars(input.symbol_name) * NAME_CHAR_W / 2;
      const [vx, vy, just] = designatorAt(pos, bodyLeft, bodyRight, bodyTop, bodyBottom, clr, free, halfW);
      designators.push([vx, vy]);
      L.push(`      (at ${fx(vx)} ${fx(vy)} 0)`, `      (effects (font (size 1.27 1.27))${just})`);
    }
    L.push('    )');
    const baked = input.chip_outline ? bakeChipOutline(input.chip_outline, input.symbol_name, [bodyLeft, bodyBottom, bodyRight, bodyTop], free, designators) : null;
    for (const [prop, val] of [['Footprint', input.package], ['Datasheet', input.datasheet_url], ['Description', input.description], ['Manufacturer', input.manufacturer]]) {
      L.push(`    (property "${prop}" "${val || ''}"`, '      (at 0 0 0)', '      (effects (font (size 1.27 1.27)) (hide yes))', '    )');
    }
    for (const [dist, pn] of (input.distributor_pn || [])) {
      const label = dist ? dist[0].toUpperCase() + dist.slice(1) : '';
      L.push(`    (property "${label}" "${pn}"`, '      (at 0 0 0)', '      (effects (font (size 1.27 1.27)) (hide yes))', '    )');
    }
    if (input.chip_outline && baked) {
      L.push(`    (property "Adom_Chip_Outline" "${input.chip_outline.source}@${input.chip_outline.size}"`, '      (at 0 0 0)', '      (effects (font (size 1.27 1.27)) (hide yes))', '    )');
    }
    L.push(`    (symbol "${input.symbol_name}_0_1"`);
    L.push(`      (rectangle (start ${fx(bodyLeft)} ${fx(bodyTop)}) (end ${fx(bodyRight)} ${fx(bodyBottom)})`,
      '        (stroke (width 0.254) (type default))', '        (fill (type background))', '      )');
    for (const l of leftLabels) L.push(`      (text "${l.name}" (at ${fx(bodyLeft + 1.02)} ${fx(l.y)} 0)`, '        (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify left))', '      )');
    for (const l of rightLabels) L.push(`      (text "${l.name}" (at ${fx(bodyRight - 1.02)} ${fx(l.y)} 0)`, '        (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify right))', '      )');
    for (const l of topLabels) L.push(`      (text "${l.name}" (at ${fx(l.x)} ${fx(bodyTop - 1.02)} 900)`, '        (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify right))', '      )');
    for (const l of bottomLabels) L.push(`      (text "${l.name}" (at ${fx(l.x)} ${fx(bodyBottom + 1.02)} 900)`, '        (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify left))', '      )');
    let lasered = false;
    if (baked) { L.push(...baked[0]); lasered = baked[1]; }
    if (input.chip_name_centered && !lasered) {
      L.push(`      (text "${input.symbol_name}" (at 0 ${fx((bodyTop + bodyBottom) / 2)} 0)`, '        (effects (font (size 1.27 1.27) (color 100 100 100 1)))', '      )');
    }
    L.push('    )');
    L.push(`    (symbol "${input.symbol_name}_1_1"`);
    for (const p of leftPins) L.push(`      (pin ${p.ptype} line (at ${fx(bodyLeft - PIN_LENGTH)} ${fx(p.y)} 0) (length 2.54) (name "${p.name}") (number "${p.number}"))`);
    for (const p of rightPins) L.push(`      (pin ${p.ptype} line (at ${fx(bodyRight + PIN_LENGTH)} ${fx(p.y)} 180) (length 2.54) (name "${p.name}") (number "${p.number}"))`);
    if (topPins.length) { const e = bodyTop - 0.1; for (const p of topPins) L.push(`      (pin ${p.ptype} line (at ${fx(p.x)} ${fx(e + PIN_LENGTH)} 270) (length 2.54) (name "${p.name}") (number "${p.number}"))`); }
    if (bottomPins.length) { const e = bodyBottom + 0.1; for (const p of bottomPins) L.push(`      (pin ${p.ptype} line (at ${fx(p.x)} ${fx(e - PIN_LENGTH)} 90) (length 2.54) (name "${p.name}") (number "${p.number}"))`); }
    L.push('    )', '  )', ')', '');
    return L.join('\n');
  }

  // Mirrors main.rs layout_note / compose_description (the CLI `create` path).
  function layoutNote(pinLayout, grouped, explicitSides, centred) {
    let shape;
    if (pinLayout === 'square') shape = '4-side square symbol, pins in package order';
    else if (pinLayout === 'square-grouped') shape = '4-side square symbol';
    else if (pinLayout === 'all-sides') shape = explicitSides ? '4-side symbol' : '4-side square symbol';
    else shape = '2-column symbol';
    const parts = [shape];
    if (grouped && pinLayout !== 'square') parts.push('pins grouped by function');
    if (centred) parts.push('chip name centred');
    return parts.join(', ');
  }
  function composeDescription(desc, note) {
    const d = desc.trim().replace(/\.+$/, '').trim();
    if (!note) return d;
    if (d.includes(note)) return desc.trim();
    return d === '' ? note + '.' : d + '. ' + note + '.';
  }

  /** Same JSON body as the CLI `create` / the wasm export. */
  function generateFromCreateJson(i) {
    const prefs = i.preferences || {};
    const pins = i.pins || [];
    const note = layoutNote(prefs.pinLayout || 'left-right', pins.some(p => p.group != null), pins.some(p => p.side != null), !!prefs.chipNameCentered);
    return generateIcSym({
      symbol_name: i.symbolName, manufacturer: i.manufacturer || '', package: i.package || '', description: composeDescription(i.description || '', note),
      datasheet_url: i.datasheetUrl || '', reference_prefix: i.referencePrefix || 'U',
      pins: (i.pins || []).map(p => ({ name: p.name, number: p.number, ptype: p.type, group: p.group == null ? null : p.group, side: p.side == null ? null : p.side })),
      pin_layout: prefs.pinLayout || 'left-right', chip_name_centered: !!prefs.chipNameCentered,
      ref_pos: prefs.refPos || '', value_pos: prefs.valuePos || '', distributor_pn: [],
      // Default ON (the CLI's default too): the generic isometric chip. The CLI
      // additionally discovers a real <mpn>-3d-outline-*-named.svg; the browser can't.
      chip_outline: (prefs.chipOutline == null ? true : !!prefs.chipOutline) ? genericOutline(prefs.chipSize || 'large') : null,
    });
  }
  const api = { generateIcSym, generateFromCreateJson, fx, fx3, genericOutline, bakeChipOutline, iconBox, chipSizeFrac };
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
  root.AdomSymGen = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);