From 0dac22b993b565fd3c79c29c5069c226f1a2f6a4 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:05:49 +0200 Subject: [PATCH 01/13] Add a component value calculator & image generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Value calculator" tool (Tools menu) that decodes/encodes the value of common components and generates a clean SVG picture you can attach to a part — handy for assortments imported with blank thumbnails and sparse data. Calculator tabs (bidirectional decode <-> encode), each drawing the part to the selected package with a collapsible appearance panel and an "attach to part" action (optionally as the master picture): - Resistor: 4/5/6-band colour code - Capacitor: value <-> 3-digit code <-> tolerance - SMD resistor: value <-> 3-digit / 4-digit / EIA-96 code - Inductor: colour-band code - SMD inductor: value <-> uH code Bulk "Generate component images" parts-table action: - Classifies each selected part (resistor / capacitor / inductor / diode / LED, THT or SMD) with ComponentValueGuesser and detects value, voltage, tolerance, power, ppm, pitch, diameter, colour, SMD package and marking from the name, description and parameters. - Renders a preview per part, lets you tweak appearance, and attaches the images in one go; can also write matching KiCad symbol/footprint/reference-prefix EDA fields. Backend: ComponentValueGuesser (classification + detection + EDA suggestion), GeneratedImageAttachmentHelper (stores the SVG through the existing attachment pipeline, so it is sanitised on save), two POST endpoints on PartController (generate_image, set_eda) guarded by `edit` + CSRF, a new `@tools.value_calculator` permission, a Tools-tree entry and docs. All drawing is client-side in a Stimulus controller; the un-sanitised live preview escapes part-derived text and validates colours as defence-in-depth. Tests: unit tests for ComponentValueGuesser and functional tests for the endpoints, including permission and CSRF enforcement and the persisted side effects. --- .../pages/bulkGenerate_controller.js | 313 +++ .../pages/valueCalculator_controller.js | 2328 +++++++++++++++++ config/permissions.yaml | 2 + docs/usage/component_image_generator.md | 89 + src/Controller/PartController.php | 91 + src/Controller/ToolsController.php | 143 + .../GeneratedImageAttachmentHelper.php | 148 ++ .../Parts/PartsTableActionHandler.php | 10 + src/Services/Tools/ComponentValueGuesser.php | 758 ++++++ src/Services/Trees/ToolsTreeBuilder.php | 6 + .../UserSystem/PermissionPresetsHelper.php | 1 + .../components/datatables.macro.html.twig | 3 + templates/parts/edit/_attachments.html.twig | 17 +- templates/parts/edit/edit_part_info.html.twig | 4 + templates/parts/info/_picture.html.twig | 4 + templates/parts/info/show_part_info.html.twig | 1 + .../tools/value_calculator/_bare.html.twig | 5 + .../_calculator_body.html.twig | 550 ++++ .../_generate_button.html.twig | 7 + .../_generate_modal.html.twig | 23 + .../value_calculator/bulk_generate.html.twig | 484 ++++ .../value_calculator.html.twig | 11 + .../ValueCalculatorControllerTest.php | 228 ++ .../Tools/ComponentValueGuesserTest.php | 462 ++++ translations/frontend.en.xlf | 120 + translations/messages.en.xlf | 678 +++++ 26 files changed, 6484 insertions(+), 2 deletions(-) create mode 100644 assets/controllers/pages/bulkGenerate_controller.js create mode 100644 assets/controllers/pages/valueCalculator_controller.js create mode 100644 docs/usage/component_image_generator.md create mode 100644 src/Services/Attachments/GeneratedImageAttachmentHelper.php create mode 100644 src/Services/Tools/ComponentValueGuesser.php create mode 100644 templates/tools/value_calculator/_bare.html.twig create mode 100644 templates/tools/value_calculator/_calculator_body.html.twig create mode 100644 templates/tools/value_calculator/_generate_button.html.twig create mode 100644 templates/tools/value_calculator/_generate_modal.html.twig create mode 100644 templates/tools/value_calculator/bulk_generate.html.twig create mode 100644 templates/tools/value_calculator/value_calculator.html.twig create mode 100644 tests/Controller/ValueCalculatorControllerTest.php create mode 100644 tests/Services/Tools/ComponentValueGuesserTest.php diff --git a/assets/controllers/pages/bulkGenerate_controller.js b/assets/controllers/pages/bulkGenerate_controller.js new file mode 100644 index 000000000..48e91e021 --- /dev/null +++ b/assets/controllers/pages/bulkGenerate_controller.js @@ -0,0 +1,313 @@ +/* + * 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 value-calculator 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 value-calculator controller instance (retries briefly, since it may connect after us). */ + calcController() { + const el = this.element.querySelector('[data-controller*="alueCalculator"], [data-controller*="alue-calculator"]'); + if (!el) { + return null; + } + for (const id of ["pages--valueCalculator", "pages--value-calculator"]) { + const c = this.application.getControllerForElementAndIdentifier(el, id); + 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/valueCalculator_controller.js b/assets/controllers/pages/valueCalculator_controller.js new file mode 100644 index 000000000..ca1203da1 --- /dev/null +++ b/assets/controllers/pages/valueCalculator_controller.js @@ -0,0 +1,2328 @@ +/* + * 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"; + +/** + * 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 active = this.element.querySelector(".tab-pane.active"); + if (!active) { + return; + } + 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"; + this.doAttach(svg, name, event.currentTarget); + } + + /** 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/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/docs/usage/component_image_generator.md b/docs/usage/component_image_generator.md new file mode 100644 index 000000000..a42117a42 --- /dev/null +++ b/docs/usage/component_image_generator.md @@ -0,0 +1,89 @@ +--- +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. + +## 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..bc0ca1117 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 valueCalculator(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/value_calculator/value_calculator.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/value_calculator/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..f6290b102 --- /dev/null +++ b/src/Services/Attachments/GeneratedImageAttachmentHelper.php @@ -0,0 +1,148 @@ +. + */ + +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. + */ +class GeneratedImageAttachmentHelper +{ + private const ATTACHMENT_TYPE_NAME = 'Generated image'; + + public function __construct( + private readonly EntityManagerInterface $em, + private readonly 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 + { + //handleUpload() does not enforce the upload-size limit, so guard it here. The SVG is + //stored roughly 1:1 (base64 is only the transport encoding), so its byte length is a + //good proxy for the resulting file size. + if (strlen($svg) > $this->submitHandler->getMaximumEffectiveUploadSize()) { + throw new \RuntimeException('The generated image exceeds the maximum allowed upload size.'); + } + + $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..2870c45f8 --- /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). + */ +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 recognised 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/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/value_calculator/_generate_button.html.twig b/templates/tools/value_calculator/_generate_button.html.twig new file mode 100644 index 000000000..ebe5f993c --- /dev/null +++ b/templates/tools/value_calculator/_generate_button.html.twig @@ -0,0 +1,7 @@ +{# A trigger button that opens the generator modal (which must be included once via + _generate_modal.html.twig). Expects `part` in scope. Optional `btn_class` overrides the styling. #} +{% if part.id is not null and is_granted('edit', part) and is_granted('@tools.component_image_generator') %} + +{% endif %} diff --git a/templates/tools/value_calculator/_generate_modal.html.twig b/templates/tools/value_calculator/_generate_modal.html.twig new file mode 100644 index 000000000..0b5fed934 --- /dev/null +++ b/templates/tools/value_calculator/_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/value_calculator/bulk_generate.html.twig b/templates/tools/value_calculator/bulk_generate.html.twig new file mode 100644 index 000000000..75762455c --- /dev/null +++ b/templates/tools/value_calculator/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/value_calculator/value_calculator.html.twig b/templates/tools/value_calculator/value_calculator.html.twig new file mode 100644 index 000000000..fddbb671a --- /dev/null +++ b/templates/tools/value_calculator/value_calculator.html.twig @@ -0,0 +1,11 @@ +{% extends modalMode|default(false) ? 'tools/value_calculator/_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/value_calculator/_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/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.en.xlf b/translations/frontend.en.xlf index d00994931..7b030df98 100644 --- a/translations/frontend.en.xlf +++ b/translations/frontend.en.xlf @@ -55,6 +55,126 @@ 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 diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index fa3998b54..88e830e19 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13025,6 +13025,684 @@ 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.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 From 70e9817fbe810510518edc34db7623388fdd181f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 23 Aug 2026 17:56:09 +0200 Subject: [PATCH 02/13] Fixed messages id --- translations/messages.en.xlf | 226 +++++++++++++++++------------------ 1 file changed, 113 insertions(+), 113 deletions(-) diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 88e830e19..7c994d210 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13025,679 +13025,679 @@ 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.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 From 6423b1a20466c3f149ed1090fc6ada7ab388e2dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 23 Aug 2026 18:08:13 +0200 Subject: [PATCH 03/13] Added an download SVG button to the image generator tool --- .../pages/valueCalculator_controller.js | 32 +++++++++++++++++-- docs/usage/component_image_generator.md | 4 +++ .../_calculator_body.html.twig | 11 ++++--- translations/messages.en.xlf | 6 ++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/assets/controllers/pages/valueCalculator_controller.js b/assets/controllers/pages/valueCalculator_controller.js index ca1203da1..eb2f65e07 100644 --- a/assets/controllers/pages/valueCalculator_controller.js +++ b/assets/controllers/pages/valueCalculator_controller.js @@ -357,9 +357,15 @@ export default class extends Controller { * 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; + return {svg: "", name: ""}; } const containers = { "vc-resistor": this.hasResistorSvgTarget ? this.resistorSvgTarget : null, @@ -372,7 +378,29 @@ export default class extends Controller { const container = containers[active.id]; const svg = container ? container.innerHTML.trim() : ""; const name = active.dataset.vcName || "Generated image"; - this.doAttach(svg, name, event.currentTarget); + 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). */ diff --git a/docs/usage/component_image_generator.md b/docs/usage/component_image_generator.md index a42117a42..d6ed828b1 100644 --- a/docs/usage/component_image_generator.md +++ b/docs/usage/component_image_generator.md @@ -40,6 +40,10 @@ When a part has no picture, its info page shows a **Generate image** button in t 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 diff --git a/templates/tools/value_calculator/_calculator_body.html.twig b/templates/tools/value_calculator/_calculator_body.html.twig index 876561578..57c4f89b2 100644 --- a/templates/tools/value_calculator/_calculator_body.html.twig +++ b/templates/tools/value_calculator/_calculator_body.html.twig @@ -540,11 +540,14 @@ - {% if part is not null %} -
+
+ + {% if part is not null %} -
- {% endif %} + {% endif %} +
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 7c994d210..07943e44d 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13511,6 +13511,12 @@ Buerklin-API Authentication server: Attach to part
+ + + tools.value_calc.download.button + Download SVG + + tools.value_calc.attach.generate_button From e8e29795601a14a55c3f5b61ad54d3568807996d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 23 Aug 2026 19:15:52 +0200 Subject: [PATCH 04/13] Updated translations id --- config/reference.php | 1 + translations/frontend.en.xlf | 48 ++++++++++++++++++------------------ translations/messages.en.xlf | 2 +- 3 files changed, 26 insertions(+), 25 deletions(-) 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/translations/frontend.en.xlf b/translations/frontend.en.xlf index 7b030df98..212e7f05b 100644 --- a/translations/frontend.en.xlf +++ b/translations/frontend.en.xlf @@ -55,145 +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.en.xlf b/translations/messages.en.xlf index 07943e44d..a264201d4 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13511,7 +13511,7 @@ Buerklin-API Authentication server: Attach to part - + tools.value_calc.download.button Download SVG From 387df99d612c8154c21c9a2b2a35d06d465df78d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 23 Aug 2026 22:06:13 +0200 Subject: [PATCH 05/13] Improved image generator button on part edit page --- templates/components/attachments.macro.html.twig | 16 +++++++++++----- templates/parts/edit/_attachments.html.twig | 15 ++++----------- 2 files changed, 15 insertions(+), 16 deletions(-) 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/parts/edit/_attachments.html.twig b/templates/parts/edit/_attachments.html.twig index 365a4ef61..99c962931 100644 --- a/templates/parts/edit/_attachments.html.twig +++ b/templates/parts/edit/_attachments.html.twig @@ -1,18 +1,11 @@ {% import "components/attachments.macro.html.twig" as attachments %} -{% if part.id is not null and is_granted('@tools.component_image_generator') %} -
- {% include "tools/value_calculator/_generate_button.html.twig" %} - {% trans %}tools.value_calc.attach.generate_hint{% endtrans %} -
- {# The modal itself is rendered outside the part form (in edit_part_info.html.twig) so its - named radio inputs are not submitted with the form. #} -{% endif %} - {# Wrapped in a Turbo frame (no src -> inert during normal editing) so the value-calculator can refresh just this list after attaching a generated image, without a full-page reload. #} {{ form_row(form.master_picture_attachment) }} - {{ attachments.attachment_edit_list(form.attachments) }} - \ No newline at end of file + {{ attachments.attachment_edit_list(form.attachments, part, true) }} + + + From f72a5f5bb8fb9f858208e09f8af2874cb22cde04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 23 Aug 2026 22:19:11 +0200 Subject: [PATCH 06/13] Made the image generator button for the placeholder case more sbtle by moving it into the placeholder box --- templates/parts/info/_picture.html.twig | 14 ++++++++++---- .../value_calculator/_generate_button.html.twig | 7 ------- 2 files changed, 10 insertions(+), 11 deletions(-) delete mode 100644 templates/tools/value_calculator/_generate_button.html.twig diff --git a/templates/parts/info/_picture.html.twig b/templates/parts/info/_picture.html.twig index 9f4aeb732..bebd5ad37 100644 --- a/templates/parts/info/_picture.html.twig +++ b/templates/parts/info/_picture.html.twig @@ -40,9 +40,15 @@ {% else %} - Part main image - {# No picture yet: offer to generate one right here. #} -
- {% include "tools/value_calculator/_generate_button.html.twig" %} +
+ Part main image + {# No picture yet: offer to generate one right here #} + {% if part.id is not null and is_granted('edit', part) and is_granted('@tools.component_image_generator') %} +
+ +
+ {% endif %}
{% endif %} diff --git a/templates/tools/value_calculator/_generate_button.html.twig b/templates/tools/value_calculator/_generate_button.html.twig deleted file mode 100644 index ebe5f993c..000000000 --- a/templates/tools/value_calculator/_generate_button.html.twig +++ /dev/null @@ -1,7 +0,0 @@ -{# A trigger button that opens the generator modal (which must be included once via - _generate_modal.html.twig). Expects `part` in scope. Optional `btn_class` overrides the styling. #} -{% if part.id is not null and is_granted('edit', part) and is_granted('@tools.component_image_generator') %} - -{% endif %} From 140fcdc077aecfe3b1d9de005d75793473f30155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 24 Aug 2026 00:37:35 +0200 Subject: [PATCH 07/13] Improved ToolsController --- src/Controller/ToolsController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Controller/ToolsController.php b/src/Controller/ToolsController.php index bc0ca1117..94099528c 100644 --- a/src/Controller/ToolsController.php +++ b/src/Controller/ToolsController.php @@ -148,7 +148,7 @@ public function icLogos(): Response } #[Route(path: '/component_image_generator', name: 'tools_component_image_generator')] - public function valueCalculator(Request $request, EntityManagerInterface $em, ComponentValueGuesser $guesser): Response + public function componentImageGenerator(Request $request, EntityManagerInterface $em, ComponentValueGuesser $guesser): Response { $this->denyAccessUnlessGranted('@tools.component_image_generator'); @@ -165,7 +165,7 @@ public function valueCalculator(Request $request, EntityManagerInterface $em, Co $prefillOhms = null; $prefillFarads = null; if ($part !== null) { - [$prefillOhms, $prefillFarads] = $guesser->extractValue($part); + [$prefillOhms, $prefillFarads,] = $guesser->extractValue($part); } return $this->render('tools/value_calculator/value_calculator.html.twig', [ @@ -194,7 +194,7 @@ public function bulkGenerate(Request $request, EntityManagerInterface $em, Compo $overwrite = $request->query->getBoolean('overwrite'); $idsParam = (string) $request->query->get('ids', ''); $ids = array_values(array_filter( - array_map('intval', explode(',', $idsParam)), + array_map(intval(...), explode(',', $idsParam)), static fn (int $id): bool => $id > 0 )); From 5a6622e4e87cfbaed7f000aec8fc7e3be57cb406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 24 Aug 2026 00:50:07 +0200 Subject: [PATCH 08/13] Added test for GeneratedImageAttachmentHelper --- .../GeneratedImageAttachmentHelper.php | 13 +- src/Services/Tools/ComponentValueGuesser.php | 4 +- .../GeneratedImageAttachmentHelperTest.php | 323 ++++++++++++++++++ 3 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 tests/Services/Attachments/GeneratedImageAttachmentHelperTest.php diff --git a/src/Services/Attachments/GeneratedImageAttachmentHelper.php b/src/Services/Attachments/GeneratedImageAttachmentHelper.php index f6290b102..52ace8ece 100644 --- a/src/Services/Attachments/GeneratedImageAttachmentHelper.php +++ b/src/Services/Attachments/GeneratedImageAttachmentHelper.php @@ -33,13 +33,13 @@ * Creates attachments from SVG markup that was generated client-side (e.g. by the * resistor/capacitor value calculator) and attaches them to a part. */ -class GeneratedImageAttachmentHelper +final readonly class GeneratedImageAttachmentHelper { private const ATTACHMENT_TYPE_NAME = 'Generated image'; public function __construct( - private readonly EntityManagerInterface $em, - private readonly AttachmentSubmitHandler $submitHandler, + private EntityManagerInterface $em, + private AttachmentSubmitHandler $submitHandler, ) { } @@ -54,13 +54,6 @@ public function __construct( */ public function attachSvgToPart(Part $part, string $svg, string $name, bool $setAsPreview = true, bool $overwrite = false): PartAttachment { - //handleUpload() does not enforce the upload-size limit, so guard it here. The SVG is - //stored roughly 1:1 (base64 is only the transport encoding), so its byte length is a - //good proxy for the resulting file size. - if (strlen($svg) > $this->submitHandler->getMaximumEffectiveUploadSize()) { - throw new \RuntimeException('The generated image exceeds the maximum allowed upload size.'); - } - $type = $this->getGeneratedImageType(); //In overwrite mode, drop previously generated images so re-generating replaces them diff --git a/src/Services/Tools/ComponentValueGuesser.php b/src/Services/Tools/ComponentValueGuesser.php index 2870c45f8..a5eca763d 100644 --- a/src/Services/Tools/ComponentValueGuesser.php +++ b/src/Services/Tools/ComponentValueGuesser.php @@ -30,7 +30,7 @@ * 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). */ -class ComponentValueGuesser +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']; @@ -181,7 +181,7 @@ private function capDiscFootprint(?float $pitch, ?float $diameter): string */ public function guess(Part $part): ?array { - //Unambiguous diode part numbers (1N4148, BAT54, BZX…) are recognised first: their "1N…" style + //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); 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); + } +} From 9554d9ca426f9d227c264db5a0bdf179d4879095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 24 Aug 2026 01:05:13 +0200 Subject: [PATCH 09/13] Renamed controllers --- ....js => bulk_image_generator_controller.js} | 16 +- ...> component_image_generator_controller.js} | 0 .../_calculator_body.html.twig | 184 +++++++++--------- .../value_calculator/bulk_generate.html.twig | 86 ++++---- 4 files changed, 142 insertions(+), 144 deletions(-) rename assets/controllers/pages/{bulkGenerate_controller.js => bulk_image_generator_controller.js} (95%) rename assets/controllers/pages/{valueCalculator_controller.js => component_image_generator_controller.js} (100%) diff --git a/assets/controllers/pages/bulkGenerate_controller.js b/assets/controllers/pages/bulk_image_generator_controller.js similarity index 95% rename from assets/controllers/pages/bulkGenerate_controller.js rename to assets/controllers/pages/bulk_image_generator_controller.js index 48e91e021..5e5da9adb 100644 --- a/assets/controllers/pages/bulkGenerate_controller.js +++ b/assets/controllers/pages/bulk_image_generator_controller.js @@ -22,8 +22,8 @@ import {AlertSwal} from "../../helpers/swal"; import {trans} from "../../translator.js"; /** - * Drives the hidden value-calculator 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). + * 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", @@ -34,17 +34,15 @@ export default class extends Controller { this.tryRenderPreviews(0); } - /** The value-calculator controller instance (retries briefly, since it may connect after us). */ + /** The component image generator controller instance (retries briefly, since it may connect after us). */ calcController() { - const el = this.element.querySelector('[data-controller*="alueCalculator"], [data-controller*="alue-calculator"]'); + const el = this.element.querySelector('[data-controller*="omponent-image-generator"]'); if (!el) { return null; } - for (const id of ["pages--valueCalculator", "pages--value-calculator"]) { - const c = this.application.getControllerForElementAndIdentifier(el, id); - if (c && typeof c.generateSvg === "function") { - return c; - } + const c = this.application.getControllerForElementAndIdentifier(el, "pages--component-image-generator"); + if (c && typeof c.generateSvg === "function") { + return c; } return null; } diff --git a/assets/controllers/pages/valueCalculator_controller.js b/assets/controllers/pages/component_image_generator_controller.js similarity index 100% rename from assets/controllers/pages/valueCalculator_controller.js rename to assets/controllers/pages/component_image_generator_controller.js diff --git a/templates/tools/value_calculator/_calculator_body.html.twig b/templates/tools/value_calculator/_calculator_body.html.twig index 57c4f89b2..bbf577827 100644 --- a/templates/tools/value_calculator/_calculator_body.html.twig +++ b/templates/tools/value_calculator/_calculator_body.html.twig @@ -29,7 +29,7 @@
{% trans %}tools.value_calc.explanation{% endtrans %}
-
+ {{ stimulus_target('pages/component_image_generator', 'previewInput') }}>
@@ -83,32 +83,32 @@
+ autocomplete="off" {{ stimulus_action('pages/component_image_generator', 'changeBandCount', 'change') }}> + autocomplete="off" checked {{ stimulus_action('pages/component_image_generator', 'changeBandCount', 'change') }}> + autocomplete="off" {{ stimulus_action('pages/component_image_generator', 'changeBandCount', 'change') }}>
{# Band color selects are rendered by the Stimulus controller #} -
+
+ {{ stimulus_target('pages/component_image_generator', 'resistorValueInput') }} + {{ stimulus_action('pages/component_image_generator', 'applyResistorValue', 'keyup.enter') }}> + {{ stimulus_action('pages/component_image_generator', 'applyResistorValue') }}>{% trans %}tools.value_calc.resistor.apply_value{% endtrans %}
{% trans %}tools.value_calc.resistor.from_value_help{% endtrans %}
@@ -121,8 +121,8 @@
+ {{ stimulus_target('pages/component_image_generator', 'resistorBodyColor') }} + {{ stimulus_action('pages/component_image_generator', 'updateResistor', 'input') }}>
+ {{ stimulus_action('pages/component_image_generator', 'applyResistorBodyColor') }}>{% trans %}tools.value_calc.body.beige{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applyResistorBodyColor') }}>{% trans %}tools.value_calc.body.lightblue{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applyResistorBodyColor') }}>{% trans %}tools.value_calc.body.blue{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applyResistorBodyColor') }}>{% trans %}tools.value_calc.body.green{% endtrans %}
@@ -156,9 +156,9 @@
{{ '' }} -
+
-
+
@@ -173,20 +173,20 @@
+ {{ stimulus_target('pages/component_image_generator', 'capValueInput') }} + {{ stimulus_action('pages/component_image_generator', 'syncCap', 'input') }}>
+ {{ stimulus_target('pages/component_image_generator', 'capCodeInput') }} + {{ stimulus_action('pages/component_image_generator', 'syncCap', 'input') }}>
+ {{ stimulus_target('pages/component_image_generator', 'capBodyColor') }} + {{ stimulus_action('pages/component_image_generator', 'updateCapacitorColor', 'input') }}>
+ {{ stimulus_action('pages/component_image_generator', 'applyCapBodyColor') }}>{% trans %}tools.value_calc.body.tan{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applyCapBodyColor') }}>{% trans %}tools.value_calc.body.blue{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applyCapBodyColor') }}>{% trans %}tools.value_calc.body.red{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applyCapBodyColor') }}>{% trans %}tools.value_calc.body.purple{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applyCapBodyColor') }}>{% trans %}tools.value_calc.body.black{% endtrans %}
@@ -230,8 +230,8 @@
{% trans %}tools.value_calc.cap.shape{% endtrans %} @@ -239,8 +239,8 @@
{% trans %}tools.value_calc.cap.lead{% endtrans %} + {{ stimulus_target('pages/component_image_generator', 'capDiameter') }} + {{ stimulus_action('pages/component_image_generator', 'updateCapDimensions', 'input') }}> mm @@ -267,8 +267,8 @@
{% trans %}tools.value_calc.cap.pitch{% endtrans %} + {{ stimulus_target('pages/component_image_generator', 'capVoltage') }} + {{ stimulus_action('pages/component_image_generator', 'updateCapDimensions', 'input') }}> V @@ -297,8 +297,8 @@
-
-
+
+
@@ -313,41 +313,41 @@
+ {{ stimulus_target('pages/component_image_generator', 'smdValueInput') }} + {{ stimulus_action('pages/component_image_generator', 'syncSmd', 'input') }}>
+ {{ stimulus_target('pages/component_image_generator', 'smdCode3') }} + {{ stimulus_action('pages/component_image_generator', 'syncSmd', 'input') }}>
+ {{ stimulus_target('pages/component_image_generator', 'smdCode4') }} + {{ stimulus_action('pages/component_image_generator', 'syncSmd', 'input') }}>
+ {{ stimulus_target('pages/component_image_generator', 'smdEia96') }} + {{ stimulus_action('pages/component_image_generator', 'syncSmd', 'input') }}>
@@ -358,8 +358,8 @@
+ {{ stimulus_target('pages/component_image_generator', 'smdBodyColor') }} + {{ stimulus_action('pages/component_image_generator', 'updateSmdColor', 'input') }}>
+ {{ stimulus_action('pages/component_image_generator', 'applySmdBodyColor') }}>{% trans %}tools.value_calc.body.black{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applySmdBodyColor') }}>{% trans %}tools.value_calc.body.grey{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applySmdBodyColor') }}>{% trans %}tools.value_calc.body.white{% endtrans %}
@@ -393,8 +393,8 @@
-
-
+
+
@@ -410,27 +410,27 @@
+ autocomplete="off" checked {{ stimulus_action('pages/component_image_generator', 'changeIndBandCount', 'change') }}> + autocomplete="off" {{ stimulus_action('pages/component_image_generator', 'changeIndBandCount', 'change') }}>
{# Band colour selects are rendered by the Stimulus controller #} -
+
+ {{ stimulus_target('pages/component_image_generator', 'indValueInput') }} + {{ stimulus_action('pages/component_image_generator', 'applyInductorValue', 'keyup.enter') }}> + {{ stimulus_action('pages/component_image_generator', 'applyInductorValue') }}>{% trans %}tools.value_calc.resistor.apply_value{% endtrans %}
{% trans %}tools.value_calc.inductor.from_value_help{% endtrans %}
@@ -444,17 +444,17 @@
+ {{ stimulus_target('pages/component_image_generator', 'indBodyColor') }} + {{ stimulus_action('pages/component_image_generator', 'updateInductor', 'input') }}>
+ {{ stimulus_action('pages/component_image_generator', 'applyIndBodyColor') }}>{% trans %}tools.value_calc.body.green{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applyIndBodyColor') }}>{% trans %}tools.value_calc.body.blue{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applyIndBodyColor') }}>{% trans %}tools.value_calc.body.beige{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applyIndBodyColor') }}>{% trans %}tools.value_calc.body.black{% endtrans %}
@@ -464,7 +464,7 @@
{{ '' }} -
+
@@ -480,14 +480,14 @@
+ {{ stimulus_target('pages/component_image_generator', 'smdIndValueInput') }} + {{ stimulus_action('pages/component_image_generator', 'syncSmdInductor', 'input') }}>
+ {{ stimulus_target('pages/component_image_generator', 'smdIndCode') }} + {{ stimulus_action('pages/component_image_generator', 'syncSmdInductor', 'input') }}>
@@ -498,8 +498,8 @@
+ {{ stimulus_target('pages/component_image_generator', 'smdIndBodyColor') }} + {{ stimulus_action('pages/component_image_generator', 'syncSmdInductor', 'input') }}>
+ {{ stimulus_action('pages/component_image_generator', 'applySmdIndBodyColor') }}>{% trans %}tools.value_calc.body.black{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applySmdIndBodyColor') }}>{% trans %}tools.value_calc.body.grey{% endtrans %} + {{ stimulus_action('pages/component_image_generator', 'applySmdIndBodyColor') }}>{% trans %}tools.value_calc.body.blue{% endtrans %}
@@ -532,8 +532,8 @@
-
-
+
+
@@ -541,11 +541,11 @@
- {% if part is not null %} - {% endif %} diff --git a/templates/tools/value_calculator/bulk_generate.html.twig b/templates/tools/value_calculator/bulk_generate.html.twig index 75762455c..49f3c88ca 100644 --- a/templates/tools/value_calculator/bulk_generate.html.twig +++ b/templates/tools/value_calculator/bulk_generate.html.twig @@ -88,7 +88,7 @@ {% trans %}tools.bulk_gen.back{% endtrans %} {% else %} -
+
{# One hidden value-calculator, reused to render every preview below. #} @@ -114,8 +114,8 @@
{% trans %}tools.value_calc.cap.shape{% endtrans %} @@ -125,8 +125,8 @@
{% trans %}tools.value_calc.cap.lead{% endtrans %} + {{ stimulus_target('pages/bulk_image_generator', 'batchDiameter') }} + {{ stimulus_action('pages/bulk_image_generator', 'applyDiameter', 'input') }}> mm
{% trans %}tools.value_calc.cap.pitch{% endtrans %} + {{ stimulus_target('pages/bulk_image_generator', 'batchPower') }} + {{ stimulus_action('pages/bulk_image_generator', 'applyPower', 'change') }}> @@ -170,8 +170,8 @@
{% trans %}tools.value_calc.field.ppm{% endtrans %} + {{ stimulus_target('pages/bulk_image_generator', 'batchPackage') }} + {{ stimulus_action('pages/bulk_image_generator', 'applyPackage', 'change') }}> @@ -202,15 +202,15 @@
{% trans %}tools.value_calc.cap.voltage{% endtrans %} + {{ stimulus_target('pages/bulk_image_generator', 'batchVoltage') }} + {{ stimulus_action('pages/bulk_image_generator', 'applyVoltage', 'input') }}> V
{% trans %}tools.value_calc.field.tolerance{% endtrans %} + {{ stimulus_target('pages/bulk_image_generator', 'batchColor') }} + {{ stimulus_action('pages/bulk_image_generator', 'applyColor', 'input') }}>
- - - - - - + + + + + +
@@ -286,7 +286,7 @@ - + @@ -307,7 +307,7 @@ 'diode': 'tools.value_calc.diode.title', } %} {% set name_key = name_keys[c.type]|default('tools.value_calc.smd.title') %} - + {{ stimulus_action('pages/bulk_image_generator', 'regenerateRow', 'input') }}> {% if c.type == 'capacitor' %} + {{ stimulus_action('pages/bulk_image_generator', 'regenerateRow', 'input') }}> {% endif %} {% if c.type == 'capacitor' %} {% set rp = c.pitch ?: 5.08 %} + {{ stimulus_action('pages/bulk_image_generator', 'regenerateRow', 'change') }}> @@ -389,7 +389,7 @@ + {{ stimulus_action('pages/bulk_image_generator', 'regenerateRow', 'change') }}> {% for pkg in ['0201', '0402', '0603', '0805', '1206', '1210', '2010', '2512'] %} {% endfor %} @@ -413,7 +413,7 @@
+ {{ stimulus_action('pages/bulk_image_generator', 'regenerateRow', 'input') }}> V
{% endif %} @@ -424,7 +424,7 @@ {% else %} {% set rtol = c.tolerance ? c.tolerance|replace({'±': '', '%': '', ' ': '', 'pF': '', 'PF': ''})|trim : '' %} - - - - - + + + + + {% endif %} @@ -371,11 +371,11 @@ {% set rp = c.pitch ?: 5.08 %} {% elseif c.type == 'resistor' %} {% set rpow = c.power ?: 0.25 %} @@ -391,12 +391,12 @@ {% elseif c.type in ['smd_resistor', 'smd_inductor', 'smd_capacitor'] %}
{% trans %}tools.bulk_gen.part{% endtrans %} {% trans %}tools.bulk_gen.type{% endtrans %} {% trans %}tools.bulk_gen.value{% endtrans %}