diff --git a/assets/controllers/pages/bulkGenerate_controller.js b/assets/controllers/pages/bulkGenerate_controller.js new file mode 100644 index 000000000..4d0ca312c --- /dev/null +++ b/assets/controllers/pages/bulkGenerate_controller.js @@ -0,0 +1,311 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-server). + * + * Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Controller} from "@hotwired/stimulus"; +import {AlertSwal} from "../../helpers/swal"; +import {trans} from "../../translator.js"; + +/** + * Drives the hidden 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), + 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..1e5eab383 --- /dev/null +++ b/assets/controllers/pages/valueCalculator_controller.js @@ -0,0 +1,1564 @@ +/* + * 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", + "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; + } + + // 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.setSmdFields(value, null); + this.redrawSmd(); + return this.hasSmdSvgTarget ? this.smdSvgTarget.innerHTML.trim() : ""; + } + // Resistor (through-hole colour bands) + 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 = ""; + } + } + + /** + * 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, + }; + 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) { + this.bandCount = parseInt(event.target.value, 10); + // Preserve the currently shown value when switching band count + const current = this.computeResistance(); + this.renderBandSelects(); + if (current && current.ohms > 0) { + this.setBandsFromValue(current.ohms, current.tolerance); + } + this.updateResistor(); + } + + /** Returns the list of band "roles" for the current band count. */ + bandRoles() { + if (this.bandCount === 4) { + return ["digit", "digit", "multiplier", "tolerance"]; + } + if (this.bandCount === 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() { + const roles = this.bandRoles(); + let html = ""; + roles.forEach((role, index) => { + const options = this.colorsForRole(role) + .map((name) => ``) + .join(""); + const col = roles.length >= 6 ? "col" : "col-sm"; + html += ` +
+ + +
`; + }); + this.bandSelectsTarget.innerHTML = html; + + // Fill in the (translated) role labels via the data-role-label hook. + this.bandSelectsTarget.querySelectorAll("[data-role-label]").forEach((el) => { + el.textContent = trans(this.labelForRole(el.dataset.roleLabel)); + }); + } + + colorLabel(name) { + return trans("tools.value_calc.color." + name); + } + + /** Reads the currently selected color of every band select. */ + selectedColors() { + return Array.from(this.bandSelectsTarget.querySelectorAll("select")) + .map((sel) => sel.value); + } + + computeResistance() { + const roles = this.bandRoles(); + const colors = this.selectedColors(); + 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 { + ohms: parseInt(digits, 10) * multiplier, + tolerance, + 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`; + } + this.resistorResultTarget.textContent = text; + this.drawResistor(this.selectedColors()); + } + + /** + * Determine the band colors representing the given resistance and write + * them into the selects. + */ + setBandsFromValue(ohms, tolerance) { + if (!(ohms > 0)) { + return false; + } + const numDigits = this.bandCount === 4 ? 2 : 3; + + // Normalize ohms into significant figures + power of ten + let exp = Math.floor(Math.log10(ohms)) - (numDigits - 1); + let digits = Math.round(ohms / 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(); + const selects = this.bandSelectsTarget.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(); + } + + /** 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 = 185; + 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.bodyColor(this.hasResistorBodyColorTarget ? this.resistorBodyColorTarget : null, "#d8c7a0"); + const dim = RESISTOR_POWERS[this.resistorPowerValue()]; + const callouts = + this.dimH(bodyX, bodyX + bodyW, bodyBottom + 16, `L ${this.formatMm(dim.len)}`) + + this.dimH(margin + 4, width - margin - 4, height - 14, `pitch ${this.formatMm(dim.pitch)} (${dim.pitchIn})`) + + this.dimV(bodyY, bodyBottom, bodyX + bodyW + 28, `⌀ ${this.formatMm(dim.dia)}`, bodyX + bodyW); + + 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})`; + } + } + + 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; + 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}`; + } + 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.bodyColor(this.hasCapBodyColorTarget ? this.capBodyColorTarget : null, "#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); + + const svg = ` + + + ${this.leadGradient(uid)} + ${this.blurFilter(uid)} + + + + + + + + + ${leads} + + + + + + + + + + ${marking} + ${voltageSvg} + + ${callouts} + `; + 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. */ + 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"; + } + + /* + * --------------------------------------------------------------- + * 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; + 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); + this.smdResultTarget.textContent = this.formatOhms(this.smdOhms); + this.drawSmd(this.smdSvgTarget, marking); + 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) { + const uid = this.svgId(); + const w = 300; + const h = 190; + 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.bodyColor(this.hasSmdBodyColorTarget ? this.smdBodyColorTarget : null, "#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); + + const svg = ` + + + ${this.metalGradient(uid)} + ${this.glossGradient(uid)} + ${this.blurFilter(uid)} + + ${this.shadowFilter(uid)} + + + + + + + + + + + ${marking} + + ${callouts} + `; + 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"; + } + + /* + * --------------------------------------------------------------- + * 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 39e91b57e..187d4047a 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" + value_calculator: + label: "perm.tools.value_calculator" 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 76dffb4d0..426cd5c09 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; @@ -129,4 +133,125 @@ public function icLogos(): Response return $this->render('tools/ic_logos/ic_logos.html.twig'); } + + #[Route(path: '/value_calc', name: 'tools_value_calculator')] + public function valueCalculator(Request $request, EntityManagerInterface $em, ComponentValueGuesser $guesser): Response + { + $this->denyAccessUnlessGranted('@tools.value_calculator'); + + //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.value_calculator'); + + $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'], + '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; + 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; + } + } + + 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, + '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..9bd3c4e00 --- /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->getMaximumAllowedUploadSize()) { + 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..2886015c2 --- /dev/null +++ b/src/Services/Tools/ComponentValueGuesser.php @@ -0,0 +1,480 @@ +. + */ + +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', + ]; + + /** + * 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} $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') { + return [ + 'symbol' => 'Device:C', + 'reference' => 'C', + 'footprint' => $this->capDiscFootprint($guess['pitch'] ?? null, $guess['diameter'] ?? null), + ]; + } + + 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', 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}|null + * value is ohms (resistors) or farads (capacitors); null if it can't be classified. + */ + public function guess(Part $part): ?array + { + [$ohms, $farads] = $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' => null, + 'tolerance' => $tolerance, + 'pitch' => null, + 'diameter' => null, + 'power' => $this->detectPower($part), + 'ppm' => $this->detectPpm($part), + 'color' => $color, + ]; + } + + if ($farads !== null && $farads > 0) { + return [ + 'type' => 'capacitor', + 'value' => $farads, + 'package' => null, + 'voltage' => $this->detectVoltage($part), + 'tolerance' => $tolerance, + 'pitch' => $this->detectPitch($part), + 'diameter' => $this->detectDiameter($part), + 'power' => null, + 'ppm' => null, + 'color' => $color, + ]; + } + + return 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) and/or capacitance (farads) of a part: first from its + * parameters, then (for parts named by their value, e.g. "10nF") from the name/description. + * + * @return array{0: float|null, 1: float|null} + */ + public function extractValue(Part $part): array + { + try { + [$ohms, $farads] = $this->fromParameters($part); + if ($ohms !== null || $farads !== null) { + return [$ohms, $farads]; + } + + $text = trim($part->getName().' '.$part->getDescription()); + + //A farad unit is unambiguous, so a capacitance found in the name wins. + $farads = $this->parseFaradsFromText($text); + if ($farads !== null) { + return [null, $farads]; + } + + return [$this->parseOhmsFromText($text), null]; + } catch (\Throwable) { + return [null, null]; + } + } + + /** + * Reads the resistance/capacitance 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} + */ + private function fromParameters(Part $part): array + { + $ohms = null; + $farads = 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; + + if (!$isRes && !$isCap) { + continue; + } + + $num = $param->getValueTypical(); + if ($num === null || $num <= 0) { + continue; + } + + $prefix = (string) preg_replace('/(Ω|ohms?|F|farads?)$/iu', '', $unit); + $value = $num * $this->prefixFactor($prefix); + + if ($isRes && $ohms === null) { + $ohms = $value; + } elseif ($isCap && $farads === null) { + $farads = $value; + } + } + + return [$ohms, $farads]; + } + + /** 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 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 6397e3af1..d6928e773 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -137,6 +137,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.value_calculator')) { + $nodes[] = (new TreeViewNode( + $this->translator->trans('tools.value_calc.title'), + $this->urlGenerator->generate('tools_value_calculator') + ))->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 3d125b277..3f0f8da8d 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -168,6 +168,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', 'value_calculator', 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..81e1ae2af 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -78,6 +78,9 @@ + + + + + + + {% endif %} + + + +
+ {# ---------------- Resistor color code ---------------- #} +
+
+
+
{{ '' }} +
+
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+ + + + + + + + +
+
+
+ +
+ +
+ +
+ + + + +
+
+
+ + {# Band color selects are rendered by the Stimulus controller #} +
+ +
+ +
+ +
+
+ +
+

{% trans %}tools.value_calc.resistor.from_value_help{% endtrans %}

+
+ +
+
+ + +
+
+
+
+ + {# ---------------- Capacitor code ---------------- #} +
+

{% trans %}tools.value_calc.capacitor.intro{% 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 %}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + {% 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..6c1a7cf44 --- /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.value_calculator') %} + +{% 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..724f1befb --- /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.value_calculator') %} + +{% 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..3cc183eff --- /dev/null +++ b/templates/tools/value_calculator/bulk_generate.html.twig @@ -0,0 +1,446 @@ +{% 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 %} +
+ {% 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 %} + +
+
+ {% trans %}tools.value_calc.cap.voltage{% endtrans %} + + V +
+ {% endif %} + {% if has_tht_resistors %} +
+ {% trans %}tools.value_calc.field.power{% endtrans %} + +
+
+ {% trans %}tools.value_calc.field.ppm{% endtrans %} + +
+ {% endif %} + {% if has_smd_resistors %} +
+ {% trans %}tools.value_calc.smd.package{% endtrans %} + +
+ {% endif %} +
+ {% 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_key = c.type == 'capacitor' ? 'tools.value_calc.capacitor.title' : (c.type == 'resistor' ? 'tools.value_calc.resistor.title' : '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.type }} + {%- if c.type == '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 -%} + {%- 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 == 'resistor' ? '#e8d9b5' : '#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 == 'smd_resistor' %} + {% set rpkg = c.package ?: '0805' %} + + {% endif %} +
+ {% set rtol = c.tolerance ? c.tolerance|replace({'±': '', '%': '', ' ': '', 'pF': '', 'PF': ''})|trim : '' %} + + + +
+ + +
+
+
+ +
+ + +
+
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..cd9a2906d --- /dev/null +++ b/tests/Controller/ValueCalculatorControllerTest.php @@ -0,0 +1,154 @@ +. + */ + +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 + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $admin = $em->getRepository(User::class)->findOneBy(['name' => 'admin']); + if ($admin === null) { + $this->markTestSkipped("Fixture user 'admin' not found."); + } + $client->loginUser($admin); + $client->followRedirects(false); + + return $client; + } + + public function testValueCalculatorPageLoads(): void + { + $client = $this->loginAdmin(); + $client->request('GET', '/en/tools/value_calc'); + self::assertResponseIsSuccessful(); + } + + public function testValueCalculatorPrefillsFromPart(): void + { + $client = $this->loginAdmin(); + $client->request('GET', '/en/tools/value_calc?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(); + } + + 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(); + } + + /** + * 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..f03405603 --- /dev/null +++ b/tests/Services/Tools/ComponentValueGuesserTest.php @@ -0,0 +1,256 @@ +. + */ + +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 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']); + } +} diff --git a/translations/frontend.en.xlf b/translations/frontend.en.xlf index d00994931..4fa29ab40 100644 --- a/translations/frontend.en.xlf +++ b/translations/frontend.en.xlf @@ -55,6 +55,120 @@ Go! + + + 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 360218689..3e4577a3a 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12995,6 +12995,612 @@ Buerklin-API Authentication server: Last stocktake + + + perm.tools.value_calculator + Resistor & capacitor calculator + + + + + tools.value_calc.title + Resistor & capacitor calculator + + + + + 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 resistor color bands, capacitor codes and SMD resistor markings. Pick the band colors to read a resistor, or enter a value to generate the matching color code. + + + + + tools.value_calc.body_color + Body color + + + + + 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 color code + + + + + tools.value_calc.capacitor.title + Capacitor code + + + + + tools.value_calc.smd.title + SMD resistor code + + + + + 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