diff --git a/assets/controllers/pages/bulk_image_generator_controller.js b/assets/controllers/pages/bulk_image_generator_controller.js new file mode 100644 index 000000000..5e5da9adb --- /dev/null +++ b/assets/controllers/pages/bulk_image_generator_controller.js @@ -0,0 +1,311 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-server). + * + * Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Controller} from "@hotwired/stimulus"; +import {AlertSwal} from "../../helpers/swal"; +import {trans} from "../../translator.js"; + +/** + * Drives the hidden component image generator to render a preview for every candidate row, then attaches + * the checked ones to their parts via the per-part generate-image endpoint (with a progress bar). + */ +export default class extends Controller { + static targets = ["row", "progress", "progressBar", "attachBtn", "edaBtn", + "batchPitch", "batchDiameter", "batchColor", "batchVoltage", "batchShape", "batchLead", "batchTolerance", + "batchPower", "batchPpm", "batchPackage"]; + + connect() { + this.tryRenderPreviews(0); + } + + /** The component image generator controller instance (retries briefly, since it may connect after us). */ + calcController() { + const el = this.element.querySelector('[data-controller*="omponent-image-generator"]'); + if (!el) { + return null; + } + const c = this.application.getControllerForElementAndIdentifier(el, "pages--component-image-generator"); + if (c && typeof c.generateSvg === "function") { + return c; + } + return null; + } + + tryRenderPreviews(attempt) { + const calc = this.calcController(); + if (!calc) { + if (attempt < 20) { + setTimeout(() => this.tryRenderPreviews(attempt + 1), 50); + } + return; + } + this.rowTargets.forEach((row) => this.renderPreview(row, calc)); + } + + renderPreview(row, calc) { + //Each row carries its own appearance inputs; shape/lead length are batch-wide. + const colorEl = row.querySelector("[data-row-color]"); + const diamEl = row.querySelector("[data-row-diameter]"); + const pitchEl = row.querySelector("[data-row-pitch]"); + const voltEl = row.querySelector("[data-row-voltage]"); + const tolEl = row.querySelector("[data-row-tolerance]"); + const powerEl = row.querySelector("[data-row-power]"); + const ppmEl = row.querySelector("[data-row-ppm]"); + const pkgEl = row.querySelector("[data-row-package]"); + const svg = calc.generateSvg(row.dataset.type, parseFloat(row.dataset.value), { + voltage: voltEl && voltEl.value ? parseFloat(voltEl.value) : (row.dataset.voltage ? parseFloat(row.dataset.voltage) : 0), + package: pkgEl && pkgEl.value ? pkgEl.value : (row.dataset.package || null), + power: powerEl && powerEl.value ? parseFloat(powerEl.value) : (row.dataset.power ? parseFloat(row.dataset.power) : 0), + ppm: ppmEl && ppmEl.value ? parseFloat(ppmEl.value) : (row.dataset.ppm ? parseFloat(row.dataset.ppm) : 0), + tolerance: tolEl + ? (tolEl.value ? parseFloat(tolEl.value) : null) + : (row.dataset.tolerance ? parseFloat(row.dataset.tolerance) : null), + pitch: pitchEl ? pitchEl.value : (row.dataset.pitch || null), + diameter: diamEl && diamEl.value ? parseFloat(diamEl.value) : (row.dataset.diameter ? parseFloat(row.dataset.diameter) : 0), + subtype: row.dataset.subtype || null, + marking: row.dataset.marking || null, + bodyColor: colorEl ? colorEl.value : null, + shape: this.hasBatchShapeTarget ? this.batchShapeTarget.value : "disc", + leadLength: this.hasBatchLeadTarget ? this.batchLeadTarget.value : "medium", + }); + row.dataset.svg = svg; + const cell = row.querySelector("[data-bulk-preview]"); + if (cell) { + cell.innerHTML = svg || ""; + } + //Clear the calculator's scratch SVG so its leftover copy can't collide (duplicate ids) + //with the copy we just placed in this row's cell — that made the last preview render off. + if (typeof calc.clearScratchSvg === "function") { + calc.clearScratchSvg(); + } + } + + /** Re-render every preview (used by batch controls and the shape/lead selects). */ + regenerate() { + const calc = this.calcController(); + if (calc) { + this.rowTargets.forEach((row) => this.renderPreview(row, calc)); + } + } + + /** Re-render only the row whose per-row appearance input changed. */ + regenerateRow(event) { + const calc = this.calcController(); + const row = event.target.closest("tr"); + if (calc && row) { + this.renderPreview(row, calc); + } + } + + /** Copy a batch-bar value into every row's matching input, then re-render. */ + applyToAll(selector, value) { + if (value === "" || value === null || value === undefined) { + return; + } + this.rowTargets.forEach((row) => { + const el = row.querySelector(selector); + if (el) { + el.value = value; + } + }); + this.regenerate(); + } + + applyColor() { + this.applyToAll("[data-row-color]", this.hasBatchColorTarget ? this.batchColorTarget.value : ""); + } + + applyDiameter() { + this.applyToAll("[data-row-diameter]", this.hasBatchDiameterTarget ? this.batchDiameterTarget.value : ""); + } + + applyPitch() { + this.applyToAll("[data-row-pitch]", this.hasBatchPitchTarget ? this.batchPitchTarget.value : ""); + } + + applyVoltage() { + this.applyToAll("[data-row-voltage]", this.hasBatchVoltageTarget ? this.batchVoltageTarget.value : ""); + } + + applyPower() { + this.applyToAll("[data-row-power]", this.hasBatchPowerTarget ? this.batchPowerTarget.value : ""); + } + + applyPpm() { + this.applyToAll("[data-row-ppm]", this.hasBatchPpmTarget ? this.batchPpmTarget.value : ""); + } + + applyPackage() { + this.applyToAll("[data-row-package]", this.hasBatchPackageTarget ? this.batchPackageTarget.value : ""); + } + + /** + * Applies the batch tolerance to every row that offers that option (tolerance choices differ + * between capacitors and resistors). "—" always applies, so it can also clear every row. + */ + applyTolerance() { + if (!this.hasBatchToleranceTarget) { + return; + } + const value = this.batchToleranceTarget.value; + this.rowTargets.forEach((row) => { + const el = row.querySelector("[data-row-tolerance]"); + if (el && (value === "" || Array.from(el.options).some((o) => o.value === value))) { + el.value = value; + } + }); + this.regenerate(); + } + + /** A body-colour quick-pick button: set the batch colour input, then apply it to all rows. */ + pickColor(event) { + if (this.hasBatchColorTarget) { + this.batchColorTarget.value = event.currentTarget.dataset.color; + } + this.applyColor(); + } + + /** Returns to the previous page (the parts list the action came from); the link's href is the no-JS fallback. */ + goBack(event) { + if (window.history.length > 1) { + event.preventDefault(); + window.history.back(); + } + } + + /** Header checkbox: check/uncheck every row. */ + toggleAll(event) { + const checked = event.currentTarget.checked; + this.rowTargets.forEach((row) => { + const cb = row.querySelector("input[type=checkbox]"); + if (cb) { + cb.checked = checked; + } + }); + } + + /** Attaches the generated picture of each checked row to its part. */ + attachSelected() { + return this.runBatch( + (row) => { + if (!(row.dataset.svg || "").includes(" { + //Prefer the values the user may have edited in the row's inputs; fall back to the suggestions. + const symInput = row.querySelector("[data-bulk-eda-symbol]"); + const refInput = row.querySelector("[data-bulk-eda-reference]"); + const fpInput = row.querySelector("[data-bulk-eda-footprint]"); + const body = new FormData(); + body.append("kicad_symbol", symInput ? symInput.value.trim() : (row.dataset.kicadSymbol || "")); + body.append("reference_prefix", refInput ? refInput.value.trim() : (row.dataset.referencePrefix || "")); + body.append("kicad_footprint", fpInput ? fpInput.value.trim() : (row.dataset.kicadFootprint || "")); + body.append("_token", row.dataset.edaCsrf || ""); + return {url: row.dataset.edaEndpoint, body}; + }, + trans("tools.bulk_gen.eda_written"), + this.hasEdaBtnTarget ? this.edaBtnTarget : null + ); + } + + /** + * Runs a POST for every checked row, updating the progress bar. buildRequest(row) returns + * {url, body} or null to skip the row. + */ + async runBatch(buildRequest, doneWord, btn) { + const rows = this.rowTargets.filter((row) => { + const cb = row.querySelector("input[type=checkbox]"); + return cb && cb.checked; + }); + if (rows.length === 0) { + AlertSwal.fire({title: trans("tools.value_calc.attach.nothing")}); + return; + } + + if (btn) { + btn.disabled = true; + } + if (this.hasProgressTarget) { + this.progressTarget.classList.remove("d-none"); + } + + let done = 0; + let ok = 0; + let failed = 0; + for (const row of rows) { + const req = buildRequest(row); + if (!req) { + done++; + this.updateProgress(done, rows.length); + continue; + } + try { + const resp = await fetch(req.url, {method: "POST", body: req.body, headers: {"X-Requested-With": "XMLHttpRequest"}}); + const data = await resp.json().catch(() => ({})); + if (resp.ok && data && data.success) { + ok++; + row.classList.add("table-success"); + } else { + failed++; + row.classList.add("table-danger"); + } + } catch (e) { + failed++; + row.classList.add("table-danger"); + } + done++; + this.updateProgress(done, rows.length); + } + + if (btn) { + btn.disabled = false; + } + AlertSwal.fire({ + title: `${ok} / ${rows.length} ${doneWord}${failed ? ` · ${failed} ${trans("tools.bulk_gen.failed")}` : ""}`, + icon: failed ? "warning" : "success", + }); + } + + updateProgress(done, total) { + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + if (this.hasProgressBarTarget) { + this.progressBarTarget.style.width = pct + "%"; + this.progressBarTarget.textContent = `${done}/${total}`; + } + } +} diff --git a/assets/controllers/pages/component_image_generator_controller.js b/assets/controllers/pages/component_image_generator_controller.js new file mode 100644 index 000000000..3d722ec82 --- /dev/null +++ b/assets/controllers/pages/component_image_generator_controller.js @@ -0,0 +1,2357 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Controller} from "@hotwired/stimulus"; +import {AlertSwal} from "../../helpers/swal"; +import {trans} from "../../translator.js"; +import '../../css/components/component_image_generator.css'; + +/** + * Color definitions for the resistor color code. + * digit: significant figure (null if the color can't be used for a digit band) + * multiplier: factor applied by a multiplier band + * tolerance: tolerance in percent (null if not usable as tolerance band) + * temp: temperature coefficient in ppm/K (null if not usable as temp. band) + * hex/text: colors used to draw the band and a readable label on top of it + */ +const RESISTOR_COLORS = { + black: {digit: 0, multiplier: 1e0, tolerance: null, temp: 250, hex: "#000000", text: "#ffffff"}, + brown: {digit: 1, multiplier: 1e1, tolerance: 1, temp: 100, hex: "#5c3a21", text: "#ffffff"}, + red: {digit: 2, multiplier: 1e2, tolerance: 2, temp: 50, hex: "#c8102e", text: "#ffffff"}, + orange: {digit: 3, multiplier: 1e3, tolerance: null, temp: 15, hex: "#f25c05", text: "#000000"}, + yellow: {digit: 4, multiplier: 1e4, tolerance: null, temp: 25, hex: "#f2c200", text: "#000000"}, + green: {digit: 5, multiplier: 1e5, tolerance: 0.5, temp: 20, hex: "#1a8f3c", text: "#ffffff"}, + blue: {digit: 6, multiplier: 1e6, tolerance: 0.25, temp: 10, hex: "#0a4ea3", text: "#ffffff"}, + violet: {digit: 7, multiplier: 1e7, tolerance: 0.1, temp: 5, hex: "#6a2c91", text: "#ffffff"}, + grey: {digit: 8, multiplier: 1e8, tolerance: 0.05, temp: 1, hex: "#808080", text: "#ffffff"}, + white: {digit: 9, multiplier: 1e9, tolerance: null, temp: null, hex: "#f5f5f5", text: "#000000"}, + gold: {digit: null, multiplier: 0.1, tolerance: 5, temp: null, hex: "#c2a000", text: "#000000"}, + silver: {digit: null, multiplier: 0.01, tolerance: 10, temp: null, hex: "#b3b3b3", text: "#000000"}, +}; + +// Capacitor tolerance letter codes (percent, or absolute in pF for small caps) +const CAP_TOLERANCE = { + B: "±0.10 pF", C: "±0.25 pF", D: "±0.5 pF", F: "±1%", G: "±2%", + J: "±5%", K: "±10%", M: "±20%", Z: "+80% / -20%", +}; + +// EIA-96 significant value lookup (code 01..96) +const EIA96_VALUES = [ + 100, 102, 105, 107, 110, 113, 115, 118, 121, 124, 127, 130, 133, 137, 140, 143, + 147, 150, 154, 158, 162, 165, 169, 174, 178, 182, 187, 191, 196, 200, 205, 210, + 215, 221, 226, 232, 237, 243, 249, 255, 261, 267, 274, 280, 287, 294, 301, 309, + 316, 324, 332, 340, 348, 357, 365, 374, 383, 392, 402, 412, 422, 432, 442, 453, + 464, 475, 487, 499, 511, 523, 536, 549, 562, 576, 590, 604, 619, 634, 649, 665, + 681, 698, 715, 732, 750, 768, 787, 806, 825, 845, 866, 887, 909, 931, 953, 976, +]; +const EIA96_MULTIPLIERS = { + Z: 0.001, Y: 0.01, R: 0.01, X: 0.1, S: 0.1, A: 1, B: 10, C: 100, D: 1000, E: 10000, F: 100000, +}; + +// Typical dimensions of axial THT resistors per power rating. +// len/dia = body length and diameter (mm), pitch = typical lead spacing (mm). +const RESISTOR_POWERS = { + "0.125": {label: "1/8 W", len: 3.4, dia: 1.9, pitch: 7.62, pitchIn: "0.3\""}, + "0.25": {label: "1/4 W", len: 6.3, dia: 2.4, pitch: 10.16, pitchIn: "0.4\""}, + "0.5": {label: "1/2 W", len: 9.0, dia: 3.2, pitch: 12.7, pitchIn: "0.5\""}, + "1": {label: "1 W", len: 11.5, dia: 4.5, pitch: 15.24, pitchIn: "0.6\""}, + "2": {label: "2 W", len: 15.5, dia: 5.0, pitch: 20.32, pitchIn: "0.8\""}, +}; + +// Standard SMD (chip) packages: imperial code -> metric code, size (mm), power (W). +const SMD_PACKAGES = { + "0201": {metric: "0603", l: 0.6, w: 0.3, power: 0.05}, + "0402": {metric: "1005", l: 1.0, w: 0.5, power: 0.063}, + "0603": {metric: "1608", l: 1.6, w: 0.8, power: 0.1}, + "0805": {metric: "2012", l: 2.0, w: 1.25, power: 0.125}, + "1206": {metric: "3216", l: 3.2, w: 1.6, power: 0.25}, + "1210": {metric: "3225", l: 3.2, w: 2.5, power: 0.33}, + "2010": {metric: "5025", l: 5.0, w: 2.5, power: 0.5}, + "2512": {metric: "6332", l: 6.3, w: 3.2, power: 1.0}, +}; + +// Common lead pitches for radial ceramic capacitors. +const CAP_PITCHES = { + "2.5": "0.1\"", + "2.54": "0.1\"", + "5": "0.2\"", + "5.08": "0.2\"", + "7.5": "", + "10": "", + "15": "", +}; + +// Lead length presets (extra pixels the leads extend below the body). +const CAP_LEAD_LENGTHS = {short: 48, medium: 84, long: 130}; + +// Axial resistor lead length presets (pixels each lead extends beyond the body; medium = original look). +const RESISTOR_LEAD_LENGTHS = {short: 40, medium: 90, long: 150}; + +const DIM_COLOR = "#6b7280"; + +export default class extends Controller { + static targets = [ + "resistorSvg", "bandSelects", "resistorResult", "resistorValueInput", "resistorBodyColor", + "resistorPower", "resistorSpec", + "capValueInput", "capCodeInput", "capTolerance", "capSvg", "capResult", "capBodyColor", + "capPitch", "capDiameter", "capVoltage", "capSpec", "capShape", "capLead", + "smdValueInput", "smdCode3", "smdCode4", "smdEia96", + "smdSvg", "smdResult", "smdBodyColor", "smdPackage", "smdSpec", + "smdIndValueInput", "smdIndCode", "smdIndPackage", "smdIndBodyColor", "smdIndSvg", "smdIndSpec", + "smdCapValueInput", "smdCapPackage", "smdCapBodyColor", "smdCapVoltage", "smdCapTolerance", "smdCapSvg", "smdCapSpec", + "indBandSelects", "indValueInput", "indSvg", "indResult", "indBodyColor", + "previewInput", + ]; + + static values = { + endpoint: String, + csrf: String, + prefillOhms: { type: Number, default: 0 }, + prefillFarads: { type: Number, default: 0 }, + }; + + connect() { + this.bandCount = 5; + this.renderBandSelects(); + this.updateCapSpec(); + + // When opened from a part, pre-fill with the part's detected resistance/capacitance; + // otherwise fall back to illustrative demo values so nothing starts empty. Each section + // is isolated so a failure in one can't block the others (and surfaces on screen). + const partOhms = this.prefillOhmsValue > 0 ? this.prefillOhmsValue : null; + const partFarads = this.prefillFaradsValue > 0 ? this.prefillFaradsValue : null; + + const resistorOhms = partOhms ?? 4700; + if (!this.setBandsFromValue(resistorOhms, 1) && this.hasResistorValueInputTarget) { + // Not representable as standard color bands: show it in the value input instead. + this.resistorValueInputTarget.value = this.formatOhms(resistorOhms); + } + this.updateResistor(); + + try { + this.smdMarking = "code3"; + // 10 kΩ demo has a clean code in every representation (103 / 1002 / 01C) so the + // EIA-96 field isn't "—" on first open, unlike an E24 value such as 4.7 kΩ. + this.smdOhms = partOhms ?? 10000; + this.setSmdFields(this.smdOhms, null); + this.redrawSmd(); + } catch (e) { + console.error("value_calc: SMD init failed", e); + if (this.hasSmdResultTarget) this.smdResultTarget.textContent = "error: " + e.message; + } + try { + this.capPf = (partFarads ?? 100e-9) * 1e12; + this.setCapFields(this.capPf, null); + this.redrawCap(); + } catch (e) { + console.error("value_calc: capacitor init failed", e); + if (this.hasCapResultTarget) this.capResultTarget.textContent = "error: " + e.message; + } + try { + //THT inductor colour-band tab: 4 bands read as µH, demo value 100 µH. + this.indBandCount = 4; + if (this.hasIndBandSelectsTarget) { + this.renderBandSelects(this.indBandSelectsTarget, this.indBandCount, "updateInductor"); + this.setBandsFromValue(100, 10, this.indBandSelectsTarget, this.indBandCount); + this.updateInductor(); + } + } catch (e) { + console.error("value_calc: THT inductor init failed", e); + } + try { + //The SMD inductor tab starts on an illustrative value so it isn't empty on first open. + this.syncSmdInductor(); + } catch (e) { + console.error("value_calc: SMD inductor init failed", e); + } + try { + this.syncSmdCap(); + } catch (e) { + console.error("value_calc: SMD capacitor init failed", e); + } + + // Jump to the tab matching the part's detected type. + if (partFarads !== null && partOhms === null) { + this.activateTab("vc-capacitor-tab"); + } else if (partOhms !== null) { + this.activateTab("vc-resistor-tab"); + } + } + + /** Activates a Bootstrap tab by its button id (no-op if unavailable). */ + activateTab(id) { + const btn = document.getElementById(id); + if (!btn) { + return; + } + try { + btn.click(); + } catch (e) { + /* ignore — the default tab is fine */ + } + } + + /** + * Public helper used by the bulk generator: renders the picture for the given component and + * returns its SVG markup (without any tab interaction). `type` is 'resistor', 'smd_resistor' + * or 'capacitor'; `value` is ohms (resistors) or farads (capacitors); `options` may carry + * {voltage, package, tolerance}. + */ + generateSvg(type, value, options = {}) { + try { + if (options.bodyColor) { + if (this.hasCapBodyColorTarget) { + this.capBodyColorTarget.value = options.bodyColor; + } + if (this.hasSmdBodyColorTarget) { + this.smdBodyColorTarget.value = options.bodyColor; + } + if (this.hasResistorBodyColorTarget) { + this.resistorBodyColorTarget.value = options.bodyColor; + } + } + if (type === "capacitor") { + if (this.hasCapDiameterTarget && options.diameter > 0) { + this.capDiameterTarget.value = String(options.diameter); + } + if (this.hasCapPitchTarget && options.pitch) { + this.capPitchTarget.value = String(options.pitch); + } + if (this.hasCapVoltageTarget) { + this.capVoltageTarget.value = options.voltage > 0 ? String(options.voltage) : ""; + } + if (this.hasCapShapeTarget && options.shape) { + this.capShapeTarget.value = options.shape; + } + if (this.hasCapLeadTarget && options.leadLength) { + this.capLeadTarget.value = options.leadLength; + } + if (this.hasCapToleranceTarget) { + this.capToleranceTarget.value = options.tolerance ? this.capToleranceLetterForPercent(options.tolerance) : ""; + } + this.updateCapSpec(); + this.capPf = value * 1e12; + this.setCapFields(this.capPf, null); + this.redrawCap(); + return this.hasCapSvgTarget ? this.capSvgTarget.innerHTML.trim() : ""; + } + if (type === "smd_resistor" || type === "smd") { + if (options.package && this.hasSmdPackageTarget) { + this.smdPackageTarget.value = options.package; + } + //On SMD resistors the tolerance is expressed by the marking system: 1% (or tighter) + //uses the 4-digit code, looser tolerances use the 3-digit code. Fall back to 3-digit + //if the 4-digit code can't represent the value. + const wants4 = options.tolerance != null && options.tolerance <= 1; + this.smdMarking = wants4 && this.ohmsTo4Digit(value) ? "code4" : "code3"; + this.smdOhms = value; + this.smdTolerance = options.tolerance; + this.smdVoltage = options.voltage; + this.setSmdFields(value, null); + this.redrawSmd(); + return this.hasSmdSvgTarget ? this.smdSvgTarget.innerHTML.trim() : ""; + } + if (type === "inductor") { + //The inductor colour code is the resistor code read as microhenries. + if (options.leadLength) { + this.resistorLead = options.leadLength; + } + const desiredBands = (options.tolerance != null && options.tolerance <= 2) ? 5 : 4; + if (this.hasBandSelectsTarget && this.bandCount !== desiredBands) { + this.bandCount = desiredBands; + this.renderBandSelects(); + } + this.setBandsFromValue(value / 1e-6, options.tolerance ?? 10); + this.drawInductor(this.selectedColors(), value, options.bodyColor, null, null, {tolerance: options.tolerance, voltage: options.voltage}); + return this.hasResistorSvgTarget ? this.resistorSvgTarget.innerHTML.trim() : ""; + } + if (type === "smd_inductor") { + //Molded/shielded SMD power inductor: the printed marking is the 3-digit EIA code in µH. + const marking = this.henriesToInductorCode(value / 1e-6); + const t = this.hasSmdIndSvgTarget ? this.smdIndSvgTarget : this.smdSvgTarget; + this.drawSmdInductor(t, marking, value, options); + return t ? t.innerHTML.trim() : ""; + } + if (type === "smd_capacitor") { + //MLCC chip: unmarked, value shown as a caption. `value` is farads. + this.smdCapPf = value * 1e12; + const t = this.hasSmdCapSvgTarget ? this.smdCapSvgTarget : this.capSvgTarget; + this.drawSmdCapacitor(t, {package: options.package || "0805", bodyColor: options.bodyColor, voltage: options.voltage, tolerance: options.tolerance}); + return t ? t.innerHTML.trim() : ""; + } + if (type === "diode") { + //Bulk-only type (no interactive tab): draws into the shared scratch target, like the inductor. + this.drawDiode(this.resistorSvgTarget, options.subtype || "diode", value, options); + return this.hasResistorSvgTarget ? this.resistorSvgTarget.innerHTML.trim() : ""; + } + // Resistor (through-hole colour bands) + this.resistorVoltage = options.voltage; //shown on the picture when the part lists a rated voltage + if (this.hasResistorPowerTarget && options.power) { + this.resistorPowerTarget.value = this.resistorPowerKey(options.power); + } + if (options.leadLength) { + this.resistorLead = options.leadLength; + } + //Band count follows real convention: 6 bands when a temp coefficient is given, + //5 bands for tight tolerance (≤2 %), otherwise 4 bands. + const desiredBands = options.ppm ? 6 : ((options.tolerance != null && options.tolerance <= 2) ? 5 : 4); + if (this.hasBandSelectsTarget && this.bandCount !== desiredBands) { + this.bandCount = desiredBands; + this.renderBandSelects(); + } + if (!this.setBandsFromValue(value, options.tolerance ?? 5) && this.hasResistorValueInputTarget) { + this.resistorValueInputTarget.value = this.formatOhms(value); + } + if (options.ppm && this.hasBandSelectsTarget) { + this.applyTempBand(options.ppm); + } + this.updateResistor(); + return this.hasResistorSvgTarget ? this.resistorSvgTarget.innerHTML.trim() : ""; + } catch (e) { + console.error("value_calc: generateSvg failed", e); + return ""; + } + } + + /** + * Empties the shared preview SVG targets. Bulk previews call this after copying each generated + * SVG into its own cell, so the last-rendered SVG isn't left here with an id that then collides + * with the copy in the (visible) cell — which made the last preview render unclipped. + */ + clearScratchSvg() { + if (this.hasCapSvgTarget) { + this.capSvgTarget.innerHTML = ""; + } + if (this.hasSmdSvgTarget) { + this.smdSvgTarget.innerHTML = ""; + } + if (this.hasResistorSvgTarget) { + this.resistorSvgTarget.innerHTML = ""; + } + if (this.hasSmdIndSvgTarget) { + this.smdIndSvgTarget.innerHTML = ""; + } + if (this.hasSmdCapSvgTarget) { + this.smdCapSvgTarget.innerHTML = ""; + } + } + + /** + * Posts the currently shown SVG of the chosen picture to the server so it gets attached to + * the part. Uses a background request so the modal can close without navigating away (which + * would otherwise trigger the browser's "unsaved changes" prompt and lose the edit form). + */ + attachToPart(event) { + const {svg, name} = this.activeSvgAndName(); + this.doAttach(svg, name, event.currentTarget); + } + + /** Reads the SVG markup and label of the currently active tab's preview picture. */ + activeSvgAndName() { + const active = this.element.querySelector(".tab-pane.active"); + if (!active) { + return {svg: "", name: ""}; + } + const containers = { + "vc-resistor": this.hasResistorSvgTarget ? this.resistorSvgTarget : null, + "vc-capacitor": this.hasCapSvgTarget ? this.capSvgTarget : null, + "vc-smd": this.hasSmdSvgTarget ? this.smdSvgTarget : null, + "vc-inductor": this.hasIndSvgTarget ? this.indSvgTarget : null, + "vc-smdind": this.hasSmdIndSvgTarget ? this.smdIndSvgTarget : null, + "vc-smdcap": this.hasSmdCapSvgTarget ? this.smdCapSvgTarget : null, + }; + const container = containers[active.id]; + const svg = container ? container.innerHTML.trim() : ""; + const name = active.dataset.vcName || "Generated image"; + return {svg, name}; + } + + /** Downloads the SVG shown in the currently active tab as a standalone .svg file. */ + downloadSvg() { + const {svg, name} = this.activeSvgAndName(); + if (!svg.includes("\n${svg}`; + const blob = new Blob([withHeader], {type: "image/svg+xml"}); + const url = URL.createObjectURL(blob); + const filename = `${(name || "component").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "component"}.svg`; + + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } + + /** Sends one SVG to the server to be attached to the part (background request, no navigation). */ + doAttach(svg, name, btn) { + if (!this.hasEndpointValue) { + return; + } + if (!svg.includes(" r.json().then((data) => ({ok: r.ok, data}))) + .then(({ok, data}) => { + if (btn) { + btn.disabled = false; + } + if (ok && data && data.success) { + this.finishAttach(data.message); + } else { + AlertSwal.fire({title: (data && data.message) || trans("tools.value_calc.invalid_input")}); + } + }) + .catch(() => { + if (btn) { + btn.disabled = false; + } + AlertSwal.fire({title: trans("tools.value_calc.invalid_input")}); + }); + } + + /** + * After a successful attach: close the generator modal (via its dismiss control, which works + * even when Bootstrap isn't exposed globally), then either reload the read-only part page so + * the new picture shows, or — on the edit form — just toast so unsaved changes aren't lost. + */ + finishAttach(message) { + const modalEl = document.getElementById("vcGenerateModal"); + if (modalEl) { + const dismiss = modalEl.querySelector("[data-bs-dismiss='modal']"); + if (dismiss) { + dismiss.click(); + } else { + window.bootstrap?.Modal?.getInstance(modalEl)?.hide(); + } + } + + // On the edit page, refresh just the attachment list via its Turbo frame: the new image + // shows and the form includes it (so orphanRemoval can't delete it on the next save) — + // without a full-page reload or the unsaved-changes prompt. Elsewhere (part info page) + // just reload so the new picture appears. + const frame = document.getElementById("part-attachments-frame"); + if (frame) { + if (frame.getAttribute("src") && typeof frame.reload === "function") { + frame.reload(); + } else { + frame.setAttribute("src", window.location.href.split("#")[0]); + } + AlertSwal.fire({title: message, icon: "success", timer: 2000, showConfirmButton: false}); + } else { + window.location.reload(); + } + } + + /* + * --------------------------------------------------------------- + * Resistor color code + * --------------------------------------------------------------- + */ + + changeBandCount(event) { + // Read the currently shown value BEFORE changing bandCount: computeResistance() reads + // this.bandCount against the still-old (not yet re-rendered) selects, so it must run while + // both are still in sync — otherwise the role count no longer matches the select count and + // the value is silently lost (bands reset to their defaults instead of being preserved). + const current = this.computeResistance(); + this.bandCount = parseInt(event.target.value, 10); + this.renderBandSelects(); + if (current && current.ohms > 0) { + this.setBandsFromValue(current.ohms, current.tolerance); + } + this.updateResistor(); + } + + /** Returns the list of band "roles" for the given band count (defaults to the resistor tab's). */ + bandRoles(count = this.bandCount) { + if (count === 4) { + return ["digit", "digit", "multiplier", "tolerance"]; + } + if (count === 6) { + return ["digit", "digit", "digit", "multiplier", "tolerance", "temp"]; + } + return ["digit", "digit", "digit", "multiplier", "tolerance"]; + } + + /** Colors that are valid for a given band role. */ + colorsForRole(role) { + return Object.keys(RESISTOR_COLORS).filter((name) => RESISTOR_COLORS[name][role] !== null); + } + + labelForRole(role) { + return { + digit: "tools.value_calc.resistor.band_digit", + multiplier: "tools.value_calc.resistor.band_multiplier", + tolerance: "tools.value_calc.resistor.band_tolerance", + temp: "tools.value_calc.resistor.band_temp", + }[role]; + } + + renderBandSelects(target = this.bandSelectsTarget, count = this.bandCount, action = "updateResistor") { + const roles = this.bandRoles(count); + let html = ""; + //Each band gets an ordinal prefix ("1st", "2nd", …) so the three identical "Digit" bands are + //no longer ambiguous — the number matches reading the physical part left-to-right. + roles.forEach((role, index) => { + const options = this.colorsForRole(role) + .map((name) => ``) + .join(""); + const col = roles.length >= 6 ? "col" : "col-sm"; + const label = `${this.ordinal(index + 1)} ${trans("tools.value_calc.band")} · ${trans(this.labelForRole(role))}`; + html += ` +
+ + +
`; + }); + target.innerHTML = html; + } + + /** English ordinal for a small band index (1 -> "1st", 2 -> "2nd", 3 -> "3rd", 4 -> "4th", …). */ + ordinal(n) { + if (n === 1) { + return "1st"; + } + if (n === 2) { + return "2nd"; + } + if (n === 3) { + return "3rd"; + } + return `${n}th`; + } + + /** + * Builds the " · 50 V · ±10%" spec suffix appended to the value printed on a generated picture, + * so the image also carries the rated voltage (caps) and tolerance when they are known. Parts + * that don't apply are simply omitted. + */ + specSuffix(opts = {}) { + let s = ""; + const v = opts.voltage !== undefined && opts.voltage !== null ? parseFloat(opts.voltage) : NaN; + if (Number.isFinite(v) && v > 0) { + s += ` · ${this.trimNumber(v)} V`; + } + const t = opts.tolerance !== undefined && opts.tolerance !== null && opts.tolerance !== "" ? parseFloat(opts.tolerance) : NaN; + if (Number.isFinite(t) && t > 0) { + s += ` · ±${this.trimNumber(t)}%`; + } + return s; + } + + colorLabel(name) { + return trans("tools.value_calc.color." + name); + } + + /** Reads the currently selected color of every band select in the given target. */ + selectedColorsFrom(target) { + return Array.from(target.querySelectorAll("select")).map((sel) => sel.value); + } + + /** Reads the currently selected color of every resistor band select. */ + selectedColors() { + return this.selectedColorsFrom(this.bandSelectsTarget); + } + + /** Reads a band-coded value (digits × multiplier), tolerance % and temp ppm from a selects target. */ + computeBandValue(target, count) { + const roles = this.bandRoles(count); + const colors = this.selectedColorsFrom(target); + if (colors.length !== roles.length) { + return null; + } + + let digits = ""; + let multiplier = 1; + let tolerance = null; + let temp = null; + + roles.forEach((role, i) => { + const color = RESISTOR_COLORS[colors[i]]; + if (role === "digit") { + digits += color.digit.toString(); + } else if (role === "multiplier") { + multiplier = color.multiplier; + } else if (role === "tolerance") { + tolerance = color.tolerance; + } else if (role === "temp") { + temp = color.temp; + } + }); + + return {value: parseInt(digits, 10) * multiplier, tolerance, temp}; + } + + computeResistance() { + const r = this.computeBandValue(this.bandSelectsTarget, this.bandCount); + return r === null ? null : {ohms: r.value, tolerance: r.tolerance, temp: r.temp}; + } + + updateResistor() { + const res = this.computeResistance(); + if (!res) { + return; + } + + let text = this.formatOhms(res.ohms); + if (res.tolerance !== null) { + text += ` ±${res.tolerance}%`; + } + if (res.temp !== null) { + text += ` · ${res.temp} ppm/K`; + } + if (this.hasResistorResultTarget) { + this.resistorResultTarget.textContent = text; + } + this.drawResistor(this.selectedColors()); + } + + /** + * Determine the band colors representing the given resistance and write + * them into the selects. + */ + setBandsFromValue(value, tolerance, target = this.bandSelectsTarget, count = this.bandCount) { + if (!(value > 0)) { + return false; + } + const numDigits = count === 4 ? 2 : 3; + + // Normalize the value into significant figures + power of ten + let exp = Math.floor(Math.log10(value)) - (numDigits - 1); + let digits = Math.round(value / Math.pow(10, exp)); + if (digits >= Math.pow(10, numDigits)) { + digits = Math.round(digits / 10); + exp += 1; + } + const multiplier = Math.pow(10, exp); + + // Find a color whose multiplier matches (within float tolerance) + const multiplierColor = Object.keys(RESISTOR_COLORS).find( + (name) => RESISTOR_COLORS[name].multiplier !== null + && Math.abs(RESISTOR_COLORS[name].multiplier - multiplier) < multiplier * 1e-6 + ); + if (!multiplierColor) { + // Value out of representable range + return false; + } + + const digitStr = digits.toString().padStart(numDigits, "0"); + const roles = this.bandRoles(count); + const selects = target.querySelectorAll("select"); + let digitIdx = 0; + roles.forEach((role, i) => { + if (role === "digit") { + selects[i].value = this.colorForDigit(parseInt(digitStr[digitIdx], 10)); + digitIdx += 1; + } else if (role === "multiplier") { + selects[i].value = multiplierColor; + } else if (role === "tolerance" && tolerance !== null && tolerance !== undefined) { + const tolColor = this.colorForTolerance(tolerance); + if (tolColor) { + selects[i].value = tolColor; + } + } + }); + return true; + } + + colorForDigit(digit) { + return Object.keys(RESISTOR_COLORS).find((name) => RESISTOR_COLORS[name].digit === digit); + } + + colorForTolerance(tolerance) { + return Object.keys(RESISTOR_COLORS).find( + (name) => RESISTOR_COLORS[name].tolerance === tolerance + ); + } + + /** Snaps a wattage to the nearest defined resistor power rating key (e.g. 0.3 -> "0.25"). */ + resistorPowerKey(watts) { + const keys = Object.keys(RESISTOR_POWERS).map(Number); + let best = keys[0]; + for (const k of keys) { + if (Math.abs(k - watts) < Math.abs(best - watts)) { + best = k; + } + } + return String(best); + } + + /** The band colour whose temperature coefficient is nearest to the given ppm/K value. */ + colorForTemp(ppm) { + let best = null; + let bestDelta = Infinity; + for (const name of Object.keys(RESISTOR_COLORS)) { + const t = RESISTOR_COLORS[name].temp; + if (t === null || t === undefined) { + continue; + } + const delta = Math.abs(t - ppm); + if (delta < bestDelta) { + bestDelta = delta; + best = name; + } + } + return best; + } + + /** Sets the temperature-coefficient band (6-band resistors) to the colour matching the ppm value. */ + applyTempBand(ppm) { + const roles = this.bandRoles(); + const selects = this.bandSelectsTarget.querySelectorAll("select"); + const tempColor = this.colorForTemp(ppm); + roles.forEach((role, i) => { + if (role === "temp" && tempColor && selects[i]) { + selects[i].value = tempColor; + } + }); + } + + applyResistorValue() { + const raw = this.resistorValueInputTarget.value; + const ohms = this.parseValue(raw, "R"); + if (ohms === null || !(ohms > 0)) { + this.resistorValueInputTarget.classList.add("is-invalid"); + return; + } + // Keep whatever tolerance is currently selected, default to 1% + const current = this.computeResistance(); + const tol = current && current.tolerance !== null ? current.tolerance : 1; + if (!this.setBandsFromValue(ohms, tol)) { + this.resistorValueInputTarget.classList.add("is-invalid"); + return; + } + this.resistorValueInputTarget.classList.remove("is-invalid"); + this.updateResistor(); + } + + applyResistorBodyColor(event) { + if (this.hasResistorBodyColorTarget) { + this.resistorBodyColorTarget.value = event.currentTarget.dataset.color; + } + this.updateResistor(); + } + + /* + * --------------------------------------------------------------- + * THT inductor colour code (interactive) — same bands as a resistor, read as µH. + * --------------------------------------------------------------- + */ + + changeIndBandCount(event) { + //Same ordering requirement as changeBandCount(): read the value while the (still-old) DOM + //and the (still-old) band count agree, before switching the count and re-rendering. + const current = this.computeBandValue(this.indBandSelectsTarget, this.indBandCount); + this.indBandCount = parseInt(event.target.value, 10); + this.renderBandSelects(this.indBandSelectsTarget, this.indBandCount, "updateInductor"); + if (current && current.value > 0) { + this.setBandsFromValue(current.value, current.tolerance, this.indBandSelectsTarget, this.indBandCount); + } + this.updateInductor(); + } + + /** Reads the inductor band colours, computes the µH value and redraws the barrel. */ + updateInductor() { + if (!this.hasIndSvgTarget) { + return; + } + const r = this.computeBandValue(this.indBandSelectsTarget, this.indBandCount); + if (!r) { + return; + } + const henries = r.value * 1e-6; //the band value is read in microhenries + let text = this.formatHenries(henries); + if (r.tolerance !== null) { + text += ` ±${r.tolerance}%`; + } + if (this.hasIndResultTarget) { + this.indResultTarget.textContent = text; + } + const color = this.hasIndBodyColorTarget ? this.indBodyColorTarget.value : null; + this.drawInductor(this.selectedColorsFrom(this.indBandSelectsTarget), henries, color, this.indSvgTarget, "medium", {tolerance: r.tolerance}); + } + + /** Sets the inductor bands from a typed inductance (bare number = µH; accepts nH/µH/mH/H). */ + applyInductorValue() { + const raw = (this.hasIndValueInputTarget ? this.indValueInputTarget.value : "").trim(); + const m = raw.match(/^([\d.]+)\s*(p|n|u|µ|m)?\s*h?$/i); + if (!m) { + if (this.hasIndValueInputTarget) { + this.indValueInputTarget.classList.add("is-invalid"); + } + return; + } + const num = parseFloat(m[1]); + const factors = {p: 1e-12, n: 1e-9, u: 1e-6, "µ": 1e-6, m: 1e-3}; + const henries = m[2] ? num * factors[m[2].toLowerCase()] : num * 1e-6; //bare number = µH + const uH = henries / 1e-6; + const current = this.computeBandValue(this.indBandSelectsTarget, this.indBandCount); + const tol = current && current.tolerance !== null ? current.tolerance : 10; + if (!(uH > 0) || !this.setBandsFromValue(uH, tol, this.indBandSelectsTarget, this.indBandCount)) { + if (this.hasIndValueInputTarget) { + this.indValueInputTarget.classList.add("is-invalid"); + } + return; + } + if (this.hasIndValueInputTarget) { + this.indValueInputTarget.classList.remove("is-invalid"); + } + this.updateInductor(); + } + + applyIndBodyColor(event) { + if (this.hasIndBodyColorTarget) { + this.indBodyColorTarget.value = event.currentTarget.dataset.color; + } + this.updateInductor(); + } + + /** Draws a 3D-shaded axial resistor SVG with bands and dimension callouts. */ + drawResistor(colors) { + const uid = this.svgId(); + const margin = 6; + const leadExt = RESISTOR_LEAD_LENGTHS[this.resistorLeadValue()] ?? RESISTOR_LEAD_LENGTHS.medium; + const bodyW = 208; + const bodyH = 66; + const bodyX = margin + leadExt; + const width = bodyW + 2 * (margin + leadExt); + const height = 205; + const cy = 60; + const bodyY = cy - bodyH / 2; + const bodyBottom = bodyY + bodyH; + + // Distribute the bands across the body, leaving the tolerance band set apart + const n = colors.length; + const bandW = 16; + const leftPad = 22; + const rightPad = 32; // extra gap before the tolerance band + const usable = bodyW - leftPad - rightPad; + const step = usable / (n - 1); + + let bands = ""; + colors.forEach((name, i) => { + const c = RESISTOR_COLORS[name]; + // Put the last band (tolerance/temp) towards the right end + let x = bodyX + leftPad + i * step; + if (i === n - 1) { + x = bodyX + bodyW - rightPad + 8; + } + bands += ``; + }); + + const body = this.safeColor(this.bodyColor(this.hasResistorBodyColorTarget ? this.resistorBodyColorTarget : null, "#d8c7a0"), "#d8c7a0"); + const dim = RESISTOR_POWERS[this.resistorPowerValue()]; + //The bands are the "real" value encoding, but printing the decoded value too (like every + //other drawing in this tool) makes the picture self-explanatory on its own. + const res = this.computeResistance(); + const resistanceLabel = res ? this.formatOhms(res.ohms) + this.specSuffix({voltage: this.resistorVoltage, tolerance: res.tolerance}) : ""; + const callouts = + this.dimH(bodyX, bodyX + bodyW, bodyBottom + 16, `L ${this.formatMm(dim.len)}`) + + this.dimH(margin + 4, width - margin - 4, height - 34, `pitch ${this.formatMm(dim.pitch)} (${dim.pitchIn})`) + + this.dimV(bodyY, bodyBottom, bodyX + bodyW + 28, `⌀ ${this.formatMm(dim.dia)}`, bodyX + bodyW) + + (resistanceLabel ? `${resistanceLabel}` : ""); + + const svg = ` + + + ${this.leadGradient(uid)} + ${this.cylinderGradient(uid)} + ${this.endVignetteGradient(uid)} + ${this.blurFilter(uid)} + + ${this.shadowFilter(uid)} + + + + + + ${bands} + + + + + + + ${callouts} + `; + this.resistorSvgTarget.innerHTML = svg; + + if (this.hasResistorSpecTarget) { + this.resistorSpecTarget.textContent = + `${dim.label} · ${this.formatMm(dim.len)} × ⌀${this.formatMm(dim.dia)} · pitch ${this.formatMm(dim.pitch)} (${dim.pitchIn})`; + } + } + + /** + * Draws a molded axial inductor: the same colour-band cylinder as a resistor, but a fatter + * green body and a henry value label. The bands are set by the shared resistor band engine + * (the inductor colour code is identical, read as microhenries). + */ + drawInductor(colors, henries, bodyColorOverride, target = null, leadKey = null, spec = {}) { + const tgt = target || this.resistorSvgTarget; + const uid = this.svgId(); + const margin = 6; + const leadExt = RESISTOR_LEAD_LENGTHS[leadKey || this.resistorLeadValue()] ?? RESISTOR_LEAD_LENGTHS.medium; + const bodyW = 168; + const bodyH = 78; + const bodyX = margin + leadExt; + const width = bodyW + 2 * (margin + leadExt); + const height = 196; + const cy = 62; + const bodyY = cy - bodyH / 2; + const bodyBottom = bodyY + bodyH; + + const n = colors.length; + const bandW = 16; + const leftPad = 22; + const rightPad = 32; + const usable = bodyW - leftPad - rightPad; + const step = usable / (n - 1); + let bands = ""; + colors.forEach((name, i) => { + const c = RESISTOR_COLORS[name]; + let x = bodyX + leftPad + i * step; + if (i === n - 1) { + x = bodyX + bodyW - rightPad + 8; + } + bands += ``; + }); + + const colorTarget = bodyColorOverride ? {value: bodyColorOverride} : (this.hasResistorBodyColorTarget ? this.resistorBodyColorTarget : null); + const body = this.safeColor(this.bodyColor(colorTarget, "#2f6f4c"), "#2f6f4c"); + //A colour-coded THT inductor's physical size isn't implied by its inductance, so we don't + //draw a (fake) dimension callout here — just the decoded value below the barrel. + const callouts = + `${this.formatHenries(henries)}${this.specSuffix(spec)}`; + + tgt.innerHTML = ` + + + ${this.leadGradient(uid)} + ${this.cylinderGradient(uid)} + ${this.endVignetteGradient(uid)} + ${this.blurFilter(uid)} + + ${this.shadowFilter(uid)} + + + + + + ${bands} + + + + + + + ${callouts} + `; + } + + /** Human-readable inductance: nH / µH / mH / H. */ + formatHenries(h) { + if (h >= 1) { + return `${this.trimNumber(h)} H`; + } + if (h >= 1e-3) { + return `${this.trimNumber(h / 1e-3)} mH`; + } + if (h >= 1e-6) { + return `${this.trimNumber(h / 1e-6)} µH`; + } + return `${this.trimNumber(h / 1e-9)} nH`; + } + + /** + * The marking printed on an SMD inductor, read in microhenries: R-notation below 10 µH + * (4.7 -> 4R7, 0.47 -> R47) and the 3-digit EIA code from 10 µH upwards (100 -> 101, 22 -> 220). + */ + henriesToInductorCode(uH) { + if (!(uH > 0)) { + return ""; + } + if (uH < 10) { + let s = parseFloat(uH.toFixed(2)).toString(); + if (!s.includes(".")) { + s += ".0"; + } + return s.startsWith("0.") ? "R" + s.slice(2) : s.replace(".", "R"); + } + let exp = Math.floor(Math.log10(uH)) - 1; + let significant = Math.round(uH / Math.pow(10, exp)); + if (significant >= 100) { + significant = Math.round(significant / 10); + exp += 1; + } + if (exp < 0) { + exp = 0; + } + return significant.toString().padStart(2, "0") + exp.toString(); + } + + /** + * Draws a molded / shielded SMD power inductor: a dark rounded ferrite block with a soft domed + * highlight, metal end terminations and the printed µH marking. Sized from the chip package. + */ + drawSmdInductor(target, marking, henries, options = {}) { + const uid = this.svgId(); + const w = 300; + const pkgKey = SMD_PACKAGES[options.package] ? options.package : "1210"; + const pkg = SMD_PACKAGES[pkgKey]; + + //Simple top-down chip with the value code printed on it (same style as the SMD resistor); the + //body follows the package L:W ratio so a 1210 looks square and a 0402 a 2:1 rectangle. + const bodyW = Math.round(122 + 66 * (pkg.l - 0.6) / (6.3 - 0.6)); + const aspect = pkg.l / pkg.w; + const bodyH = Math.max(48, Math.min(140, Math.round(bodyW / aspect))); + const capW = Math.max(14, Math.round(bodyW * 0.14)); + const cx = w / 2; + const bodyX = Math.round(cx - bodyW / 2); + const bodyY = Math.round(84 - bodyH / 2); + const bodyBottom = bodyY + bodyH; + const cy = bodyY + bodyH / 2; + const innerX = bodyX + capW; + const innerW = bodyW - 2 * capW; + + const fill = this.safeColor(options.bodyColor, "#33363d"); + const textColor = this.contrastColor(fill); + const fontSize = Math.max(15, Math.min(38, Math.round(bodyH * 0.5), Math.round(innerW * 1.6 / Math.max(3, marking.length)))); + + const callouts = + this.dimH(bodyX, bodyX + bodyW, bodyBottom + 18, `L ${this.formatMm(pkg.l)}`) + + this.dimV(bodyY, bodyBottom, bodyX + bodyW + 16, `W ${this.formatMm(pkg.w)}`, bodyX + bodyW); + + const h = bodyBottom + 54; + const valueCaption = `${this.formatHenries(henries)}${this.specSuffix(options)}`; + + target.innerHTML = ` + + + ${this.metalGradient(uid)} + ${this.glossGradient(uid)} + + ${this.shadowFilter(uid)} + + + + + + + + + + ${this.escapeXml(marking)} + + ${callouts} + ${valueCaption} + `; + + if (options.specEl) { + options.specEl.textContent = + `${pkgKey} (${pkg.metric}) · ${this.formatMm(pkg.l)} × ${this.formatMm(pkg.w)} · ${this.formatHenries(henries)}`; + } + } + + /** + * Draws a diode. LEDs become a coloured 5 mm dome (long lead = anode, short lead + flat = + * cathode); every other kind (rectifier / Zener / Schottky / TVS) becomes an axial body with a + * cathode band. The kind only changes colour/caption — diode markings aren't standardised. + */ + drawDiode(target, subtype, voltage, options = {}) { + if (subtype === "led") { + this.drawLed(target, this.safeColor(options.bodyColor || options.color, "#c0392b")); + return; + } + this.drawAxialDiode(target, subtype, voltage, options); + } + + /** + * Axial diode: a dark glass/epoxy body with a light cathode band near one end and two leads. + * A recognised part marking (e.g. "1N4001") is printed lengthwise on the body itself, like a + * real diode — otherwise a small caption below the leads shows the voltage or the diode kind. + */ + drawAxialDiode(target, subtype, voltage, options = {}) { + const uid = this.svgId(); + const margin = 6; + const leadExt = 74; + const bodyW = 150; + const bodyH = 64; + const bodyX = margin + leadExt; + const width = bodyW + 2 * (margin + leadExt); + const cy = 58; + const bodyY = cy - bodyH / 2; + const bodyBottom = bodyY + bodyH; + + const body = this.safeColor(options.bodyColor, "#20242a"); + //Cathode band (the stripe marking the "line" side of the diode symbol), near the right end. + const bandW = 15; + const bandX = bodyX + bodyW - 34; + + const marking = options.marking || null; + const hasVoltage = voltage && voltage > 0; + const labels = {diode: "Diode", zener: "Zener", schottky: "Schottky", tvs: "TVS"}; + //Below the body we show: the voltage (if known — e.g. Zener/TVS), else — when there's no + //part-number marking on the body — the diode kind. So voltage is shown whenever we have it. + const caption = hasVoltage ? `${this.trimNumber(voltage)} V` : (marking ? "" : (labels[subtype] || "Diode")); + + //Compact canvas only when the body carries a marking AND there's no caption to fit below. + const height = (marking && caption === "") ? bodyBottom + bodyY : bodyBottom + 30; + const textColor = this.contrastColor(body); + const markingFontSize = marking + ? Math.max(11, Math.min(18, Math.round((bodyW - 8) * 1.7 / Math.max(4, marking.length)))) + : 0; + const bodyMarking = marking + ? `${this.escapeXml(marking)}` + : ""; + const belowCaption = caption === "" + ? "" + : `${caption}`; + + target.innerHTML = ` + + + ${this.leadGradient(uid)} + ${this.cylinderGradient(uid)} + ${this.endVignetteGradient(uid)} + ${this.blurFilter(uid)} + + ${this.shadowFilter(uid)} + + + + + + + + + + ${bodyMarking} + + + + ${belowCaption} + `; + } + + /** 5 mm through-hole LED: a coloured epoxy dome with a reflector cup, specular highlight and legs. */ + drawLed(target, color) { + const uid = this.svgId(); + const width = 200; + const height = 232; + const cx = 100; + const domeR = 50; + const domeTopY = 26; + const sidesTopY = domeTopY + domeR; + const rimY = 150; + const left = cx - domeR; + const right = cx + domeR; + const flangeTop = rimY; + const flangeH = 16; + const flangeBottom = rimY + flangeH; + const flangeL = 44; + const flangeR = 156; + + const anodeX = cx - 18; + const cathodeX = cx + 18; + const leadTop = flangeBottom - 2; + const anodeBottom = height - 30; + const cathodeBottom = height - 50; + + //Rounded-top body: straight sides up to a hemisphere. + const bodyPath = `M ${left} ${rimY} L ${left} ${sidesTopY} A ${domeR} ${domeR} 0 0 1 ${right} ${sidesTopY} L ${right} ${rimY} Z`; + //Flange: rounded on the anode (left) side, flat on the cathode (right) side. + const flangePath = `M ${flangeL + 6} ${flangeTop} L ${flangeR} ${flangeTop} L ${flangeR} ${flangeBottom} L ${flangeL + 6} ${flangeBottom} Q ${flangeL} ${flangeBottom} ${flangeL} ${flangeBottom - 6} L ${flangeL} ${flangeTop + 6} Q ${flangeL} ${flangeTop} ${flangeL + 6} ${flangeTop} Z`; + + target.innerHTML = ` + + + ${this.leadGradient(uid)} + + + + + + ${this.blurFilter(uid)} + ${this.shadowFilter(uid)} + + + + + + + + + + + + LED + `; + } + + resistorPowerValue() { + const v = this.hasResistorPowerTarget ? this.resistorPowerTarget.value : "0.25"; + return RESISTOR_POWERS[v] ? v : "0.25"; + } + + resistorLeadValue() { + const v = (this.hasResistorLeadTarget ? this.resistorLeadTarget.value : null) || this.resistorLead || "medium"; + return Object.prototype.hasOwnProperty.call(RESISTOR_LEAD_LENGTHS, v) ? v : "medium"; + } + + /* + * --------------------------------------------------------------- + * Capacitor code + * --------------------------------------------------------------- + */ + + /** Recomputes the linked value/code fields (and the picture) from whichever was edited. */ + syncCap(event) { + const field = event.currentTarget.dataset.field; + const raw = event.currentTarget.value; + let pf = null; + if (field === "value") { + const farads = this.parseValue(raw, "F"); + pf = farads === null ? null : farads * 1e12; + } else { + // Split off an optional trailing tolerance letter (e.g. the K in 104K) and, + // if it is a known code, reflect it in the tolerance selector. + const up = raw.trim().toUpperCase(); + const letterMatch = up.match(/^([0-9R]+)([A-Z])$/); + const body = letterMatch ? letterMatch[1] : up; + if (letterMatch && this.hasCapToleranceTarget && CAP_TOLERANCE[letterMatch[2]]) { + this.capToleranceTarget.value = letterMatch[2]; + } + pf = this.capCodeToPf(body); + } + if (pf === null || !(pf > 0)) { + event.currentTarget.classList.add("is-invalid"); + this.capPf = null; + if (this.hasCapResultTarget) { + this.capResultTarget.textContent = raw.trim() === "" ? "" : trans("tools.value_calc.invalid_input"); + } + this.capSvgTarget.innerHTML = ""; + return; + } + event.currentTarget.classList.remove("is-invalid"); + this.capPf = pf; + this.setCapFields(pf, field); + this.redrawCap(); + } + + /** Writes the value/code fields from a capacitance in pF (skips the field being edited). */ + setCapFields(pf, except) { + if (except !== "value" && this.hasCapValueInputTarget) { + this.capValueInputTarget.value = this.formatFarads(pf); + this.capValueInputTarget.classList.remove("is-invalid"); + } + if (except !== "code" && this.hasCapCodeInputTarget) { + const code = this.pfToCapCode(pf); + this.capCodeInputTarget.value = code ?? ""; + this.capCodeInputTarget.classList.remove("is-invalid"); + } + } + + /** Draws the capacitor picture (the printed code) plus the value/spec/tolerance text. */ + redrawCap() { + if (this.capPf === null || this.capPf === undefined || !(this.capPf > 0)) { + return; + } + const code = this.pfToCapCode(this.capPf); + const letter = this.hasCapToleranceTarget ? this.capToleranceTarget.value : ""; + let text = `${this.formatFarads(this.capPf)} (${this.formatFarads(this.capPf, true)})`; + const tol = this.capToleranceText(); + if (tol !== "") { + text += ` · ${trans("tools.value_calc.tolerance")}: ${tol}`; + } + if (this.hasCapResultTarget) { + this.capResultTarget.textContent = text; + } + //Real caps print the tolerance letter right after the code (e.g. "104K"). + const marking = code ? (letter ? code + letter : code) : this.formatFarads(this.capPf); + this.drawCapacitor(this.capSvgTarget, marking); + } + + /** Maps a tolerance percentage (or small-cap pF value) to its capacitor letter code. */ + capToleranceLetterForPercent(p) { + const map = {0.1: "B", 0.25: "C", 0.5: "D", 1: "F", 2: "G", 5: "J", 10: "K", 20: "M"}; + return map[p] || ""; + } + + /** Human-readable tolerance for the selected capacitor tolerance letter, or "". */ + capToleranceText() { + const letter = this.hasCapToleranceTarget ? this.capToleranceTarget.value : ""; + return letter && CAP_TOLERANCE[letter] ? CAP_TOLERANCE[letter] : ""; + } + + /** + * Converts a printed ceramic/film capacitor code into picofarads. + * Supports R-notation (4R7 = 4.7 pF), plain 1-2 digit values (47 = 47 pF) + * and the 3-digit EIA code (104 = 100 nF, with 8/9 as ×0.01/×0.1). + * Returns null when the code can't be parsed. + */ + capCodeToPf(code) { + if (/^\d*R\d*$/.test(code) && code.includes("R")) { + // R-notation, e.g. 4R7 = 4.7 pF, R47 = 0.47 pF + const val = parseFloat(code.replace("R", ".")); + return Number.isNaN(val) ? null : val; + } + if (/^\d{1,2}$/.test(code)) { + // Plain value directly in pF (typical for caps below 100 pF) + return parseInt(code, 10); + } + if (/^\d{3}$/.test(code)) { + const significant = parseInt(code.substring(0, 2), 10); + const mult = parseInt(code.charAt(2), 10); + if (mult === 8) { + return significant * 0.01; + } + if (mult === 9) { + return significant * 0.1; + } + return significant * Math.pow(10, mult); + } + return null; + } + + + /** + * Returns the marking that is typically printed on a ceramic capacitor for + * the given value in picofarads: R-notation below 10 pF, the plain value + * for 10-99 pF, and the 3-digit EIA code from 100 pF upwards. + */ + pfToCapCode(pf) { + if (pf < 10) { + // R-notation, e.g. 4.7 -> 4R7, 0.47 -> R47 + const s = parseFloat(pf.toFixed(2)).toString(); + if (Number.isInteger(pf)) { + return s; + } + return s.startsWith("0.") ? "R" + s.slice(2) : s.replace(".", "R"); + } + // Plain value only fits 10-99 pF; values that round up to 100 must use the EIA code below (100 pF -> "101"). + if (Math.round(pf) < 100) { + return Math.round(pf).toString(); + } + // Two significant figures + power-of-ten multiplier digit + let exp = Math.floor(Math.log10(pf)) - 1; + let significant = Math.round(pf / Math.pow(10, exp)); + if (significant >= 100) { + significant = Math.round(significant / 10); + exp += 1; + } + if (exp < 0 || exp > 7) { + return null; + } + return significant.toString().padStart(2, "0") + exp.toString(); + } + + /** Draws a ceramic capacitor (radial disc or dipped MLCC blob) with marking and callouts. */ + drawCapacitor(target, marking) { + const uid = this.svgId(); + const shape = this.capShapeValue(); + const diam = this.capDiameterValue(); + const pitch = this.capPitchValue(); + const pitchIn = CAP_PITCHES[pitch]; + const voltage = this.capVoltageValue(); + const fill = this.safeColor(this.bodyColor(this.hasCapBodyColorTarget ? this.capBodyColorTarget : null, "#e0a63a"), "#e0a63a"); + const textColor = this.contrastColor(fill); + const shadow = textColor === "#f5f5f5" ? "#00000088" : "#ffffff66"; + + const W = 230; + const cx = W / 2; + const pxPerMm = 7; + const topMargin = 34; + // The body grows with the chosen diameter, within sensible visual bounds. + const r = Math.max(42, Math.min(96, 52 + (diam - 5) * 4)); + const cy = topMargin + r; + const pitchPx = Math.max(16, parseFloat(pitch) * pxPerMm); + const leadX1 = cx - pitchPx / 2; + const leadX2 = cx + pitchPx / 2; + const leadExtra = CAP_LEAD_LENGTHS[this.capLeadValue()] ?? CAP_LEAD_LENGTHS.medium; + + // Body outline + highlight geometry for the chosen shape. + let bodyPath, bodyBottom, gloss, spec, botShadow, topDip, textCy; + if (shape === "blob") { + // Multilayer (MLCC) style: a tall, dipped rounded body. + const bw = r * 1.5; + const bh = r * 1.95; + const bx = cx - bw / 2; + const by = topMargin; + const k = bw * 0.44; + bodyBottom = by + bh; + bodyPath = + `M ${bx} ${by + k} Q ${bx} ${by} ${bx + k} ${by} L ${bx + bw - k} ${by} ` + + `Q ${bx + bw} ${by} ${bx + bw} ${by + k} L ${bx + bw} ${bodyBottom - k} ` + + `Q ${bx + bw} ${bodyBottom} ${bx + bw - k} ${bodyBottom} L ${bx + k} ${bodyBottom} ` + + `Q ${bx} ${bodyBottom} ${bx} ${bodyBottom - k} Z`; + gloss = {cx, cy: by + bh * 0.26, rx: bw * 0.4, ry: bh * 0.22}; + spec = {cx: cx - bw * 0.22, cy: by + bh * 0.16, rx: 13, ry: 7}; + botShadow = {cx, cy: bodyBottom - bh * 0.1, rx: bw * 0.42, ry: bh * 0.12}; + topDip = {cx, cy: by + 3, rx: bw * 0.18, ry: 7}; + textCy = by + bh * 0.42; + } else { + // Radial disc: a near-full circle whose bottom tapers *inward* to the two lead exits, + // with a small dip between the leads. The shoulder is always kept wider than the lead + // roots so the taper never bulges out past the circle. + const shoulderHalf = Math.min(r - 3, Math.max(0.6 * r, pitchPx / 2 + 14)); + const shoulderY = cy + Math.sqrt(Math.max(0, r * r - shoulderHalf * shoulderHalf)); + const rt = {x: cx + shoulderHalf, y: shoulderY}; + const lt = {x: cx - shoulderHalf, y: shoulderY}; + const rRoot = Math.min(leadX2 + 4, rt.x - 2); + const lRoot = Math.max(leadX1 - 4, lt.x + 2); + bodyBottom = cy + r + Math.max(6, r * 0.1); + const notchY = bodyBottom - Math.max(7, r * 0.13); + const drop = bodyBottom - shoulderY; + bodyPath = + `M ${lt.x} ${lt.y} ` + + `A ${r} ${r} 0 1 1 ${rt.x} ${rt.y} ` + + `C ${rt.x} ${shoulderY + drop * 0.5} ${rRoot + 4} ${bodyBottom - drop * 0.28} ${rRoot} ${bodyBottom} ` + + `Q ${cx + (rRoot - cx) * 0.5} ${bodyBottom} ${cx} ${notchY} ` + + `Q ${cx - (rRoot - cx) * 0.5} ${bodyBottom} ${lRoot} ${bodyBottom} ` + + `C ${lRoot - 4} ${bodyBottom - drop * 0.28} ${lt.x} ${shoulderY + drop * 0.5} ${lt.x} ${lt.y} Z`; + gloss = {cx, cy: cy - r * 0.28, rx: r * 0.72, ry: r * 0.34}; + spec = {cx: cx - r * 0.26, cy: cy - r * 0.44, rx: r * 0.16, ry: r * 0.09}; + botShadow = {cx, cy: bodyBottom - r * 0.14, rx: r * 0.6, ry: r * 0.2}; + topDip = {cx, cy: cy - r + 5, rx: r * 0.14, ry: 6}; + textCy = cy - r * 0.04; + } + + // Marking text (capacitance code) with an optional printed voltage line below it. + const baseFont = Math.round(Math.max(20, Math.min(40, r * 0.52))); + const codeFont = marking.length > 4 ? Math.round(baseFont * 0.8) : baseFont; + const codeY = voltage ? textCy - codeFont * 0.42 : textCy; + const voltFont = Math.round(codeFont * 0.55); + const voltageSvg = voltage + ? `${voltage}V` + : ""; + + // Leads. + const leadTop = bodyBottom - 6; + const leadEnd = bodyBottom + leadExtra; + const leadW = 3.4; + const leads = + `` + + ``; + + const H = Math.ceil(leadEnd + 22); + const pitchLabel = pitchIn ? `pitch ${this.formatMm(parseFloat(pitch))} (${pitchIn})` : `pitch ${this.formatMm(parseFloat(pitch))}`; + const callouts = + this.dimH(cx - r, cx + r, topMargin - 12, `⌀ ${this.formatMm(diam)}`) + + this.dimH(leadX1, leadX2, H - 10, pitchLabel); + + //The body shows the printed code (and voltage); add the decoded capacitance + tolerance below. + const capTol = this.capToleranceText(); + const valueLabel = this.capPf > 0 ? this.formatFarads(this.capPf) + (capTol ? ` · ${capTol}` : "") : ""; + const totalH = H + (valueLabel ? 24 : 0); + const valueCaption = valueLabel + ? `${valueLabel}` + : ""; + + const svg = ` + + + ${this.leadGradient(uid)} + ${this.blurFilter(uid)} + + + + + + + + + ${leads} + + + + + + + + + + ${this.escapeXml(marking)} + ${voltageSvg} + + ${callouts} + ${valueCaption} + `; + target.innerHTML = svg; + this.updateCapSpec(); + } + + capShapeValue() { + const v = this.hasCapShapeTarget ? this.capShapeTarget.value : "disc"; + return v === "blob" ? "blob" : "disc"; + } + + capLeadValue() { + const v = this.hasCapLeadTarget ? this.capLeadTarget.value : "medium"; + return Object.prototype.hasOwnProperty.call(CAP_LEAD_LENGTHS, v) ? v : "medium"; + } + + capPitchValue() { + const v = this.hasCapPitchTarget ? this.capPitchTarget.value : "5.08"; + return CAP_PITCHES[v] !== undefined ? v : "5.08"; + } + + capDiameterValue() { + const v = this.hasCapDiameterTarget ? parseFloat(this.capDiameterTarget.value) : NaN; + return Number.isFinite(v) && v > 0 ? v : 5; + } + + capVoltageValue() { + const v = this.hasCapVoltageTarget ? this.capVoltageTarget.value.trim() : ""; + return /^\d+(\.\d+)?$/.test(v) ? v : ""; + } + + updateCapSpec() { + if (!this.hasCapSpecTarget) { + return; + } + const pitch = this.capPitchValue(); + const pitchIn = CAP_PITCHES[pitch]; + let spec = `⌀ ${this.formatMm(this.capDiameterValue())} · pitch ${this.formatMm(parseFloat(pitch))}${pitchIn ? ` (${pitchIn})` : ""}`; + const voltage = this.capVoltageValue(); + if (voltage) { + spec += ` · ${voltage} V`; + } + this.capSpecTarget.textContent = spec; + } + + /** Re-renders the capacitor picture when the body color changes. */ + updateCapacitorColor() { + this.redrawCap(); + } + + /** Updates the spec line and re-renders the capacitor picture. */ + updateCapDimensions() { + this.updateCapSpec(); + this.redrawCap(); + } + + applyCapBodyColor(event) { + if (this.hasCapBodyColorTarget) { + this.capBodyColorTarget.value = event.currentTarget.dataset.color; + } + this.redrawCap(); + } + + /** Unique id prefix per drawn SVG, so gradient/filter ids never collide. */ + svgId() { + this.svgSeq = (this.svgSeq || 0) + 1; + return `vc${this.svgSeq}_`; + } + + /** Vertical metallic gradient used for component leads. */ + leadGradient(uid) { + return ` + + + + + `; + } + + /** Vertical metallic gradient for SMD terminations. */ + metalGradient(uid) { + return ` + + + + `; + } + + /** Top-light / bottom-dark overlay that turns a flat shape into a cylinder. */ + cylinderGradient(uid) { + return ` + + + + + + `; + } + + /** Softer top-gloss overlay for caps and SMD bodies. */ + glossGradient(uid) { + return ` + + + + + `; + } + + /** Radial highlight used as a specular reflection on the cap body. */ + specularGradient(uid) { + return ` + + + `; + } + + /** Horizontal vignette that darkens the rounded ends of a cylinder. */ + endVignetteGradient(uid) { + return ` + + + + + + + `; + } + + /** Soft gaussian blur, used for specular streaks and ground shadows. */ + blurFilter(uid) { + return ` + + `; + } + + /** Soft, slightly offset drop shadow filter. */ + /** + * A soft, slightly offset drop shadow, built from primitives that survive the server-side SVG + * sanitizer applied when the picture is attached to a part. The shorthand element + * is NOT on the sanitizer's filter-primitive allow-list and gets stripped on save, leaving an + * empty — which the SVG spec defines as fully transparent, silently hiding whatever + * element referenced it (only visible once the attachment is viewed as a real, saved image + * rather than in this live preview). This is the equivalent built from feGaussianBlur/feOffset/ + * feFlood/feComposite/feMerge, all of which are allow-listed and pass through unchanged. + */ + shadowFilter(uid) { + return ` + + + + + + + + + `; + } + + /** Horizontal dimension line with end ticks, arrows and a centered label above. */ + dimH(x1, x2, y, label) { + const t = 4; + return ` + + + + + + ${label} + `; + } + + /** Vertical dimension line (label centered above) with optional extension lines. */ + dimV(y1, y2, x, label, extFromX = null) { + const t = 4; + const ext = extFromX === null ? "" : + ` + `; + return ` + ${ext} + + + + + + ${label} + `; + } + + /** Formats a millimeter value without trailing zeros. */ + formatMm(mm) { + return `${this.trimNumber(mm)} mm`; + } + + /** Formats a power rating in watts, preferring the fractional label. */ + formatPower(watts) { + const fractions = {0.125: "1/8 W", 0.25: "1/4 W", 0.33: "1/3 W", 0.5: "1/2 W"}; + return fractions[watts] ?? `${this.trimNumber(watts)} W`; + } + + /** Returns the value of a color input, falling back to a default. */ + bodyColor(target, fallback) { + return target && target.value ? target.value : fallback; + } + + /** Picks black or white text for readable contrast on the given hex color. */ + contrastColor(hex) { + const c = hex.replace("#", ""); + if (c.length < 6) { + return "#1a1100"; + } + const r = parseInt(c.substring(0, 2), 16); + const g = parseInt(c.substring(2, 4), 16); + const b = parseInt(c.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.6 ? "#1a1100" : "#f5f5f5"; + } + + /** + * Escapes a string for safe interpolation into the SVG markup we build with template strings and + * assign via innerHTML. The live preview is NOT server-sanitized, so anything derived from part + * data (e.g. a diode marking) must be escaped here as defence-in-depth against markup injection. + */ + escapeXml(value) { + return String(value).replace(/[&<>"']/g, (c) => + ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"})[c]); + } + + /** Returns hex only if it is a valid #rgb/#rrggbb(aa) colour, else the fallback — so a colour value can't break out of an attribute. */ + safeColor(hex, fallback = "#000000") { + return /^#[0-9a-fA-F]{3,8}$/.test(String(hex)) ? String(hex) : fallback; + } + + /* + * --------------------------------------------------------------- + * SMD resistor code + * --------------------------------------------------------------- + */ + + /** Recomputes all linked SMD fields (and the picture) from whichever was edited. */ + syncSmd(event) { + const field = event.currentTarget.dataset.field; + const raw = event.currentTarget.value; + const ohms = field === "value" ? this.parseValue(raw, "R") : this.smdCodeToOhms(raw); + if (ohms === null || !(ohms > 0)) { + event.currentTarget.classList.add("is-invalid"); + this.smdOhms = null; + if (this.hasSmdResultTarget) { + this.smdResultTarget.textContent = raw.trim() === "" ? "" : trans("tools.value_calc.invalid_input"); + } + this.smdSvgTarget.innerHTML = ""; + return; + } + event.currentTarget.classList.remove("is-invalid"); + this.smdOhms = ohms; + this.setSmdFields(ohms, field); + this.redrawSmd(); + } + + /** Fills the value / 3-digit / 4-digit / EIA-96 fields (skips the field being edited). */ + setSmdFields(ohms, except) { + const fields = { + value: () => this.formatOhms(ohms), + code3: () => this.ohmsToSmdCode(ohms) ?? "", + code4: () => this.ohmsTo4Digit(ohms) ?? "", + eia96: () => this.ohmsToEia96(ohms) ?? "—", + }; + const targets = { + value: this.hasSmdValueInputTarget ? this.smdValueInputTarget : null, + code3: this.hasSmdCode3Target ? this.smdCode3Target : null, + code4: this.hasSmdCode4Target ? this.smdCode4Target : null, + eia96: this.hasSmdEia96Target ? this.smdEia96Target : null, + }; + for (const key of Object.keys(fields)) { + const t = targets[key]; + if (!t) { + continue; + } + if (key !== except) { + t.value = fields[key](); + } + t.classList.remove("is-invalid"); + } + } + + /** Draws the SMD chip using the marking currently selected as "printed on the part". */ + redrawSmd() { + if (this.smdOhms === null || this.smdOhms === undefined || !(this.smdOhms > 0)) { + return; + } + const codes = { + code3: this.ohmsToSmdCode(this.smdOhms), + code4: this.ohmsTo4Digit(this.smdOhms), + eia96: this.ohmsToEia96(this.smdOhms), + }; + const mark = this.smdMarking || "code3"; + const marking = codes[mark] || codes.code3 || this.formatOhms(this.smdOhms); + if (this.hasSmdResultTarget) { + this.smdResultTarget.textContent = this.formatOhms(this.smdOhms); + } + this.drawSmd(this.smdSvgTarget, marking, {tolerance: this.smdTolerance, voltage: this.smdVoltage}); + this.highlightSmdMarking(); + } + + /** Chooses which code is printed on the drawn chip. */ + pickSmdMarking(event) { + this.smdMarking = event.currentTarget.dataset.mark; + this.redrawSmd(); + } + + /** Outlines the field whose code is currently drawn on the chip. */ + highlightSmdMarking() { + const map = { + code3: this.hasSmdCode3Target ? this.smdCode3Target : null, + code4: this.hasSmdCode4Target ? this.smdCode4Target : null, + eia96: this.hasSmdEia96Target ? this.smdEia96Target : null, + }; + const active = this.smdMarking || "code3"; + for (const [key, t] of Object.entries(map)) { + if (t) { + t.classList.toggle("border-primary", key === active); + t.classList.toggle("border-2", key === active); + } + } + } + + /** Parses any SMD marking (R-notation, EIA-96, 3-digit, 4-digit) to ohms, or null. */ + smdCodeToOhms(raw) { + const code = (raw || "").trim().toUpperCase(); + if (code === "") { + return null; + } + if (code.includes("R") && /^\d*R\d*$/.test(code)) { + const v = parseFloat(code.replace("R", ".")); + return Number.isNaN(v) ? null : v; + } + if (/^\d{2}[A-Z]$/.test(code)) { + const n = parseInt(code.substring(0, 2), 10); + const letter = code.charAt(2); + if (n >= 1 && n <= 96 && EIA96_MULTIPLIERS[letter] !== undefined) { + return EIA96_VALUES[n - 1] * EIA96_MULTIPLIERS[letter]; + } + return null; + } + if (/^\d{3}$/.test(code)) { + return parseInt(code.substring(0, 2), 10) * Math.pow(10, parseInt(code.charAt(2), 10)); + } + if (/^\d{4}$/.test(code)) { + return parseInt(code.substring(0, 3), 10) * Math.pow(10, parseInt(code.charAt(3), 10)); + } + return null; + } + + /** ohms -> 4-digit precision code (3 significant figures), R-notation below 100 Ω. */ + ohmsTo4Digit(ohms) { + if (!(ohms > 0)) { + return null; + } + if (ohms < 100) { + let s = parseFloat(ohms.toPrecision(3)).toString(); + if (!s.includes(".")) { + s += ".0"; + } + return s.startsWith("0.") ? "R" + s.slice(2) : s.replace(".", "R"); + } + let exp = Math.floor(Math.log10(ohms)) - 2; + let significant = Math.round(ohms / Math.pow(10, exp)); + if (significant >= 1000) { + significant = Math.round(significant / 10); + exp += 1; + } + if (exp < 0 || exp > 9) { + return null; + } + return significant.toString().padStart(3, "0") + exp.toString(); + } + + /** ohms -> EIA-96 code (value code + multiplier letter) for E96 values, else null. */ + ohmsToEia96(ohms) { + if (!(ohms > 0)) { + return null; + } + const order = ["A", "B", "C", "D", "E", "F", "X", "S", "Y", "R", "Z"]; + for (const letter of order) { + const base = ohms / EIA96_MULTIPLIERS[letter]; + const idx = EIA96_VALUES.findIndex((v) => Math.abs(v - base) < 0.5); + if (idx >= 0) { + return String(idx + 1).padStart(2, "0") + letter; + } + } + return null; + } + + /** Re-renders the SMD chip when the package changes. */ + updateSmd() { + this.redrawSmd(); + } + + /** Re-renders the SMD chip when the body color changes. */ + updateSmdColor() { + this.redrawSmd(); + } + + applySmdBodyColor(event) { + if (this.hasSmdBodyColorTarget) { + this.smdBodyColorTarget.value = event.currentTarget.dataset.color; + } + this.redrawSmd(); + } + + /** + * Converts a resistance in ohms into the printed SMD marking: R-notation below + * 10 Ω (4.7 -> 4R7, 0.47 -> R47) and the 3-digit EIA code from 10 Ω upwards. + * Returns null when the value is out of the representable range. + */ + ohmsToSmdCode(ohms) { + if (!(ohms > 0)) { + return null; + } + if (ohms < 10) { + let s = parseFloat(ohms.toFixed(2)).toString(); + if (!s.includes(".")) { + s += ".0"; + } + return s.startsWith("0.") ? "R" + s.slice(2) : s.replace(".", "R"); + } + // Two significant figures + power-of-ten multiplier digit + let exp = Math.floor(Math.log10(ohms)) - 1; + let significant = Math.round(ohms / Math.pow(10, exp)); + if (significant >= 100) { + significant = Math.round(significant / 10); + exp += 1; + } + if (exp < 0 || exp > 7) { + return null; + } + return significant.toString().padStart(2, "0") + exp.toString(); + } + + /** Draws a 3D-shaded SMD chip resistor with marking and dimension callouts. */ + drawSmd(target, marking, spec = {}) { + const uid = this.svgId(); + const w = 300; + const pkgKey = this.smdPackageValue(); + const pkg = SMD_PACKAGES[pkgKey]; + + // Body proportions follow the package: the length maps to a modest on-screen width + // (kept readable rather than true 1:1 scale) and the L:W ratio sets the height, so + // a 2512 looks noticeably larger than a 0402 and a 1210 looks squarer. + const bodyW = Math.round(120 + 90 * (pkg.l - 0.6) / (6.3 - 0.6)); + const aspect = pkg.l / pkg.w; + const bodyH = Math.max(48, Math.min(122, Math.round(bodyW / aspect))); + const capW = Math.max(14, Math.round(bodyW * 0.13)); + + const cx = w / 2; + const bodyX = Math.round(cx - bodyW / 2); + const bodyY = Math.round(78 - bodyH / 2); + const bodyBottom = bodyY + bodyH; + const cy = bodyY + bodyH / 2; + + const innerX = bodyX + capW; + const innerW = bodyW - 2 * capW; + + // Fit the marking inside the ceramic window (bounded by both width and height). + const fontSize = Math.max(14, Math.min( + 34, + Math.round(bodyH * 0.5), + Math.round(innerW * 1.6 / Math.max(3, marking.length)) + )); + + const fill = this.safeColor(this.bodyColor(this.hasSmdBodyColorTarget ? this.smdBodyColorTarget : null, "#262626"), "#262626"); + const textColor = this.contrastColor(fill); + + const callouts = + this.dimH(bodyX, bodyX + bodyW, bodyBottom + 18, `L ${this.formatMm(pkg.l)}`) + + this.dimV(bodyY, bodyBottom, bodyX + bodyW + 16, `W ${this.formatMm(pkg.w)}`, bodyX + bodyW); + + //The chip itself shows the printed code; print the decoded value (+ tolerance) as a caption below. + const valueLabel = this.smdOhms > 0 ? this.formatOhms(this.smdOhms) + this.specSuffix(spec) : ""; + const h = bodyBottom + 54; + const valueCaption = valueLabel + ? `${valueLabel}` + : ""; + + const svg = ` + + + ${this.metalGradient(uid)} + ${this.glossGradient(uid)} + ${this.blurFilter(uid)} + + ${this.shadowFilter(uid)} + + + + + + + + + + + ${this.escapeXml(marking)} + + ${callouts} + ${valueCaption} + `; + target.innerHTML = svg; + + if (this.hasSmdSpecTarget) { + this.smdSpecTarget.textContent = + `${pkgKey} (${pkg.metric}) · ${this.formatMm(pkg.l)} × ${this.formatMm(pkg.w)} · ${this.formatPower(pkg.power)}`; + } + } + + smdPackageValue() { + const v = this.hasSmdPackageTarget ? this.smdPackageTarget.value : "0805"; + return SMD_PACKAGES[v] ? v : "0805"; + } + + /* + * --------------------------------------------------------------- + * SMD capacitor tab (interactive) — an MLCC chip. These are (almost) always unmarked, so there + * is nothing to decode: you enter the value + package and it draws the picture to attach. + * --------------------------------------------------------------- + */ + + /** Reads the SMD-capacitor value input and redraws the MLCC chip. */ + syncSmdCap() { + if (!this.hasSmdCapSvgTarget) { + return; + } + const raw = this.hasSmdCapValueInputTarget ? this.smdCapValueInputTarget.value : "100n"; + const farads = this.parseValue(raw, "F"); + if (farads === null || !(farads > 0)) { + if (this.hasSmdCapValueInputTarget) { + this.smdCapValueInputTarget.classList.toggle("is-invalid", (raw || "").trim() !== ""); + } + this.smdCapSvgTarget.innerHTML = ""; + return; + } + if (this.hasSmdCapValueInputTarget) { + this.smdCapValueInputTarget.classList.remove("is-invalid"); + } + this.smdCapPf = farads * 1e12; //formatFarads() works in picofarads + this.drawSmdCapacitor(this.smdCapSvgTarget, { + package: this.smdCapPackageValue(), + bodyColor: this.hasSmdCapBodyColorTarget ? this.smdCapBodyColorTarget.value : null, + voltage: this.hasSmdCapVoltageTarget ? this.smdCapVoltageTarget.value : null, + tolerance: this.hasSmdCapToleranceTarget ? this.smdCapToleranceTarget.value : null, + specEl: this.hasSmdCapSpecTarget ? this.smdCapSpecTarget : null, + }); + } + + smdCapPackageValue() { + const v = this.hasSmdCapPackageTarget ? this.smdCapPackageTarget.value : "0805"; + return SMD_PACKAGES[v] ? v : "0805"; + } + + applySmdCapBodyColor(event) { + if (this.hasSmdCapBodyColorTarget) { + this.smdCapBodyColorTarget.value = event.currentTarget.dataset.color; + } + this.syncSmdCap(); + } + + /** + * Draws a surface-mount MLCC capacitor: a tan ceramic block with wide metal end terminations and + * (as on real MLCCs) no printed marking — the decoded value is shown as a caption below instead. + */ + drawSmdCapacitor(target, options = {}) { + const uid = this.svgId(); + const w = 300; + const pkgKey = SMD_PACKAGES[options.package] ? options.package : "0805"; + const pkg = SMD_PACKAGES[pkgKey]; + + const bodyW = Math.round(120 + 90 * (pkg.l - 0.6) / (6.3 - 0.6)); + const aspect = pkg.l / pkg.w; + const bodyH = Math.max(48, Math.min(122, Math.round(bodyW / aspect))); + //MLCC end terminations are noticeably wider than a chip resistor's. + const termW = Math.max(20, Math.round(bodyW * 0.20)); + + const cx = w / 2; + const bodyX = Math.round(cx - bodyW / 2); + const bodyY = Math.round(78 - bodyH / 2); + const bodyBottom = bodyY + bodyH; + const innerX = bodyX + termW; + const innerW = bodyW - 2 * termW; + + const fill = this.safeColor(options.bodyColor, "#c8a37a"); + + const callouts = + this.dimH(bodyX, bodyX + bodyW, bodyBottom + 18, `L ${this.formatMm(pkg.l)}`) + + this.dimV(bodyY, bodyBottom, bodyX + bodyW + 16, `W ${this.formatMm(pkg.w)}`, bodyX + bodyW); + + const valueLabel = this.smdCapPf > 0 ? this.formatFarads(this.smdCapPf) + this.specSuffix(options) : ""; + const h = bodyBottom + 54; + const valueCaption = valueLabel + ? `${valueLabel}` + : ""; + + target.innerHTML = ` + + + ${this.metalGradient(uid)} + ${this.glossGradient(uid)} + ${this.blurFilter(uid)} + + ${this.shadowFilter(uid)} + + + + + + + + + + + + ${callouts} + ${valueCaption} + `; + + if (options.specEl) { + options.specEl.textContent = + `${pkgKey} (${pkg.metric}) · ${this.formatMm(pkg.l)} × ${this.formatMm(pkg.w)}${valueLabel ? ` · ${valueLabel}` : ""}`; + } + } + + /* + * --------------------------------------------------------------- + * SMD inductor tab (interactive) + * --------------------------------------------------------------- + */ + + /** + * Recomputes the linked SMD-inductor value/code fields (and the picture) from whichever was + * edited — typing a value fills the code, and (like the SMD resistor tab) typing a code fills + * the value. Called with no event for programmatic redraws (package/colour change, init). + */ + syncSmdInductor(event) { + if (!this.hasSmdIndSvgTarget) { + return; + } + const field = event && event.currentTarget && event.currentTarget.dataset ? event.currentTarget.dataset.field : null; + const activeInput = field === "code" && this.hasSmdIndCodeTarget ? this.smdIndCodeTarget + : (this.hasSmdIndValueInputTarget ? this.smdIndValueInputTarget : null); + + let henries; + if (field === "code") { + henries = this.inductorCodeToHenries(this.hasSmdIndCodeTarget ? this.smdIndCodeTarget.value : ""); + } else { + const raw = (this.hasSmdIndValueInputTarget ? this.smdIndValueInputTarget.value : "100u").trim(); + //Accept "100µH", "10mH", "4.7uH"; a bare number (no prefix) reads as µH, matching the THT inductor tab. + const m = raw.match(/^([\d.]+)\s*(p|n|u|µ|m)?\s*h?$/i); + if (m) { + const num = parseFloat(m[1]); + const factors = {p: 1e-12, n: 1e-9, u: 1e-6, "µ": 1e-6, m: 1e-3}; + henries = m[2] ? num * factors[m[2].toLowerCase()] : num * 1e-6; + } else { + henries = null; + } + } + + if (henries === null || !(henries > 0)) { + if (activeInput) { + activeInput.classList.toggle("is-invalid", activeInput.value.trim() !== ""); + } + this.smdIndSvgTarget.innerHTML = ""; + return; + } + if (activeInput) { + activeInput.classList.remove("is-invalid"); + } + + const marking = this.henriesToInductorCode(henries / 1e-6); + if (field !== "code" && this.hasSmdIndCodeTarget) { + this.smdIndCodeTarget.value = marking; + } + if (field !== "value" && this.hasSmdIndValueInputTarget) { + this.smdIndValueInputTarget.value = this.formatHenries(henries); + } + this.drawSmdInductor(this.smdIndSvgTarget, marking, henries, { + package: this.smdIndPackageValue(), + bodyColor: this.hasSmdIndBodyColorTarget ? this.smdIndBodyColorTarget.value : null, + specEl: this.hasSmdIndSpecTarget ? this.smdIndSpecTarget : null, + }); + } + + /** + * Parses an SMD-inductor marking back to henries: R-notation (4R7 = 4.7 µH) or the 3-digit EIA + * code (101 = 100 µH), the same two forms {@see henriesToInductorCode} prints. Returns null for + * anything else (e.g. a 4-digit or EIA-96 code, which this chip type isn't drawn with). + */ + inductorCodeToHenries(raw) { + const code = (raw || "").trim().toUpperCase(); + if (code === "") { + return null; + } + if (code.includes("R") && /^\d*R\d*$/.test(code)) { + const v = parseFloat(code.replace("R", ".")); + return Number.isNaN(v) ? null : v * 1e-6; + } + if (/^\d{3}$/.test(code)) { + const uH = parseInt(code.substring(0, 2), 10) * Math.pow(10, parseInt(code.charAt(2), 10)); + return uH * 1e-6; + } + return null; + } + + smdIndPackageValue() { + const v = this.hasSmdIndPackageTarget ? this.smdIndPackageTarget.value : "1210"; + return SMD_PACKAGES[v] ? v : "1210"; + } + + applySmdIndBodyColor(event) { + if (this.hasSmdIndBodyColorTarget) { + this.smdIndBodyColorTarget.value = event.currentTarget.dataset.color; + } + this.syncSmdInductor(); + } + + /* + * --------------------------------------------------------------- + * Diode tab (interactive) + * --------------------------------------------------------------- + */ + + /* + * --------------------------------------------------------------- + * Helpers + * --------------------------------------------------------------- + */ + + /** + * Parses a human entered value like "4k7", "4.7k", "100n", "1M5" into a + * plain number. baseUnit is "R" (ohms) or "F" (farads) and is used to strip + * a trailing unit symbol. Returns null if it can't be parsed. + */ + parseValue(raw, baseUnit) { + if (raw === null || raw === undefined) { + return null; + } + // Keep the original case: the prefix "m" (milli) and "M" (mega) must stay distinct. + let s = raw.trim(); + if (s === "") { + return null; + } + // Strip a trailing unit symbol (ohm, ω, f) — matched case-insensitively. + s = s.replace(/ohm[s]?$/i, "").replace(/Ω/gi, "").trim(); + if (baseUnit === "F") { + s = s.replace(/farad[s]?$/i, "").replace(/f$/i, "").trim(); + } + + // RKM style: prefix used as decimal separator, e.g. 4k7, 1R5, 2u2, 4M7 + let m = s.match(/^(\d+)\s*(p|n|u|µ|m|k|meg|g|r)\s*(\d+)$/i); + if (m) { + const factor = this.prefixFactor(m[2]); + return factor === null ? null : parseFloat(`${m[1]}.${m[3]}`) * factor; + } + + // Number followed by an optional prefix, e.g. 4.7k, 100n, 470, 10M + m = s.match(/^([\d.]+)\s*(p|n|u|µ|m|k|meg|g|r)?$/i); + if (m) { + const num = parseFloat(m[1]); + if (Number.isNaN(num)) { + return null; + } + const factor = this.prefixFactor(m[2]); + return factor === null ? null : num * factor; + } + + return null; + } + + /** + * Resolves an SI prefix (or the RKM "R" separator) to a multiplication factor. + * Case sensitive only for m (milli) vs M (mega); all other prefixes are + * case-insensitive. Returns 1 for "no prefix"/R, or null for an unknown prefix. + */ + prefixFactor(prefix) { + if (prefix === undefined || prefix === "" || prefix.toLowerCase() === "r") { + return 1; + } + if (prefix === "m") { + return 1e-3; + } + if (prefix === "M") { + return 1e6; + } + const factors = {p: 1e-12, n: 1e-9, u: 1e-6, "µ": 1e-6, k: 1e3, meg: 1e6, g: 1e9}; + return factors[prefix.toLowerCase()] ?? null; + } + + formatOhms(ohms) { + return this.formatWithPrefix(ohms, "Ω", false); + } + + /** + * Formats a capacitance. The input value is always given in picofarads. + * When pfForm is true, the value is rendered in plain pF, otherwise the most + * fitting SI prefix (pF/nF/µF/mF/F) is used. + */ + formatFarads(pf, pfForm = false) { + if (pfForm) { + return `${this.trimNumber(pf)} pF`; + } + return this.formatWithPrefix(pf * 1e-12, "F", true); + } + + formatWithPrefix(value, unit, isFarad) { + if (value === 0) { + return `0 ${unit}`; + } + const steps = isFarad + ? [[1e-12, "p"], [1e-9, "n"], [1e-6, "µ"], [1e-3, "m"], [1, ""]] + : [[1e-3, "m"], [1, ""], [1e3, "k"], [1e6, "M"], [1e9, "G"]]; + + let chosen = steps[0]; + for (const step of steps) { + if (value >= step[0]) { + chosen = step; + } + } + return `${this.trimNumber(value / chosen[0])} ${chosen[1]}${unit}`; + } + + trimNumber(num) { + return parseFloat(num.toFixed(3)).toString(); + } +} diff --git a/assets/css/components/component_image_generator.css b/assets/css/components/component_image_generator.css new file mode 100644 index 000000000..428d7e2b8 --- /dev/null +++ b/assets/css/components/component_image_generator.css @@ -0,0 +1,43 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* Keep every preview the same footprint so the panel doesn't jump between tabs. */ +.vc-pic { display: flex; align-items: center; justify-content: center; min-height: 14rem; } +.vc-pic svg { max-height: 15rem; max-width: 100%; width: auto; height: auto; } +/* Every tab reserves the same height and lays out as a column, so the row can grow to fill it + and the "Appearance" toggle always pins to the very bottom of the frame — identical on each + tab whether or not the tab has an intro line above its controls. */ +.tab-content > .tab-pane { min-height: 24rem; } +.tab-content > .tab-pane.active { display: flex; flex-direction: column; } +/* The "Appearance" disclosure is pinned to the bottom of its column. On wide screens it + opens *upward* as a floating popover, so toggling it never resizes the tab or nudges the + picture — every tab keeps the exact same frame whether it's open or closed. */ +.vc-appearance { position: relative; } +@media (min-width: 992px) { + .vc-appearance[open] > .vc-appearance-panel { + position: absolute; + bottom: calc(100% + 0.4rem); + left: 0; + right: 0; + z-index: 20; + max-height: 22rem; + overflow-y: auto; + box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.25); + } +} diff --git a/config/permissions.yaml b/config/permissions.yaml index a7a35c0ae..b925330ae 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -163,6 +163,8 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co label: "tools.builtin_footprints_viewer.title" ic_logos: label: "perm.tools.ic_logos" + component_image_generator: + label: "perm.tools.component_image_generator" info_providers: label: "perm.part.info_providers" diff --git a/config/reference.php b/config/reference.php index 9ab425c6a..fb027468c 100644 --- a/config/reference.php +++ b/config/reference.php @@ -2433,6 +2433,7 @@ * translations_path?: scalar|Param|null, // Default: "%translator.default_path%" * format?: scalar|Param|null, // Default: "xlf" * xliff_version?: scalar|Param|null, // Default: "2.0" + * default_locale?: scalar|Param|null, // Default: "%kernel.default_locale%" * use_intl_icu_format?: bool|Param, // Default: false * writer_options?: list, * } diff --git a/docs/usage/component_image_generator.md b/docs/usage/component_image_generator.md new file mode 100644 index 000000000..d6ed828b1 --- /dev/null +++ b/docs/usage/component_image_generator.md @@ -0,0 +1,93 @@ +--- +layout: default +title: Component image generator +parent: Usage +--- + +# Component image generator + +Part-DB can **draw schematic-style pictures of passive components** (resistors, SMD resistors and +ceramic capacitors) from their value, and attach them to your parts. This is handy when you import a +bulk assortment (for example a resistor or capacitor kit) that arrives with no pictures and only a +value in the name — instead of blank thumbnails you get a clean, consistent illustration for every part. + +There are two ways to use it: + +* the **Value calculator** tool, to draw a single component interactively, and +* the **Generate component images** bulk action, to illustrate a whole selection of parts at once. + +Both produce a lightweight, transparent **SVG** that is attached as the part's picture (so it stays +crisp at any size). The images are illustrations, not photographs — they show the colour-band code, +capacitor code, or SMD marking together with dimension callouts. + +## Value calculator + +Open **Tools → Value calculator** (requires the `Value calculator` permission). It has three tabs: + +* **Resistor** – enter a resistance (or pick the colour bands) and get a 4/5/6-band axial resistor. + The tolerance, power rating and temperature coefficient (ppm) are reflected in the bands and body size. +* **SMD resistor** – enter a resistance and package (0402, 0603, 0805 …) to get a chip resistor with + the 3-digit, 4-digit or EIA-96 marking. +* **Capacitor** – enter a capacitance to get a radial disc (or MLCC blob) ceramic capacitor with the + printed code, optional voltage line and tolerance letter. + +Every field is linked: editing the value updates the code (and vice-versa), and the picture redraws +live. You can change the body colour, size, lead length and other appearance options. + +### Attaching to a part + +When a part has no picture, its info page shows a **Generate image** button in the picture area. +This opens the calculator in a dialog, pre-filled from the part's value. Click **Attach** and the +drawing is saved as the part's picture without leaving the page. + +Every tab also has a **Download SVG** button, which saves the currently shown picture as a standalone +`.svg` file — useful if you just want the image itself (for a datasheet, label, or other document) +without attaching it to a part. + +## Bulk "Generate component images" + +To illustrate many parts at once, select them in any parts table and choose +**Actions → Generate component images**. + +Part-DB classifies each selected part as a resistor, SMD resistor or capacitor, reads its value and +other properties, and shows a review table with a **live preview** for every part. You can adjust any +value before writing, then: + +* **Attach pictures** – saves the generated image as each checked part's picture, or +* **Write KiCad settings** – writes the suggested KiCad symbol / footprint / reference prefix to each + checked part (see [EDA / KiCad integration](eda_integration.md)). + +Only parts **without a picture** are listed by default. If some of your selection already have a +picture, a notice offers to **re-generate / overwrite** them (see [Overwriting](#overwriting-existing-pictures)). + +### What is auto-detected — and how to get the best results + +Each property is read from the part's **parameters** first, then from its **name and description**. +A part is only listed if it has no picture yet and a value can be read from it. To improve detection, +add any of the following (a plain CSV import usually only fills the name/description, so putting the +value in the name is the most reliable option): + +| Property | Add a parameter named… | …or write in the name / description | +|----------|------------------------|-------------------------------------| +| **Value** (required) | `Resistance` / `Capacitance` (unit Ω or F) | `10nF`, `0.1µF`, `4n7` · `4k7`, `470R`, `10k`, `1M` | +| **Rated voltage** (capacitors) | `Voltage` | `50V`, `100V` | +| **Tolerance** | `Tolerance` | `±5%`, `1%`, `0.1%` | +| **Power** (through-hole resistors) | — | `0.25W`, `1/4W`, `1W` | +| **Temp. coefficient** (resistors) | — | `50ppm`, `±25 ppm/°C` | +| **Lead pitch** (capacitors) | `Pitch` / `RM` / `Lead spacing` | `pitch 5mm`, `RM5` | +| **Body diameter** (capacitors) | `Diameter` | `⌀5mm` | +| **SMD size** (resistors) | the assigned footprint | `0402`, `0603`, `0805`, `1206`, … | +| **Body colour** | — | `blue body`, `beige`, `green`, … | + +How the properties are drawn depends on the component type, matching real-world conventions: + +* **Capacitor** – tolerance shows as the letter after the code (`104K` = ±10%); voltage as a printed line. +* **Through-hole resistor** – tolerance and temperature coefficient are colour bands; power sets the body size. +* **SMD resistor** – tolerance is expressed by the marking system (4-digit for 1 %, 3-digit for looser); + power/voltage are not printed on a chip, so its size comes from the package instead. + +### Overwriting existing pictures + +By default, parts that already have a picture are skipped. Use the **Re-generate / overwrite** button +to include them anyway. When you then attach a picture to such a part, the new image becomes the +part's preview and replaces any **previously generated** image — manually uploaded photos are kept. diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index c4c0e5260..f748f3a05 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -38,6 +38,7 @@ use App\Form\Part\PartBaseType; use App\Form\Part\PartLotType; use App\Services\Attachments\AttachmentSubmitHandler; +use App\Services\Attachments\GeneratedImageAttachmentHelper; use App\Services\Attachments\PartPreviewGenerator; use App\Services\EntityMergers\Mergers\PartMerger; use App\Services\InfoProviderSystem\PartInfoRetriever; @@ -206,6 +207,96 @@ public function edit(Part $part, Request $request): Response ]); } + #[Route(path: '/{id}/generate_image', name: 'part_generate_image', methods: ['POST'])] + public function generateImage(Part $part, Request $request, GeneratedImageAttachmentHelper $helper): Response + { + $this->denyAccessUnlessGranted('edit', $part); + + if (!$this->isCsrfTokenValid('generate_image' . $part->getID(), $request->request->get('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token'); + } + + $ajax = $request->isXmlHttpRequest(); + + $svg = (string) $request->request->get('svg', ''); + //Basic guard: the payload must look like an SVG image (it is sanitized again on storage) + if ($svg === '' || !str_contains($svg, 'generateImageResult($part, false, 'part.generate_image.flash.invalid', $ajax); + } + + $name = trim((string) $request->request->get('name', '')); + $setAsPreview = $request->request->getBoolean('preview', true); + $overwrite = $request->request->getBoolean('overwrite', false); + + //Guard the persistence so a storage/validation failure shows a flash instead of a 500. + try { + $helper->attachSvgToPart($part, $svg, $name !== '' ? $name : 'Generated image', $setAsPreview, $overwrite); + $this->commentHelper->setMessage('Generated component image'); + $this->em->flush(); + } catch (\Throwable) { + return $this->generateImageResult($part, false, 'part.generate_image.flash.invalid', $ajax); + } + + return $this->generateImageResult($part, true, 'part.generate_image.flash.success', $ajax); + } + + /** + * Writes the KiCad/EDA fields (symbol, footprint, reference prefix) of a part. Used by the bulk + * image generator to assign EDA settings to a whole assortment at once. + */ + #[Route(path: '/{id}/set_eda', name: 'part_set_eda', methods: ['POST'])] + public function setEda(Part $part, Request $request): Response + { + $this->denyAccessUnlessGranted('edit', $part); + + if (!$this->isCsrfTokenValid('set_eda' . $part->getID(), $request->request->get('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token'); + } + + $eda = $part->getEdaInfo(); + if ($request->request->has('kicad_symbol')) { + $eda->setKicadSymbol(trim((string) $request->request->get('kicad_symbol')) ?: null); + } + if ($request->request->has('reference_prefix')) { + $eda->setReferencePrefix(trim((string) $request->request->get('reference_prefix')) ?: null); + } + if ($request->request->has('kicad_footprint')) { + $eda->setKicadFootprint(trim((string) $request->request->get('kicad_footprint')) ?: null); + } + + $ajax = $request->isXmlHttpRequest(); + try { + $this->commentHelper->setMessage('Bulk EDA settings'); + $this->em->flush(); + } catch (\Throwable) { + return $ajax + ? $this->json(['success' => false], Response::HTTP_UNPROCESSABLE_ENTITY) + : $this->redirectToRoute('part_info', ['id' => $part->getID()]); + } + + return $ajax + ? $this->json(['success' => true]) + : $this->redirectToRoute('part_info', ['id' => $part->getID()]); + } + + /** + * Returns the outcome of a generate-image request as JSON (for the modal/AJAX flow) or as a + * flash + redirect (for a normal form submit). + */ + private function generateImageResult(Part $part, bool $success, string $messageKey, bool $ajax): Response + { + if ($ajax) { + return $this->json([ + 'success' => $success, + 'message' => $this->translator->trans($messageKey), + ], $success ? Response::HTTP_OK : Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $this->addFlash($success ? 'success' : 'error', $messageKey); + + return $this->redirectToRoute('part_info', ['id' => $part->getID()]); + } + #[Route(path: '/{id}/bulk-import-complete/{jobId}', name: 'part_bulk_import_complete', methods: ['POST'])] public function markBulkImportComplete(Part $part, int $jobId, Request $request): Response { diff --git a/src/Controller/ToolsController.php b/src/Controller/ToolsController.php index f3a4a27f7..ea241daeb 100644 --- a/src/Controller/ToolsController.php +++ b/src/Controller/ToolsController.php @@ -22,7 +22,9 @@ */ namespace App\Controller; +use App\Entity\Parts\Part; use App\Services\Attachments\AttachmentSubmitHandler; +use App\Services\Tools\ComponentValueGuesser; use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\BuiltinAttachmentsFinder; use App\Services\Doctrine\DBInfoHelper; @@ -30,7 +32,9 @@ use App\Services\System\GitVersionInfoProvider; use App\Services\System\UpdateAvailableFacade; use App\Settings\AppSettings; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Runtime\SymfonyRuntime; @@ -142,4 +146,143 @@ public function icLogos(): Response return $this->render('tools/ic_logos/ic_logos.html.twig'); } + + #[Route(path: '/component_image_generator', name: 'tools_component_image_generator')] + public function componentImageGenerator(Request $request, EntityManagerInterface $em, ComponentValueGuesser $guesser): Response + { + $this->denyAccessUnlessGranted('@tools.component_image_generator'); + + //Optionally the calculator can be opened in the context of a part, to attach the generated image to it. + $part = null; + $partId = $request->query->getInt('part'); + if ($partId > 0) { + $part = $em->find(Part::class, $partId); + if ($part !== null) { + $this->denyAccessUnlessGranted('edit', $part); + } + } + + $prefillOhms = null; + $prefillFarads = null; + if ($part !== null) { + [$prefillOhms, $prefillFarads,] = $guesser->extractValue($part); + } + + return $this->render('tools/component_image_generator/image_generator.html.twig', [ + 'part' => $part, + 'prefill_ohms' => $prefillOhms, + 'prefill_farads' => $prefillFarads, + //When embedded in the part-page modal, render only the calculator inside a Turbo frame. + 'modalMode' => $request->query->getBoolean('modal'), + ]); + } + + /** + * Landing page for the "Generate component images" bulk action: classifies the selected parts + * (skipping ones that already have a picture or can't be classified) and lets the user review, + * then generate + attach pictures. Reached from the parts table action bar with ?ids=1,2,3. + */ + #[Route(path: '/bulk_generate_images', name: 'tools_bulk_generate')] + public function bulkGenerate(Request $request, EntityManagerInterface $em, ComponentValueGuesser $guesser): Response + { + $this->denyAccessUnlessGranted('@tools.component_image_generator'); + + $candidates = []; + $skipped = 0; + $withPicture = 0; + //When set, parts that already have a picture are included too (their preview gets overwritten). + $overwrite = $request->query->getBoolean('overwrite'); + $idsParam = (string) $request->query->get('ids', ''); + $ids = array_values(array_filter( + array_map(intval(...), explode(',', $idsParam)), + static fn (int $id): bool => $id > 0 + )); + + if ($ids !== []) { + foreach ($em->getRepository(Part::class)->findBy(['id' => $ids]) as $part) { + if (!$this->isGranted('edit', $part)) { + continue; + } + $hasPicture = $part->getMasterPictureAttachment() !== null; + //By default only illustrate parts without a picture; in overwrite mode include all. + if ($hasPicture && !$overwrite) { + //Offer a re-generate action only for the ones we could actually classify. + if ($guesser->guess($part) !== null) { + $withPicture++; + } else { + $skipped++; + } + continue; + } + $guess = $guesser->guess($part); + if ($guess === null) { + $skipped++; + continue; + } + $eda = $guesser->edaSuggestion($guess); + $candidates[] = [ + 'part' => $part, + 'type' => $guess['type'], + 'subtype' => $guess['subtype'] ?? null, + 'marking' => $guess['marking'] ?? null, + 'value' => $guess['value'], + 'package' => $guess['package'], + 'voltage' => $guess['voltage'], + 'tolerance' => $guess['tolerance'], + 'pitch' => $guess['pitch'], + 'diameter' => $guess['diameter'], + 'power' => $guess['power'], + 'ppm' => $guess['ppm'], + 'color' => $guess['color'], + 'has_picture' => $hasPicture, + 'kicad_symbol' => $eda['symbol'], + 'reference_prefix' => $eda['reference'], + 'kicad_footprint' => $eda['footprint'], + ]; + } + } + + $hasCaps = false; + $hasThtResistors = false; + $hasSmdResistors = false; + $hasInductors = false; + $hasSmdInductors = false; + $hasSmdCapacitors = false; + $hasDiodes = false; + foreach ($candidates as $candidate) { + if ($candidate['type'] === 'capacitor') { + $hasCaps = true; + } elseif ($candidate['type'] === 'resistor') { + //Power and temperature-coefficient bands only apply to through-hole resistors; + //SMD chips just carry the printed value code (sized by their package). + $hasThtResistors = true; + } elseif ($candidate['type'] === 'smd_resistor') { + $hasSmdResistors = true; + } elseif ($candidate['type'] === 'inductor') { + $hasInductors = true; + } elseif ($candidate['type'] === 'smd_inductor') { + $hasSmdInductors = true; + } elseif ($candidate['type'] === 'smd_capacitor') { + $hasSmdCapacitors = true; + } elseif ($candidate['type'] === 'diode') { + $hasDiodes = true; + } + } + + return $this->render('tools/component_image_generator/bulk_generate.html.twig', [ + 'candidates' => $candidates, + 'skipped' => $skipped, + 'selected_count' => count($ids), + 'has_caps' => $hasCaps, + 'has_tht_resistors' => $hasThtResistors, + 'has_smd_resistors' => $hasSmdResistors, + 'has_inductors' => $hasInductors, + 'has_smd_inductors' => $hasSmdInductors, + 'has_smd_capacitors' => $hasSmdCapacitors, + 'has_diodes' => $hasDiodes, + 'with_picture' => $withPicture, + 'overwrite' => $overwrite, + 'ids_param' => $idsParam, + ]); + } } diff --git a/src/Services/Attachments/GeneratedImageAttachmentHelper.php b/src/Services/Attachments/GeneratedImageAttachmentHelper.php new file mode 100644 index 000000000..52ace8ece --- /dev/null +++ b/src/Services/Attachments/GeneratedImageAttachmentHelper.php @@ -0,0 +1,141 @@ +. + */ + +namespace App\Services\Attachments; + +use App\Entity\Attachments\AttachmentType; +use App\Entity\Attachments\AttachmentUpload; +use App\Entity\Attachments\PartAttachment; +use App\Entity\Parts\Part; +use Doctrine\ORM\EntityManagerInterface; + +/** + * Creates attachments from SVG markup that was generated client-side (e.g. by the + * resistor/capacitor value calculator) and attaches them to a part. + */ +final readonly class GeneratedImageAttachmentHelper +{ + private const ATTACHMENT_TYPE_NAME = 'Generated image'; + + public function __construct( + private EntityManagerInterface $em, + private AttachmentSubmitHandler $submitHandler, + ) { + } + + /** + * Stores the given SVG markup as a sanitized picture attachment of the part. + * + * @param Part $part The part the image should be attached to + * @param string $svg The raw SVG markup + * @param string $name The name shown for the attachment + * @param bool $setAsPreview Whether the image should become the part's preview picture + * @param bool $overwrite Remove any previously generated image(s) first (replace instead of add) + */ + public function attachSvgToPart(Part $part, string $svg, string $name, bool $setAsPreview = true, bool $overwrite = false): PartAttachment + { + $type = $this->getGeneratedImageType(); + + //In overwrite mode, drop previously generated images so re-generating replaces them + //instead of accumulating "Generated image (2)", "(3)", … + if ($overwrite) { + foreach ($part->getAttachments()->toArray() as $existing) { + if ($existing->getAttachmentType()?->getName() === $type->getName()) { + if ($part->getMasterPictureAttachment() === $existing) { + $part->setMasterPictureAttachment(null); + } + $part->removeAttachment($existing); + $this->em->remove($existing); + } + } + } + + $attachment = new PartAttachment(); + //De-duplicate the name so generating the same image twice does not violate the + //(name, attachment_type, element) unique constraint on PartAttachment. + $attachment->setName($this->uniqueName($part, $name !== '' ? $name : 'Generated image', $type)); + $attachment->setAttachmentType($type); + $part->addAttachment($attachment); + + //Reuse the regular upload pipeline so the SVG is sanitized and (optionally) becomes the preview image. + $upload = new AttachmentUpload( + file: null, + data: base64_encode($svg), + filename: 'generated.svg', + becomePreviewIfEmpty: $setAsPreview, + ); + $this->submitHandler->handleUpload($attachment, $upload); + + //If explicitly requested, force this attachment to become the preview picture even if one already exists. + if ($setAsPreview && $attachment->isPicture()) { + $part->setMasterPictureAttachment($attachment); + } + + $this->em->persist($attachment); + + return $attachment; + } + + /** + * Builds a name that is unique among the part's attachments of the given type, + * appending " (2)", " (3)", … on collision (mirrors the info-provider importer). + */ + private function uniqueName(Part $part, string $baseName, AttachmentType $type): string + { + $taken = []; + foreach ($part->getAttachments() as $existing) { + if ($existing->getAttachmentType()?->getName() === $type->getName()) { + $taken[] = $existing->getName(); + } + } + + if (!in_array($baseName, $taken, true)) { + return $baseName; + } + + $i = 2; + while (in_array($baseName.' ('.$i.')', $taken, true)) { + $i++; + } + + return $baseName.' ('.$i.')'; + } + + /** + * Returns the attachment type used for generated images, creating it if needed. + */ + private function getGeneratedImageType(): AttachmentType + { + /** @var AttachmentType $type */ + $type = $this->em->getRepository(AttachmentType::class)->findOrCreateForInfoProvider(self::ATTACHMENT_TYPE_NAME); + + //A newly created type is not persisted yet, and the attachment_type relation does not cascade persist. + if ($type->getID() === null) { + $type->setFiletypeFilter('image/*'); + $type->setAlternativeNames(self::ATTACHMENT_TYPE_NAME); + $this->em->persist($type); + } + + return $type; + } +} diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index b0353e29f..6cd1b0f2a 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -137,6 +137,16 @@ public function handleAction(string $action, array $selected_parts, ?int $target ); } + if ($action === 'generate_images') { + $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); + return new RedirectResponse( + $this->urlGenerator->generate('tools_bulk_generate', [ + 'ids' => $ids, + '_redirect' => $redirect_url + ]) + ); + } + //Iterate over the parts and apply the action to it: foreach ($selected_parts as $part) { if (!$part instanceof Part) { diff --git a/src/Services/Tools/ComponentValueGuesser.php b/src/Services/Tools/ComponentValueGuesser.php new file mode 100644 index 000000000..a5eca763d --- /dev/null +++ b/src/Services/Tools/ComponentValueGuesser.php @@ -0,0 +1,758 @@ +. + */ + +namespace App\Services\Tools; + +use App\Entity\Parts\Part; + +/** + * Best-effort classification of a part as a resistor / SMD resistor / capacitor, together with its + * electrical value, from its parameters, footprint, category and name. Used by the value calculator + * (to pre-fill) and the bulk image generator (to classify a whole assortment). + */ +final readonly class ComponentValueGuesser +{ + /** Imperial SMD chip package codes that mark a part as surface-mount. */ + private const SMD_PACKAGES = ['01005', '0201', '0402', '0603', '0805', '1206', '1210', '2010', '2512']; + + /** Imperial -> metric size, for building KiCad SMD footprint names. */ + private const SMD_METRIC = [ + '0201' => '0603', '0402' => '1005', '0603' => '1608', '0805' => '2012', + '1206' => '3216', '1210' => '3225', '2010' => '5025', '2512' => '6332', + ]; + + /** Named THT/SMD diode/LED package -> KiCad footprint. Keyed by the token {@see detectDiodePackage()} returns. */ + private const DIODE_PACKAGE_FOOTPRINTS = [ + 'DO-41' => 'Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal', + 'DO-35' => 'Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal', + 'DO-15' => 'Diode_THT:D_DO-15_P12.70mm_Horizontal', + 'DO-201' => 'Diode_THT:D_DO-201AD_P15.24mm_Horizontal', + 'SOD-123' => 'Diode_SMD:D_SOD-123', + 'SOD-323' => 'Diode_SMD:D_SOD-323', + 'SOT-23' => 'Diode_SMD:D_SOT-23', + 'SMA' => 'Diode_SMD:D_SMA', + 'SMB' => 'Diode_SMD:D_SMB', + 'SMC' => 'Diode_SMD:D_SMC', + //LED dome sizes (only ever matched when the subtype is 'led', see detectDiodePackage()). + '3MM' => 'LED_THT:LED_D3.0mm', + '5MM' => 'LED_THT:LED_D5.0mm', + '10MM' => 'LED_THT:LED_D10.0mm', + ]; + + /** + * Suggested KiCad/EDA settings for a classified component. Uses the detected package for SMD + * parts and the lead pitch / body diameter for through-hole ceramic discs. + * + * @param array{type: string, package: string|null, pitch: float|null, diameter: float|null, subtype?: string|null} $guess + * + * @return array{symbol: string, reference: string, footprint: string|null} + */ + public function edaSuggestion(array $guess): array + { + $type = $guess['type']; + $package = $guess['package'] ?? null; + + if ($type === 'capacitor' || $type === 'smd_capacitor') { + //SMD (MLCC) capacitors get a chip footprint; through-hole discs are sized by pitch/diameter. + if ($type === 'smd_capacitor' && $package !== null && isset(self::SMD_METRIC[$package])) { + $footprint = 'Capacitor_SMD:C_'.$package.'_'.self::SMD_METRIC[$package].'Metric'; + } else { + $footprint = $this->capDiscFootprint($guess['pitch'] ?? null, $guess['diameter'] ?? null); + } + + return ['symbol' => 'Device:C', 'reference' => 'C', 'footprint' => $footprint]; + } + + if ($type === 'inductor' || $type === 'smd_inductor') { + //SMD inductors get a chip footprint; through-hole ones are left blank (editable afterwards). + $footprint = null; + if ($type === 'smd_inductor' && $package !== null && isset(self::SMD_METRIC[$package])) { + $footprint = 'Inductor_SMD:L_'.$package.'_'.self::SMD_METRIC[$package].'Metric'; + } + + return ['symbol' => 'Device:L', 'reference' => 'L', 'footprint' => $footprint]; + } + + if ($type === 'diode') { + $subtype = $guess['subtype'] ?? 'diode'; + $symbol = match ($subtype) { + 'led' => 'Device:LED', + 'zener' => 'Device:D_Zener', + 'schottky' => 'Device:D_Schottky', + 'tvs' => 'Device:D_TVS', + default => 'Device:D', + }; + //A named package (DO-41, SOD-123, a 3mm LED dome, ...) maps to a fixed footprint; an + //imperial chip code (0805, ...) is only meaningful for SMD diodes/LEDs sized like a chip. + $footprint = null; + if ($package !== null) { + $footprint = self::DIODE_PACKAGE_FOOTPRINTS[$package] ?? null; + if ($footprint === null && isset(self::SMD_METRIC[$package])) { + $prefix = $subtype === 'led' ? 'LED_SMD:LED' : 'Diode_SMD:D'; + $footprint = $prefix.'_'.$package.'_'.self::SMD_METRIC[$package].'Metric'; + } + } + + return ['symbol' => $symbol, 'reference' => 'D', 'footprint' => $footprint]; + } + + if ($type === 'smd_resistor' && $package !== null && isset(self::SMD_METRIC[$package])) { + $footprint = 'Resistor_SMD:R_'.$package.'_'.self::SMD_METRIC[$package].'Metric'; + } else { + //Through-hole resistor: default to the common 1/4 W axial footprint (editable afterwards). + $footprint = 'Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal'; + } + + return ['symbol' => 'Device:R', 'reference' => 'R', 'footprint' => $footprint]; + } + + /** + * Picks a standard KiCad through-hole ceramic disc footprint for the given lead pitch (mm) and + * body diameter (mm), choosing the pitch bucket (2.50 / 5.00 / 7.50 mm) then the nearest disc + * diameter within it. Defaults to a 5 mm pitch / 5 mm disc. + */ + private function capDiscFootprint(?float $pitch, ?float $diameter): string + { + $p = $pitch ?? 5.0; + $d = $diameter ?? 5.0; + + if ($p < 3.8) { + $options = [ + [3.0, 'Capacitor_THT:C_Disc_D3.0mm_W1.6mm_P2.50mm'], + [3.8, 'Capacitor_THT:C_Disc_D3.8mm_W2.6mm_P2.50mm'], + [5.0, 'Capacitor_THT:C_Disc_D5.0mm_W2.5mm_P2.50mm'], + ]; + } elseif ($p < 6.5) { + $options = [ + [5.0, 'Capacitor_THT:C_Disc_D5.0mm_W2.5mm_P5.00mm'], + [6.0, 'Capacitor_THT:C_Disc_D6.0mm_W2.5mm_P5.00mm'], + [7.5, 'Capacitor_THT:C_Disc_D7.5mm_W2.5mm_P5.00mm'], + [10.0, 'Capacitor_THT:C_Disc_D10.0mm_W2.5mm_P5.00mm'], + ]; + } else { + $options = [ + [7.5, 'Capacitor_THT:C_Disc_D7.5mm_W5.0mm_P7.50mm'], + [10.5, 'Capacitor_THT:C_Disc_D10.5mm_W5.0mm_P7.50mm'], + ]; + } + + $best = $options[0][1]; + $bestDelta = INF; + foreach ($options as [$dia, $fp]) { + $delta = abs($dia - $d); + if ($delta < $bestDelta) { + $bestDelta = $delta; + $best = $fp; + } + } + + return $best; + } + + /** + * Classifies a part. + * + * @return array{type: 'resistor'|'smd_resistor'|'capacitor'|'smd_capacitor'|'inductor'|'smd_inductor'|'diode', value: float, + * package: string|null, voltage: int|null, tolerance: string|null, pitch: float|null, + * diameter: float|null, power: float|null, ppm: int|null, color: string|null, + * subtype: string|null}|null + * value is ohms (resistors), farads (capacitors), henries (inductors) or the rated/forward + * voltage (diodes, 0 if unknown); subtype names the diode kind. Null if it can't be classified. + */ + public function guess(Part $part): ?array + { + //Unambiguous diode part numbers (1N4148, BAT54, BZX…) are recognized first: their "1N…" style + //would otherwise be misread as an RKM value (e.g. "1N4148" -> 1.4148 nF). + $text = mb_strtolower($part->getName().' '.$part->getDescription()); + $pnSubtype = $this->detectDiodePartNumber($text); + if ($pnSubtype !== null) { + return $this->buildDiodeGuess($part, $pnSubtype); + } + + [$ohms, $farads, $henries] = $this->extractValue($part); + $tolerance = $this->detectTolerance($part); + $color = $this->detectBodyColor($part); + + if ($ohms !== null && $ohms > 0) { + $package = $this->detectSmdPackage($part); + + return [ + 'type' => $package !== null ? 'smd_resistor' : 'resistor', + 'value' => $ohms, + 'package' => $package, + 'voltage' => $this->detectVoltage($part), + 'tolerance' => $tolerance, + 'pitch' => null, + 'diameter' => null, + 'power' => $this->detectPower($part), + 'ppm' => $this->detectPpm($part), + 'color' => $color, + 'subtype' => null, + ]; + } + + if ($farads !== null && $farads > 0) { + //A surface-mount cap is drawn as an (unmarked) MLCC chip; a THT one as a ceramic disc. + $package = $this->detectSmdPackage($part); + + return [ + 'type' => $package !== null ? 'smd_capacitor' : 'capacitor', + 'value' => $farads, + 'package' => $package, + 'voltage' => $this->detectVoltage($part), + 'tolerance' => $tolerance, + 'pitch' => $package !== null ? null : $this->detectPitch($part), + 'diameter' => $package !== null ? null : $this->detectDiameter($part), + 'power' => null, + 'ppm' => null, + 'color' => $color, + 'subtype' => null, + ]; + } + + if ($henries !== null && $henries > 0) { + //A surface-mount inductor is drawn as a molded chip with a µH code; a THT one as a colour barrel. + $package = $this->detectSmdPackage($part); + + return [ + 'type' => $package !== null ? 'smd_inductor' : 'inductor', + 'value' => $henries, + 'package' => $package, + 'voltage' => $this->detectVoltage($part), + 'tolerance' => $tolerance, + 'pitch' => null, + 'diameter' => null, + 'power' => null, + 'ppm' => null, + 'color' => $color, + 'subtype' => null, + ]; + } + + //Diodes named only by a keyword ("diode", "LED", …) are a fallback after the passive checks, + //so "220R resistor for LED" stays a resistor (its resistance is detected first). + $kwSubtype = $this->detectDiodeKeyword($text); + if ($kwSubtype !== null) { + return $this->buildDiodeGuess($part, $kwSubtype); + } + + return null; + } + + /** + * Recognises a diode from an unambiguous part-number family (1N4148, 1N400x, BAT54, BZX…, SMBJ…). + * These are checked before the passive-value parsing, as their "1N…" style would otherwise be + * misread as an RKM capacitance/resistance. + * + * @return 'led'|'zener'|'schottky'|'tvs'|'diode'|null + */ + private function detectDiodePartNumber(string $text): ?string + { + //Zener families: BZX/BZV/BZT and 1N47xx / 1N52xx (with an optional letter suffix, e.g. 1N4733A). + if (preg_match('/\bbz[xvt]\d/u', $text) === 1 + || preg_match('/\b1n(4[67]\d{2}|52\d{2})[a-z]?\b/u', $text) === 1) { + return 'zener'; + } + //Schottky families: BAT, 1N58xx, MBR. + if (preg_match('/\bbat\d/u', $text) === 1 + || preg_match('/\b1n58\d{2}[a-z]?\b/u', $text) === 1 + || preg_match('/\bmbr\d/u', $text) === 1) { + return 'schottky'; + } + //TVS families: SMAJ/SMBJ, P6KE, 1.5KE (the KE families carry a voltage suffix, e.g. P6KE18A). + if (preg_match('/\bsm[ab]j\d/u', $text) === 1 + || preg_match('/\bp6ke\d/u', $text) === 1 + || preg_match('/\b1\.5ke\d/u', $text) === 1) { + return 'tvs'; + } + //General-purpose / rectifier families: 1N4148, 1N400x, 1N914, BAV/BAS (optional letter suffix). + if (preg_match('/\b1n(400\d|4148|914)[a-z]?\b/u', $text) === 1 + || preg_match('/\bba[vs]\d/u', $text) === 1) { + return 'diode'; + } + + return null; + } + + /** + * Recognises a diode from a descriptive keyword ("diode", "LED", "Zener", "Schottky", "TVS"). + * Weaker than a part-number match, so this is only consulted after the passive-value checks. + * + * @return 'led'|'zener'|'schottky'|'tvs'|'diode'|null + */ + private function detectDiodeKeyword(string $text): ?string + { + if (preg_match('/\bled\b/u', $text) === 1 || preg_match('/light[- ]emitting/u', $text) === 1) { + return 'led'; + } + if (preg_match('/\bzener\b/u', $text) === 1) { + return 'zener'; + } + if (preg_match('/\bschottky\b/u', $text) === 1) { + return 'schottky'; + } + if (preg_match('/\btvs\b/u', $text) === 1 || preg_match('/transient|transil/u', $text) === 1) { + return 'tvs'; + } + if (preg_match('/\bdiode\b/u', $text) === 1 || preg_match('/\brectifier\b/u', $text) === 1) { + return 'diode'; + } + + return null; + } + + /** + * Recognises a named THT/SMD diode or LED package (DO-41, SOD-123, a 3/5/10 mm LED dome, ...) + * from the footprint name / name / description, else falls back to an imperial chip code + * (0805, ...) for diodes/LEDs labelled like a resistor chip. The LED dome sizes are only + * meaningful (and only checked) when $subtype is 'led'. + */ + private function detectDiodePackage(Part $part, string $subtype): ?string + { + $haystacks = []; + if ($part->getFootprint() !== null) { + $haystacks[] = $part->getFootprint()->getName(); + } + $haystacks[] = $part->getName(); + $haystacks[] = $part->getDescription(); + + $patterns = [ + 'DO-41' => '/\bdo[\s-]?41\b/iu', + 'DO-35' => '/\bdo[\s-]?35\b/iu', + 'DO-15' => '/\bdo[\s-]?15\b/iu', + 'DO-201' => '/\bdo[\s-]?201\w*\b/iu', + 'SOD-123' => '/\bsod[\s-]?123\b/iu', + 'SOD-323' => '/\bsod[\s-]?323\b/iu', + 'SOT-23' => '/\bsot[\s-]?23\b/iu', + 'SMA' => '/\bsma\b/iu', + 'SMB' => '/\bsmb\b/iu', + 'SMC' => '/\bsmc\b/iu', + ]; + + foreach ($haystacks as $text) { + if ($text === '') { + continue; + } + foreach ($patterns as $token => $pattern) { + if (preg_match($pattern, $text) === 1) { + return $token; + } + } + } + + if ($subtype === 'led') { + foreach ($haystacks as $text) { + if ($text !== '' && preg_match('/\b(3|5|10)\s?mm\b/iu', $text, $m) === 1) { + return $m[1].'MM'; + } + } + } + + return $this->detectSmdPackage($part); + } + + /** + * Extracts a recognisable diode/rectifier part-number marking (e.g. "1N4001", "BAT54") from the + * name/description, for printing on the generated drawing. Not used for LEDs, which aren't + * normally marked with their part number. + */ + private function detectDiodeMarking(Part $part): ?string + { + $text = $part->getName().' '.$part->getDescription(); + if (preg_match('/\b(1N\d{3,4}[A-Za-z]?|BZX\d{2}[A-Za-z0-9]*|BAT\d{2,3}[A-Za-z]?|BAV\d{2,3}|BAS\d{2,3}|MBR\d+[A-Za-z]?|SMBJ\d+[A-Za-z]?|SMAJ\d+[A-Za-z]?|P6KE\d+[A-Za-z]?)\b/u', $text, $m) === 1) { + return mb_strtoupper($m[1]); + } + + return null; + } + + /** + * Builds the classification array for a diode of the given kind, filling in the emission colour + * (LEDs) or rated/forward voltage (Zener / TVS) where they can be read from the part. + * + * @param 'led'|'zener'|'schottky'|'tvs'|'diode' $subtype + * + * @return array{type: 'diode', value: float, package: string|null, voltage: int|null, tolerance: null, + * pitch: null, diameter: null, power: null, ppm: null, color: string|null, subtype: string, + * marking: string|null} + */ + private function buildDiodeGuess(Part $part, string $subtype): array + { + $color = $this->detectBodyColor($part); + if ($subtype === 'led') { + //The "colour" of an LED is its emission colour; default to a typical red. + $color ??= '#c0392b'; + } + $voltage = ($subtype === 'zener' || $subtype === 'tvs') ? $this->detectVoltage($part) : null; + + return [ + 'type' => 'diode', + 'value' => (float) ($voltage ?? 0), + 'package' => $this->detectDiodePackage($part, $subtype), + 'voltage' => $voltage, + 'tolerance' => null, + 'pitch' => null, + 'diameter' => null, + 'power' => null, + 'ppm' => null, + 'color' => $color, + 'subtype' => $subtype, + //LEDs aren't normally marked with their part number, unlike axial diodes/rectifiers. + 'marking' => $subtype !== 'led' ? $this->detectDiodeMarking($part) : null, + ]; + } + + /** Temperature coefficient in ppm/K (e.g. "50ppm", "±25 ppm/°C") from the name/description, else null. */ + private function detectPpm(Part $part): ?int + { + $text = $part->getName().' '.$part->getDescription(); + if (preg_match('/(\d+(?:[.,]\d+)?)\s*ppm/iu', $text, $m) === 1) { + return (int) round((float) str_replace(',', '.', $m[1])); + } + + return null; + } + + /** Rated power in watts (e.g. "0.25 W", "1/4 W", "1W") from the name/description, else null. */ + private function detectPower(Part $part): ?float + { + $text = $part->getName().' '.$part->getDescription(); + //Fractional watt, e.g. "1/4 W", "1/2W". + if (preg_match('#(\d+)\s*/\s*(\d+)\s*W(?![a-zA-Z0-9])#u', $text, $m) === 1 && (int) $m[2] !== 0) { + return (float) $m[1] / (float) $m[2]; + } + //Decimal watt, e.g. "0.25 W", "1 W", "0.5W". + if (preg_match('/(\d+(?:[.,]\d+)?)\s*W(?![a-zA-Z0-9])/u', $text, $m) === 1) { + return (float) str_replace(',', '.', $m[1]); + } + + return null; + } + + /** Detects a body colour word (e.g. "blue body") in the name/description; returns a hex colour or null. */ + private function detectBodyColor(Part $part): ?string + { + $text = mb_strtolower($part->getName().' '.$part->getDescription()); + //Ordered so more specific words win; each maps to the swatch used by the drawing. + $colors = [ + 'beige' => '#e8d9b5', 'tan' => '#e8d9b5', 'cream' => '#e8d9b5', + 'blue' => '#2f6db0', 'green' => '#2e7d4f', 'red' => '#b34a2f', + 'brown' => '#6b4a2f', 'black' => '#20242a', 'grey' => '#8a9099', + 'gray' => '#8a9099', 'purple' => '#7b4fb0', 'violet' => '#7b4fb0', + 'amber' => '#e0a63a', 'yellow' => '#e0a63a', 'white' => '#e8e8e8', + ]; + foreach ($colors as $word => $hex) { + if (preg_match('/\b'.$word.'\b/u', $text) === 1) { + return $hex; + } + } + + return null; + } + + /** Lead pitch in mm, from a Pitch/RM parameter or the name ("pitch 2.54mm", "RM5"), else null. */ + private function detectPitch(Part $part): ?float + { + try { + foreach ($part->getParameters() as $param) { + if (preg_match('/pitch|lead spacing|raster|\brm\b|pin distance/u', mb_strtolower($param->getName())) === 1 + && $param->getValueTypical() !== null && $param->getValueTypical() > 0) { + return (float) $param->getValueTypical(); + } + } + } catch (\Throwable) { + //fall through to text parsing + } + + $text = $part->getName().' '.$part->getDescription(); + if (preg_match('/(?:pitch|rm|raster)\s*[:=]?\s*(\d+(?:[.,]\d+)?)\s*mm?/iu', $text, $m) === 1 + || preg_match('/(\d+(?:[.,]\d+)?)\s*mm\s*pitch/iu', $text, $m) === 1) { + return (float) str_replace(',', '.', $m[1]); + } + + return null; + } + + /** Body diameter in mm, from a Diameter/Size parameter or the name ("⌀5mm"), else null. */ + private function detectDiameter(Part $part): ?float + { + try { + foreach ($part->getParameters() as $param) { + if (preg_match('/diameter|durchmesser|body size/u', mb_strtolower($param->getName())) === 1 + && $param->getValueTypical() !== null && $param->getValueTypical() > 0) { + return (float) $param->getValueTypical(); + } + } + } catch (\Throwable) { + //fall through to text parsing + } + + $text = $part->getName().' '.$part->getDescription(); + if (preg_match('/[⌀Ø]\s*(\d+(?:[.,]\d+)?)/u', $text, $m) === 1 + || preg_match('/(?:diameter|durchmesser)\s*[:=]?\s*(\d+(?:[.,]\d+)?)\s*mm?/iu', $text, $m) === 1) { + return (float) str_replace(',', '.', $m[1]); + } + + return null; + } + + /** Rated voltage in volts, from a Voltage parameter or the name/description ("50V"), else null. */ + private function detectVoltage(Part $part): ?int + { + try { + foreach ($part->getParameters() as $param) { + if (preg_match('/voltage|spannung|\bvdc\b/u', mb_strtolower($param->getName())) === 1 + && $param->getValueTypical() !== null && $param->getValueTypical() > 0) { + return (int) round($param->getValueTypical()); + } + } + } catch (\Throwable) { + //fall through to text parsing + } + + if (preg_match('/(\d+(?:[.,]\d+)?)\s*V(?:DC|AC)?\b/iu', $part->getName().' '.$part->getDescription(), $m) === 1) { + return (int) round((float) str_replace(',', '.', $m[1])); + } + + return null; + } + + /** Tolerance as a display string (e.g. "±10%") from a Tolerance parameter or the name, else null. */ + private function detectTolerance(Part $part): ?string + { + try { + foreach ($part->getParameters() as $param) { + if (preg_match('/toleran/u', mb_strtolower($param->getName())) !== 1) { + continue; + } + $text = trim($param->getValueText() ?? ''); + if ($text !== '') { + return $text; + } + if ($param->getValueTypical() !== null) { + return '±'.rtrim(rtrim(sprintf('%.2f', $param->getValueTypical()), '0'), '.').'%'; + } + } + } catch (\Throwable) { + //fall through to text parsing + } + + $text = $part->getName().' '.$part->getDescription(); + if (preg_match('/±\s*(\d+(?:[.,]\d+)?)\s*%/u', $text, $m) === 1 + || preg_match('/\b(\d+(?:[.,]\d+)?)\s*%/u', $text, $m) === 1) { + return '±'.str_replace(',', '.', $m[1]).'%'; + } + + return null; + } + + /** + * Extracts the resistance (ohms), capacitance (farads) and/or inductance (henries) of a part: + * first from its parameters, then (for parts named by their value, e.g. "10nF") from the name. + * + * @return array{0: float|null, 1: float|null, 2: float|null} [ohms, farads, henries] + */ + public function extractValue(Part $part): array + { + try { + [$ohms, $farads, $henries] = $this->fromParameters($part); + if ($ohms !== null || $farads !== null || $henries !== null) { + return [$ohms, $farads, $henries]; + } + + $text = trim($part->getName().' '.$part->getDescription()); + + //Farad and henry units are unambiguous, so a match in the name wins over resistance. + $farads = $this->parseFaradsFromText($text); + if ($farads !== null) { + return [null, $farads, null]; + } + $henries = $this->parseHenriesFromText($text); + if ($henries !== null) { + return [null, null, $henries]; + } + + return [$this->parseOhmsFromText($text), null, null]; + } catch (\Throwable) { + return [null, null, null]; + } + } + + /** + * Reads the resistance/capacitance/inductance from the part's parameters. The number lives in + * value_typical; its SI prefix is baked into the unit string (e.g. 4.7 + "kΩ" -> 4700 Ω). + * + * @return array{0: float|null, 1: float|null, 2: float|null} [ohms, farads, henries] + */ + private function fromParameters(Part $part): array + { + $ohms = null; + $farads = null; + $henries = null; + + foreach ($part->getParameters() as $param) { + $name = mb_strtolower($param->getName()); + $unit = trim($param->getUnit() ?? ''); + + $isRes = preg_match('/resist|widerstand|ohm/u', $name) === 1 + || str_contains($unit, 'Ω') || stripos($unit, 'ohm') !== false; + $isCap = preg_match('/capacit|kapazit|farad/u', $name) === 1 + || preg_match('/^(meg|[pnuµmkMg])?F$/u', $unit) === 1; + $isInd = preg_match('/induct|induktivit/u', $name) === 1 + || preg_match('/^(meg|[pnuµmk])?H$/u', $unit) === 1; + + if (!$isRes && !$isCap && !$isInd) { + continue; + } + + $num = $param->getValueTypical(); + if ($num === null || $num <= 0) { + continue; + } + + $prefix = (string) preg_replace('/(Ω|ohms?|F|farads?|H|henr(y|ies))$/iu', '', $unit); + $value = $num * $this->prefixFactor($prefix); + + if ($isRes && $ohms === null) { + $ohms = $value; + } elseif ($isCap && $farads === null) { + $farads = $value; + } elseif ($isInd && $henries === null) { + $henries = $value; + } + } + + return [$ohms, $farads, $henries]; + } + + /** Parses a capacitance (farads) out of free text like "10nF", "0.1uF" or "4n7", else null. */ + private function parseFaradsFromText(string $text): ?float + { + if (preg_match('/(\d+(?:[.,]\d+)?)\s*(p|n|u|µ|m)?F\b/iu', $text, $m) === 1) { + return (float) str_replace(',', '.', $m[1]) * $this->prefixFactor(mb_strtolower($m[2] ?? '')); + } + //RKM notation, e.g. 4n7 = 4.7 nF, 2p2 = 2.2 pF. + if (preg_match('/\b(\d+)(p|n|u|µ)(\d+)\b/iu', $text, $m) === 1) { + return (float) ($m[1].'.'.$m[3]) * $this->prefixFactor(mb_strtolower($m[2])); + } + + return null; + } + + /** Parses an inductance (henries) out of free text like "100µH", "10mH", "4.7uH" or "1H", else null. */ + private function parseHenriesFromText(string $text): ?float + { + //Uppercase H only (so "MHz" and "100h" hours don't match); the (?![a-zA-Z0-9]) avoids "MHz". + if (preg_match('/(\d+(?:[.,]\d+)?)\s*(p|n|u|µ|m)?H(?![a-zA-Z0-9])/u', $text, $m) === 1) { + return (float) str_replace(',', '.', $m[1]) * $this->prefixFactor(mb_strtolower($m[2] ?? '')); + } + + return null; + } + + /** Parses a resistance (ohms) out of free text like "4k7", "10k", "470R" or "4.7kΩ", else null. */ + private function parseOhmsFromText(string $text): ?float + { + //RKM notation, e.g. 4k7 = 4.7 kΩ, 1R5 = 1.5 Ω, 2M2 = 2.2 MΩ. + //NB: we use (?ohmPrefixFactor($m[2]); + + return (float) ($m[1].'.'.$m[3]) * $factor; + } + //Number followed by a magnitude letter, e.g. 10k, 4.7M, 470R, or "10 kΩ" / "1 MΩ" with a unit. + if (preg_match('/(\d+(?:[.,]\d+)?)\s*(k|K|M|G|R)(?![a-zA-Z0-9])/u', $text, $m) === 1) { + if (strtoupper($m[2]) === 'R') { + return (float) str_replace(',', '.', $m[1]); + } + + return (float) str_replace(',', '.', $m[1]) * $this->ohmPrefixFactor($m[2]); + } + //Explicit ohm unit, e.g. 470Ω, 1 ohm. + if (preg_match('/(\d+(?:[.,]\d+)?)\s*(?:Ω|ohms?)/iu', $text, $m) === 1) { + return (float) str_replace(',', '.', $m[1]); + } + + return null; + } + + /** kilo/mega/giga factor for a resistance magnitude letter ("M" means mega in this context). */ + private function ohmPrefixFactor(string $p): float + { + return match (mb_strtolower($p)) { + 'k' => 1e3, + 'm' => 1e6, + 'g' => 1e9, + default => 1.0, + }; + } + + /** + * Returns the SMD package code (e.g. "0603") if the part looks surface-mount, else null. + * Checks the footprint name, then the part name/description, for a known chip code. + */ + private function detectSmdPackage(Part $part): ?string + { + $haystacks = []; + if ($part->getFootprint() !== null) { + $haystacks[] = $part->getFootprint()->getName(); + } + $haystacks[] = $part->getName(); + $haystacks[] = $part->getDescription(); + + foreach ($haystacks as $text) { + if ($text === '') { + continue; + } + foreach (self::SMD_PACKAGES as $pkg) { + //Match the code as a standalone token so "0603" doesn't match inside "10603". + if (preg_match('/(^|[^0-9])'.$pkg.'([^0-9]|$)/', $text) === 1) { + return $pkg; + } + } + } + + return null; + } + + /** + * SI prefix -> factor. Only the single-letter "m" (milli) vs "M" (mega) distinction is + * case-sensitive; every other prefix (including the spelled-out "meg" = mega) is matched + * case-insensitively. + */ + private function prefixFactor(string $prefix): float + { + $prefix = trim($prefix); + if ($prefix === '' || $prefix === 'M') { + return $prefix === 'M' ? 1e6 : 1.0; + } + if ($prefix === 'm') { + return 1e-3; + } + + $factors = ['p' => 1e-12, 'n' => 1e-9, 'u' => 1e-6, 'µ' => 1e-6, 'k' => 1e3, 'meg' => 1e6, 'g' => 1e9]; + + return $factors[mb_strtolower($prefix)] ?? 1.0; + } +} diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 97792aa81..dc1b0d3b7 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -140,6 +140,12 @@ protected function getToolsNode(): array $this->urlGenerator->generate('tools_ic_logos') ))->setIcon('fa-treeview fa-fw fa-solid fa-flag'); } + if ($this->security->isGranted('@tools.component_image_generator')) { + $nodes[] = (new TreeViewNode( + $this->translator->trans('tools.value_calc.title'), + $this->urlGenerator->generate('tools_component_image_generator') + ))->setIcon('fa-treeview fa-fw fa-solid fa-palette'); + } if ($this->security->isGranted('@parts.import')) { $nodes[] = (new TreeViewNode( $this->translator->trans('parts.import.title'), diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php index 6da9a0040..378b27a1d 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -174,6 +174,7 @@ private function readOnly(HasPermissionsInterface $perm_holder): HasPermissionsI $this->permissionResolver->setPermission($perm_holder, 'tools', 'reel_calculator', PermissionData::ALLOW); $this->permissionResolver->setPermission($perm_holder, 'tools', 'builtin_footprints_viewer', PermissionData::ALLOW); $this->permissionResolver->setPermission($perm_holder, 'tools', 'ic_logos', PermissionData::ALLOW); + $this->permissionResolver->setPermission($perm_holder, 'tools', 'component_image_generator', PermissionData::ALLOW); //Set attachments permissions $this->permissionResolver->setPermission($perm_holder, 'attachments', 'list_attachments', PermissionData::ALLOW); diff --git a/templates/components/attachments.macro.html.twig b/templates/components/attachments.macro.html.twig index 96482d754..e4002610c 100644 --- a/templates/components/attachments.macro.html.twig +++ b/templates/components/attachments.macro.html.twig @@ -1,5 +1,5 @@ -{# Renders a editable list of all attachments. form is the Attachment CollectionType #} -{% macro attachment_edit_list(form, part_mode = false) %} +{# Renders a editable list of all attachments. form is the Attachment CollectionType, parent is the element that holds the attachments #} +{% macro attachment_edit_list(form, parent = null, image_generator_btn = false) %} {% form_theme form with ['parts/edit/edit_form_styles.html.twig'] %} {% import 'components/collection_type.macro.html.twig' as collection %} @@ -13,15 +13,21 @@
- - + + {% if image_generator_btn and parent and parent.id is not null and is_granted('edit', parent) and is_granted('@tools.component_image_generator') %} + + {% endif %}
{% endmacro %} @@ -44,4 +50,4 @@ {% elseif not attachment_helper.fileExisting(attachment) %} {% endif %} -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index 90f8a3e18..42a30f519 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -78,6 +78,9 @@ + + + + + + + {% endif %} + + + + {# min-height keeps the card the same size on every tab, so switching tabs doesn't resize the panel #} +
+ {# ---------------- Resistor color code ---------------- #} +
+
+
+
+ +
+
+ + + + + + + + +
+
+
+ + {# Band color selects are rendered by the Stimulus controller #} +
+ +
+ +
+
+ + +
+
{% trans %}tools.value_calc.resistor.from_value_help{% endtrans %}
+
+
+ +
+ {% trans %}tools.value_calc.appearance{% endtrans %} +
+
+ +
+ +
+
+
+ +
+ +
+ + + + +
+
+
+
+
+
+
+
+
{{ '' }} +
+
+
+
+
+
+
+ + {# ---------------- Capacitor code ---------------- #} +
+

{% trans %}tools.value_calc.capacitor.intro{% endtrans %}

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {% trans %}tools.value_calc.appearance{% endtrans %} +
+
+ +
+ +
+ + + + + +
+
+
+
+ +
+
+ {% trans %}tools.value_calc.cap.shape{% endtrans %} + +
+
+ {% trans %}tools.value_calc.cap.lead{% endtrans %} + +
+
+
+
+ +
+
+ {% trans %}tools.value_calc.cap.diameter{% endtrans %} + + mm + + + + + +
+
+ {% trans %}tools.value_calc.cap.pitch{% endtrans %} + +
+
+ {% trans %}tools.value_calc.cap.voltage{% endtrans %} + + V + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {# ---------------- SMD resistor code ---------------- #} +
+
+
+
{% trans %}tools.value_calc.smd.on_chip_help{% endtrans %}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {% trans %}tools.value_calc.appearance{% endtrans %} +
+
+ +
+ +
+
+
+ +
+ +
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {# ---------------- THT inductor colour code ---------------- #} +
+

{% trans %}tools.value_calc.inductor.intro{% endtrans %}

+
+
+
+ +
+
+ + + + + +
+
+
+ {# Band colour selects are rendered by the Stimulus controller #} +
+ +
+ +
+
+ + +
+
{% trans %}tools.value_calc.inductor.from_value_help{% endtrans %}
+
+
+ +
+ {% trans %}tools.value_calc.appearance{% endtrans %} +
+
+ +
+ +
+ + + + +
+
+
+
+
+
+
+
+
{{ '' }} +
+
+
+
+
+
+ + {# ---------------- SMD inductor code ---------------- #} +
+

{% trans %}tools.value_calc.smd_inductor.intro{% endtrans %}

+
+
+
+
+ + +
+
+ + +
+
+ +
+ {% trans %}tools.value_calc.appearance{% endtrans %} +
+
+ +
+ +
+
+
+ +
+ +
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + {% if part is not null %} + + {% endif %} +
+ diff --git a/templates/tools/component_image_generator/_generate_modal.html.twig b/templates/tools/component_image_generator/_generate_modal.html.twig new file mode 100644 index 000000000..0b5fed934 --- /dev/null +++ b/templates/tools/component_image_generator/_generate_modal.html.twig @@ -0,0 +1,23 @@ +{# The value-calculator generator modal (lazy-loaded via a Turbo frame). Include ONCE per page + where a generate trigger button is shown. Expects `part` in scope. #} +{% if part.id is not null and is_granted('edit', part) and is_granted('@tools.component_image_generator') %} + +{% endif %} diff --git a/templates/tools/component_image_generator/bulk_generate.html.twig b/templates/tools/component_image_generator/bulk_generate.html.twig new file mode 100644 index 000000000..5fb9aa7ae --- /dev/null +++ b/templates/tools/component_image_generator/bulk_generate.html.twig @@ -0,0 +1,484 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}tools.bulk_gen.title{% endtrans %}{% endblock %} + +{% block card_title %} + {% trans %}tools.bulk_gen.title{% endtrans %} +{% endblock %} + +{% block card_content %} +

{% trans %}tools.bulk_gen.intro{% endtrans %}

+ +
+ + {% trans %}tools.bulk_gen.help.title{% endtrans %} + + {# Example values as blue "chips", parameter names as grey chips — theme-aware (Bootstrap 5.3 subtle utilities). #} + {% set chip = 'badge fw-normal font-monospace bg-primary-subtle text-primary-emphasis border border-primary-subtle' %} + {% set pchip = 'badge fw-normal font-monospace bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle' %} +
+

{% trans %}tools.bulk_gen.help.intro{% endtrans %}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans %}tools.bulk_gen.help.col_field{% endtrans %}{% trans %}tools.bulk_gen.help.col_param{% endtrans %}{% trans %}tools.bulk_gen.help.col_text{% endtrans %}
{% trans %}tools.bulk_gen.help.value{% endtrans %}Resistance Capacitance Ω / F
10nF0.1µF4n74k7470R10k1M
{% trans %}tools.bulk_gen.help.voltage{% endtrans %}Voltage
50V100V
{% trans %}tools.bulk_gen.help.tolerance{% endtrans %}Tolerance
±5%10%
{% trans %}tools.bulk_gen.help.pitch{% endtrans %}Pitch RM Lead spacing
pitch 5mmRM55mm pitch
{% trans %}tools.bulk_gen.help.diameter{% endtrans %}Diameter
⌀5mmdiameter 5mm
{% trans %}tools.bulk_gen.help.smd{% endtrans %}{% trans %}tools.bulk_gen.help.footprint_col{% endtrans %}
04020603080512061210
+
+
+
+ + {% if with_picture > 0 and not overwrite %} +
+ {% trans with {'%count%': with_picture} %}tools.bulk_gen.with_picture{% endtrans %} + + {% trans with {'%count%': with_picture} %}tools.bulk_gen.regenerate{% endtrans %} + +
+ {% elseif overwrite %} +
+ {% trans %}tools.bulk_gen.overwrite_mode{% endtrans %} + {% trans %}tools.bulk_gen.overwrite_exit{% endtrans %} +
+ {% endif %} + + {% if selected_count == 0 or candidates is empty %} + {% if selected_count == 0 %} +
{% trans %}tools.bulk_gen.no_selection{% endtrans %}
+ {% elseif with_picture == 0 %} +
{% trans with {'%count%': selected_count} %}tools.bulk_gen.none{% endtrans %}
+ {% endif %} + + {% trans %}tools.bulk_gen.back{% endtrans %} + + {% else %} +
+ {# One hidden value-calculator, reused to render every preview below. #} + + +
+

+ {{ candidates|length }} {% trans %}tools.bulk_gen.found{% endtrans %} + {%- if skipped > 0 %} ({{ skipped }} {% trans %}tools.bulk_gen.skipped{% endtrans %}){% endif %} +

+ + {% trans %}tools.bulk_gen.back{% endtrans %} + +
+ + {% set first = candidates|first %} + {% set batch_color = first.color ?: (first.type == 'capacitor' ? '#e0a63a' : (first.type == 'resistor' ? '#e8d9b5' : '#20242a')) %} +
+
+ {% trans %}tools.bulk_gen.apply_all{% endtrans %}: + {% if has_caps %} +
+ {% trans %}tools.value_calc.cap.shape{% endtrans %} + +
+ {% endif %} + {% if has_caps or has_tht_resistors or has_inductors %} +
+ {% trans %}tools.value_calc.cap.lead{% endtrans %} + +
+ {% endif %} + {% if has_caps %} +
+ {% trans %}tools.value_calc.cap.diameter{% endtrans %} + + mm +
+
+ {% trans %}tools.value_calc.cap.pitch{% endtrans %} + +
+ {% endif %} + {% if has_tht_resistors %} +
+ {% trans %}tools.value_calc.field.power{% endtrans %} + +
+
+ {% trans %}tools.value_calc.field.ppm{% endtrans %} + +
+ {% endif %} + {% if has_smd_resistors or has_smd_inductors or has_smd_capacitors %} +
+ {% trans %}tools.value_calc.smd.package{% endtrans %} + +
+ {% endif %} +
+ {% trans %}tools.value_calc.cap.voltage{% endtrans %} + + V +
+
+ {% trans %}tools.value_calc.field.tolerance{% endtrans %} + +
+
+ +
+ + + + + + +
+
+
+
+ + {# Editable KiCad column suggestions: pick a common symbol/footprint or type your own. #} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{% trans %}tools.bulk_gen.kicad_hint{% endtrans %}

+
+ + + + + + + + + + + + + + + {% for c in candidates %} + {% set name_keys = { + 'capacitor': 'tools.value_calc.capacitor.title', + 'smd_capacitor': 'tools.value_calc.capacitor.title', + 'inductor': 'tools.value_calc.inductor.title', + 'smd_inductor': 'tools.value_calc.inductor.title', + 'resistor': 'tools.value_calc.resistor.title', + 'diode': 'tools.value_calc.diode.title', + } %} + {% set name_key = name_keys[c.type]|default('tools.value_calc.smd.title') %} + + + + + + + + + + + {% endfor %} + +
{% trans %}tools.bulk_gen.part{% endtrans %}{% trans %}tools.bulk_gen.type{% endtrans %}{% trans %}tools.bulk_gen.value{% endtrans %}{% trans %}tools.bulk_gen.appearance{% endtrans %}{% trans %}tools.value_calc.field.tolerance{% endtrans %}{% trans %}tools.bulk_gen.kicad{% endtrans %}{% trans %}tools.bulk_gen.preview{% endtrans %}
+ {{ c.part.name }} + {%- if c.has_picture %} {% trans %}tools.bulk_gen.has_picture{% endtrans %}{% endif %} + {{ c.subtype ?: c.type }} + {%- if c.type in ['capacitor', 'smd_capacitor'] -%} + {%- if c.value >= 1e-6 %}{{ (c.value / 1e-6)|round(3) }} µF + {%- elseif c.value >= 1e-9 %}{{ (c.value / 1e-9)|round(3) }} nF + {%- else %}{{ (c.value * 1e12)|round(1) }} pF{% endif -%} + {%- elseif c.type in ['inductor', 'smd_inductor'] -%} + {%- if c.value >= 1 %}{{ c.value|round(3) }} H + {%- elseif c.value >= 1e-3 %}{{ (c.value / 1e-3)|round(3) }} mH + {%- elseif c.value >= 1e-6 %}{{ (c.value / 1e-6)|round(3) }} µH + {%- else %}{{ (c.value / 1e-9)|round(1) }} nH{% endif -%} + {%- elseif c.type == 'diode' -%} + {%- if c.marking %}{{ c.marking }} + {%- elseif c.voltage %}{{ c.voltage }} V {{ (c.subtype ?: 'diode')|capitalize }} + {%- else %}{{ (c.subtype ?: 'diode')|capitalize }}{% endif -%} + {%- else -%} + {%- if c.value >= 1e6 %}{{ (c.value / 1e6)|round(3) }} MΩ + {%- elseif c.value >= 1e3 %}{{ (c.value / 1e3)|round(3) }} kΩ + {%- else %}{{ c.value|round(3) }} Ω{% endif -%} + {%- endif -%} + {% if c.package %} · {{ c.package }}{% endif %} + + {% set rowcolor = c.color ?: (c.type == 'capacitor' ? '#e0a63a' : (c.type == 'smd_capacitor' ? '#c8a37a' : (c.type == 'resistor' ? '#e8d9b5' : (c.type == 'inductor' ? '#2f6f4c' : (c.type == 'smd_inductor' ? '#38332e' : '#20242a'))))) %} +
+ + {% if c.type == 'capacitor' %} + + {% endif %} +
+ {% if c.type == 'capacitor' %} + {% set rp = c.pitch ?: 5.08 %} + + {% elseif c.type == 'resistor' %} + {% set rpow = c.power ?: 0.25 %} +
+ + +
+ {% elseif c.type in ['smd_resistor', 'smd_inductor', 'smd_capacitor'] %} + {% set rpkg = c.package ?: (c.type == 'smd_inductor' ? '1210' : '0805') %} + + {% endif %} + {# Rated voltage — printed on the generated image. Not shown for LEDs (no voltage marking). #} + {% if not (c.type == 'diode' and c.subtype == 'led') %} +
+ + V +
+ {% endif %} +
+ {% if c.type in ['diode', 'smd_capacitor'] %} + — + {% else %} + {% set rtol = c.tolerance ? c.tolerance|replace({'±': '', '%': '', ' ': '', 'pF': '', 'PF': ''})|trim : '' %} + + {% endif %} + + +
+ + +
+
+
+ +
+ + +
+
0/0
+
+ + {% trans %}tools.bulk_gen.back{% endtrans %} + +
+
+ {% endif %} +{% endblock %} diff --git a/templates/tools/component_image_generator/image_generator.html.twig b/templates/tools/component_image_generator/image_generator.html.twig new file mode 100644 index 000000000..bc0769ef9 --- /dev/null +++ b/templates/tools/component_image_generator/image_generator.html.twig @@ -0,0 +1,11 @@ +{% extends modalMode|default(false) ? 'tools/component_image_generator/_bare.html.twig' : 'main_card.html.twig' %} + +{% block title %}{% trans %}tools.value_calc.title{% endtrans %}{% endblock %} + +{% block card_title %} + {% trans %}tools.value_calc.title{% endtrans %} +{% endblock %} + +{% block card_content %} + {% include 'tools/component_image_generator/_calculator_body.html.twig' %} +{% endblock %} diff --git a/tests/Controller/ValueCalculatorControllerTest.php b/tests/Controller/ValueCalculatorControllerTest.php new file mode 100644 index 000000000..85ced8002 --- /dev/null +++ b/tests/Controller/ValueCalculatorControllerTest.php @@ -0,0 +1,228 @@ +. + */ + +namespace App\Tests\Controller; + +use App\Entity\Parts\Part; +use App\Entity\UserSystem\User; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\DomCrawler\Crawler; + +/** + * Functional smoke tests for the value calculator tool and the "Generate component images" + * bulk action. They exercise the controller entry points (access control, classification loop, + * EDA suggestions, rendering) end to end. Part edits are rolled back by DAMADoctrineTestBundle. + */ +#[Group('DB')] +#[Group('slow')] +final class ValueCalculatorControllerTest extends WebTestCase +{ + private function loginAdmin(): KernelBrowser + { + return $this->loginAs('admin'); + } + + private function loginAs(string $username): KernelBrowser + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['name' => $username]); + if ($user === null) { + $this->markTestSkipped("Fixture user '$username' not found."); + } + $client->loginUser($user); + $client->followRedirects(false); + + return $client; + } + + /** + * Part-DB answers an unauthorized request with 401/403 or a redirect (to the login/permission + * page) depending on context, so accept any of those — the point is that access is refused. + */ + private function assertDenied(KernelBrowser $client): void + { + $code = $client->getResponse()->getStatusCode(); + $this->assertTrue( + $code === 401 || $code === 403 || $client->getResponse()->isRedirect(), + "Expected 401/403/redirect for an unauthorized request, got $code" + ); + } + + public function testValueCalculatorPageLoads(): void + { + $client = $this->loginAdmin(); + $client->request('GET', '/en/tools/component_image_generator'); + self::assertResponseIsSuccessful(); + } + + public function testValueCalculatorPrefillsFromPart(): void + { + $client = $this->loginAdmin(); + $client->request('GET', '/en/tools/component_image_generator?part=1'); + self::assertResponseIsSuccessful(); + } + + public function testBulkGenerateWithoutSelection(): void + { + $client = $this->loginAdmin(); + $client->request('GET', '/en/tools/bulk_generate_images'); + self::assertResponseIsSuccessful(); + } + + public function testBulkGenerateClassifiesAResistor(): void + { + $client = $this->loginAdmin(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $part = $em->find(Part::class, 1); + if ($part === null) { + $this->markTestSkipped('Fixture part #1 not found.'); + } + //Make the part look like a resistor without a picture so it becomes a candidate and the + //classification + EDA-suggestion code path runs. + $part->setName('Resistor 10kΩ 0.25W 1% blue body 50ppm'); + $part->setMasterPictureAttachment(null); + $em->flush(); + + $client->request('GET', '/en/tools/bulk_generate_images?ids=1'); + self::assertResponseIsSuccessful(); + } + + public function testBulkGenerateOverwriteMode(): void + { + $client = $this->loginAdmin(); + $client->request('GET', '/en/tools/bulk_generate_images?ids=1&overwrite=1'); + self::assertResponseIsSuccessful(); + } + + public function testGenerateImageAttachesPicture(): void + { + $client = $this->loginAdmin(); + $row = $this->resistorCandidateRow($client); + //The row carries the per-part generate endpoint and its CSRF token (as the JS uses them). + $client->request('POST', (string) $row->attr('data-endpoint'), [ + 'svg' => '', + 'name' => 'Test generated image', + 'preview' => '1', + '_token' => (string) $row->attr('data-csrf'), + ], [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']); + + self::assertResponseIsSuccessful(); + $payload = json_decode((string) $client->getResponse()->getContent(), true); + self::assertIsArray($payload); + self::assertTrue($payload['success'] ?? false, 'Expected {success: true} from the generate-image endpoint.'); + + //The picture must actually be attached and (preview=1) set as the master picture. + $em = static::getContainer()->get(EntityManagerInterface::class); + $em->clear(); + $part = $em->find(Part::class, 1); + self::assertNotNull($part->getMasterPictureAttachment(), 'Generated image should be set as the master picture.'); + } + + public function testGenerateImageRejectsInvalidCsrf(): void + { + $client = $this->loginAdmin(); + $row = $this->resistorCandidateRow($client); + $client->request('POST', (string) $row->attr('data-endpoint'), [ + 'svg' => '', + 'name' => 'Should be rejected', + '_token' => 'definitely-not-a-valid-token', + ], [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']); + + $this->assertDenied($client); + } + + public function testValueCalculatorDeniedWithoutPermission(): void + { + $client = $this->loginAs('noread'); + $client->request('GET', '/en/tools/component_image_generator'); + $this->assertDenied($client); + } + + public function testBulkGenerateDeniedWithoutPermission(): void + { + $client = $this->loginAs('noread'); + $client->request('GET', '/en/tools/bulk_generate_images?ids=1'); + $this->assertDenied($client); + } + + public function testGenerateImageDeniedWithoutEditPermission(): void + { + $client = $this->loginAs('noread'); + $client->request('POST', '/en/part/1/generate_image', [ + 'svg' => '', + '_token' => 'irrelevant-edit-is-checked-first', + ], [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']); + $this->assertDenied($client); + } + + public function testSetEdaWritesKicadFields(): void + { + $client = $this->loginAdmin(); + $row = $this->resistorCandidateRow($client); + $client->request('POST', (string) $row->attr('data-eda-endpoint'), [ + 'kicad_symbol' => 'Device:R', + 'reference_prefix' => 'R', + 'kicad_footprint' => 'Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal', + '_token' => (string) $row->attr('data-eda-csrf'), + ], [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']); + + self::assertResponseIsSuccessful(); + $payload = json_decode((string) $client->getResponse()->getContent(), true); + self::assertTrue($payload['success'] ?? false, 'Expected {success: true} from the set-eda endpoint.'); + + //The EDA fields must actually be written to the part. + $em = static::getContainer()->get(EntityManagerInterface::class); + $em->clear(); + $eda = $em->find(Part::class, 1)->getEdaInfo(); + self::assertSame('Device:R', $eda->getKicadSymbol()); + self::assertSame('R', $eda->getReferencePrefix()); + self::assertSame('Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal', $eda->getKicadFootprint()); + } + + /** + * Renames fixture part #1 into a pictureless resistor and returns its candidate row from the + * bulk review page — which carries the generate/set-eda endpoints and their CSRF tokens. + */ + private function resistorCandidateRow(KernelBrowser $client): Crawler + { + $em = static::getContainer()->get(EntityManagerInterface::class); + $part = $em->find(Part::class, 1); + if ($part === null) { + $this->markTestSkipped('Fixture part #1 not found.'); + } + $part->setName('Resistor 10kΩ 0.25W 1% blue body'); + $part->setMasterPictureAttachment(null); + $em->flush(); + + $crawler = $client->request('GET', '/en/tools/bulk_generate_images?ids=1'); + self::assertResponseIsSuccessful(); + $row = $crawler->filter('tr[data-csrf]')->first(); + self::assertGreaterThan(0, $row->count(), 'Expected a classified candidate row on the bulk review page.'); + + return $row; + } +} diff --git a/tests/Services/Attachments/GeneratedImageAttachmentHelperTest.php b/tests/Services/Attachments/GeneratedImageAttachmentHelperTest.php new file mode 100644 index 000000000..472549cac --- /dev/null +++ b/tests/Services/Attachments/GeneratedImageAttachmentHelperTest.php @@ -0,0 +1,323 @@ +. + */ + +namespace App\Tests\Services\Attachments; + +use App\Entity\Attachments\AttachmentType; +use App\Entity\Attachments\AttachmentUpload; +use App\Entity\Attachments\PartAttachment; +use App\Entity\Parts\Part; +use App\Repository\StructuralDBElementRepository; +use App\Services\Attachments\AttachmentSubmitHandler; +use App\Services\Attachments\GeneratedImageAttachmentHelper; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +final class GeneratedImageAttachmentHelperTest extends TestCase +{ + private EntityManagerInterface&MockObject $em; + private AttachmentSubmitHandler&MockObject $submitHandler; + private StructuralDBElementRepository&MockObject $repository; + private GeneratedImageAttachmentHelper $service; + + protected function setUp(): void + { + $this->em = $this->createMock(EntityManagerInterface::class); + $this->submitHandler = $this->createMock(AttachmentSubmitHandler::class); + $this->repository = $this->createMock(StructuralDBElementRepository::class); + + $this->em->method('getRepository') + ->with(AttachmentType::class) + ->willReturn($this->repository); + + $this->service = new GeneratedImageAttachmentHelper($this->em, $this->submitHandler); + } + + /** + * Configures the submit handler mock to behave like a successful upload of a picture file. + * Returns an ArrayObject that gets filled with the AttachmentUpload instances the handler + * was called with (in call order) as the test runs. + * + * @return \ArrayObject + */ + private function mockSuccessfulPictureUpload(): \ArrayObject + { + $seenUploads = new \ArrayObject(); + $this->submitHandler->method('handleUpload') + ->willReturnCallback(function (PartAttachment $attachment, ?AttachmentUpload $upload) use ($seenUploads) { + $seenUploads[] = $upload; + $attachment->setInternalPath('attachments/1/generated.svg'); + + return $attachment; + }); + + return $seenUploads; + } + + private function setId(object $entity, ?int $id): void + { + $reflection = new \ReflectionClass($entity); + $reflection->getProperty('id')->setValue($entity, $id); + } + + /** + * Builds a fresh AttachmentType named "Generated image" and makes it the result of + * getGeneratedImageType(). $persisted controls whether it already has an ID (i.e. already exists in DB). + */ + private function mockGeneratedImageType(bool $persisted): AttachmentType + { + $type = new AttachmentType(); + $type->setName('Generated image'); + if ($persisted) { + $this->setId($type, 5); + } + + $this->repository->method('findOrCreateForInfoProvider') + ->with('Generated image') + ->willReturn($type); + + return $type; + } + + public function testAttachSvgToPartCreatesAttachmentWithGivenNameAndType(): void + { + $this->mockGeneratedImageType(persisted: true); + $uploads = $this->mockSuccessfulPictureUpload(); + + $part = new Part(); + $attachment = $this->service->attachSvgToPart($part, '', 'My resistor'); + + $this->assertSame('My resistor', $attachment->getName()); + $this->assertSame('Generated image', $attachment->getAttachmentType()?->getName()); + $this->assertTrue($part->getAttachments()->contains($attachment)); + + //The upload pipeline must receive the base64 encoded SVG data as a generated.svg file + $this->assertCount(1, $uploads); + $this->assertSame(base64_encode(''), $uploads[0]->data); + $this->assertSame('generated.svg', $uploads[0]->filename); + $this->assertNull($uploads[0]->file); + } + + public function testAttachSvgToPartFallsBackToDefaultNameWhenNameIsEmpty(): void + { + $this->mockGeneratedImageType(persisted: true); + $this->mockSuccessfulPictureUpload(); + + $part = new Part(); + $attachment = $this->service->attachSvgToPart($part, '', ''); + + $this->assertSame('Generated image', $attachment->getName()); + } + + public function testAttachSvgToPartSetsAsPreviewByDefault(): void + { + $this->mockGeneratedImageType(persisted: true); + $uploads = $this->mockSuccessfulPictureUpload(); + + $part = new Part(); + $attachment = $this->service->attachSvgToPart($part, '', 'Preview me'); + + $this->assertTrue($uploads[0]->becomePreviewIfEmpty); + $this->assertSame($attachment, $part->getMasterPictureAttachment()); + } + + public function testAttachSvgToPartDoesNotSetPreviewWhenNotRequested(): void + { + $this->mockGeneratedImageType(persisted: true); + $uploads = $this->mockSuccessfulPictureUpload(); + + $part = new Part(); + $this->service->attachSvgToPart($part, '', 'No preview', setAsPreview: false); + + $this->assertFalse($uploads[0]->becomePreviewIfEmpty); + $this->assertNull($part->getMasterPictureAttachment()); + } + + public function testAttachSvgToPartPersistsTheNewAttachment(): void + { + $this->mockGeneratedImageType(persisted: true); + $this->mockSuccessfulPictureUpload(); + + $persisted = []; + $this->em->method('persist')->willReturnCallback(function (object $entity) use (&$persisted): void { + $persisted[] = $entity; + }); + + $part = new Part(); + $attachment = $this->service->attachSvgToPart($part, '', 'Test'); + + $this->assertContains($attachment, $persisted); + } + + public function testAttachSvgToPartDeduplicatesNameOnCollision(): void + { + $type = $this->mockGeneratedImageType(persisted: true); + $this->mockSuccessfulPictureUpload(); + + $part = new Part(); + + $existing = new PartAttachment(); + $existing->setName('Resistor'); + $existing->setAttachmentType($type); + $part->addAttachment($existing); + + $attachment = $this->service->attachSvgToPart($part, '', 'Resistor'); + + $this->assertSame('Resistor (2)', $attachment->getName()); + } + + public function testAttachSvgToPartDeduplicatesNameSkippingAlreadyTakenNumbers(): void + { + $type = $this->mockGeneratedImageType(persisted: true); + $this->mockSuccessfulPictureUpload(); + + $part = new Part(); + + foreach (['Resistor', 'Resistor (2)'] as $name) { + $existing = new PartAttachment(); + $existing->setName($name); + $existing->setAttachmentType($type); + $part->addAttachment($existing); + } + + $attachment = $this->service->attachSvgToPart($part, '', 'Resistor'); + + $this->assertSame('Resistor (3)', $attachment->getName()); + } + + public function testAttachSvgToPartDoesNotDeduplicateAgainstOtherAttachmentTypes(): void + { + $this->mockGeneratedImageType(persisted: true); + $this->mockSuccessfulPictureUpload(); + + $part = new Part(); + + $otherType = new AttachmentType(); + $otherType->setName('Datasheet'); + + $existing = new PartAttachment(); + $existing->setName('Resistor'); + $existing->setAttachmentType($otherType); + $part->addAttachment($existing); + + $attachment = $this->service->attachSvgToPart($part, '', 'Resistor'); + + $this->assertSame('Resistor', $attachment->getName()); + } + + public function testAttachSvgToPartWithoutOverwriteKeepsPreviousGeneratedImages(): void + { + $type = $this->mockGeneratedImageType(persisted: true); + $this->mockSuccessfulPictureUpload(); + + $part = new Part(); + $previous = new PartAttachment(); + $previous->setName('Generated image'); + $previous->setAttachmentType($type); + $part->addAttachment($previous); + + $this->em->expects($this->never())->method('remove'); + + $this->service->attachSvgToPart($part, '', 'Generated image', overwrite: false); + + $this->assertTrue($part->getAttachments()->contains($previous)); + } + + public function testAttachSvgToPartWithOverwriteRemovesPreviousGeneratedImagesOnly(): void + { + $type = $this->mockGeneratedImageType(persisted: true); + $this->mockSuccessfulPictureUpload(); + + $part = new Part(); + + $previousGenerated = new PartAttachment(); + $previousGenerated->setName('Generated image'); + $previousGenerated->setAttachmentType($type); + $part->addAttachment($previousGenerated); + $part->setMasterPictureAttachment($previousGenerated); + + $otherType = new AttachmentType(); + $otherType->setName('Datasheet'); + $unrelated = new PartAttachment(); + $unrelated->setName('Datasheet'); + $unrelated->setAttachmentType($otherType); + $part->addAttachment($unrelated); + + $removed = []; + $this->em->method('remove')->willReturnCallback(function (object $entity) use (&$removed): void { + $removed[] = $entity; + }); + + $newAttachment = $this->service->attachSvgToPart($part, '', 'Generated image', overwrite: true); + + $this->assertSame([$previousGenerated], $removed); + $this->assertFalse($part->getAttachments()->contains($previousGenerated)); + $this->assertTrue($part->getAttachments()->contains($unrelated)); + $this->assertTrue($part->getAttachments()->contains($newAttachment)); + //The new attachment should have become the master picture, replacing the removed one + $this->assertSame($newAttachment, $part->getMasterPictureAttachment()); + //Since the old master picture was removed, the name should not need de-duplication + $this->assertSame('Generated image', $newAttachment->getName()); + } + + public function testGetGeneratedImageTypeConfiguresAndPersistsNewlyCreatedType(): void + { + $type = $this->mockGeneratedImageType(persisted: false); + $this->mockSuccessfulPictureUpload(); + + $persisted = []; + $this->em->method('persist')->willReturnCallback(function (object $entity) use (&$persisted): void { + $persisted[] = $entity; + }); + + $part = new Part(); + $this->service->attachSvgToPart($part, '', 'Test'); + + $this->assertSame('image/*', $type->getFiletypeFilter()); + $this->assertSame('Generated image', $type->getAlternativeNames()); + $this->assertContains($type, $persisted); + } + + public function testGetGeneratedImageTypeDoesNotReconfigureOrPersistExistingType(): void + { + $type = $this->mockGeneratedImageType(persisted: true); + $type->setFiletypeFilter(''); + $type->setAlternativeNames(null); + $this->mockSuccessfulPictureUpload(); + + $persisted = []; + $this->em->method('persist')->willReturnCallback(function (object $entity) use (&$persisted): void { + $persisted[] = $entity; + }); + + $part = new Part(); + $this->service->attachSvgToPart($part, '', 'Test'); + + //Existing types must not be modified... + $this->assertSame('', $type->getFiletypeFilter()); + $this->assertNull($type->getAlternativeNames()); + //...nor persisted again (only the new attachment should be persisted) + $this->assertNotContains($type, $persisted); + } +} diff --git a/tests/Services/Tools/ComponentValueGuesserTest.php b/tests/Services/Tools/ComponentValueGuesserTest.php new file mode 100644 index 000000000..f76671de9 --- /dev/null +++ b/tests/Services/Tools/ComponentValueGuesserTest.php @@ -0,0 +1,462 @@ +. + */ + +namespace App\Tests\Services\Tools; + +use App\Entity\Parameters\PartParameter; +use App\Entity\Parts\Part; +use App\Services\Tools\ComponentValueGuesser; +use PHPUnit\Framework\TestCase; + +/** + * Unit tests for the classification/parsing logic behind the value calculator and bulk image + * generator. The guesser has no dependencies, so the tests construct plain Part entities in memory. + */ +class ComponentValueGuesserTest extends TestCase +{ + private ComponentValueGuesser $guesser; + + protected function setUp(): void + { + $this->guesser = new ComponentValueGuesser(); + } + + private function part(string $name, ?string $description = null): Part + { + $part = new Part(); + $part->setName($name); + if ($description !== null) { + $part->setDescription($description); + } + + return $part; + } + + private function partWithParameter(string $paramName, float $value, string $unit): Part + { + $part = new Part(); + $part->setName('Some part'); + $param = new PartParameter(); + $param->setName($paramName); + $param->setValueTypical($value); + $param->setUnit($unit); + $part->addParameter($param); + + return $part; + } + + /** + * @dataProvider resistorValueProvider + */ + public function testResistorValueFromName(string $name, float $expectedOhms): void + { + [$ohms, $farads] = $this->guesser->extractValue($this->part($name)); + self::assertNull($farads, "Expected no capacitance for '$name'"); + self::assertNotNull($ohms, "Expected a resistance for '$name'"); + self::assertEqualsWithDelta($expectedOhms, $ohms, $expectedOhms * 1e-9 + 1e-9); + } + + public static function resistorValueProvider(): \Generator + { + yield 'plain ohm with space' => ['100 Ω', 100.0]; + yield 'plain ohm no space' => ['470Ω', 470.0]; + yield 'R notation' => ['470R', 470.0]; + yield 'kilo with space (regression: Ω is a PCRE word char under /u)' => ['1 kΩ', 1000.0]; + yield 'kilo no space' => ['10kΩ', 10000.0]; + yield 'mega with space' => ['1 MΩ', 1_000_000.0]; + yield 'decimal kilo' => ['4.7 kΩ', 4700.0]; + yield 'RKM kilo' => ['4k7', 4700.0]; + yield 'RKM mega' => ['2M2', 2_200_000.0]; + yield 'bare magnitude letter' => ['10k', 10000.0]; + yield 'realistic imported name' => ['Resistor 10 kΩ 0.25W 1% Metal Film', 10000.0]; + yield 'realistic mega name' => ['Resistor 1 MΩ 0.25W 1% Metal Film', 1_000_000.0]; + } + + /** + * @dataProvider capacitorValueProvider + */ + public function testCapacitorValueFromName(string $name, float $expectedFarads): void + { + [$ohms, $farads] = $this->guesser->extractValue($this->part($name)); + self::assertNull($ohms, "Expected no resistance for '$name'"); + self::assertNotNull($farads, "Expected a capacitance for '$name'"); + self::assertEqualsWithDelta($expectedFarads, $farads, $expectedFarads * 1e-6); + } + + public static function capacitorValueProvider(): \Generator + { + yield 'nanofarad' => ['10nF', 10e-9]; + yield 'microfarad greek mu' => ['0.1µF', 0.1e-6]; + yield 'picofarad' => ['100pF', 100e-12]; + yield 'RKM nano' => ['4n7', 4.7e-9]; + yield 'RKM pico' => ['2p2', 2.2e-12]; + yield 'named ceramic cap' => ['Ceramic capacitor 100nF', 100e-9]; + } + + public function testValueFromResistanceParameter(): void + { + $part = $this->partWithParameter('Resistance', 4.7, 'kΩ'); + [$ohms, $farads] = $this->guesser->extractValue($part); + self::assertNull($farads); + self::assertEqualsWithDelta(4700.0, $ohms, 1e-6); + } + + public function testValueFromCapacitanceParameter(): void + { + $part = $this->partWithParameter('Capacitance', 100.0, 'nF'); + [$ohms, $farads] = $this->guesser->extractValue($part); + self::assertNull($ohms); + self::assertEqualsWithDelta(100e-9, $farads, 1e-15); + } + + public function testClassifiesThroughHoleResistor(): void + { + $guess = $this->guesser->guess($this->part('Resistor 10 kΩ 0.25W 1% blue body')); + self::assertNotNull($guess); + self::assertSame('resistor', $guess['type']); + self::assertEqualsWithDelta(10000.0, $guess['value'], 1e-6); + self::assertSame(0.25, $guess['power']); + self::assertSame('±1%', $guess['tolerance']); + self::assertSame('#2f6db0', $guess['color']); + } + + public function testClassifiesSmdResistorFromPackage(): void + { + $guess = $this->guesser->guess($this->part('Resistor 4.7 kΩ 0805 1% SMD')); + self::assertNotNull($guess); + self::assertSame('smd_resistor', $guess['type']); + self::assertSame('0805', $guess['package']); + } + + public function testClassifiesCapacitor(): void + { + $guess = $this->guesser->guess($this->part('Ceramic capacitor 100nF 50V')); + self::assertNotNull($guess); + self::assertSame('capacitor', $guess['type']); + self::assertSame(50, $guess['voltage']); + } + + public function testClassifiesSmdCapacitorFromPackage(): void + { + $guess = $this->guesser->guess($this->part('MLCC capacitor 100nF 0805 X7R')); + self::assertNotNull($guess); + self::assertSame('smd_capacitor', $guess['type']); + self::assertSame('0805', $guess['package']); + self::assertEqualsWithDelta(100e-9, $guess['value'], 1e-15); + + $eda = $this->guesser->edaSuggestion($guess); + self::assertSame('Device:C', $eda['symbol']); + self::assertSame('Capacitor_SMD:C_0805_2012Metric', $eda['footprint']); + } + + public function testUnclassifiableReturnsNull(): void + { + self::assertNull($this->guesser->guess($this->part('Arduino Uno R3 development board'))); + } + + /** + * @dataProvider powerProvider + */ + public function testDetectPower(string $name, float $expected): void + { + $guess = $this->guesser->guess($this->part($name)); + self::assertNotNull($guess); + self::assertSame($expected, $guess['power']); + } + + public static function powerProvider(): \Generator + { + yield 'decimal watt' => ['Resistor 1k 0.25W', 0.25]; + yield 'fractional watt' => ['Resistor 1k 1/4W', 0.25]; + yield 'half watt spaced' => ['Resistor 1k 0.5 W', 0.5]; + yield 'one watt' => ['Resistor 1k 1W', 1.0]; + } + + /** + * @dataProvider ppmProvider + */ + public function testDetectPpm(string $name, ?int $expected): void + { + $guess = $this->guesser->guess($this->part($name)); + self::assertNotNull($guess); + self::assertSame($expected, $guess['ppm']); + } + + public static function ppmProvider(): \Generator + { + yield 'plain ppm' => ['Resistor 1k 50ppm', 50]; + yield 'ppm per celsius' => ['Resistor 1k 100 ppm/°C', 100]; + yield 'no ppm' => ['Resistor 1k', null]; + } + + /** + * @dataProvider toleranceProvider + */ + public function testDetectTolerance(string $name, ?string $expected): void + { + $guess = $this->guesser->guess($this->part($name)); + self::assertNotNull($guess); + self::assertSame($expected, $guess['tolerance']); + } + + public static function toleranceProvider(): \Generator + { + yield 'plus-minus percent' => ['Resistor 1k ±5%', '±5%']; + yield 'bare percent' => ['Resistor 1k 1%', '±1%']; + yield 'sub-percent' => ['Resistor 1k 0.1%', '±0.1%']; + } + + /** + * @dataProvider colorProvider + */ + public function testDetectBodyColor(string $description, ?string $expected): void + { + $guess = $this->guesser->guess($this->part('Resistor 1k', $description)); + self::assertNotNull($guess); + self::assertSame($expected, $guess['color']); + } + + public static function colorProvider(): \Generator + { + yield 'blue body' => ['blue body metal film', '#2f6db0']; + yield 'green' => ['green body', '#2e7d4f']; + yield 'no colour word' => ['axial resistor', null]; + yield 'colour word inside another word is ignored' => ['tantalum resistor', null]; + } + + public function testEdaSuggestionForThroughHoleResistor(): void + { + $eda = $this->guesser->edaSuggestion(['type' => 'resistor', 'package' => null, 'pitch' => null, 'diameter' => null]); + self::assertSame('Device:R', $eda['symbol']); + self::assertSame('R', $eda['reference']); + self::assertStringContainsString('Resistor_THT:R_Axial', (string) $eda['footprint']); + } + + public function testEdaSuggestionForSmdResistor(): void + { + $eda = $this->guesser->edaSuggestion(['type' => 'smd_resistor', 'package' => '0805', 'pitch' => null, 'diameter' => null]); + self::assertSame('Device:R', $eda['symbol']); + self::assertSame('Resistor_SMD:R_0805_2012Metric', $eda['footprint']); + } + + public function testEdaSuggestionForCapacitorUsesPitch(): void + { + $eda = $this->guesser->edaSuggestion(['type' => 'capacitor', 'package' => null, 'pitch' => 5.08, 'diameter' => 5.0]); + self::assertSame('Device:C', $eda['symbol']); + self::assertSame('C', $eda['reference']); + self::assertStringContainsString('P5.00mm', (string) $eda['footprint']); + } + + /** + * @dataProvider inductorValueProvider + */ + public function testClassifiesInductor(string $name, float $expectedHenries): void + { + $guess = $this->guesser->guess($this->part($name)); + self::assertNotNull($guess, "Expected '$name' to classify"); + self::assertSame('inductor', $guess['type']); + self::assertEqualsWithDelta($expectedHenries, $guess['value'], $expectedHenries * 1e-6); + } + + public static function inductorValueProvider(): \Generator + { + yield 'microhenry µ' => ['Inductor 100µH', 100e-6]; + yield 'microhenry u' => ['Choke 4.7uH', 4.7e-6]; + yield 'millihenry' => ['Coil 10mH', 10e-3]; + yield 'nanohenry' => ['100nH inductor', 100e-9]; + yield 'henry' => ['1H filter choke', 1.0]; + } + + public function testInductanceFromParameter(): void + { + $part = $this->partWithParameter('Inductance', 100.0, 'µH'); + $guess = $this->guesser->guess($part); + self::assertNotNull($guess); + self::assertSame('inductor', $guess['type']); + self::assertEqualsWithDelta(100e-6, $guess['value'], 1e-12); + } + + public function testMegahertzIsNotMistakenForInductance(): void + { + //"100MHz" must not parse as 100 mH — the (?![a-zA-Z0-9]) guard prevents it. + self::assertNull($this->guesser->guess($this->part('Crystal oscillator 100MHz'))); + } + + public function testEdaSuggestionForInductor(): void + { + $eda = $this->guesser->edaSuggestion(['type' => 'inductor', 'package' => null, 'pitch' => null, 'diameter' => null]); + self::assertSame('Device:L', $eda['symbol']); + self::assertSame('L', $eda['reference']); + self::assertNull($eda['footprint']); + } + + public function testClassifiesSmdInductorFromPackage(): void + { + $guess = $this->guesser->guess($this->part('Inductor 10µH 0805 SMD')); + self::assertNotNull($guess); + self::assertSame('smd_inductor', $guess['type']); + self::assertSame('0805', $guess['package']); + self::assertEqualsWithDelta(10e-6, $guess['value'], 1e-12); + } + + public function testEdaSuggestionForSmdInductor(): void + { + $eda = $this->guesser->edaSuggestion(['type' => 'smd_inductor', 'package' => '1210', 'pitch' => null, 'diameter' => null]); + self::assertSame('Device:L', $eda['symbol']); + self::assertSame('L', $eda['reference']); + self::assertSame('Inductor_SMD:L_1210_3225Metric', $eda['footprint']); + } + + /** + * @dataProvider diodeProvider + */ + public function testClassifiesDiode(string $name, string $expectedSubtype): void + { + $guess = $this->guesser->guess($this->part($name)); + self::assertNotNull($guess, "Expected '$name' to classify"); + self::assertSame('diode', $guess['type']); + self::assertSame($expectedSubtype, $guess['subtype']); + } + + public static function diodeProvider(): \Generator + { + yield 'led word' => ['LED red 5mm 20mA', 'led']; + yield 'light emitting' => ['Light-emitting diode green', 'led']; + yield 'zener word' => ['Zener diode 5.1V', 'zener']; + yield 'zener BZX family' => ['BZX55C5V1', 'zener']; + yield 'zener 1N47xx' => ['1N4733A', 'zener']; + yield 'schottky word' => ['Schottky barrier diode', 'schottky']; + yield 'schottky BAT family' => ['BAT54', 'schottky']; + yield 'schottky 1N58xx' => ['1N5819', 'schottky']; + yield 'tvs word' => ['TVS diode array', 'tvs']; + yield 'tvs SMBJ family' => ['SMBJ15A', 'tvs']; + yield 'generic rectifier' => ['Rectifier diode', 'diode']; + yield '1N4148 small signal' => ['1N4148 switching', 'diode']; + yield '1N4007 rectifier' => ['1N4007', 'diode']; + yield 'BAV family' => ['BAV99 dual', 'diode']; + } + + public function testLedUsesEmissionColor(): void + { + $guess = $this->guesser->guess($this->part('LED blue 5mm')); + self::assertNotNull($guess); + self::assertSame('diode', $guess['type']); + self::assertSame('led', $guess['subtype']); + self::assertSame('#2f6db0', $guess['color']); + } + + public function testZenerCarriesVoltage(): void + { + $guess = $this->guesser->guess($this->part('Zener diode 5.1V 0.5W')); + self::assertNotNull($guess); + self::assertSame('zener', $guess['subtype']); + self::assertSame(5, $guess['voltage']); + } + + public function testResistorForLedStaysResistor(): void + { + //"220R" yields a resistance, which is classified before the diode fallback ever runs. + $guess = $this->guesser->guess($this->part('220R resistor for LED indicator')); + self::assertNotNull($guess); + self::assertSame('resistor', $guess['type']); + } + + /** + * @dataProvider diodeEdaProvider + */ + public function testEdaSuggestionForDiode(string $subtype, string $expectedSymbol): void + { + $eda = $this->guesser->edaSuggestion(['type' => 'diode', 'subtype' => $subtype, 'package' => null, 'pitch' => null, 'diameter' => null]); + self::assertSame($expectedSymbol, $eda['symbol']); + self::assertSame('D', $eda['reference']); + self::assertNull($eda['footprint']); + } + + public static function diodeEdaProvider(): \Generator + { + yield 'generic' => ['diode', 'Device:D']; + yield 'led' => ['led', 'Device:LED']; + yield 'zener' => ['zener', 'Device:D_Zener']; + yield 'schottky' => ['schottky', 'Device:D_Schottky']; + yield 'tvs' => ['tvs', 'Device:D_TVS']; + } + + public function testEdaSuggestionForSmdDiodeFootprint(): void + { + $eda = $this->guesser->edaSuggestion(['type' => 'diode', 'subtype' => 'diode', 'package' => '0805', 'pitch' => null, 'diameter' => null]); + self::assertSame('Diode_SMD:D_0805_2012Metric', $eda['footprint']); + } + + public function testEdaSuggestionForSmdLedFootprint(): void + { + $eda = $this->guesser->edaSuggestion(['type' => 'diode', 'subtype' => 'led', 'package' => '0805', 'pitch' => null, 'diameter' => null]); + self::assertSame('LED_SMD:LED_0805_2012Metric', $eda['footprint']); + } + + public function testDetectsThtDiodePackageAndMarking(): void + { + //Real-world case: importing a 1N400x rectifier kit, whose names spell out the THT package. + $guess = $this->guesser->guess($this->part('1N4001 Rectifier Diode 1A 50V DO-41')); + self::assertNotNull($guess); + self::assertSame('diode', $guess['type']); + self::assertSame('diode', $guess['subtype']); + self::assertSame('DO-41', $guess['package']); + self::assertSame('1N4001', $guess['marking']); + + $eda = $this->guesser->edaSuggestion($guess); + self::assertSame('Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal', $eda['footprint']); + } + + public function testDetectsSotSchottkyPackage(): void + { + $guess = $this->guesser->guess($this->part('BAT54 Schottky diode SOT-23')); + self::assertNotNull($guess); + self::assertSame('schottky', $guess['subtype']); + self::assertSame('SOT-23', $guess['package']); + self::assertSame('BAT54', $guess['marking']); + + $eda = $this->guesser->edaSuggestion($guess); + self::assertSame('Diode_SMD:D_SOT-23', $eda['footprint']); + } + + public function testDetectsLedDomeSizeFootprint(): void + { + $guess = $this->guesser->guess($this->part('LED red 5mm diffused')); + self::assertNotNull($guess); + self::assertSame('led', $guess['subtype']); + self::assertSame('5MM', $guess['package']); + //LEDs aren't normally printed with a part number. + self::assertNull($guess['marking']); + + $eda = $this->guesser->edaSuggestion($guess); + self::assertSame('LED_THT:LED_D5.0mm', $eda['footprint']); + } + + public function testMarkingNullWhenNoRecognisablePartNumber(): void + { + $guess = $this->guesser->guess($this->part('Generic rectifier diode')); + self::assertNotNull($guess); + self::assertSame('diode', $guess['subtype']); + self::assertNull($guess['marking']); + } +} diff --git a/translations/frontend.de.xlf b/translations/frontend.de.xlf index 317b7c7fd..cd1aa5156 100644 --- a/translations/frontend.de.xlf +++ b/translations/frontend.de.xlf @@ -79,5 +79,125 @@ Nein + + + tools.value_calc.band + Ring + + + + + tools.value_calc.resistor.band_digit + Ziffer + + + + + tools.value_calc.resistor.band_multiplier + Multiplikator + + + + + tools.value_calc.resistor.band_tolerance + Toleranz + + + + + tools.value_calc.resistor.band_temp + Temp.-Koeffizient + + + + + tools.value_calc.color.black + Schwarz + + + + + tools.value_calc.color.brown + Braun + + + + + tools.value_calc.color.red + Rot + + + + + tools.value_calc.color.orange + Orange + + + + + tools.value_calc.color.yellow + Gelb + + + + + tools.value_calc.color.green + Grün + + + + + tools.value_calc.color.blue + Blau + + + + + tools.value_calc.color.violet + Violett + + + + + tools.value_calc.color.grey + Grau + + + + + tools.value_calc.color.white + Weiß + + + + + tools.value_calc.color.gold + Gold + + + + + tools.value_calc.color.silver + Silber + + + + + tools.value_calc.invalid_input + Ungültige Eingabe + + + + + tools.value_calc.tolerance + Toleranz + + + + + tools.value_calc.attach.nothing + Bitte generieren Sie zuerst ein Bild. + + diff --git a/translations/frontend.en.xlf b/translations/frontend.en.xlf index d00994931..212e7f05b 100644 --- a/translations/frontend.en.xlf +++ b/translations/frontend.en.xlf @@ -55,25 +55,145 @@ Go! - + + + tools.value_calc.band + band + + + + + tools.value_calc.resistor.band_digit + Digit + + + + + tools.value_calc.resistor.band_multiplier + Multiplier + + + + + tools.value_calc.resistor.band_tolerance + Tolerance + + + + + tools.value_calc.resistor.band_temp + Temp. coeff. + + + + + tools.value_calc.color.black + Black + + + + + tools.value_calc.color.brown + Brown + + + + + tools.value_calc.color.red + Red + + + + + tools.value_calc.color.orange + Orange + + + + + tools.value_calc.color.yellow + Yellow + + + + + tools.value_calc.color.green + Green + + + + + tools.value_calc.color.blue + Blue + + + + + tools.value_calc.color.violet + Violet + + + + + tools.value_calc.color.grey + Grey + + + + + tools.value_calc.color.white + White + + + + + tools.value_calc.color.gold + Gold + + + + + tools.value_calc.color.silver + Silver + + + + + tools.value_calc.invalid_input + Invalid input + + + + + tools.value_calc.tolerance + Tolerance + + + + + tools.value_calc.attach.nothing + Please generate an image first. + + + user.password_strength.crack_time Estimated time to crack: %time% - + dialog.btn.ok OK - + dialog.btn.cancel Cancel - + dialog.btn.deny No diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index b09f5e98b..ad405a000 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -14259,5 +14259,689 @@ Buerklin-API-Authentication-Server: OAuth2-Client erfolgreich aktualisiert! + + + perm.tools.component_image_generator + Bauteilbild-Generator + + + + + tools.value_calc.title + Bauteilbild-Generator + + + + + tools.bulk_gen.title + Bauteilbilder im Stapel generieren + + + + + tools.bulk_gen.intro + Ausgewählte Bauteile, die noch kein Bild haben und wie ein Widerstand, SMD-Widerstand oder Kondensator aussehen, werden unten aufgelistet. Überprüfen Sie den automatisch erkannten Typ und Wert und generieren und hängen Sie dann die Bilder in einem Schritt an. + + + + + tools.bulk_gen.pick_location + — Lagerort auswählen — + + + + + tools.bulk_gen.load + Bauteile laden + + + + + tools.bulk_gen.none + Keines der %count% ausgewählten Bauteile konnte als Widerstand, SMD-Widerstand oder Kondensator ohne vorhandenes Bild erkannt werden. + + + + + tools.bulk_gen.found + Bauteil(e) bereit zur Bildgenerierung. + + + + + tools.bulk_gen.part + Bauteil + + + + + tools.bulk_gen.type + Erkannter Typ + + + + + tools.bulk_gen.value + Wert + + + + + tools.bulk_gen.wip + Überprüfen Sie hier die erkannten Typen und Werte — das Generieren und Anhängen der Bilder ist der nächste Schritt. + + + + + tools.bulk_gen.no_selection + Es wurden keine Bauteile ausgewählt. Wählen Sie Bauteile in einer Liste aus und wählen Sie die Aktion "Bauteilbilder generieren". + + + + + tools.bulk_gen.skipped + übersprungen: haben bereits ein Bild oder sind nicht klassifizierbar + + + + + tools.bulk_gen.preview + Vorschau + + + + + tools.bulk_gen.appearance + Bild-Einstellungen + + + + + tools.bulk_gen.apply_all + Auf alle Zeilen anwenden + + + + + tools.bulk_gen.back + Zurück zu den Bauteilen + + + + + tools.bulk_gen.with_picture + %count% ausgewählte(s) Bauteil(e) haben bereits ein Bild und wurden nicht aufgelistet. + + + + + tools.bulk_gen.regenerate + Diese %count% neu generieren / überschreiben + + + + + tools.bulk_gen.overwrite_mode + Überschreibmodus: Bauteile, die bereits ein Bild haben, werden einbezogen (markiert mit "hat Bild"). Das Generieren ersetzt ein zuvor generiertes Bild und wird zur Vorschau — manuell hochgeladene Bilder bleiben erhalten. + + + + + tools.bulk_gen.has_picture_hint + Dieses Bauteil hat bereits ein Bild. Das Generieren setzt das neue Bild als Vorschau und ersetzt ein zuvor generiertes Bild; hochgeladene Fotos bleiben erhalten. + + + + + tools.bulk_gen.overwrite_exit + Nur Bauteile ohne Bild + + + + + tools.bulk_gen.has_picture + hat Bild + + + + + tools.bulk_gen.help.title + So erhalten Sie die beste automatische Erkennung — was in die Details eines Bauteils gehört + + + + + tools.bulk_gen.help.intro + Jedes Feld wird zuerst aus den Parametern des Bauteils gelesen, dann aus seinem Namen und der Beschreibung. Fügen Sie eines der unten genannten hinzu, um die Erkennung zu verbessern. Ein Bauteil wird hier nur aufgelistet, wenn es noch kein Bild hat und sein Wert gelesen werden kann. + + + + + tools.bulk_gen.help.col_field + Feld + + + + + tools.bulk_gen.help.col_param + Parameter mit dem Namen hinzufügen… + + + + + tools.bulk_gen.help.col_text + …oder im Namen / der Beschreibung angeben + + + + + tools.bulk_gen.help.value + Wert (zur Klassifizierung erforderlich) + + + + + tools.bulk_gen.help.voltage + Nennspannung (Kondensatoren) + + + + + tools.bulk_gen.help.tolerance + Toleranz + + + + + tools.bulk_gen.help.pitch + Anschlussabstand (Kondensatoren) + + + + + tools.bulk_gen.help.diameter + Gehäusedurchmesser (Kondensatoren) + + + + + tools.bulk_gen.help.smd + SMD-Größe (Widerstände → Footprint) + + + + + tools.bulk_gen.help.footprint_col + der zugewiesene Footprint + + + + + tools.bulk_gen.kicad + KiCad + + + + + tools.bulk_gen.write_eda + KiCad-Einstellungen für ausgewählte Bauteile schreiben + + + + + tools.bulk_gen.eda_written + EDA-Einstellungen geschrieben + + + + + tools.bulk_gen.footprint_ph + Footprint (optional) + + + + + tools.bulk_gen.reference + Referenzpräfix + + + + + tools.bulk_gen.kicad_hint + Vorgeschlagene Werte — bearbeiten Sie jedes Feld vor dem Schreiben. + + + + + tools.bulk_gen.generate_attach + Bilder an ausgewählte Bauteile anhängen + + + + + tools.bulk_gen.attached + angehängt + + + + + tools.bulk_gen.failed + fehlgeschlagen + + + + + part_list.action.group.images + Bilder + + + + + part_list.action.generate_images + Bauteilbilder generieren + + + + + tools.value_calc.explanation + Entschlüsseln und visualisieren Sie den Wert gängiger Bauteile — Farbringe (Widerstände, Induktivitäten), Kondensatorcodes und SMD-Chip-Beschriftungen. Lesen Sie ein Bauteil, indem Sie seine Ringe/seinen Code auswählen, oder geben Sie einen Wert ein, um die passenden Ringe/den Code sowie ein Bild zu erzeugen, das Sie an das Bauteil anhängen können. + + + + + tools.value_calc.body_color + Gehäusefarbe + + + + + tools.value_calc.appearance + Bild-Einstellungen (für das generierte Bild) + + + + + tools.value_calc.body.beige + Beige + + + + + tools.value_calc.body.blue + Blau + + + + + tools.value_calc.body.green + Grün + + + + + tools.value_calc.body.lightblue + Hellblau + + + + + tools.value_calc.body.tan + Hautfarben + + + + + tools.value_calc.body.red + Rot + + + + + tools.value_calc.body.purple + Lila + + + + + tools.value_calc.body.black + Schwarz + + + + + tools.value_calc.body.grey + Grau + + + + + tools.value_calc.body.white + Weiß + + + + + tools.value_calc.resistor.title + Widerstand + + + + + tools.value_calc.inductor.title + Induktivität + + + + + tools.value_calc.inductor.result + Induktivität + + + + + tools.value_calc.inductor.intro + Bedrahtete axiale Induktivitäten verwenden denselben Farbring-Code wie Widerstände, aber der Wert wird in Mikrohenry (µH) angegeben. Wählen Sie die Ringfarben oder geben Sie einen Wert ein, um sie automatisch zu setzen. + + + + + tools.value_calc.inductor.from_value + Aus Wert setzen + + + + + tools.value_calc.inductor.from_value_help + Geben Sie eine Induktivität ein, um die Ringe automatisch einzufärben. Eine reine Zahl wird als µH gelesen (z.B. 100 = 100µH); Sie können auch 100µH, 10mH oder 470nH schreiben. + + + + + tools.value_calc.diode.title + Diode + + + + + tools.value_calc.smd_inductor.title + SMD-Induktivität + + + + + tools.value_calc.smd_inductor.intro + Umspritzte/abgeschirmte SMD-Leistungsinduktivität — geben Sie die Induktivität ein (z.B. 100µH, 10mH), um ihren aufgedruckten µH-Code zu sehen. + + + + + tools.value_calc.smd_capacitor.title + SMD-Kondensator + + + + + tools.value_calc.smd_capacitor.intro + Oberflächenmontierte MLCC-Kondensatoren sind fast immer unbeschriftet, es gibt also nichts zu entschlüsseln — geben Sie einfach Wert und Gehäuse ein, um ein Bild zu erzeugen, das Sie an das Bauteil anhängen können. + + + + + tools.value_calc.body.brown + Braun + + + + + tools.value_calc.capacitor.title + Kondensator + + + + + tools.value_calc.smd.title + SMD-Widerstand + + + + + tools.value_calc.resistor.bands + Anzahl der Ringe + + + + + tools.value_calc.resistor.bands_4 + 4 Ringe + + + + + tools.value_calc.resistor.bands_5 + 5 Ringe + + + + + tools.value_calc.resistor.bands_6 + 6 Ringe + + + + + tools.value_calc.attach.context + Das generierte Bild kann an das Bauteil "%part%" angehängt werden. Wählen Sie unten ein Bauteil aus und klicken Sie auf "An Bauteil anhängen". + + + + + tools.value_calc.attach.as_preview + Als Vorschaubild verwenden + + + + + tools.value_calc.attach.button + An Bauteil anhängen + + + + + tools.value_calc.download.button + SVG herunterladen + + + + + tools.value_calc.attach.generate_button + Bauteilbild generieren + + + + + tools.value_calc.attach.generate_hint + Generieren Sie ein Bild für Widerstand, Kondensator oder SMD und hängen Sie es an dieses Bauteil an. + + + + + part.generate_image.flash.success + Das generierte Bild wurde an das Bauteil angehängt. + + + + + part.generate_image.flash.invalid + Es wurde kein gültiges Bild generiert. Bitte versuchen Sie es erneut. + + + + + tools.value_calc.size + Größe + + + + + tools.value_calc.resistor.power + Leistung + + + + + tools.value_calc.smd.package + Gehäusegröße + + + + + tools.value_calc.cap.diameter + Durchmesser + + + + + tools.value_calc.cap.pitch + Anschlussabstand + + + + + tools.value_calc.cap.voltage + Spannung + + + + + tools.value_calc.cap.shape + Form + + + + + tools.value_calc.cap.shape.disc + Scheibe + + + + + tools.value_calc.cap.shape.blob + Tropfen (MLCC) + + + + + tools.value_calc.cap.lead + Anschlussdrähte + + + + + tools.value_calc.cap.lead.short + Kurz + + + + + tools.value_calc.cap.lead.medium + Mittel + + + + + tools.value_calc.cap.lead.long + Lang + + + + + tools.value_calc.resistor.result + Widerstand + + + + + tools.value_calc.resistor.from_value_help + Geben Sie einen Widerstandswert ein, um die Farbringe zu erzeugen. Sie können Suffixe wie k, M und die RKM-Notation verwenden (z.B. 4k7). + + + + + tools.value_calc.resistor.from_value + Wert für Farbcode + + + + + tools.value_calc.resistor.apply_value + Ringe generieren + + + + + tools.value_calc.capacitor.intro + Funktioniert für die aufgedruckten Zahlencodes auf Keramik- (MLCC), Folien- und ähnlichen Kondensatoren. Kleine Kondensatoren unter 100 pF sind meist direkt aufgedruckt (z.B. 47 oder 4R7), größere verwenden den 3-stelligen Code (z.B. 104 = 100 nF). + + + + + tools.value_calc.smd.on_chip_help + Bearbeiten Sie ein beliebiges Feld, um die anderen zu aktualisieren. Verwenden Sie die Radiobuttons, um auszuwählen, welcher Code auf dem Chip aufgedruckt ist. + + + + + tools.value_calc.field.value + Wert + + + + + tools.value_calc.field.code + Code + + + + + tools.value_calc.field.tolerance + Toleranz + + + + + tools.value_calc.field.power + Leistung + + + + + tools.value_calc.field.ppm + Temp.-Koeffizient (ppm/K) + + + + + tools.value_calc.field.code3 + 3-stellig + + + + + tools.value_calc.field.code4 + 4-stellig + + + + + tools.value_calc.field.eia96 + EIA-96 + + + + + tools.value_calc.field.on_chip + Auf dem Chip aufgedruckt + + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index fa3998b54..a264201d4 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13025,6 +13025,690 @@ Buerklin-API Authentication server: Last stocktake + + + perm.tools.component_image_generator + Component image generator + + + + + tools.value_calc.title + Component image generator + + + + + tools.bulk_gen.title + Bulk generate component images + + + + + tools.bulk_gen.intro + Selected parts that have no picture yet and look like a resistor, SMD resistor or capacitor are listed below. Review the auto-detected type and value, then generate and attach pictures in one go. + + + + + tools.bulk_gen.pick_location + — Select a storage location — + + + + + tools.bulk_gen.load + Load parts + + + + + tools.bulk_gen.none + None of the %count% selected part(s) could be classified as a resistor, SMD resistor or capacitor without an existing picture. + + + + + tools.bulk_gen.found + part(s) ready to illustrate. + + + + + tools.bulk_gen.part + Part + + + + + tools.bulk_gen.type + Detected type + + + + + tools.bulk_gen.value + Value + + + + + tools.bulk_gen.wip + Review the detected types and values here — generating and attaching the pictures is the next step. + + + + + tools.bulk_gen.no_selection + No parts were selected. Select parts in a list and choose the "Generate component images" action. + + + + + tools.bulk_gen.skipped + skipped: already have a picture or not classifiable + + + + + tools.bulk_gen.preview + Preview + + + + + tools.bulk_gen.appearance + Picture appearance + + + + + tools.bulk_gen.apply_all + Apply to all rows + + + + + tools.bulk_gen.back + Back to parts + + + + + tools.bulk_gen.with_picture + %count% selected part(s) already have a picture and were not listed. + + + + + tools.bulk_gen.regenerate + Re-generate / overwrite those %count% + + + + + tools.bulk_gen.overwrite_mode + Overwrite mode: parts that already have a picture are included (marked "has picture"). Generating replaces an earlier generated image and becomes the preview — manually uploaded pictures are kept. + + + + + tools.bulk_gen.has_picture_hint + This part already has a picture. Generating sets the new image as the preview and replaces any earlier generated image; uploaded photos are kept. + + + + + tools.bulk_gen.overwrite_exit + Only parts without a picture + + + + + tools.bulk_gen.has_picture + has picture + + + + + tools.bulk_gen.help.title + How to get the most auto-filled — what to put in a part's details + + + + + tools.bulk_gen.help.intro + Each field is read from the part's parameters first, then from its name and description. Add any of the below to improve detection. A part is only listed here if it has no picture yet and its value can be read. + + + + + tools.bulk_gen.help.col_field + Field + + + + + tools.bulk_gen.help.col_param + Add a parameter named… + + + + + tools.bulk_gen.help.col_text + …or write in the name / description + + + + + tools.bulk_gen.help.value + Value (required to classify) + + + + + tools.bulk_gen.help.voltage + Rated voltage (capacitors) + + + + + tools.bulk_gen.help.tolerance + Tolerance + + + + + tools.bulk_gen.help.pitch + Lead pitch (capacitors) + + + + + tools.bulk_gen.help.diameter + Body diameter (capacitors) + + + + + tools.bulk_gen.help.smd + SMD size (resistors → footprint) + + + + + tools.bulk_gen.help.footprint_col + the assigned footprint + + + + + tools.bulk_gen.kicad + KiCad + + + + + tools.bulk_gen.write_eda + Write KiCad settings to checked parts + + + + + tools.bulk_gen.eda_written + EDA settings written + + + + + tools.bulk_gen.footprint_ph + Footprint (optional) + + + + + tools.bulk_gen.reference + Reference prefix + + + + + tools.bulk_gen.kicad_hint + Suggested values — edit any field before writing. + + + + + tools.bulk_gen.generate_attach + Attach pictures to the checked parts + + + + + tools.bulk_gen.attached + attached + + + + + tools.bulk_gen.failed + failed + + + + + part_list.action.group.images + Images + + + + + part_list.action.generate_images + Generate component images + + + + + tools.value_calc.explanation + Decode and visualize the value of common components — colour bands (resistors, inductors), capacitor codes and SMD chip markings. Read a part by picking its bands/code, or enter a value to generate the matching bands/code and a picture you can attach to the part. + + + + + tools.value_calc.body_color + Body color + + + + + tools.value_calc.appearance + Picture appearance (for the generated image) + + + + + tools.value_calc.body.beige + Beige + + + + + tools.value_calc.body.blue + Blue + + + + + tools.value_calc.body.green + Green + + + + + tools.value_calc.body.lightblue + Light blue + + + + + tools.value_calc.body.tan + Tan + + + + + tools.value_calc.body.red + Red + + + + + tools.value_calc.body.purple + Purple + + + + + tools.value_calc.body.black + Black + + + + + tools.value_calc.body.grey + Grey + + + + + tools.value_calc.body.white + White + + + + + tools.value_calc.resistor.title + Resistor + + + + + tools.value_calc.inductor.title + Inductor + + + + + tools.value_calc.inductor.result + Inductance + + + + + tools.value_calc.inductor.intro + Molded axial inductors use the same colour-band code as resistors, but the value is read in microhenries (µH). Pick the band colours, or type a value to set them. + + + + + tools.value_calc.inductor.from_value + Set from value + + + + + tools.value_calc.inductor.from_value_help + Enter an inductance to colour the bands automatically. A bare number is read as µH (e.g. 100 = 100µH); you can also write 100µH, 10mH or 470nH. + + + + + tools.value_calc.diode.title + Diode + + + + + tools.value_calc.smd_inductor.title + SMD inductor + + + + + tools.value_calc.smd_inductor.intro + Molded/shielded SMD power inductor — enter the inductance (e.g. 100µH, 10mH) to see its printed µH code. + + + + + tools.value_calc.smd_capacitor.title + SMD capacitor + + + + + tools.value_calc.smd_capacitor.intro + Surface-mount MLCC capacitors are almost always unmarked, so there's nothing to decode — just enter the value and package to generate a picture you can attach to the part. + + + + + tools.value_calc.body.brown + Brown + + + + + tools.value_calc.capacitor.title + Capacitor + + + + + tools.value_calc.smd.title + SMD resistor + + + + + tools.value_calc.resistor.bands + Number of bands + + + + + tools.value_calc.resistor.bands_4 + 4 bands + + + + + tools.value_calc.resistor.bands_5 + 5 bands + + + + + tools.value_calc.resistor.bands_6 + 6 bands + + + + + tools.value_calc.attach.context + The generated image can be attached to part "%part%". Pick a component below and click "Attach to part". + + + + + tools.value_calc.attach.as_preview + Use as preview image + + + + + tools.value_calc.attach.button + Attach to part + + + + + tools.value_calc.download.button + Download SVG + + + + + tools.value_calc.attach.generate_button + Generate component image + + + + + tools.value_calc.attach.generate_hint + Generate a resistor, capacitor or SMD picture and attach it to this part. + + + + + part.generate_image.flash.success + Generated image was attached to the part. + + + + + part.generate_image.flash.invalid + No valid image was generated. Please try again. + + + + + tools.value_calc.size + Size + + + + + tools.value_calc.resistor.power + Power rating + + + + + tools.value_calc.smd.package + Package size + + + + + tools.value_calc.cap.diameter + Diameter + + + + + tools.value_calc.cap.pitch + Pitch + + + + + tools.value_calc.cap.voltage + Voltage + + + + + tools.value_calc.cap.shape + Shape + + + + + tools.value_calc.cap.shape.disc + Disc + + + + + tools.value_calc.cap.shape.blob + Blob (MLCC) + + + + + tools.value_calc.cap.lead + Leads + + + + + tools.value_calc.cap.lead.short + Short + + + + + tools.value_calc.cap.lead.medium + Medium + + + + + tools.value_calc.cap.lead.long + Long + + + + + tools.value_calc.resistor.result + Resistance + + + + + tools.value_calc.resistor.from_value_help + Enter a resistance value to generate the color bands. You can use suffixes like k, M and the RKM notation (e.g. 4k7). + + + + + tools.value_calc.resistor.from_value + Value to color code + + + + + tools.value_calc.resistor.apply_value + Generate bands + + + + + tools.value_calc.capacitor.intro + Works for the printed number codes on ceramic (MLCC), film and similar capacitors. Small caps below 100 pF are usually printed directly (e.g. 47 or 4R7), larger ones use the 3-digit code (e.g. 104 = 100 nF). + + + + + tools.value_calc.smd.on_chip_help + Edit any field to update the others. Use the radio to pick which code is printed on the chip. + + + + + tools.value_calc.field.value + Value + + + + + tools.value_calc.field.code + Code + + + + + tools.value_calc.field.tolerance + Tolerance + + + + + tools.value_calc.field.power + Power + + + + + tools.value_calc.field.ppm + Temp. coeff. (ppm/K) + + + + + tools.value_calc.field.code3 + 3-digit + + + + + tools.value_calc.field.code4 + 4-digit + + + + + tools.value_calc.field.eia96 + EIA-96 + + + + + tools.value_calc.field.on_chip + Printed on the chip + + part.table.eda_reference