mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-03 23:21:44 +00:00
Add a component value calculator & image generator
Adds a "Value calculator" tool (Tools menu) that decodes/encodes the value of common components and generates a clean SVG picture you can attach to a part — handy for assortments imported with blank thumbnails and sparse data. Calculator tabs (bidirectional decode <-> encode), each drawing the part to the selected package with a collapsible appearance panel and an "attach to part" action (optionally as the master picture): - Resistor: 4/5/6-band colour code - Capacitor: value <-> 3-digit code <-> tolerance - SMD resistor: value <-> 3-digit / 4-digit / EIA-96 code - Inductor: colour-band code - SMD inductor: value <-> uH code Bulk "Generate component images" parts-table action: - Classifies each selected part (resistor / capacitor / inductor / diode / LED, THT or SMD) with ComponentValueGuesser and detects value, voltage, tolerance, power, ppm, pitch, diameter, colour, SMD package and marking from the name, description and parameters. - Renders a preview per part, lets you tweak appearance, and attaches the images in one go; can also write matching KiCad symbol/footprint/reference-prefix EDA fields. Backend: ComponentValueGuesser (classification + detection + EDA suggestion), GeneratedImageAttachmentHelper (stores the SVG through the existing attachment pipeline, so it is sanitised on save), two POST endpoints on PartController (generate_image, set_eda) guarded by `edit` + CSRF, a new `@tools.value_calculator` permission, a Tools-tree entry and docs. All drawing is client-side in a Stimulus controller; the un-sanitised live preview escapes part-derived text and validates colours as defence-in-depth. Tests: unit tests for ComponentValueGuesser and functional tests for the endpoints, including permission and CSRF enforcement and the persisted side effects.
This commit is contained in:
parent
12b53f15ba
commit
d73bc42362
26 changed files with 6484 additions and 2 deletions
313
assets/controllers/pages/bulkGenerate_controller.js
Normal file
313
assets/controllers/pages/bulkGenerate_controller.js
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-server).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
import {AlertSwal} from "../../helpers/swal";
|
||||
import {trans} from "../../translator.js";
|
||||
|
||||
/**
|
||||
* Drives the hidden value-calculator to render a preview for every candidate row, then attaches the
|
||||
* checked ones to their parts via the per-part generate-image endpoint (with a progress bar).
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ["row", "progress", "progressBar", "attachBtn", "edaBtn",
|
||||
"batchPitch", "batchDiameter", "batchColor", "batchVoltage", "batchShape", "batchLead", "batchTolerance",
|
||||
"batchPower", "batchPpm", "batchPackage"];
|
||||
|
||||
connect() {
|
||||
this.tryRenderPreviews(0);
|
||||
}
|
||||
|
||||
/** The value-calculator controller instance (retries briefly, since it may connect after us). */
|
||||
calcController() {
|
||||
const el = this.element.querySelector('[data-controller*="alueCalculator"], [data-controller*="alue-calculator"]');
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
for (const id of ["pages--valueCalculator", "pages--value-calculator"]) {
|
||||
const c = this.application.getControllerForElementAndIdentifier(el, id);
|
||||
if (c && typeof c.generateSvg === "function") {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
tryRenderPreviews(attempt) {
|
||||
const calc = this.calcController();
|
||||
if (!calc) {
|
||||
if (attempt < 20) {
|
||||
setTimeout(() => this.tryRenderPreviews(attempt + 1), 50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.rowTargets.forEach((row) => this.renderPreview(row, calc));
|
||||
}
|
||||
|
||||
renderPreview(row, calc) {
|
||||
//Each row carries its own appearance inputs; shape/lead length are batch-wide.
|
||||
const colorEl = row.querySelector("[data-row-color]");
|
||||
const diamEl = row.querySelector("[data-row-diameter]");
|
||||
const pitchEl = row.querySelector("[data-row-pitch]");
|
||||
const voltEl = row.querySelector("[data-row-voltage]");
|
||||
const tolEl = row.querySelector("[data-row-tolerance]");
|
||||
const powerEl = row.querySelector("[data-row-power]");
|
||||
const ppmEl = row.querySelector("[data-row-ppm]");
|
||||
const pkgEl = row.querySelector("[data-row-package]");
|
||||
const svg = calc.generateSvg(row.dataset.type, parseFloat(row.dataset.value), {
|
||||
voltage: voltEl && voltEl.value ? parseFloat(voltEl.value) : (row.dataset.voltage ? parseFloat(row.dataset.voltage) : 0),
|
||||
package: pkgEl && pkgEl.value ? pkgEl.value : (row.dataset.package || null),
|
||||
power: powerEl && powerEl.value ? parseFloat(powerEl.value) : (row.dataset.power ? parseFloat(row.dataset.power) : 0),
|
||||
ppm: ppmEl && ppmEl.value ? parseFloat(ppmEl.value) : (row.dataset.ppm ? parseFloat(row.dataset.ppm) : 0),
|
||||
tolerance: tolEl
|
||||
? (tolEl.value ? parseFloat(tolEl.value) : null)
|
||||
: (row.dataset.tolerance ? parseFloat(row.dataset.tolerance) : null),
|
||||
pitch: pitchEl ? pitchEl.value : (row.dataset.pitch || null),
|
||||
diameter: diamEl && diamEl.value ? parseFloat(diamEl.value) : (row.dataset.diameter ? parseFloat(row.dataset.diameter) : 0),
|
||||
subtype: row.dataset.subtype || null,
|
||||
marking: row.dataset.marking || null,
|
||||
bodyColor: colorEl ? colorEl.value : null,
|
||||
shape: this.hasBatchShapeTarget ? this.batchShapeTarget.value : "disc",
|
||||
leadLength: this.hasBatchLeadTarget ? this.batchLeadTarget.value : "medium",
|
||||
});
|
||||
row.dataset.svg = svg;
|
||||
const cell = row.querySelector("[data-bulk-preview]");
|
||||
if (cell) {
|
||||
cell.innerHTML = svg || "";
|
||||
}
|
||||
//Clear the calculator's scratch SVG so its leftover copy can't collide (duplicate ids)
|
||||
//with the copy we just placed in this row's cell — that made the last preview render off.
|
||||
if (typeof calc.clearScratchSvg === "function") {
|
||||
calc.clearScratchSvg();
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-render every preview (used by batch controls and the shape/lead selects). */
|
||||
regenerate() {
|
||||
const calc = this.calcController();
|
||||
if (calc) {
|
||||
this.rowTargets.forEach((row) => this.renderPreview(row, calc));
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-render only the row whose per-row appearance input changed. */
|
||||
regenerateRow(event) {
|
||||
const calc = this.calcController();
|
||||
const row = event.target.closest("tr");
|
||||
if (calc && row) {
|
||||
this.renderPreview(row, calc);
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy a batch-bar value into every row's matching input, then re-render. */
|
||||
applyToAll(selector, value) {
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
this.rowTargets.forEach((row) => {
|
||||
const el = row.querySelector(selector);
|
||||
if (el) {
|
||||
el.value = value;
|
||||
}
|
||||
});
|
||||
this.regenerate();
|
||||
}
|
||||
|
||||
applyColor() {
|
||||
this.applyToAll("[data-row-color]", this.hasBatchColorTarget ? this.batchColorTarget.value : "");
|
||||
}
|
||||
|
||||
applyDiameter() {
|
||||
this.applyToAll("[data-row-diameter]", this.hasBatchDiameterTarget ? this.batchDiameterTarget.value : "");
|
||||
}
|
||||
|
||||
applyPitch() {
|
||||
this.applyToAll("[data-row-pitch]", this.hasBatchPitchTarget ? this.batchPitchTarget.value : "");
|
||||
}
|
||||
|
||||
applyVoltage() {
|
||||
this.applyToAll("[data-row-voltage]", this.hasBatchVoltageTarget ? this.batchVoltageTarget.value : "");
|
||||
}
|
||||
|
||||
applyPower() {
|
||||
this.applyToAll("[data-row-power]", this.hasBatchPowerTarget ? this.batchPowerTarget.value : "");
|
||||
}
|
||||
|
||||
applyPpm() {
|
||||
this.applyToAll("[data-row-ppm]", this.hasBatchPpmTarget ? this.batchPpmTarget.value : "");
|
||||
}
|
||||
|
||||
applyPackage() {
|
||||
this.applyToAll("[data-row-package]", this.hasBatchPackageTarget ? this.batchPackageTarget.value : "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the batch tolerance to every row that offers that option (tolerance choices differ
|
||||
* between capacitors and resistors). "—" always applies, so it can also clear every row.
|
||||
*/
|
||||
applyTolerance() {
|
||||
if (!this.hasBatchToleranceTarget) {
|
||||
return;
|
||||
}
|
||||
const value = this.batchToleranceTarget.value;
|
||||
this.rowTargets.forEach((row) => {
|
||||
const el = row.querySelector("[data-row-tolerance]");
|
||||
if (el && (value === "" || Array.from(el.options).some((o) => o.value === value))) {
|
||||
el.value = value;
|
||||
}
|
||||
});
|
||||
this.regenerate();
|
||||
}
|
||||
|
||||
/** A body-colour quick-pick button: set the batch colour input, then apply it to all rows. */
|
||||
pickColor(event) {
|
||||
if (this.hasBatchColorTarget) {
|
||||
this.batchColorTarget.value = event.currentTarget.dataset.color;
|
||||
}
|
||||
this.applyColor();
|
||||
}
|
||||
|
||||
/** Returns to the previous page (the parts list the action came from); the link's href is the no-JS fallback. */
|
||||
goBack(event) {
|
||||
if (window.history.length > 1) {
|
||||
event.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
/** Header checkbox: check/uncheck every row. */
|
||||
toggleAll(event) {
|
||||
const checked = event.currentTarget.checked;
|
||||
this.rowTargets.forEach((row) => {
|
||||
const cb = row.querySelector("input[type=checkbox]");
|
||||
if (cb) {
|
||||
cb.checked = checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Attaches the generated picture of each checked row to its part. */
|
||||
attachSelected() {
|
||||
return this.runBatch(
|
||||
(row) => {
|
||||
if (!(row.dataset.svg || "").includes("<svg")) {
|
||||
return null;
|
||||
}
|
||||
const body = new FormData();
|
||||
body.append("svg", row.dataset.svg);
|
||||
body.append("name", row.dataset.name || "Generated image");
|
||||
body.append("preview", "1");
|
||||
//Replace the existing generated image for parts that already had a picture.
|
||||
if (row.dataset.overwrite) {
|
||||
body.append("overwrite", "1");
|
||||
}
|
||||
body.append("_token", row.dataset.csrf || "");
|
||||
return {url: row.dataset.endpoint, body};
|
||||
},
|
||||
trans("tools.bulk_gen.attached"),
|
||||
this.hasAttachBtnTarget ? this.attachBtnTarget : null
|
||||
);
|
||||
}
|
||||
|
||||
/** Writes the (editable) KiCad symbol / footprint / reference of each checked row to its part. */
|
||||
writeEda() {
|
||||
return this.runBatch(
|
||||
(row) => {
|
||||
//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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
2328
assets/controllers/pages/valueCalculator_controller.js
Normal file
2328
assets/controllers/pages/valueCalculator_controller.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -163,6 +163,8 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
|||
label: "tools.builtin_footprints_viewer.title"
|
||||
ic_logos:
|
||||
label: "perm.tools.ic_logos"
|
||||
component_image_generator:
|
||||
label: "perm.tools.component_image_generator"
|
||||
|
||||
info_providers:
|
||||
label: "perm.part.info_providers"
|
||||
|
|
|
|||
89
docs/usage/component_image_generator.md
Normal file
89
docs/usage/component_image_generator.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -38,6 +38,7 @@ use App\Exceptions\AttachmentDownloadException;
|
|||
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 @@ final class PartController extends AbstractController
|
|||
]);
|
||||
}
|
||||
|
||||
#[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, '<svg')) {
|
||||
return $this->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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ declare(strict_types=1);
|
|||
*/
|
||||
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\Doctrine\NatsortDebugHelper;
|
|||
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,143 @@ class ToolsController extends AbstractController
|
|||
|
||||
return $this->render('tools/ic_logos/ic_logos.html.twig');
|
||||
}
|
||||
|
||||
#[Route(path: '/component_image_generator', name: 'tools_component_image_generator')]
|
||||
public function valueCalculator(Request $request, EntityManagerInterface $em, ComponentValueGuesser $guesser): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@tools.component_image_generator');
|
||||
|
||||
//Optionally the calculator can be opened in the context of a part, to attach the generated image to it.
|
||||
$part = null;
|
||||
$partId = $request->query->getInt('part');
|
||||
if ($partId > 0) {
|
||||
$part = $em->find(Part::class, $partId);
|
||||
if ($part !== null) {
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
}
|
||||
}
|
||||
|
||||
$prefillOhms = null;
|
||||
$prefillFarads = null;
|
||||
if ($part !== null) {
|
||||
[$prefillOhms, $prefillFarads] = $guesser->extractValue($part);
|
||||
}
|
||||
|
||||
return $this->render('tools/value_calculator/value_calculator.html.twig', [
|
||||
'part' => $part,
|
||||
'prefill_ohms' => $prefillOhms,
|
||||
'prefill_farads' => $prefillFarads,
|
||||
//When embedded in the part-page modal, render only the calculator inside a Turbo frame.
|
||||
'modalMode' => $request->query->getBoolean('modal'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing page for the "Generate component images" bulk action: classifies the selected parts
|
||||
* (skipping ones that already have a picture or can't be classified) and lets the user review,
|
||||
* then generate + attach pictures. Reached from the parts table action bar with ?ids=1,2,3.
|
||||
*/
|
||||
#[Route(path: '/bulk_generate_images', name: 'tools_bulk_generate')]
|
||||
public function bulkGenerate(Request $request, EntityManagerInterface $em, ComponentValueGuesser $guesser): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@tools.component_image_generator');
|
||||
|
||||
$candidates = [];
|
||||
$skipped = 0;
|
||||
$withPicture = 0;
|
||||
//When set, parts that already have a picture are included too (their preview gets overwritten).
|
||||
$overwrite = $request->query->getBoolean('overwrite');
|
||||
$idsParam = (string) $request->query->get('ids', '');
|
||||
$ids = array_values(array_filter(
|
||||
array_map('intval', explode(',', $idsParam)),
|
||||
static fn (int $id): bool => $id > 0
|
||||
));
|
||||
|
||||
if ($ids !== []) {
|
||||
foreach ($em->getRepository(Part::class)->findBy(['id' => $ids]) as $part) {
|
||||
if (!$this->isGranted('edit', $part)) {
|
||||
continue;
|
||||
}
|
||||
$hasPicture = $part->getMasterPictureAttachment() !== null;
|
||||
//By default only illustrate parts without a picture; in overwrite mode include all.
|
||||
if ($hasPicture && !$overwrite) {
|
||||
//Offer a re-generate action only for the ones we could actually classify.
|
||||
if ($guesser->guess($part) !== null) {
|
||||
$withPicture++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$guess = $guesser->guess($part);
|
||||
if ($guess === null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$eda = $guesser->edaSuggestion($guess);
|
||||
$candidates[] = [
|
||||
'part' => $part,
|
||||
'type' => $guess['type'],
|
||||
'subtype' => $guess['subtype'] ?? null,
|
||||
'marking' => $guess['marking'] ?? null,
|
||||
'value' => $guess['value'],
|
||||
'package' => $guess['package'],
|
||||
'voltage' => $guess['voltage'],
|
||||
'tolerance' => $guess['tolerance'],
|
||||
'pitch' => $guess['pitch'],
|
||||
'diameter' => $guess['diameter'],
|
||||
'power' => $guess['power'],
|
||||
'ppm' => $guess['ppm'],
|
||||
'color' => $guess['color'],
|
||||
'has_picture' => $hasPicture,
|
||||
'kicad_symbol' => $eda['symbol'],
|
||||
'reference_prefix' => $eda['reference'],
|
||||
'kicad_footprint' => $eda['footprint'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$hasCaps = false;
|
||||
$hasThtResistors = false;
|
||||
$hasSmdResistors = false;
|
||||
$hasInductors = false;
|
||||
$hasSmdInductors = false;
|
||||
$hasSmdCapacitors = false;
|
||||
$hasDiodes = false;
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate['type'] === 'capacitor') {
|
||||
$hasCaps = true;
|
||||
} elseif ($candidate['type'] === 'resistor') {
|
||||
//Power and temperature-coefficient bands only apply to through-hole resistors;
|
||||
//SMD chips just carry the printed value code (sized by their package).
|
||||
$hasThtResistors = true;
|
||||
} elseif ($candidate['type'] === 'smd_resistor') {
|
||||
$hasSmdResistors = true;
|
||||
} elseif ($candidate['type'] === 'inductor') {
|
||||
$hasInductors = true;
|
||||
} elseif ($candidate['type'] === 'smd_inductor') {
|
||||
$hasSmdInductors = true;
|
||||
} elseif ($candidate['type'] === 'smd_capacitor') {
|
||||
$hasSmdCapacitors = true;
|
||||
} elseif ($candidate['type'] === 'diode') {
|
||||
$hasDiodes = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('tools/value_calculator/bulk_generate.html.twig', [
|
||||
'candidates' => $candidates,
|
||||
'skipped' => $skipped,
|
||||
'selected_count' => count($ids),
|
||||
'has_caps' => $hasCaps,
|
||||
'has_tht_resistors' => $hasThtResistors,
|
||||
'has_smd_resistors' => $hasSmdResistors,
|
||||
'has_inductors' => $hasInductors,
|
||||
'has_smd_inductors' => $hasSmdInductors,
|
||||
'has_smd_capacitors' => $hasSmdCapacitors,
|
||||
'has_diodes' => $hasDiodes,
|
||||
'with_picture' => $withPicture,
|
||||
'overwrite' => $overwrite,
|
||||
'ids_param' => $idsParam,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
148
src/Services/Attachments/GeneratedImageAttachmentHelper.php
Normal file
148
src/Services/Attachments/GeneratedImageAttachmentHelper.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Services\Attachments;
|
||||
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\Attachments\AttachmentUpload;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Creates attachments from SVG markup that was generated client-side (e.g. by the
|
||||
* resistor/capacitor value calculator) and attaches them to a part.
|
||||
*/
|
||||
class GeneratedImageAttachmentHelper
|
||||
{
|
||||
private const ATTACHMENT_TYPE_NAME = 'Generated image';
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AttachmentSubmitHandler $submitHandler,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the given SVG markup as a sanitized picture attachment of the part.
|
||||
*
|
||||
* @param Part $part The part the image should be attached to
|
||||
* @param string $svg The raw SVG markup
|
||||
* @param string $name The name shown for the attachment
|
||||
* @param bool $setAsPreview Whether the image should become the part's preview picture
|
||||
* @param bool $overwrite Remove any previously generated image(s) first (replace instead of add)
|
||||
*/
|
||||
public function attachSvgToPart(Part $part, string $svg, string $name, bool $setAsPreview = true, bool $overwrite = false): PartAttachment
|
||||
{
|
||||
//handleUpload() does not enforce the upload-size limit, so guard it here. The SVG is
|
||||
//stored roughly 1:1 (base64 is only the transport encoding), so its byte length is a
|
||||
//good proxy for the resulting file size.
|
||||
if (strlen($svg) > $this->submitHandler->getMaximumEffectiveUploadSize()) {
|
||||
throw new \RuntimeException('The generated image exceeds the maximum allowed upload size.');
|
||||
}
|
||||
|
||||
$type = $this->getGeneratedImageType();
|
||||
|
||||
//In overwrite mode, drop previously generated images so re-generating replaces them
|
||||
//instead of accumulating "Generated image (2)", "(3)", …
|
||||
if ($overwrite) {
|
||||
foreach ($part->getAttachments()->toArray() as $existing) {
|
||||
if ($existing->getAttachmentType()?->getName() === $type->getName()) {
|
||||
if ($part->getMasterPictureAttachment() === $existing) {
|
||||
$part->setMasterPictureAttachment(null);
|
||||
}
|
||||
$part->removeAttachment($existing);
|
||||
$this->em->remove($existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$attachment = new PartAttachment();
|
||||
//De-duplicate the name so generating the same image twice does not violate the
|
||||
//(name, attachment_type, element) unique constraint on PartAttachment.
|
||||
$attachment->setName($this->uniqueName($part, $name !== '' ? $name : 'Generated image', $type));
|
||||
$attachment->setAttachmentType($type);
|
||||
$part->addAttachment($attachment);
|
||||
|
||||
//Reuse the regular upload pipeline so the SVG is sanitized and (optionally) becomes the preview image.
|
||||
$upload = new AttachmentUpload(
|
||||
file: null,
|
||||
data: base64_encode($svg),
|
||||
filename: 'generated.svg',
|
||||
becomePreviewIfEmpty: $setAsPreview,
|
||||
);
|
||||
$this->submitHandler->handleUpload($attachment, $upload);
|
||||
|
||||
//If explicitly requested, force this attachment to become the preview picture even if one already exists.
|
||||
if ($setAsPreview && $attachment->isPicture()) {
|
||||
$part->setMasterPictureAttachment($attachment);
|
||||
}
|
||||
|
||||
$this->em->persist($attachment);
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a name that is unique among the part's attachments of the given type,
|
||||
* appending " (2)", " (3)", … on collision (mirrors the info-provider importer).
|
||||
*/
|
||||
private function uniqueName(Part $part, string $baseName, AttachmentType $type): string
|
||||
{
|
||||
$taken = [];
|
||||
foreach ($part->getAttachments() as $existing) {
|
||||
if ($existing->getAttachmentType()?->getName() === $type->getName()) {
|
||||
$taken[] = $existing->getName();
|
||||
}
|
||||
}
|
||||
|
||||
if (!in_array($baseName, $taken, true)) {
|
||||
return $baseName;
|
||||
}
|
||||
|
||||
$i = 2;
|
||||
while (in_array($baseName.' ('.$i.')', $taken, true)) {
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $baseName.' ('.$i.')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the attachment type used for generated images, creating it if needed.
|
||||
*/
|
||||
private function getGeneratedImageType(): AttachmentType
|
||||
{
|
||||
/** @var AttachmentType $type */
|
||||
$type = $this->em->getRepository(AttachmentType::class)->findOrCreateForInfoProvider(self::ATTACHMENT_TYPE_NAME);
|
||||
|
||||
//A newly created type is not persisted yet, and the attachment_type relation does not cascade persist.
|
||||
if ($type->getID() === null) {
|
||||
$type->setFiletypeFilter('image/*');
|
||||
$type->setAlternativeNames(self::ATTACHMENT_TYPE_NAME);
|
||||
$this->em->persist($type);
|
||||
}
|
||||
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
|
|
@ -137,6 +137,16 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
|||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
|||
758
src/Services/Tools/ComponentValueGuesser.php
Normal file
758
src/Services/Tools/ComponentValueGuesser.php
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Services\Tools;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
|
||||
/**
|
||||
* Best-effort classification of a part as a resistor / SMD resistor / capacitor, together with its
|
||||
* electrical value, from its parameters, footprint, category and name. Used by the value calculator
|
||||
* (to pre-fill) and the bulk image generator (to classify a whole assortment).
|
||||
*/
|
||||
class ComponentValueGuesser
|
||||
{
|
||||
/** Imperial SMD chip package codes that mark a part as surface-mount. */
|
||||
private const SMD_PACKAGES = ['01005', '0201', '0402', '0603', '0805', '1206', '1210', '2010', '2512'];
|
||||
|
||||
/** Imperial -> metric size, for building KiCad SMD footprint names. */
|
||||
private const SMD_METRIC = [
|
||||
'0201' => '0603', '0402' => '1005', '0603' => '1608', '0805' => '2012',
|
||||
'1206' => '3216', '1210' => '3225', '2010' => '5025', '2512' => '6332',
|
||||
];
|
||||
|
||||
/** Named THT/SMD diode/LED package -> KiCad footprint. Keyed by the token {@see detectDiodePackage()} returns. */
|
||||
private const DIODE_PACKAGE_FOOTPRINTS = [
|
||||
'DO-41' => 'Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal',
|
||||
'DO-35' => 'Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal',
|
||||
'DO-15' => 'Diode_THT:D_DO-15_P12.70mm_Horizontal',
|
||||
'DO-201' => 'Diode_THT:D_DO-201AD_P15.24mm_Horizontal',
|
||||
'SOD-123' => 'Diode_SMD:D_SOD-123',
|
||||
'SOD-323' => 'Diode_SMD:D_SOD-323',
|
||||
'SOT-23' => 'Diode_SMD:D_SOT-23',
|
||||
'SMA' => 'Diode_SMD:D_SMA',
|
||||
'SMB' => 'Diode_SMD:D_SMB',
|
||||
'SMC' => 'Diode_SMD:D_SMC',
|
||||
//LED dome sizes (only ever matched when the subtype is 'led', see detectDiodePackage()).
|
||||
'3MM' => 'LED_THT:LED_D3.0mm',
|
||||
'5MM' => 'LED_THT:LED_D5.0mm',
|
||||
'10MM' => 'LED_THT:LED_D10.0mm',
|
||||
];
|
||||
|
||||
/**
|
||||
* Suggested KiCad/EDA settings for a classified component. Uses the detected package for SMD
|
||||
* parts and the lead pitch / body diameter for through-hole ceramic discs.
|
||||
*
|
||||
* @param array{type: string, package: string|null, pitch: float|null, diameter: float|null, subtype?: string|null} $guess
|
||||
*
|
||||
* @return array{symbol: string, reference: string, footprint: string|null}
|
||||
*/
|
||||
public function edaSuggestion(array $guess): array
|
||||
{
|
||||
$type = $guess['type'];
|
||||
$package = $guess['package'] ?? null;
|
||||
|
||||
if ($type === 'capacitor' || $type === 'smd_capacitor') {
|
||||
//SMD (MLCC) capacitors get a chip footprint; through-hole discs are sized by pitch/diameter.
|
||||
if ($type === 'smd_capacitor' && $package !== null && isset(self::SMD_METRIC[$package])) {
|
||||
$footprint = 'Capacitor_SMD:C_'.$package.'_'.self::SMD_METRIC[$package].'Metric';
|
||||
} else {
|
||||
$footprint = $this->capDiscFootprint($guess['pitch'] ?? null, $guess['diameter'] ?? null);
|
||||
}
|
||||
|
||||
return ['symbol' => 'Device:C', 'reference' => 'C', 'footprint' => $footprint];
|
||||
}
|
||||
|
||||
if ($type === 'inductor' || $type === 'smd_inductor') {
|
||||
//SMD inductors get a chip footprint; through-hole ones are left blank (editable afterwards).
|
||||
$footprint = null;
|
||||
if ($type === 'smd_inductor' && $package !== null && isset(self::SMD_METRIC[$package])) {
|
||||
$footprint = 'Inductor_SMD:L_'.$package.'_'.self::SMD_METRIC[$package].'Metric';
|
||||
}
|
||||
|
||||
return ['symbol' => 'Device:L', 'reference' => 'L', 'footprint' => $footprint];
|
||||
}
|
||||
|
||||
if ($type === 'diode') {
|
||||
$subtype = $guess['subtype'] ?? 'diode';
|
||||
$symbol = match ($subtype) {
|
||||
'led' => 'Device:LED',
|
||||
'zener' => 'Device:D_Zener',
|
||||
'schottky' => 'Device:D_Schottky',
|
||||
'tvs' => 'Device:D_TVS',
|
||||
default => 'Device:D',
|
||||
};
|
||||
//A named package (DO-41, SOD-123, a 3mm LED dome, ...) maps to a fixed footprint; an
|
||||
//imperial chip code (0805, ...) is only meaningful for SMD diodes/LEDs sized like a chip.
|
||||
$footprint = null;
|
||||
if ($package !== null) {
|
||||
$footprint = self::DIODE_PACKAGE_FOOTPRINTS[$package] ?? null;
|
||||
if ($footprint === null && isset(self::SMD_METRIC[$package])) {
|
||||
$prefix = $subtype === 'led' ? 'LED_SMD:LED' : 'Diode_SMD:D';
|
||||
$footprint = $prefix.'_'.$package.'_'.self::SMD_METRIC[$package].'Metric';
|
||||
}
|
||||
}
|
||||
|
||||
return ['symbol' => $symbol, 'reference' => 'D', 'footprint' => $footprint];
|
||||
}
|
||||
|
||||
if ($type === 'smd_resistor' && $package !== null && isset(self::SMD_METRIC[$package])) {
|
||||
$footprint = 'Resistor_SMD:R_'.$package.'_'.self::SMD_METRIC[$package].'Metric';
|
||||
} else {
|
||||
//Through-hole resistor: default to the common 1/4 W axial footprint (editable afterwards).
|
||||
$footprint = 'Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal';
|
||||
}
|
||||
|
||||
return ['symbol' => 'Device:R', 'reference' => 'R', 'footprint' => $footprint];
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a standard KiCad through-hole ceramic disc footprint for the given lead pitch (mm) and
|
||||
* body diameter (mm), choosing the pitch bucket (2.50 / 5.00 / 7.50 mm) then the nearest disc
|
||||
* diameter within it. Defaults to a 5 mm pitch / 5 mm disc.
|
||||
*/
|
||||
private function capDiscFootprint(?float $pitch, ?float $diameter): string
|
||||
{
|
||||
$p = $pitch ?? 5.0;
|
||||
$d = $diameter ?? 5.0;
|
||||
|
||||
if ($p < 3.8) {
|
||||
$options = [
|
||||
[3.0, 'Capacitor_THT:C_Disc_D3.0mm_W1.6mm_P2.50mm'],
|
||||
[3.8, 'Capacitor_THT:C_Disc_D3.8mm_W2.6mm_P2.50mm'],
|
||||
[5.0, 'Capacitor_THT:C_Disc_D5.0mm_W2.5mm_P2.50mm'],
|
||||
];
|
||||
} elseif ($p < 6.5) {
|
||||
$options = [
|
||||
[5.0, 'Capacitor_THT:C_Disc_D5.0mm_W2.5mm_P5.00mm'],
|
||||
[6.0, 'Capacitor_THT:C_Disc_D6.0mm_W2.5mm_P5.00mm'],
|
||||
[7.5, 'Capacitor_THT:C_Disc_D7.5mm_W2.5mm_P5.00mm'],
|
||||
[10.0, 'Capacitor_THT:C_Disc_D10.0mm_W2.5mm_P5.00mm'],
|
||||
];
|
||||
} else {
|
||||
$options = [
|
||||
[7.5, 'Capacitor_THT:C_Disc_D7.5mm_W5.0mm_P7.50mm'],
|
||||
[10.5, 'Capacitor_THT:C_Disc_D10.5mm_W5.0mm_P7.50mm'],
|
||||
];
|
||||
}
|
||||
|
||||
$best = $options[0][1];
|
||||
$bestDelta = INF;
|
||||
foreach ($options as [$dia, $fp]) {
|
||||
$delta = abs($dia - $d);
|
||||
if ($delta < $bestDelta) {
|
||||
$bestDelta = $delta;
|
||||
$best = $fp;
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a part.
|
||||
*
|
||||
* @return array{type: 'resistor'|'smd_resistor'|'capacitor'|'smd_capacitor'|'inductor'|'smd_inductor'|'diode', value: float,
|
||||
* package: string|null, voltage: int|null, tolerance: string|null, pitch: float|null,
|
||||
* diameter: float|null, power: float|null, ppm: int|null, color: string|null,
|
||||
* subtype: string|null}|null
|
||||
* value is ohms (resistors), farads (capacitors), henries (inductors) or the rated/forward
|
||||
* voltage (diodes, 0 if unknown); subtype names the diode kind. Null if it can't be classified.
|
||||
*/
|
||||
public function guess(Part $part): ?array
|
||||
{
|
||||
//Unambiguous diode part numbers (1N4148, BAT54, BZX…) are recognised first: their "1N…" style
|
||||
//would otherwise be misread as an RKM value (e.g. "1N4148" -> 1.4148 nF).
|
||||
$text = mb_strtolower($part->getName().' '.$part->getDescription());
|
||||
$pnSubtype = $this->detectDiodePartNumber($text);
|
||||
if ($pnSubtype !== null) {
|
||||
return $this->buildDiodeGuess($part, $pnSubtype);
|
||||
}
|
||||
|
||||
[$ohms, $farads, $henries] = $this->extractValue($part);
|
||||
$tolerance = $this->detectTolerance($part);
|
||||
$color = $this->detectBodyColor($part);
|
||||
|
||||
if ($ohms !== null && $ohms > 0) {
|
||||
$package = $this->detectSmdPackage($part);
|
||||
|
||||
return [
|
||||
'type' => $package !== null ? 'smd_resistor' : 'resistor',
|
||||
'value' => $ohms,
|
||||
'package' => $package,
|
||||
'voltage' => $this->detectVoltage($part),
|
||||
'tolerance' => $tolerance,
|
||||
'pitch' => null,
|
||||
'diameter' => null,
|
||||
'power' => $this->detectPower($part),
|
||||
'ppm' => $this->detectPpm($part),
|
||||
'color' => $color,
|
||||
'subtype' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($farads !== null && $farads > 0) {
|
||||
//A surface-mount cap is drawn as an (unmarked) MLCC chip; a THT one as a ceramic disc.
|
||||
$package = $this->detectSmdPackage($part);
|
||||
|
||||
return [
|
||||
'type' => $package !== null ? 'smd_capacitor' : 'capacitor',
|
||||
'value' => $farads,
|
||||
'package' => $package,
|
||||
'voltage' => $this->detectVoltage($part),
|
||||
'tolerance' => $tolerance,
|
||||
'pitch' => $package !== null ? null : $this->detectPitch($part),
|
||||
'diameter' => $package !== null ? null : $this->detectDiameter($part),
|
||||
'power' => null,
|
||||
'ppm' => null,
|
||||
'color' => $color,
|
||||
'subtype' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($henries !== null && $henries > 0) {
|
||||
//A surface-mount inductor is drawn as a molded chip with a µH code; a THT one as a colour barrel.
|
||||
$package = $this->detectSmdPackage($part);
|
||||
|
||||
return [
|
||||
'type' => $package !== null ? 'smd_inductor' : 'inductor',
|
||||
'value' => $henries,
|
||||
'package' => $package,
|
||||
'voltage' => $this->detectVoltage($part),
|
||||
'tolerance' => $tolerance,
|
||||
'pitch' => null,
|
||||
'diameter' => null,
|
||||
'power' => null,
|
||||
'ppm' => null,
|
||||
'color' => $color,
|
||||
'subtype' => null,
|
||||
];
|
||||
}
|
||||
|
||||
//Diodes named only by a keyword ("diode", "LED", …) are a fallback after the passive checks,
|
||||
//so "220R resistor for LED" stays a resistor (its resistance is detected first).
|
||||
$kwSubtype = $this->detectDiodeKeyword($text);
|
||||
if ($kwSubtype !== null) {
|
||||
return $this->buildDiodeGuess($part, $kwSubtype);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognises a diode from an unambiguous part-number family (1N4148, 1N400x, BAT54, BZX…, SMBJ…).
|
||||
* These are checked before the passive-value parsing, as their "1N…" style would otherwise be
|
||||
* misread as an RKM capacitance/resistance.
|
||||
*
|
||||
* @return 'led'|'zener'|'schottky'|'tvs'|'diode'|null
|
||||
*/
|
||||
private function detectDiodePartNumber(string $text): ?string
|
||||
{
|
||||
//Zener families: BZX/BZV/BZT and 1N47xx / 1N52xx (with an optional letter suffix, e.g. 1N4733A).
|
||||
if (preg_match('/\bbz[xvt]\d/u', $text) === 1
|
||||
|| preg_match('/\b1n(4[67]\d{2}|52\d{2})[a-z]?\b/u', $text) === 1) {
|
||||
return 'zener';
|
||||
}
|
||||
//Schottky families: BAT, 1N58xx, MBR.
|
||||
if (preg_match('/\bbat\d/u', $text) === 1
|
||||
|| preg_match('/\b1n58\d{2}[a-z]?\b/u', $text) === 1
|
||||
|| preg_match('/\bmbr\d/u', $text) === 1) {
|
||||
return 'schottky';
|
||||
}
|
||||
//TVS families: SMAJ/SMBJ, P6KE, 1.5KE (the KE families carry a voltage suffix, e.g. P6KE18A).
|
||||
if (preg_match('/\bsm[ab]j\d/u', $text) === 1
|
||||
|| preg_match('/\bp6ke\d/u', $text) === 1
|
||||
|| preg_match('/\b1\.5ke\d/u', $text) === 1) {
|
||||
return 'tvs';
|
||||
}
|
||||
//General-purpose / rectifier families: 1N4148, 1N400x, 1N914, BAV/BAS (optional letter suffix).
|
||||
if (preg_match('/\b1n(400\d|4148|914)[a-z]?\b/u', $text) === 1
|
||||
|| preg_match('/\bba[vs]\d/u', $text) === 1) {
|
||||
return 'diode';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognises a diode from a descriptive keyword ("diode", "LED", "Zener", "Schottky", "TVS").
|
||||
* Weaker than a part-number match, so this is only consulted after the passive-value checks.
|
||||
*
|
||||
* @return 'led'|'zener'|'schottky'|'tvs'|'diode'|null
|
||||
*/
|
||||
private function detectDiodeKeyword(string $text): ?string
|
||||
{
|
||||
if (preg_match('/\bled\b/u', $text) === 1 || preg_match('/light[- ]emitting/u', $text) === 1) {
|
||||
return 'led';
|
||||
}
|
||||
if (preg_match('/\bzener\b/u', $text) === 1) {
|
||||
return 'zener';
|
||||
}
|
||||
if (preg_match('/\bschottky\b/u', $text) === 1) {
|
||||
return 'schottky';
|
||||
}
|
||||
if (preg_match('/\btvs\b/u', $text) === 1 || preg_match('/transient|transil/u', $text) === 1) {
|
||||
return 'tvs';
|
||||
}
|
||||
if (preg_match('/\bdiode\b/u', $text) === 1 || preg_match('/\brectifier\b/u', $text) === 1) {
|
||||
return 'diode';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognises a named THT/SMD diode or LED package (DO-41, SOD-123, a 3/5/10 mm LED dome, ...)
|
||||
* from the footprint name / name / description, else falls back to an imperial chip code
|
||||
* (0805, ...) for diodes/LEDs labelled like a resistor chip. The LED dome sizes are only
|
||||
* meaningful (and only checked) when $subtype is 'led'.
|
||||
*/
|
||||
private function detectDiodePackage(Part $part, string $subtype): ?string
|
||||
{
|
||||
$haystacks = [];
|
||||
if ($part->getFootprint() !== null) {
|
||||
$haystacks[] = $part->getFootprint()->getName();
|
||||
}
|
||||
$haystacks[] = $part->getName();
|
||||
$haystacks[] = $part->getDescription();
|
||||
|
||||
$patterns = [
|
||||
'DO-41' => '/\bdo[\s-]?41\b/iu',
|
||||
'DO-35' => '/\bdo[\s-]?35\b/iu',
|
||||
'DO-15' => '/\bdo[\s-]?15\b/iu',
|
||||
'DO-201' => '/\bdo[\s-]?201\w*\b/iu',
|
||||
'SOD-123' => '/\bsod[\s-]?123\b/iu',
|
||||
'SOD-323' => '/\bsod[\s-]?323\b/iu',
|
||||
'SOT-23' => '/\bsot[\s-]?23\b/iu',
|
||||
'SMA' => '/\bsma\b/iu',
|
||||
'SMB' => '/\bsmb\b/iu',
|
||||
'SMC' => '/\bsmc\b/iu',
|
||||
];
|
||||
|
||||
foreach ($haystacks as $text) {
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($patterns as $token => $pattern) {
|
||||
if (preg_match($pattern, $text) === 1) {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($subtype === 'led') {
|
||||
foreach ($haystacks as $text) {
|
||||
if ($text !== '' && preg_match('/\b(3|5|10)\s?mm\b/iu', $text, $m) === 1) {
|
||||
return $m[1].'MM';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->detectSmdPackage($part);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a recognisable diode/rectifier part-number marking (e.g. "1N4001", "BAT54") from the
|
||||
* name/description, for printing on the generated drawing. Not used for LEDs, which aren't
|
||||
* normally marked with their part number.
|
||||
*/
|
||||
private function detectDiodeMarking(Part $part): ?string
|
||||
{
|
||||
$text = $part->getName().' '.$part->getDescription();
|
||||
if (preg_match('/\b(1N\d{3,4}[A-Za-z]?|BZX\d{2}[A-Za-z0-9]*|BAT\d{2,3}[A-Za-z]?|BAV\d{2,3}|BAS\d{2,3}|MBR\d+[A-Za-z]?|SMBJ\d+[A-Za-z]?|SMAJ\d+[A-Za-z]?|P6KE\d+[A-Za-z]?)\b/u', $text, $m) === 1) {
|
||||
return mb_strtoupper($m[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the classification array for a diode of the given kind, filling in the emission colour
|
||||
* (LEDs) or rated/forward voltage (Zener / TVS) where they can be read from the part.
|
||||
*
|
||||
* @param 'led'|'zener'|'schottky'|'tvs'|'diode' $subtype
|
||||
*
|
||||
* @return array{type: 'diode', value: float, package: string|null, voltage: int|null, tolerance: null,
|
||||
* pitch: null, diameter: null, power: null, ppm: null, color: string|null, subtype: string,
|
||||
* marking: string|null}
|
||||
*/
|
||||
private function buildDiodeGuess(Part $part, string $subtype): array
|
||||
{
|
||||
$color = $this->detectBodyColor($part);
|
||||
if ($subtype === 'led') {
|
||||
//The "colour" of an LED is its emission colour; default to a typical red.
|
||||
$color ??= '#c0392b';
|
||||
}
|
||||
$voltage = ($subtype === 'zener' || $subtype === 'tvs') ? $this->detectVoltage($part) : null;
|
||||
|
||||
return [
|
||||
'type' => 'diode',
|
||||
'value' => (float) ($voltage ?? 0),
|
||||
'package' => $this->detectDiodePackage($part, $subtype),
|
||||
'voltage' => $voltage,
|
||||
'tolerance' => null,
|
||||
'pitch' => null,
|
||||
'diameter' => null,
|
||||
'power' => null,
|
||||
'ppm' => null,
|
||||
'color' => $color,
|
||||
'subtype' => $subtype,
|
||||
//LEDs aren't normally marked with their part number, unlike axial diodes/rectifiers.
|
||||
'marking' => $subtype !== 'led' ? $this->detectDiodeMarking($part) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/** Temperature coefficient in ppm/K (e.g. "50ppm", "±25 ppm/°C") from the name/description, else null. */
|
||||
private function detectPpm(Part $part): ?int
|
||||
{
|
||||
$text = $part->getName().' '.$part->getDescription();
|
||||
if (preg_match('/(\d+(?:[.,]\d+)?)\s*ppm/iu', $text, $m) === 1) {
|
||||
return (int) round((float) str_replace(',', '.', $m[1]));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rated power in watts (e.g. "0.25 W", "1/4 W", "1W") from the name/description, else null. */
|
||||
private function detectPower(Part $part): ?float
|
||||
{
|
||||
$text = $part->getName().' '.$part->getDescription();
|
||||
//Fractional watt, e.g. "1/4 W", "1/2W".
|
||||
if (preg_match('#(\d+)\s*/\s*(\d+)\s*W(?![a-zA-Z0-9])#u', $text, $m) === 1 && (int) $m[2] !== 0) {
|
||||
return (float) $m[1] / (float) $m[2];
|
||||
}
|
||||
//Decimal watt, e.g. "0.25 W", "1 W", "0.5W".
|
||||
if (preg_match('/(\d+(?:[.,]\d+)?)\s*W(?![a-zA-Z0-9])/u', $text, $m) === 1) {
|
||||
return (float) str_replace(',', '.', $m[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Detects a body colour word (e.g. "blue body") in the name/description; returns a hex colour or null. */
|
||||
private function detectBodyColor(Part $part): ?string
|
||||
{
|
||||
$text = mb_strtolower($part->getName().' '.$part->getDescription());
|
||||
//Ordered so more specific words win; each maps to the swatch used by the drawing.
|
||||
$colors = [
|
||||
'beige' => '#e8d9b5', 'tan' => '#e8d9b5', 'cream' => '#e8d9b5',
|
||||
'blue' => '#2f6db0', 'green' => '#2e7d4f', 'red' => '#b34a2f',
|
||||
'brown' => '#6b4a2f', 'black' => '#20242a', 'grey' => '#8a9099',
|
||||
'gray' => '#8a9099', 'purple' => '#7b4fb0', 'violet' => '#7b4fb0',
|
||||
'amber' => '#e0a63a', 'yellow' => '#e0a63a', 'white' => '#e8e8e8',
|
||||
];
|
||||
foreach ($colors as $word => $hex) {
|
||||
if (preg_match('/\b'.$word.'\b/u', $text) === 1) {
|
||||
return $hex;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Lead pitch in mm, from a Pitch/RM parameter or the name ("pitch 2.54mm", "RM5"), else null. */
|
||||
private function detectPitch(Part $part): ?float
|
||||
{
|
||||
try {
|
||||
foreach ($part->getParameters() as $param) {
|
||||
if (preg_match('/pitch|lead spacing|raster|\brm\b|pin distance/u', mb_strtolower($param->getName())) === 1
|
||||
&& $param->getValueTypical() !== null && $param->getValueTypical() > 0) {
|
||||
return (float) $param->getValueTypical();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
//fall through to text parsing
|
||||
}
|
||||
|
||||
$text = $part->getName().' '.$part->getDescription();
|
||||
if (preg_match('/(?:pitch|rm|raster)\s*[:=]?\s*(\d+(?:[.,]\d+)?)\s*mm?/iu', $text, $m) === 1
|
||||
|| preg_match('/(\d+(?:[.,]\d+)?)\s*mm\s*pitch/iu', $text, $m) === 1) {
|
||||
return (float) str_replace(',', '.', $m[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Body diameter in mm, from a Diameter/Size parameter or the name ("⌀5mm"), else null. */
|
||||
private function detectDiameter(Part $part): ?float
|
||||
{
|
||||
try {
|
||||
foreach ($part->getParameters() as $param) {
|
||||
if (preg_match('/diameter|durchmesser|body size/u', mb_strtolower($param->getName())) === 1
|
||||
&& $param->getValueTypical() !== null && $param->getValueTypical() > 0) {
|
||||
return (float) $param->getValueTypical();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
//fall through to text parsing
|
||||
}
|
||||
|
||||
$text = $part->getName().' '.$part->getDescription();
|
||||
if (preg_match('/[⌀Ø]\s*(\d+(?:[.,]\d+)?)/u', $text, $m) === 1
|
||||
|| preg_match('/(?:diameter|durchmesser)\s*[:=]?\s*(\d+(?:[.,]\d+)?)\s*mm?/iu', $text, $m) === 1) {
|
||||
return (float) str_replace(',', '.', $m[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rated voltage in volts, from a Voltage parameter or the name/description ("50V"), else null. */
|
||||
private function detectVoltage(Part $part): ?int
|
||||
{
|
||||
try {
|
||||
foreach ($part->getParameters() as $param) {
|
||||
if (preg_match('/voltage|spannung|\bvdc\b/u', mb_strtolower($param->getName())) === 1
|
||||
&& $param->getValueTypical() !== null && $param->getValueTypical() > 0) {
|
||||
return (int) round($param->getValueTypical());
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
//fall through to text parsing
|
||||
}
|
||||
|
||||
if (preg_match('/(\d+(?:[.,]\d+)?)\s*V(?:DC|AC)?\b/iu', $part->getName().' '.$part->getDescription(), $m) === 1) {
|
||||
return (int) round((float) str_replace(',', '.', $m[1]));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Tolerance as a display string (e.g. "±10%") from a Tolerance parameter or the name, else null. */
|
||||
private function detectTolerance(Part $part): ?string
|
||||
{
|
||||
try {
|
||||
foreach ($part->getParameters() as $param) {
|
||||
if (preg_match('/toleran/u', mb_strtolower($param->getName())) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$text = trim($param->getValueText() ?? '');
|
||||
if ($text !== '') {
|
||||
return $text;
|
||||
}
|
||||
if ($param->getValueTypical() !== null) {
|
||||
return '±'.rtrim(rtrim(sprintf('%.2f', $param->getValueTypical()), '0'), '.').'%';
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
//fall through to text parsing
|
||||
}
|
||||
|
||||
$text = $part->getName().' '.$part->getDescription();
|
||||
if (preg_match('/±\s*(\d+(?:[.,]\d+)?)\s*%/u', $text, $m) === 1
|
||||
|| preg_match('/\b(\d+(?:[.,]\d+)?)\s*%/u', $text, $m) === 1) {
|
||||
return '±'.str_replace(',', '.', $m[1]).'%';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the resistance (ohms), capacitance (farads) and/or inductance (henries) of a part:
|
||||
* first from its parameters, then (for parts named by their value, e.g. "10nF") from the name.
|
||||
*
|
||||
* @return array{0: float|null, 1: float|null, 2: float|null} [ohms, farads, henries]
|
||||
*/
|
||||
public function extractValue(Part $part): array
|
||||
{
|
||||
try {
|
||||
[$ohms, $farads, $henries] = $this->fromParameters($part);
|
||||
if ($ohms !== null || $farads !== null || $henries !== null) {
|
||||
return [$ohms, $farads, $henries];
|
||||
}
|
||||
|
||||
$text = trim($part->getName().' '.$part->getDescription());
|
||||
|
||||
//Farad and henry units are unambiguous, so a match in the name wins over resistance.
|
||||
$farads = $this->parseFaradsFromText($text);
|
||||
if ($farads !== null) {
|
||||
return [null, $farads, null];
|
||||
}
|
||||
$henries = $this->parseHenriesFromText($text);
|
||||
if ($henries !== null) {
|
||||
return [null, null, $henries];
|
||||
}
|
||||
|
||||
return [$this->parseOhmsFromText($text), null, null];
|
||||
} catch (\Throwable) {
|
||||
return [null, null, null];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the resistance/capacitance/inductance from the part's parameters. The number lives in
|
||||
* value_typical; its SI prefix is baked into the unit string (e.g. 4.7 + "kΩ" -> 4700 Ω).
|
||||
*
|
||||
* @return array{0: float|null, 1: float|null, 2: float|null} [ohms, farads, henries]
|
||||
*/
|
||||
private function fromParameters(Part $part): array
|
||||
{
|
||||
$ohms = null;
|
||||
$farads = null;
|
||||
$henries = null;
|
||||
|
||||
foreach ($part->getParameters() as $param) {
|
||||
$name = mb_strtolower($param->getName());
|
||||
$unit = trim($param->getUnit() ?? '');
|
||||
|
||||
$isRes = preg_match('/resist|widerstand|ohm/u', $name) === 1
|
||||
|| str_contains($unit, 'Ω') || stripos($unit, 'ohm') !== false;
|
||||
$isCap = preg_match('/capacit|kapazit|farad/u', $name) === 1
|
||||
|| preg_match('/^(meg|[pnuµmkMg])?F$/u', $unit) === 1;
|
||||
$isInd = preg_match('/induct|induktivit/u', $name) === 1
|
||||
|| preg_match('/^(meg|[pnuµmk])?H$/u', $unit) === 1;
|
||||
|
||||
if (!$isRes && !$isCap && !$isInd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$num = $param->getValueTypical();
|
||||
if ($num === null || $num <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$prefix = (string) preg_replace('/(Ω|ohms?|F|farads?|H|henr(y|ies))$/iu', '', $unit);
|
||||
$value = $num * $this->prefixFactor($prefix);
|
||||
|
||||
if ($isRes && $ohms === null) {
|
||||
$ohms = $value;
|
||||
} elseif ($isCap && $farads === null) {
|
||||
$farads = $value;
|
||||
} elseif ($isInd && $henries === null) {
|
||||
$henries = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return [$ohms, $farads, $henries];
|
||||
}
|
||||
|
||||
/** Parses a capacitance (farads) out of free text like "10nF", "0.1uF" or "4n7", else null. */
|
||||
private function parseFaradsFromText(string $text): ?float
|
||||
{
|
||||
if (preg_match('/(\d+(?:[.,]\d+)?)\s*(p|n|u|µ|m)?F\b/iu', $text, $m) === 1) {
|
||||
return (float) str_replace(',', '.', $m[1]) * $this->prefixFactor(mb_strtolower($m[2] ?? ''));
|
||||
}
|
||||
//RKM notation, e.g. 4n7 = 4.7 nF, 2p2 = 2.2 pF.
|
||||
if (preg_match('/\b(\d+)(p|n|u|µ)(\d+)\b/iu', $text, $m) === 1) {
|
||||
return (float) ($m[1].'.'.$m[3]) * $this->prefixFactor(mb_strtolower($m[2]));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parses an inductance (henries) out of free text like "100µH", "10mH", "4.7uH" or "1H", else null. */
|
||||
private function parseHenriesFromText(string $text): ?float
|
||||
{
|
||||
//Uppercase H only (so "MHz" and "100h" hours don't match); the (?![a-zA-Z0-9]) avoids "MHz".
|
||||
if (preg_match('/(\d+(?:[.,]\d+)?)\s*(p|n|u|µ|m)?H(?![a-zA-Z0-9])/u', $text, $m) === 1) {
|
||||
return (float) str_replace(',', '.', $m[1]) * $this->prefixFactor(mb_strtolower($m[2] ?? ''));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parses a resistance (ohms) out of free text like "4k7", "10k", "470R" or "4.7kΩ", else null. */
|
||||
private function parseOhmsFromText(string $text): ?float
|
||||
{
|
||||
//RKM notation, e.g. 4k7 = 4.7 kΩ, 1R5 = 1.5 Ω, 2M2 = 2.2 MΩ.
|
||||
//NB: we use (?<![a-zA-Z0-9]) / (?![a-zA-Z0-9]) instead of \b, because PCRE treats the "Ω"
|
||||
//that usually follows (e.g. "4k7Ω") as a word character under /u, so \b would fail there.
|
||||
if (preg_match('/(?<![a-zA-Z0-9])(\d+)(R|k|K|M|G)(\d+)(?![a-zA-Z0-9])/u', $text, $m) === 1) {
|
||||
$factor = strtoupper($m[2]) === 'R' ? 1.0 : $this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -137,6 +137,12 @@ class ToolsTreeBuilder
|
|||
$this->urlGenerator->generate('tools_ic_logos')
|
||||
))->setIcon('fa-treeview fa-fw fa-solid fa-flag');
|
||||
}
|
||||
if ($this->security->isGranted('@tools.component_image_generator')) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tools.value_calc.title'),
|
||||
$this->urlGenerator->generate('tools_component_image_generator')
|
||||
))->setIcon('fa-treeview fa-fw fa-solid fa-palette');
|
||||
}
|
||||
if ($this->security->isGranted('@parts.import')) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('parts.import.title'),
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ class PermissionPresetsHelper
|
|||
$this->permissionResolver->setPermission($perm_holder, 'tools', 'reel_calculator', PermissionData::ALLOW);
|
||||
$this->permissionResolver->setPermission($perm_holder, 'tools', 'builtin_footprints_viewer', PermissionData::ALLOW);
|
||||
$this->permissionResolver->setPermission($perm_holder, 'tools', 'ic_logos', PermissionData::ALLOW);
|
||||
$this->permissionResolver->setPermission($perm_holder, 'tools', 'component_image_generator', PermissionData::ALLOW);
|
||||
|
||||
//Set attachments permissions
|
||||
$this->permissionResolver->setPermission($perm_holder, 'attachments', 'list_attachments', PermissionData::ALLOW);
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@
|
|||
<optgroup label="{% trans %}part_list.action.action.info_provider{% endtrans %}">
|
||||
<option {% if not is_granted('@info_providers.create_parts') %}disabled{% endif %} value="bulk_info_provider_import" data-url="{{ path('bulk_info_provider_step1')}}" data-turbo="false">{% trans %}part_list.action.bulk_info_provider_import{% endtrans %}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{% trans %}part_list.action.group.images{% endtrans %}">
|
||||
<option {% if not is_granted('@tools.component_image_generator') %}disabled{% endif %} value="generate_images" data-turbo="false">{% trans %}part_list.action.generate_images{% endtrans %}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
<select class="form-select d-none" data-controller="elements--structural-entity-select" name="target" {{ stimulus_target('elements/datatables/parts', 'selectTargetPicker') }}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
{% import "components/attachments.macro.html.twig" as attachments %}
|
||||
|
||||
{{ form_row(form.master_picture_attachment) }}
|
||||
{% if part.id is not null and is_granted('@tools.component_image_generator') %}
|
||||
<div class="mb-3">
|
||||
{% include "tools/value_calculator/_generate_button.html.twig" %}
|
||||
<small class="text-muted ms-2">{% trans %}tools.value_calc.attach.generate_hint{% endtrans %}</small>
|
||||
</div>
|
||||
{# The modal itself is rendered outside the part form (in edit_part_info.html.twig) so its
|
||||
named radio inputs are not submitted with the form. #}
|
||||
{% endif %}
|
||||
|
||||
{{ attachments.attachment_edit_list(form.attachments) }}
|
||||
{# Wrapped in a Turbo frame (no src -> inert during normal editing) so the value-calculator can
|
||||
refresh just this list after attaching a generated image, without a full-page reload. #}
|
||||
<turbo-frame id="part-attachments-frame">
|
||||
{{ form_row(form.master_picture_attachment) }}
|
||||
|
||||
{{ attachments.attachment_edit_list(form.attachments) }}
|
||||
</turbo-frame>
|
||||
|
|
@ -163,4 +163,8 @@
|
|||
{{ form_row(form.reset) }}
|
||||
{{ form_errors(form) }}
|
||||
{{ form_end(form) }}
|
||||
|
||||
{# The generator modal must live OUTSIDE the part form: its calculator has named radio
|
||||
inputs (band count, SMD marking) that would otherwise be submitted with the part form. #}
|
||||
{% include "tools/value_calculator/_generate_modal.html.twig" %}
|
||||
{% endblock %}
|
||||
|
|
@ -41,4 +41,8 @@
|
|||
|
||||
{% else %}
|
||||
<img src="{{ asset('img/part_placeholder.svg') }}" class="img-fluid img-thumbnail bg-light mb-2" alt="Part main image" height="300" width="300">
|
||||
{# No picture yet: offer to generate one right here. #}
|
||||
<div class="text-center mb-2">
|
||||
{% include "tools/value_calculator/_generate_button.html.twig" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
<div class="row">
|
||||
<div class="col col-md-3 mt-auto mb-auto">
|
||||
{% include "parts/info/_picture.html.twig" %}
|
||||
{% include "tools/value_calculator/_generate_modal.html.twig" %}
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-9 col-lg-6">
|
||||
|
|
|
|||
5
templates/tools/value_calculator/_bare.html.twig
Normal file
5
templates/tools/value_calculator/_bare.html.twig
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{# Parent layout used when the calculator is embedded in a modal: it renders only the
|
||||
card_content block, wrapped in the Turbo frame the modal lazy-loads. #}
|
||||
<turbo-frame id="vc-modal-frame">
|
||||
{% block card_content %}{% endblock %}
|
||||
</turbo-frame>
|
||||
550
templates/tools/value_calculator/_calculator_body.html.twig
Normal file
550
templates/tools/value_calculator/_calculator_body.html.twig
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<style>
|
||||
/* Keep every preview the same footprint so the panel doesn't jump between tabs. */
|
||||
.vc-pic { display: flex; align-items: center; justify-content: center; min-height: 14rem; }
|
||||
.vc-pic svg { max-height: 15rem; max-width: 100%; width: auto; height: auto; }
|
||||
/* Every tab reserves the same height and lays out as a column, so the row can grow to fill it
|
||||
and the "Appearance" toggle always pins to the very bottom of the frame — identical on each
|
||||
tab whether or not the tab has an intro line above its controls. */
|
||||
.tab-content > .tab-pane { min-height: 24rem; }
|
||||
.tab-content > .tab-pane.active { display: flex; flex-direction: column; }
|
||||
/* The "Appearance" disclosure is pinned to the bottom of its column. On wide screens it
|
||||
opens *upward* as a floating popover, so toggling it never resizes the tab or nudges the
|
||||
picture — every tab keeps the exact same frame whether it's open or closed. */
|
||||
.vc-appearance { position: relative; }
|
||||
@media (min-width: 992px) {
|
||||
.vc-appearance[open] > .vc-appearance-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.4rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
max-height: 22rem;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="alert alert-info d-flex align-items-start gap-2">
|
||||
<i class="fas fa-circle-info mt-1"></i>
|
||||
<div>{% trans %}tools.value_calc.explanation{% endtrans %}</div>
|
||||
</div>
|
||||
|
||||
<div {{ stimulus_controller('pages/valueCalculator', part is not null ? {
|
||||
endpoint: path('part_generate_image', {id: part.id}),
|
||||
csrf: csrf_token('generate_image' ~ part.id),
|
||||
prefillOhms: prefill_ohms|default(0),
|
||||
prefillFarads: prefill_farads|default(0),
|
||||
} : {}) }}>
|
||||
{% if part is not null %}
|
||||
<div class="alert alert-info d-flex flex-wrap align-items-center gap-3">
|
||||
<div>
|
||||
<i class="fas fa-link"></i>
|
||||
{% trans with {'%part%': part.name} %}tools.value_calc.attach.context{% endtrans %}
|
||||
</div>
|
||||
<div class="form-check ms-auto">
|
||||
<input class="form-check-input" type="checkbox" id="vc-attach-preview" checked
|
||||
{{ stimulus_target('pages/valueCalculator', 'previewInput') }}>
|
||||
<label class="form-check-label" for="vc-attach-preview">{% trans %}tools.value_calc.attach.as_preview{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<ul class="nav nav-tabs nav-justified mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="vc-resistor-tab" data-bs-toggle="tab" data-bs-target="#vc-resistor"
|
||||
type="button" role="tab">{% trans %}tools.value_calc.resistor.title{% endtrans %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="vc-capacitor-tab" data-bs-toggle="tab" data-bs-target="#vc-capacitor"
|
||||
type="button" role="tab">{% trans %}tools.value_calc.capacitor.title{% endtrans %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="vc-smd-tab" data-bs-toggle="tab" data-bs-target="#vc-smd"
|
||||
type="button" role="tab">{% trans %}tools.value_calc.smd.title{% endtrans %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="vc-inductor-tab" data-bs-toggle="tab" data-bs-target="#vc-inductor"
|
||||
type="button" role="tab">{% trans %}tools.value_calc.inductor.title{% endtrans %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="vc-smdind-tab" data-bs-toggle="tab" data-bs-target="#vc-smdind"
|
||||
type="button" role="tab">{% trans %}tools.value_calc.smd_inductor.title{% endtrans %}</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{# min-height keeps the card the same size on every tab, so switching tabs doesn't resize the panel #}
|
||||
<div class="tab-content">
|
||||
{# ---------------- Resistor color code ---------------- #}
|
||||
<div class="tab-pane fade show active" id="vc-resistor" role="tabpanel" data-vc-name="{% trans %}tools.value_calc.resistor.title{% endtrans %}">
|
||||
<div class="row g-4 flex-grow-1">
|
||||
<div class="col-lg-7 d-flex flex-column">
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label">{% trans %}tools.value_calc.resistor.bands{% endtrans %}</label>
|
||||
<div class="col-sm-9">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="vc-band-count" id="vc-band-4" value="4"
|
||||
autocomplete="off" {{ stimulus_action('pages/valueCalculator', 'changeBandCount', 'change') }}>
|
||||
<label class="btn btn-outline-primary" for="vc-band-4">{% trans %}tools.value_calc.resistor.bands_4{% endtrans %}</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="vc-band-count" id="vc-band-5" value="5"
|
||||
autocomplete="off" checked {{ stimulus_action('pages/valueCalculator', 'changeBandCount', 'change') }}>
|
||||
<label class="btn btn-outline-primary" for="vc-band-5">{% trans %}tools.value_calc.resistor.bands_5{% endtrans %}</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="vc-band-count" id="vc-band-6" value="6"
|
||||
autocomplete="off" {{ stimulus_action('pages/valueCalculator', 'changeBandCount', 'change') }}>
|
||||
<label class="btn btn-outline-primary" for="vc-band-6">{% trans %}tools.value_calc.resistor.bands_6{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Band color selects are rendered by the Stimulus controller #}
|
||||
<div class="row mb-3" {{ stimulus_target('pages/valueCalculator', 'bandSelects') }}></div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="vc-resistor-value" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.resistor.from_value{% endtrans %}</label>
|
||||
<div class="col-sm-9">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="vc-resistor-value" placeholder="4k7"
|
||||
{{ stimulus_target('pages/valueCalculator', 'resistorValueInput') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyResistorValue', 'keyup.enter') }}>
|
||||
<button class="btn btn-primary" type="button"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyResistorValue') }}>{% trans %}tools.value_calc.resistor.apply_value{% endtrans %}</button>
|
||||
</div>
|
||||
<div class="form-text">{% trans %}tools.value_calc.resistor.from_value_help{% endtrans %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="vc-appearance mt-auto pt-2 border-top">
|
||||
<summary class="text-primary small" style="cursor: pointer;"><i class="fas fa-palette"></i> {% trans %}tools.value_calc.appearance{% endtrans %}</summary>
|
||||
<div class="vc-appearance-panel card card-body bg-body-tertiary mt-2 py-2">
|
||||
<div class="row mb-2">
|
||||
<label for="vc-resistor-power" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.resistor.power{% endtrans %}</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-select" id="vc-resistor-power" style="max-width: 12rem;"
|
||||
{{ stimulus_target('pages/valueCalculator', 'resistorPower') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateResistor', 'change') }}>
|
||||
<option value="0.125">1/8 W</option>
|
||||
<option value="0.25" selected>1/4 W</option>
|
||||
<option value="0.5">1/2 W</option>
|
||||
<option value="1">1 W</option>
|
||||
<option value="2">2 W</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="vc-resistor-body" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.body_color{% endtrans %}</label>
|
||||
<div class="col-sm-9 d-flex align-items-center flex-wrap gap-2">
|
||||
<input type="color" class="form-control form-control-color" id="vc-resistor-body" value="#d8c7a0"
|
||||
title="{% trans %}tools.value_calc.body_color{% endtrans %}"
|
||||
{{ stimulus_target('pages/valueCalculator', 'resistorBodyColor') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateResistor', 'input') }}>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#d8c7a0"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyResistorBodyColor') }}>{% trans %}tools.value_calc.body.beige{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#9fc6e0"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyResistorBodyColor') }}>{% trans %}tools.value_calc.body.lightblue{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#3f6fb0"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyResistorBodyColor') }}>{% trans %}tools.value_calc.body.blue{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#3f7d4f"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyResistorBodyColor') }}>{% trans %}tools.value_calc.body.green{% endtrans %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="position-sticky" style="top: 1rem;">
|
||||
<div class="svg-container vc-pic text-center bg-light rounded p-2 mx-auto" style="max-width: 480px;">{{ '' }}
|
||||
<div {{ stimulus_target('pages/valueCalculator', 'resistorSvg') }}></div>
|
||||
</div>
|
||||
<div class="text-center text-muted small mt-2" {{ stimulus_target('pages/valueCalculator', 'resistorSpec') }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------------- Capacitor code ---------------- #}
|
||||
<div class="tab-pane fade" id="vc-capacitor" role="tabpanel" data-vc-name="{% trans %}tools.value_calc.capacitor.title{% endtrans %}">
|
||||
<p class="text-muted">{% trans %}tools.value_calc.capacitor.intro{% endtrans %}</p>
|
||||
<div class="row g-4 flex-grow-1">
|
||||
<div class="col-lg-7 d-flex flex-column">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label small mb-1" for="vc-cap-value">{% trans %}tools.value_calc.field.value{% endtrans %}</label>
|
||||
<input type="text" class="form-control" id="vc-cap-value" placeholder="100nF" data-field="value"
|
||||
{{ stimulus_target('pages/valueCalculator', 'capValueInput') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'syncCap', 'input') }}>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label small mb-1" for="vc-cap-code">{% trans %}tools.value_calc.field.code{% endtrans %}</label>
|
||||
<input type="text" class="form-control border-primary border-2" id="vc-cap-code" placeholder="104" data-field="code"
|
||||
{{ stimulus_target('pages/valueCalculator', 'capCodeInput') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'syncCap', 'input') }}>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label small mb-1" for="vc-cap-tol">{% trans %}tools.value_calc.field.tolerance{% endtrans %}</label>
|
||||
<select class="form-select" id="vc-cap-tol"
|
||||
{{ stimulus_target('pages/valueCalculator', 'capTolerance') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'redrawCap', 'change') }}>
|
||||
<option value="">—</option>
|
||||
<option value="F">±1% (F)</option>
|
||||
<option value="G">±2% (G)</option>
|
||||
<option value="J">±5% (J)</option>
|
||||
<option value="K">±10% (K)</option>
|
||||
<option value="M">±20% (M)</option>
|
||||
<option value="C">±0.25 pF (C)</option>
|
||||
<option value="D">±0.5 pF (D)</option>
|
||||
<option value="Z">+80% / -20% (Z)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="vc-appearance mt-auto pt-2 border-top">
|
||||
<summary class="text-primary small" style="cursor: pointer;"><i class="fas fa-palette"></i> {% trans %}tools.value_calc.appearance{% endtrans %}</summary>
|
||||
<div class="vc-appearance-panel card card-body bg-body-tertiary mt-2 py-2">
|
||||
<div class="row mb-2">
|
||||
<label for="vc-cap-body" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.body_color{% endtrans %}</label>
|
||||
<div class="col-sm-9 d-flex align-items-center flex-wrap gap-2">
|
||||
<input type="color" class="form-control form-control-color" id="vc-cap-body" value="#e0a63a"
|
||||
title="{% trans %}tools.value_calc.body_color{% endtrans %}"
|
||||
{{ stimulus_target('pages/valueCalculator', 'capBodyColor') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateCapacitorColor', 'input') }}>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#e0a63a"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyCapBodyColor') }}>{% trans %}tools.value_calc.body.tan{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#2f6db0"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyCapBodyColor') }}>{% trans %}tools.value_calc.body.blue{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#b34a2f"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyCapBodyColor') }}>{% trans %}tools.value_calc.body.red{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#7b4fb0"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyCapBodyColor') }}>{% trans %}tools.value_calc.body.purple{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#303030"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyCapBodyColor') }}>{% trans %}tools.value_calc.body.black{% endtrans %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 align-items-center">
|
||||
<label for="vc-cap-shape" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.cap.shape{% endtrans %}</label>
|
||||
<div class="col-sm-9 d-flex align-items-center flex-wrap gap-3">
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.cap.shape{% endtrans %}</span>
|
||||
<select class="form-select" id="vc-cap-shape" style="width: 9rem;"
|
||||
{{ stimulus_target('pages/valueCalculator', 'capShape') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateCapDimensions', 'change') }}>
|
||||
<option value="disc" selected>{% trans %}tools.value_calc.cap.shape.disc{% endtrans %}</option>
|
||||
<option value="blob">{% trans %}tools.value_calc.cap.shape.blob{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.cap.lead{% endtrans %}</span>
|
||||
<select class="form-select" id="vc-cap-lead" style="width: 8rem;"
|
||||
{{ stimulus_target('pages/valueCalculator', 'capLead') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateCapDimensions', 'change') }}>
|
||||
<option value="short">{% trans %}tools.value_calc.cap.lead.short{% endtrans %}</option>
|
||||
<option value="medium" selected>{% trans %}tools.value_calc.cap.lead.medium{% endtrans %}</option>
|
||||
<option value="long">{% trans %}tools.value_calc.cap.lead.long{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row align-items-center">
|
||||
<label for="vc-cap-diameter" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.size{% endtrans %}</label>
|
||||
<div class="col-sm-9 d-flex align-items-center flex-wrap gap-3">
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.cap.diameter{% endtrans %}</span>
|
||||
<input type="number" class="form-control" id="vc-cap-diameter" value="5" min="1" step="0.5" style="width: 6rem;"
|
||||
list="vc-cap-diams"
|
||||
{{ stimulus_target('pages/valueCalculator', 'capDiameter') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateCapDimensions', 'input') }}>
|
||||
<span class="input-group-text">mm</span>
|
||||
<datalist id="vc-cap-diams">
|
||||
<option value="3"></option><option value="4"></option><option value="5"></option>
|
||||
<option value="6"></option><option value="8"></option><option value="10"></option>
|
||||
<option value="12"></option><option value="15"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.cap.pitch{% endtrans %}</span>
|
||||
<select class="form-select" id="vc-cap-pitch" style="width: 9rem;"
|
||||
{{ stimulus_target('pages/valueCalculator', 'capPitch') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateCapDimensions', 'change') }}>
|
||||
<option value="2.54">2.54 mm (0.1")</option>
|
||||
<option value="5.08" selected>5.08 mm (0.2")</option>
|
||||
<option value="7.5">7.5 mm</option>
|
||||
<option value="10">10 mm</option>
|
||||
<option value="15">15 mm</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.cap.voltage{% endtrans %}</span>
|
||||
<input type="number" class="form-control" id="vc-cap-voltage" placeholder="50" min="1" step="1" style="width: 6rem;"
|
||||
list="vc-cap-volts"
|
||||
{{ stimulus_target('pages/valueCalculator', 'capVoltage') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateCapDimensions', 'input') }}>
|
||||
<span class="input-group-text">V</span>
|
||||
<datalist id="vc-cap-volts">
|
||||
<option value="16"></option><option value="25"></option><option value="50"></option>
|
||||
<option value="63"></option><option value="100"></option><option value="250"></option>
|
||||
<option value="500"></option><option value="630"></option><option value="1000"></option>
|
||||
<option value="2000"></option><option value="3000"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="position-sticky" style="top: 1rem;">
|
||||
<div class="vc-pic text-center bg-light rounded p-2 mx-auto" style="max-width: 480px;" {{ stimulus_target('pages/valueCalculator', 'capSvg') }}></div>
|
||||
<div class="text-center text-muted small mt-2" {{ stimulus_target('pages/valueCalculator', 'capSpec') }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------------- SMD resistor code ---------------- #}
|
||||
<div class="tab-pane fade" id="vc-smd" role="tabpanel" data-vc-name="{% trans %}tools.value_calc.smd.title{% endtrans %}">
|
||||
<div class="row g-4 flex-grow-1">
|
||||
<div class="col-lg-7 d-flex flex-column">
|
||||
<div class="form-text mb-1">{% trans %}tools.value_calc.smd.on_chip_help{% endtrans %}</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6 col-xl-3">
|
||||
<label class="form-label small mb-1" for="vc-smd-value">{% trans %}tools.value_calc.field.value{% endtrans %}</label>
|
||||
<input type="text" class="form-control" id="vc-smd-value" placeholder="4k7" data-field="value"
|
||||
{{ stimulus_target('pages/valueCalculator', 'smdValueInput') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'syncSmd', 'input') }}>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<label class="form-label small mb-1 d-flex align-items-center gap-2" for="vc-smd-code3">
|
||||
<input class="form-check-input mt-0" type="radio" name="vc-smd-mark" data-mark="code3" checked
|
||||
title="{% trans %}tools.value_calc.field.on_chip{% endtrans %}"
|
||||
{{ stimulus_action('pages/valueCalculator', 'pickSmdMarking', 'change') }}>
|
||||
{% trans %}tools.value_calc.field.code3{% endtrans %}
|
||||
</label>
|
||||
<input type="text" class="form-control" id="vc-smd-code3" placeholder="472" data-field="code3"
|
||||
{{ stimulus_target('pages/valueCalculator', 'smdCode3') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'syncSmd', 'input') }}>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<label class="form-label small mb-1 d-flex align-items-center gap-2" for="vc-smd-code4">
|
||||
<input class="form-check-input mt-0" type="radio" name="vc-smd-mark" data-mark="code4"
|
||||
title="{% trans %}tools.value_calc.field.on_chip{% endtrans %}"
|
||||
{{ stimulus_action('pages/valueCalculator', 'pickSmdMarking', 'change') }}>
|
||||
{% trans %}tools.value_calc.field.code4{% endtrans %}
|
||||
</label>
|
||||
<input type="text" class="form-control" id="vc-smd-code4" placeholder="4701" data-field="code4"
|
||||
{{ stimulus_target('pages/valueCalculator', 'smdCode4') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'syncSmd', 'input') }}>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<label class="form-label small mb-1 d-flex align-items-center gap-2" for="vc-smd-eia96">
|
||||
<input class="form-check-input mt-0" type="radio" name="vc-smd-mark" data-mark="eia96"
|
||||
title="{% trans %}tools.value_calc.field.on_chip{% endtrans %}"
|
||||
{{ stimulus_action('pages/valueCalculator', 'pickSmdMarking', 'change') }}>
|
||||
{% trans %}tools.value_calc.field.eia96{% endtrans %}
|
||||
</label>
|
||||
<input type="text" class="form-control" id="vc-smd-eia96" placeholder="—" data-field="eia96"
|
||||
{{ stimulus_target('pages/valueCalculator', 'smdEia96') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'syncSmd', 'input') }}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="vc-appearance mt-auto pt-2 border-top">
|
||||
<summary class="text-primary small" style="cursor: pointer;"><i class="fas fa-palette"></i> {% trans %}tools.value_calc.appearance{% endtrans %}</summary>
|
||||
<div class="vc-appearance-panel card card-body bg-body-tertiary mt-2 py-2">
|
||||
<div class="row mb-2">
|
||||
<label for="vc-smd-package" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.smd.package{% endtrans %}</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-select" id="vc-smd-package" style="max-width: 18rem;"
|
||||
{{ stimulus_target('pages/valueCalculator', 'smdPackage') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateSmd', 'change') }}>
|
||||
<option value="0201">0201 (0603 metric)</option>
|
||||
<option value="0402">0402 (1005 metric)</option>
|
||||
<option value="0603">0603 (1608 metric)</option>
|
||||
<option value="0805" selected>0805 (2012 metric)</option>
|
||||
<option value="1206">1206 (3216 metric)</option>
|
||||
<option value="1210">1210 (3225 metric)</option>
|
||||
<option value="2010">2010 (5025 metric)</option>
|
||||
<option value="2512">2512 (6332 metric)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="vc-smd-body" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.body_color{% endtrans %}</label>
|
||||
<div class="col-sm-9 d-flex align-items-center flex-wrap gap-2">
|
||||
<input type="color" class="form-control form-control-color" id="vc-smd-body" value="#262626"
|
||||
title="{% trans %}tools.value_calc.body_color{% endtrans %}"
|
||||
{{ stimulus_target('pages/valueCalculator', 'smdBodyColor') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateSmdColor', 'input') }}>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#262626"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applySmdBodyColor') }}>{% trans %}tools.value_calc.body.black{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#4a4a4a"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applySmdBodyColor') }}>{% trans %}tools.value_calc.body.grey{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#e8e8e8"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applySmdBodyColor') }}>{% trans %}tools.value_calc.body.white{% endtrans %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="position-sticky" style="top: 1rem;">
|
||||
<div class="vc-pic text-center bg-light rounded p-2 mx-auto" style="max-width: 480px;" {{ stimulus_target('pages/valueCalculator', 'smdSvg') }}></div>
|
||||
<div class="text-center text-muted small mt-2" {{ stimulus_target('pages/valueCalculator', 'smdSpec') }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------------- THT inductor colour code ---------------- #}
|
||||
<div class="tab-pane fade" id="vc-inductor" role="tabpanel" data-vc-name="{% trans %}tools.value_calc.inductor.title{% endtrans %}">
|
||||
<p class="text-muted">{% trans %}tools.value_calc.inductor.intro{% endtrans %}</p>
|
||||
<div class="row g-4 flex-grow-1">
|
||||
<div class="col-lg-7 d-flex flex-column">
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label">{% trans %}tools.value_calc.resistor.bands{% endtrans %}</label>
|
||||
<div class="col-sm-9">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="vc-ind-band-count" id="vc-ind-band-4" value="4"
|
||||
autocomplete="off" checked {{ stimulus_action('pages/valueCalculator', 'changeIndBandCount', 'change') }}>
|
||||
<label class="btn btn-outline-primary" for="vc-ind-band-4">{% trans %}tools.value_calc.resistor.bands_4{% endtrans %}</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="vc-ind-band-count" id="vc-ind-band-5" value="5"
|
||||
autocomplete="off" {{ stimulus_action('pages/valueCalculator', 'changeIndBandCount', 'change') }}>
|
||||
<label class="btn btn-outline-primary" for="vc-ind-band-5">{% trans %}tools.value_calc.resistor.bands_5{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{# Band colour selects are rendered by the Stimulus controller #}
|
||||
<div class="row mb-3" {{ stimulus_target('pages/valueCalculator', 'indBandSelects') }}></div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="vc-ind-value" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.inductor.from_value{% endtrans %}</label>
|
||||
<div class="col-sm-9">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="vc-ind-value" placeholder="100µH"
|
||||
{{ stimulus_target('pages/valueCalculator', 'indValueInput') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyInductorValue', 'keyup.enter') }}>
|
||||
<button class="btn btn-primary" type="button"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyInductorValue') }}>{% trans %}tools.value_calc.resistor.apply_value{% endtrans %}</button>
|
||||
</div>
|
||||
<div class="form-text">{% trans %}tools.value_calc.inductor.from_value_help{% endtrans %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="vc-appearance mt-auto pt-2 border-top">
|
||||
<summary class="text-primary small" style="cursor: pointer;"><i class="fas fa-palette"></i> {% trans %}tools.value_calc.appearance{% endtrans %}</summary>
|
||||
<div class="vc-appearance-panel card card-body bg-body-tertiary mt-2 py-2">
|
||||
<div class="row">
|
||||
<label for="vc-ind-body" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.body_color{% endtrans %}</label>
|
||||
<div class="col-sm-9 d-flex align-items-center flex-wrap gap-2">
|
||||
<input type="color" class="form-control form-control-color" id="vc-ind-body" value="#2f6f4c"
|
||||
title="{% trans %}tools.value_calc.body_color{% endtrans %}"
|
||||
{{ stimulus_target('pages/valueCalculator', 'indBodyColor') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'updateInductor', 'input') }}>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#2f6f4c"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyIndBodyColor') }}>{% trans %}tools.value_calc.body.green{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#3f6fb0"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyIndBodyColor') }}>{% trans %}tools.value_calc.body.blue{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#8a5a2b"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyIndBodyColor') }}>{% trans %}tools.value_calc.body.beige{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#20242a"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applyIndBodyColor') }}>{% trans %}tools.value_calc.body.black{% endtrans %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="position-sticky" style="top: 1rem;">
|
||||
<div class="svg-container vc-pic text-center bg-light rounded p-2 mx-auto" style="max-width: 480px;">{{ '' }}
|
||||
<div {{ stimulus_target('pages/valueCalculator', 'indSvg') }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------------- SMD inductor code ---------------- #}
|
||||
<div class="tab-pane fade" id="vc-smdind" role="tabpanel" data-vc-name="{% trans %}tools.value_calc.inductor.title{% endtrans %}">
|
||||
<p class="text-muted">{% trans %}tools.value_calc.smd_inductor.intro{% endtrans %}</p>
|
||||
<div class="row g-4 flex-grow-1">
|
||||
<div class="col-lg-7 d-flex flex-column">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label small mb-1" for="vc-smdind-value">{% trans %}tools.value_calc.field.value{% endtrans %}</label>
|
||||
<input type="text" class="form-control" id="vc-smdind-value" value="100µH" placeholder="100µH" data-field="value"
|
||||
{{ stimulus_target('pages/valueCalculator', 'smdIndValueInput') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'syncSmdInductor', 'input') }}>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label small mb-1" for="vc-smdind-code">{% trans %}tools.value_calc.field.code{% endtrans %}</label>
|
||||
<input type="text" class="form-control font-monospace" id="vc-smdind-code" placeholder="101" data-field="code"
|
||||
{{ stimulus_target('pages/valueCalculator', 'smdIndCode') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'syncSmdInductor', 'input') }}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="vc-appearance mt-auto pt-2 border-top">
|
||||
<summary class="text-primary small" style="cursor: pointer;"><i class="fas fa-palette"></i> {% trans %}tools.value_calc.appearance{% endtrans %}</summary>
|
||||
<div class="vc-appearance-panel card card-body bg-body-tertiary mt-2 py-2">
|
||||
<div class="row mb-2">
|
||||
<label for="vc-smdind-package" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.smd.package{% endtrans %}</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-select" id="vc-smdind-package" style="max-width: 18rem;" autocomplete="off"
|
||||
{{ stimulus_target('pages/valueCalculator', 'smdIndPackage') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'syncSmdInductor', 'change') }}>
|
||||
<option value="0402">0402 (1005 metric)</option>
|
||||
<option value="0603">0603 (1608 metric)</option>
|
||||
<option value="0805">0805 (2012 metric)</option>
|
||||
<option value="1206">1206 (3216 metric)</option>
|
||||
<option value="1210" selected>1210 (3225 metric)</option>
|
||||
<option value="2010">2010 (5025 metric)</option>
|
||||
<option value="2512">2512 (6332 metric)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="vc-smdind-body" class="col-sm-3 col-form-label">{% trans %}tools.value_calc.body_color{% endtrans %}</label>
|
||||
<div class="col-sm-9 d-flex align-items-center flex-wrap gap-2">
|
||||
<input type="color" class="form-control form-control-color" id="vc-smdind-body" value="#38332e"
|
||||
title="{% trans %}tools.value_calc.body_color{% endtrans %}"
|
||||
{{ stimulus_target('pages/valueCalculator', 'smdIndBodyColor') }}
|
||||
{{ stimulus_action('pages/valueCalculator', 'syncSmdInductor', 'input') }}>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#38332e"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applySmdIndBodyColor') }}>{% trans %}tools.value_calc.body.black{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#4a4a4a"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applySmdIndBodyColor') }}>{% trans %}tools.value_calc.body.grey{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#1c3a5e"
|
||||
{{ stimulus_action('pages/valueCalculator', 'applySmdIndBodyColor') }}>{% trans %}tools.value_calc.body.blue{% endtrans %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="position-sticky" style="top: 1rem;">
|
||||
<div class="vc-pic text-center bg-light rounded p-2 mx-auto" style="max-width: 480px;" {{ stimulus_target('pages/valueCalculator', 'smdIndSvg') }}></div>
|
||||
<div class="text-center text-muted small mt-2" {{ stimulus_target('pages/valueCalculator', 'smdIndSpec') }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if part is not null %}
|
||||
<div class="text-center mt-3 pt-3 border-top">
|
||||
<button type="button" class="btn btn-success" {{ stimulus_action('pages/valueCalculator', 'attachToPart') }}>
|
||||
<i class="fas fa-paperclip"></i> {% trans %}tools.value_calc.attach.button{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{# A trigger button that opens the generator modal (which must be included once via
|
||||
_generate_modal.html.twig). Expects `part` in scope. Optional `btn_class` overrides the styling. #}
|
||||
{% if part.id is not null and is_granted('edit', part) and is_granted('@tools.component_image_generator') %}
|
||||
<button type="button" class="{{ btn_class|default('btn btn-sm btn-outline-secondary') }}" data-bs-toggle="modal" data-bs-target="#vcGenerateModal">
|
||||
<i class="fas fa-palette"></i> {% trans %}tools.value_calc.attach.generate_button{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
23
templates/tools/value_calculator/_generate_modal.html.twig
Normal file
23
templates/tools/value_calculator/_generate_modal.html.twig
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{# The value-calculator generator modal (lazy-loaded via a Turbo frame). Include ONCE per page
|
||||
where a generate trigger button is shown. Expects `part` in scope. #}
|
||||
{% if part.id is not null and is_granted('edit', part) and is_granted('@tools.component_image_generator') %}
|
||||
<div class="modal fade" id="vcGenerateModal" tabindex="-1" aria-labelledby="vcGenerateModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="vcGenerateModalLabel">
|
||||
<i class="fas fa-palette"></i> {% trans %}tools.value_calc.title{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<turbo-frame id="vc-modal-frame" src="{{ path('tools_component_image_generator', {part: part.id, modal: 1}) }}" loading="lazy">
|
||||
<div class="text-center text-muted py-5">
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
</div>
|
||||
</turbo-frame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
484
templates/tools/value_calculator/bulk_generate.html.twig
Normal file
484
templates/tools/value_calculator/bulk_generate.html.twig
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}{% trans %}tools.bulk_gen.title{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-images"></i> {% trans %}tools.bulk_gen.title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<p class="text-muted">{% trans %}tools.bulk_gen.intro{% endtrans %}</p>
|
||||
|
||||
<details class="mb-3">
|
||||
<summary class="text-primary" style="cursor: pointer;">
|
||||
<i class="fas fa-circle-question"></i> {% trans %}tools.bulk_gen.help.title{% endtrans %}
|
||||
</summary>
|
||||
{# 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' %}
|
||||
<div class="card card-body bg-body-tertiary mt-2">
|
||||
<p class="small text-muted mb-2">{% trans %}tools.bulk_gen.help.intro{% endtrans %}</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 align-middle">
|
||||
<thead>
|
||||
<tr class="small text-uppercase text-muted">
|
||||
<th class="fw-semibold">{% trans %}tools.bulk_gen.help.col_field{% endtrans %}</th>
|
||||
<th class="fw-semibold">{% trans %}tools.bulk_gen.help.col_param{% endtrans %}</th>
|
||||
<th class="fw-semibold">{% trans %}tools.bulk_gen.help.col_text{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="fw-medium">{% trans %}tools.bulk_gen.help.value{% endtrans %}</td>
|
||||
<td><span class="{{ pchip }}">Resistance</span> <span class="{{ pchip }}">Capacitance</span> <span class="text-muted small">Ω / F</span></td>
|
||||
<td><div class="d-flex flex-wrap gap-1"><span class="{{ chip }}">10nF</span><span class="{{ chip }}">0.1µF</span><span class="{{ chip }}">4n7</span><span class="{{ chip }}">4k7</span><span class="{{ chip }}">470R</span><span class="{{ chip }}">10k</span><span class="{{ chip }}">1M</span></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-medium">{% trans %}tools.bulk_gen.help.voltage{% endtrans %}</td>
|
||||
<td><span class="{{ pchip }}">Voltage</span></td>
|
||||
<td><div class="d-flex flex-wrap gap-1"><span class="{{ chip }}">50V</span><span class="{{ chip }}">100V</span></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-medium">{% trans %}tools.bulk_gen.help.tolerance{% endtrans %}</td>
|
||||
<td><span class="{{ pchip }}">Tolerance</span></td>
|
||||
<td><div class="d-flex flex-wrap gap-1"><span class="{{ chip }}">±5%</span><span class="{{ chip }}">10%</span></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-medium">{% trans %}tools.bulk_gen.help.pitch{% endtrans %}</td>
|
||||
<td><span class="{{ pchip }}">Pitch</span> <span class="{{ pchip }}">RM</span> <span class="{{ pchip }}">Lead spacing</span></td>
|
||||
<td><div class="d-flex flex-wrap gap-1"><span class="{{ chip }}">pitch 5mm</span><span class="{{ chip }}">RM5</span><span class="{{ chip }}">5mm pitch</span></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-medium">{% trans %}tools.bulk_gen.help.diameter{% endtrans %}</td>
|
||||
<td><span class="{{ pchip }}">Diameter</span></td>
|
||||
<td><div class="d-flex flex-wrap gap-1"><span class="{{ chip }}">⌀5mm</span><span class="{{ chip }}">diameter 5mm</span></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-medium">{% trans %}tools.bulk_gen.help.smd{% endtrans %}</td>
|
||||
<td class="text-muted small">{% trans %}tools.bulk_gen.help.footprint_col{% endtrans %}</td>
|
||||
<td><div class="d-flex flex-wrap gap-1"><span class="{{ chip }}">0402</span><span class="{{ chip }}">0603</span><span class="{{ chip }}">0805</span><span class="{{ chip }}">1206</span><span class="{{ chip }}">1210</span></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if with_picture > 0 and not overwrite %}
|
||||
<div class="alert alert-warning d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<span><i class="fas fa-image"></i> {% trans with {'%count%': with_picture} %}tools.bulk_gen.with_picture{% endtrans %}</span>
|
||||
<a href="{{ path('tools_bulk_generate', {ids: ids_param, overwrite: 1}) }}" class="btn btn-sm btn-warning">
|
||||
<i class="fas fa-arrows-rotate"></i> {% trans with {'%count%': with_picture} %}tools.bulk_gen.regenerate{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
{% elseif overwrite %}
|
||||
<div class="alert alert-info d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<span><i class="fas fa-triangle-exclamation"></i> {% trans %}tools.bulk_gen.overwrite_mode{% endtrans %}</span>
|
||||
<a href="{{ path('tools_bulk_generate', {ids: ids_param}) }}" class="btn btn-sm btn-outline-secondary">{% trans %}tools.bulk_gen.overwrite_exit{% endtrans %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if selected_count == 0 or candidates is empty %}
|
||||
{% if selected_count == 0 %}
|
||||
<div class="alert alert-info">{% trans %}tools.bulk_gen.no_selection{% endtrans %}</div>
|
||||
{% elseif with_picture == 0 %}
|
||||
<div class="alert alert-warning">{% trans with {'%count%': selected_count} %}tools.bulk_gen.none{% endtrans %}</div>
|
||||
{% endif %}
|
||||
<a href="{{ path('homepage') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> {% trans %}tools.bulk_gen.back{% endtrans %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div {{ stimulus_controller('pages/bulkGenerate') }}>
|
||||
{# One hidden value-calculator, reused to render every preview below. #}
|
||||
<div class="d-none" aria-hidden="true">
|
||||
{% set part = null %}
|
||||
{% include 'tools/value_calculator/_calculator_body.html.twig' %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
|
||||
<p class="mb-0">
|
||||
<strong>{{ candidates|length }}</strong> {% trans %}tools.bulk_gen.found{% endtrans %}
|
||||
{%- if skipped > 0 %} <span class="text-muted">({{ skipped }} {% trans %}tools.bulk_gen.skipped{% endtrans %})</span>{% endif %}
|
||||
</p>
|
||||
<a href="{{ path('homepage') }}" class="btn btn-sm btn-outline-secondary" {{ stimulus_action('pages/bulkGenerate', 'goBack') }}>
|
||||
<i class="fas fa-arrow-left"></i> {% trans %}tools.bulk_gen.back{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% set first = candidates|first %}
|
||||
{% set batch_color = first.color ?: (first.type == 'capacitor' ? '#e0a63a' : (first.type == 'resistor' ? '#e8d9b5' : '#20242a')) %}
|
||||
<div class="card card-body bg-body-tertiary py-2 mb-3">
|
||||
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||
<span class="text-muted small">{% trans %}tools.bulk_gen.apply_all{% endtrans %}:</span>
|
||||
{% if has_caps %}
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.cap.shape{% endtrans %}</span>
|
||||
<select class="form-select" style="width: 8rem;"
|
||||
{{ stimulus_target('pages/bulkGenerate', 'batchShape') }}
|
||||
{{ stimulus_action('pages/bulkGenerate', 'regenerate', 'change') }}>
|
||||
<option value="disc" selected>{% trans %}tools.value_calc.cap.shape.disc{% endtrans %}</option>
|
||||
<option value="blob">{% trans %}tools.value_calc.cap.shape.blob{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_caps or has_tht_resistors or has_inductors %}
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.cap.lead{% endtrans %}</span>
|
||||
<select class="form-select" style="width: 7rem;"
|
||||
{{ stimulus_target('pages/bulkGenerate', 'batchLead') }}
|
||||
{{ stimulus_action('pages/bulkGenerate', 'regenerate', 'change') }}>
|
||||
<option value="short">{% trans %}tools.value_calc.cap.lead.short{% endtrans %}</option>
|
||||
<option value="medium" selected>{% trans %}tools.value_calc.cap.lead.medium{% endtrans %}</option>
|
||||
<option value="long">{% trans %}tools.value_calc.cap.lead.long{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_caps %}
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.cap.diameter{% endtrans %}</span>
|
||||
<input type="number" class="form-control" value="5" min="1" step="0.5" style="width: 4.5rem;"
|
||||
{{ stimulus_target('pages/bulkGenerate', 'batchDiameter') }}
|
||||
{{ stimulus_action('pages/bulkGenerate', 'applyDiameter', 'input') }}>
|
||||
<span class="input-group-text">mm</span>
|
||||
</div>
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.cap.pitch{% endtrans %}</span>
|
||||
<select class="form-select" style="width: 6rem;"
|
||||
{{ stimulus_target('pages/bulkGenerate', 'batchPitch') }}
|
||||
{{ stimulus_action('pages/bulkGenerate', 'applyPitch', 'change') }}>
|
||||
<option value="2.54">2.54</option>
|
||||
<option value="5.08" selected>5.08</option>
|
||||
<option value="7.5">7.5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="15">15</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_tht_resistors %}
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.field.power{% endtrans %}</span>
|
||||
<select class="form-select" style="width: 6rem;"
|
||||
{{ stimulus_target('pages/bulkGenerate', 'batchPower') }}
|
||||
{{ stimulus_action('pages/bulkGenerate', 'applyPower', 'change') }}>
|
||||
<option value="0.125">1/8 W</option>
|
||||
<option value="0.25" selected>1/4 W</option>
|
||||
<option value="0.5">1/2 W</option>
|
||||
<option value="1">1 W</option>
|
||||
<option value="2">2 W</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.field.ppm{% endtrans %}</span>
|
||||
<select class="form-select" style="width: 7rem;"
|
||||
{{ stimulus_target('pages/bulkGenerate', 'batchPpm') }}
|
||||
{{ stimulus_action('pages/bulkGenerate', 'applyPpm', 'change') }}>
|
||||
<option value="" selected>—</option>
|
||||
<option value="100">100 ppm</option>
|
||||
<option value="50">50 ppm</option>
|
||||
<option value="25">25 ppm</option>
|
||||
<option value="15">15 ppm</option>
|
||||
<option value="10">10 ppm</option>
|
||||
<option value="5">5 ppm</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_smd_resistors or has_smd_inductors or has_smd_capacitors %}
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.smd.package{% endtrans %}</span>
|
||||
<select class="form-select" style="width: 6rem;"
|
||||
{{ stimulus_target('pages/bulkGenerate', 'batchPackage') }}
|
||||
{{ stimulus_action('pages/bulkGenerate', 'applyPackage', 'change') }}>
|
||||
<option value="0201">0201</option>
|
||||
<option value="0402">0402</option>
|
||||
<option value="0603">0603</option>
|
||||
<option value="0805" selected>0805</option>
|
||||
<option value="1206">1206</option>
|
||||
<option value="1210">1210</option>
|
||||
<option value="2010">2010</option>
|
||||
<option value="2512">2512</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.cap.voltage{% endtrans %}</span>
|
||||
<input type="number" class="form-control" placeholder="—" min="1" style="width: 4.5rem;"
|
||||
{{ stimulus_target('pages/bulkGenerate', 'batchVoltage') }}
|
||||
{{ stimulus_action('pages/bulkGenerate', 'applyVoltage', 'input') }}>
|
||||
<span class="input-group-text">V</span>
|
||||
</div>
|
||||
<div class="input-group input-group-sm" style="width: auto;">
|
||||
<span class="input-group-text">{% trans %}tools.value_calc.field.tolerance{% endtrans %}</span>
|
||||
<select class="form-select" style="width: 6.5rem;"
|
||||
{{ stimulus_target('pages/bulkGenerate', 'batchTolerance') }}
|
||||
{{ stimulus_action('pages/bulkGenerate', 'applyTolerance', 'change') }}>
|
||||
<option value="">—</option>
|
||||
<option value="0.05">±0.05% (W)</option>
|
||||
<option value="0.1">±0.1 (B)</option>
|
||||
<option value="0.25">±0.25 (C)</option>
|
||||
<option value="0.5">±0.5 (D)</option>
|
||||
<option value="1">±1% (F)</option>
|
||||
<option value="2">±2% (G)</option>
|
||||
<option value="5">±5% (J)</option>
|
||||
<option value="10">±10% (K)</option>
|
||||
<option value="20">±20% (M)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="color" class="form-control form-control-color form-control-sm" value="{{ batch_color }}"
|
||||
title="{% trans %}tools.value_calc.body_color{% endtrans %}"
|
||||
{{ stimulus_target('pages/bulkGenerate', 'batchColor') }}
|
||||
{{ stimulus_action('pages/bulkGenerate', 'applyColor', 'input') }}>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#e8d9b5" {{ stimulus_action('pages/bulkGenerate', 'pickColor') }}>{% trans %}tools.value_calc.body.beige{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#9fc6e0" {{ stimulus_action('pages/bulkGenerate', 'pickColor') }}>{% trans %}tools.value_calc.body.lightblue{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#2f6db0" {{ stimulus_action('pages/bulkGenerate', 'pickColor') }}>{% trans %}tools.value_calc.body.blue{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#b34a2f" {{ stimulus_action('pages/bulkGenerate', 'pickColor') }}>{% trans %}tools.value_calc.body.red{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#7b4fb0" {{ stimulus_action('pages/bulkGenerate', 'pickColor') }}>{% trans %}tools.value_calc.body.purple{% endtrans %}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-color="#303030" {{ stimulus_action('pages/bulkGenerate', 'pickColor') }}>{% trans %}tools.value_calc.body.black{% endtrans %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Editable KiCad column suggestions: pick a common symbol/footprint or type your own. #}
|
||||
<datalist id="bulkgen-symbols">
|
||||
<option value="Device:R"></option>
|
||||
<option value="Device:C"></option>
|
||||
<option value="Device:C_Polarized"></option>
|
||||
<option value="Device:L"></option>
|
||||
<option value="Device:D"></option>
|
||||
<option value="Device:LED"></option>
|
||||
</datalist>
|
||||
<datalist id="bulkgen-footprints">
|
||||
<option value="Resistor_SMD:R_0402_1005Metric"></option>
|
||||
<option value="Resistor_SMD:R_0603_1608Metric"></option>
|
||||
<option value="Resistor_SMD:R_0805_2012Metric"></option>
|
||||
<option value="Resistor_SMD:R_1206_3216Metric"></option>
|
||||
<option value="Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal"></option>
|
||||
<option value="Resistor_THT:R_Axial_DIN0411_L9.9mm_D3.6mm_P12.70mm_Horizontal"></option>
|
||||
<option value="Capacitor_SMD:C_0402_1005Metric"></option>
|
||||
<option value="Capacitor_SMD:C_0603_1608Metric"></option>
|
||||
<option value="Capacitor_SMD:C_0805_2012Metric"></option>
|
||||
<option value="Capacitor_SMD:C_1206_3216Metric"></option>
|
||||
<option value="Capacitor_THT:C_Disc_D3.0mm_W1.6mm_P2.50mm"></option>
|
||||
<option value="Capacitor_THT:C_Disc_D3.8mm_W2.6mm_P2.50mm"></option>
|
||||
<option value="Capacitor_THT:C_Disc_D5.0mm_W2.5mm_P2.50mm"></option>
|
||||
<option value="Capacitor_THT:C_Disc_D5.0mm_W2.5mm_P5.00mm"></option>
|
||||
<option value="Capacitor_THT:C_Disc_D6.0mm_W2.5mm_P5.00mm"></option>
|
||||
<option value="Capacitor_THT:C_Disc_D7.5mm_W2.5mm_P5.00mm"></option>
|
||||
<option value="Capacitor_THT:C_Disc_D10.0mm_W2.5mm_P5.00mm"></option>
|
||||
<option value="Capacitor_THT:C_Disc_D7.5mm_W5.0mm_P7.50mm"></option>
|
||||
<option value="Capacitor_THT:C_Disc_D10.5mm_W5.0mm_P7.50mm"></option>
|
||||
<option value="Inductor_SMD:L_0805_2012Metric"></option>
|
||||
<option value="Inductor_SMD:L_1210_3225Metric"></option>
|
||||
<option value="Inductor_THT:L_Axial_L5.3mm_D2.2mm_P10.16mm_Horizontal"></option>
|
||||
<option value="Diode_SMD:D_0805_2012Metric"></option>
|
||||
<option value="Diode_SMD:D_SOD-123"></option>
|
||||
<option value="Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal"></option>
|
||||
<option value="Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal"></option>
|
||||
<option value="LED_SMD:LED_0805_2012Metric"></option>
|
||||
<option value="LED_THT:LED_D5.0mm"></option>
|
||||
<option value="LED_THT:LED_D3.0mm"></option>
|
||||
</datalist>
|
||||
|
||||
<p class="text-muted small mb-1"><i class="fas fa-info-circle"></i> {% trans %}tools.bulk_gen.kicad_hint{% endtrans %}</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 2rem;"><input class="form-check-input" type="checkbox" checked {{ stimulus_action('pages/bulkGenerate', 'toggleAll') }}></th>
|
||||
<th>{% trans %}tools.bulk_gen.part{% endtrans %}</th>
|
||||
<th>{% trans %}tools.bulk_gen.type{% endtrans %}</th>
|
||||
<th>{% trans %}tools.bulk_gen.value{% endtrans %}</th>
|
||||
<th>{% trans %}tools.bulk_gen.appearance{% endtrans %}</th>
|
||||
<th>{% trans %}tools.value_calc.field.tolerance{% endtrans %}</th>
|
||||
<th>{% trans %}tools.bulk_gen.kicad{% endtrans %}</th>
|
||||
<th>{% trans %}tools.bulk_gen.preview{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in candidates %}
|
||||
{% set name_keys = {
|
||||
'capacitor': 'tools.value_calc.capacitor.title',
|
||||
'smd_capacitor': 'tools.value_calc.capacitor.title',
|
||||
'inductor': 'tools.value_calc.inductor.title',
|
||||
'smd_inductor': 'tools.value_calc.inductor.title',
|
||||
'resistor': 'tools.value_calc.resistor.title',
|
||||
'diode': 'tools.value_calc.diode.title',
|
||||
} %}
|
||||
{% set name_key = name_keys[c.type]|default('tools.value_calc.smd.title') %}
|
||||
<tr {{ stimulus_target('pages/bulkGenerate', 'row') }}
|
||||
data-type="{{ c.type }}"
|
||||
data-subtype="{{ c.subtype }}"
|
||||
data-marking="{{ c.marking }}"
|
||||
data-value="{{ c.value }}"
|
||||
data-voltage="{{ c.voltage }}"
|
||||
data-tolerance="{{ c.tolerance ? c.tolerance|replace({'±': '', '%': ''})|trim }}"
|
||||
data-package="{{ c.package }}"
|
||||
data-pitch="{{ c.pitch }}"
|
||||
data-diameter="{{ c.diameter }}"
|
||||
data-power="{{ c.power }}"
|
||||
data-ppm="{{ c.ppm }}"
|
||||
data-name="{{ name_key|trans }}"
|
||||
data-endpoint="{{ path('part_generate_image', {id: c.part.id}) }}"
|
||||
data-csrf="{{ csrf_token('generate_image' ~ c.part.id) }}"
|
||||
data-kicad-symbol="{{ c.kicad_symbol }}"
|
||||
data-reference-prefix="{{ c.reference_prefix }}"
|
||||
data-kicad-footprint="{{ c.kicad_footprint }}"
|
||||
data-eda-endpoint="{{ path('part_set_eda', {id: c.part.id}) }}"
|
||||
data-eda-csrf="{{ csrf_token('set_eda' ~ c.part.id) }}"
|
||||
data-overwrite="{{ c.has_picture ? '1' : '' }}">
|
||||
<td><input class="form-check-input" type="checkbox" checked></td>
|
||||
<td>
|
||||
<a href="{{ path('part_info', {id: c.part.id}) }}" target="_blank" rel="noopener">{{ c.part.name }}</a>
|
||||
{%- if c.has_picture %} <span class="badge bg-warning text-dark" title="{{ 'tools.bulk_gen.has_picture_hint'|trans }}">{% trans %}tools.bulk_gen.has_picture{% endtrans %}</span>{% endif %}
|
||||
</td>
|
||||
<td><span class="badge bg-secondary">{{ c.subtype ?: c.type }}</span></td>
|
||||
<td>
|
||||
{%- if c.type in ['capacitor', 'smd_capacitor'] -%}
|
||||
{%- if c.value >= 1e-6 %}{{ (c.value / 1e-6)|round(3) }} µF
|
||||
{%- elseif c.value >= 1e-9 %}{{ (c.value / 1e-9)|round(3) }} nF
|
||||
{%- else %}{{ (c.value * 1e12)|round(1) }} pF{% endif -%}
|
||||
{%- elseif c.type in ['inductor', 'smd_inductor'] -%}
|
||||
{%- if c.value >= 1 %}{{ c.value|round(3) }} H
|
||||
{%- elseif c.value >= 1e-3 %}{{ (c.value / 1e-3)|round(3) }} mH
|
||||
{%- elseif c.value >= 1e-6 %}{{ (c.value / 1e-6)|round(3) }} µH
|
||||
{%- else %}{{ (c.value / 1e-9)|round(1) }} nH{% endif -%}
|
||||
{%- elseif c.type == 'diode' -%}
|
||||
{%- if c.marking %}{{ c.marking }}
|
||||
{%- elseif c.voltage %}{{ c.voltage }} V {{ (c.subtype ?: 'diode')|capitalize }}
|
||||
{%- else %}{{ (c.subtype ?: 'diode')|capitalize }}{% endif -%}
|
||||
{%- else -%}
|
||||
{%- if c.value >= 1e6 %}{{ (c.value / 1e6)|round(3) }} MΩ
|
||||
{%- elseif c.value >= 1e3 %}{{ (c.value / 1e3)|round(3) }} kΩ
|
||||
{%- else %}{{ c.value|round(3) }} Ω{% endif -%}
|
||||
{%- endif -%}
|
||||
{% if c.package %} · {{ c.package }}{% endif %}
|
||||
</td>
|
||||
<td style="min-width: 155px;">
|
||||
{% set rowcolor = c.color ?: (c.type == 'capacitor' ? '#e0a63a' : (c.type == 'smd_capacitor' ? '#c8a37a' : (c.type == 'resistor' ? '#e8d9b5' : (c.type == 'inductor' ? '#2f6f4c' : (c.type == 'smd_inductor' ? '#38332e' : '#20242a'))))) %}
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<input type="color" class="form-control form-control-color form-control-sm" value="{{ rowcolor }}"
|
||||
data-row-color title="{% trans %}tools.value_calc.body_color{% endtrans %}"
|
||||
{{ stimulus_action('pages/bulkGenerate', 'regenerateRow', 'input') }}>
|
||||
{% if c.type == 'capacitor' %}
|
||||
<input type="number" class="form-control form-control-sm" value="{{ c.diameter ?: 5 }}" min="1" step="0.5" style="width: 4rem;"
|
||||
data-row-diameter title="{% trans %}tools.value_calc.cap.diameter{% endtrans %} (mm)"
|
||||
{{ stimulus_action('pages/bulkGenerate', 'regenerateRow', 'input') }}>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if c.type == 'capacitor' %}
|
||||
{% set rp = c.pitch ?: 5.08 %}
|
||||
<select class="form-select form-select-sm mb-1" data-row-pitch title="{% trans %}tools.value_calc.cap.pitch{% endtrans %}"
|
||||
{{ stimulus_action('pages/bulkGenerate', 'regenerateRow', 'change') }}>
|
||||
<option value="2.54" {{ rp == 2.54 ? 'selected' }}>2.54</option>
|
||||
<option value="5.08" {{ rp not in [2.54, 7.5, 10, 15] ? 'selected' }}>5.08</option>
|
||||
<option value="7.5" {{ rp == 7.5 ? 'selected' }}>7.5</option>
|
||||
<option value="10" {{ rp == 10 ? 'selected' }}>10</option>
|
||||
<option value="15" {{ rp == 15 ? 'selected' }}>15</option>
|
||||
</select>
|
||||
{% elseif c.type == 'resistor' %}
|
||||
{% set rpow = c.power ?: 0.25 %}
|
||||
<div class="input-group input-group-sm">
|
||||
<select class="form-select" data-row-power title="{% trans %}tools.value_calc.field.power{% endtrans %}"
|
||||
{{ stimulus_action('pages/bulkGenerate', 'regenerateRow', 'change') }}>
|
||||
<option value="0.125" {{ rpow == 0.125 ? 'selected' }}>1/8 W</option>
|
||||
<option value="0.25" {{ rpow not in [0.125, 0.5, 1, 2] ? 'selected' }}>1/4 W</option>
|
||||
<option value="0.5" {{ rpow == 0.5 ? 'selected' }}>1/2 W</option>
|
||||
<option value="1" {{ rpow == 1 ? 'selected' }}>1 W</option>
|
||||
<option value="2" {{ rpow == 2 ? 'selected' }}>2 W</option>
|
||||
</select>
|
||||
<select class="form-select" data-row-ppm title="{% trans %}tools.value_calc.field.ppm{% endtrans %}"
|
||||
{{ stimulus_action('pages/bulkGenerate', 'regenerateRow', 'change') }}>
|
||||
<option value="" {{ not c.ppm ? 'selected' }}>—</option>
|
||||
<option value="100" {{ c.ppm == 100 ? 'selected' }}>100</option>
|
||||
<option value="50" {{ c.ppm == 50 ? 'selected' }}>50</option>
|
||||
<option value="25" {{ c.ppm == 25 ? 'selected' }}>25</option>
|
||||
<option value="15" {{ c.ppm == 15 ? 'selected' }}>15</option>
|
||||
<option value="10" {{ c.ppm == 10 ? 'selected' }}>10</option>
|
||||
<option value="5" {{ c.ppm == 5 ? 'selected' }}>5</option>
|
||||
</select>
|
||||
</div>
|
||||
{% elseif c.type in ['smd_resistor', 'smd_inductor', 'smd_capacitor'] %}
|
||||
{% set rpkg = c.package ?: (c.type == 'smd_inductor' ? '1210' : '0805') %}
|
||||
<select class="form-select form-select-sm mb-1" data-row-package title="{% trans %}tools.value_calc.smd.package{% endtrans %}"
|
||||
{{ stimulus_action('pages/bulkGenerate', 'regenerateRow', 'change') }}>
|
||||
{% for pkg in ['0201', '0402', '0603', '0805', '1206', '1210', '2010', '2512'] %}
|
||||
<option value="{{ pkg }}" {{ rpkg == pkg ? 'selected' }}>{{ pkg }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
{# Rated voltage — printed on the generated image. Not shown for LEDs (no voltage marking). #}
|
||||
{% if not (c.type == 'diode' and c.subtype == 'led') %}
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" class="form-control" value="{{ c.voltage }}" placeholder="V" min="1"
|
||||
data-row-voltage title="{% trans %}tools.value_calc.cap.voltage{% endtrans %}"
|
||||
{{ stimulus_action('pages/bulkGenerate', 'regenerateRow', 'input') }}>
|
||||
<span class="input-group-text">V</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c.type in ['diode', 'smd_capacitor'] %}
|
||||
<span class="text-muted small">—</span>
|
||||
{% else %}
|
||||
{% set rtol = c.tolerance ? c.tolerance|replace({'±': '', '%': '', ' ': '', 'pF': '', 'PF': ''})|trim : '' %}
|
||||
<select class="form-select form-select-sm" data-row-tolerance style="min-width: 7.5rem;"
|
||||
{{ stimulus_action('pages/bulkGenerate', 'regenerateRow', 'change') }}>
|
||||
<option value="" {{ rtol == '' ? 'selected' }}>—</option>
|
||||
{% if c.type == 'capacitor' %}
|
||||
<option value="0.1" {{ rtol == '0.1' ? 'selected' }}>±0.1pF (B)</option>
|
||||
<option value="0.25" {{ rtol == '0.25' ? 'selected' }}>±0.25pF (C)</option>
|
||||
<option value="0.5" {{ rtol == '0.5' ? 'selected' }}>±0.5pF (D)</option>
|
||||
<option value="1" {{ rtol == '1' ? 'selected' }}>±1% (F)</option>
|
||||
<option value="2" {{ rtol == '2' ? 'selected' }}>±2% (G)</option>
|
||||
<option value="5" {{ rtol == '5' ? 'selected' }}>±5% (J)</option>
|
||||
<option value="10" {{ rtol == '10' ? 'selected' }}>±10% (K)</option>
|
||||
<option value="20" {{ rtol == '20' ? 'selected' }}>±20% (M)</option>
|
||||
{% else %}
|
||||
<option value="0.05" {{ rtol == '0.05' ? 'selected' }}>±0.05% (W)</option>
|
||||
<option value="0.1" {{ rtol == '0.1' ? 'selected' }}>±0.1% (B)</option>
|
||||
<option value="0.25" {{ rtol == '0.25' ? 'selected' }}>±0.25% (C)</option>
|
||||
<option value="0.5" {{ rtol == '0.5' ? 'selected' }}>±0.5% (D)</option>
|
||||
<option value="1" {{ rtol == '1' ? 'selected' }}>±1% (F)</option>
|
||||
<option value="2" {{ rtol == '2' ? 'selected' }}>±2% (G)</option>
|
||||
<option value="5" {{ rtol == '5' ? 'selected' }}>±5% (J)</option>
|
||||
<option value="10" {{ rtol == '10' ? 'selected' }}>±10% (K)</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small" style="min-width: 230px;">
|
||||
<input type="text" class="form-control form-control-sm font-monospace mb-1" value="{{ c.kicad_symbol }}"
|
||||
data-bulk-eda-symbol list="bulkgen-symbols" placeholder="Device:C">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control font-monospace" value="{{ c.kicad_footprint }}"
|
||||
data-bulk-eda-footprint list="bulkgen-footprints" placeholder="{% trans %}tools.bulk_gen.footprint_ph{% endtrans %}">
|
||||
<input type="text" class="form-control text-center" style="max-width: 3.5rem;" value="{{ c.reference_prefix }}"
|
||||
data-bulk-eda-reference placeholder="Ref" title="{% trans %}tools.bulk_gen.reference{% endtrans %}">
|
||||
</div>
|
||||
</td>
|
||||
<td><div data-bulk-preview class="bg-light rounded p-1 text-center" style="width: 180px;"></div></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
<button type="button" class="btn btn-success" {{ stimulus_target('pages/bulkGenerate', 'attachBtn') }} {{ stimulus_action('pages/bulkGenerate', 'attachSelected') }}>
|
||||
<i class="fas fa-paperclip"></i> {% trans %}tools.bulk_gen.generate_attach{% endtrans %}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" {{ stimulus_target('pages/bulkGenerate', 'edaBtn') }} {{ stimulus_action('pages/bulkGenerate', 'writeEda') }}>
|
||||
<i class="fas fa-microchip"></i> {% trans %}tools.bulk_gen.write_eda{% endtrans %}
|
||||
</button>
|
||||
<div class="progress flex-grow-1 d-none" style="height: 1.5rem; min-width: 12rem;" {{ stimulus_target('pages/bulkGenerate', 'progress') }}>
|
||||
<div class="progress-bar" role="progressbar" style="width: 0" {{ stimulus_target('pages/bulkGenerate', 'progressBar') }}>0/0</div>
|
||||
</div>
|
||||
<a href="{{ path('homepage') }}" class="btn btn-outline-secondary ms-auto" {{ stimulus_action('pages/bulkGenerate', 'goBack') }}>
|
||||
<i class="fas fa-arrow-left"></i> {% trans %}tools.bulk_gen.back{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
11
templates/tools/value_calculator/value_calculator.html.twig
Normal file
11
templates/tools/value_calculator/value_calculator.html.twig
Normal file
|
|
@ -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 %}
|
||||
<i class="fas fa-palette"></i> {% trans %}tools.value_calc.title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
{% include 'tools/value_calculator/_calculator_body.html.twig' %}
|
||||
{% endblock %}
|
||||
228
tests/Controller/ValueCalculatorControllerTest.php
Normal file
228
tests/Controller/ValueCalculatorControllerTest.php
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-server).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\UserSystem\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
/**
|
||||
* Functional smoke tests for the value calculator tool and the "Generate component images"
|
||||
* bulk action. They exercise the controller entry points (access control, classification loop,
|
||||
* EDA suggestions, rendering) end to end. Part edits are rolled back by DAMADoctrineTestBundle.
|
||||
*/
|
||||
#[Group('DB')]
|
||||
#[Group('slow')]
|
||||
final class ValueCalculatorControllerTest extends WebTestCase
|
||||
{
|
||||
private function loginAdmin(): KernelBrowser
|
||||
{
|
||||
return $this->loginAs('admin');
|
||||
}
|
||||
|
||||
private function loginAs(string $username): KernelBrowser
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $em->getRepository(User::class)->findOneBy(['name' => $username]);
|
||||
if ($user === null) {
|
||||
$this->markTestSkipped("Fixture user '$username' not found.");
|
||||
}
|
||||
$client->loginUser($user);
|
||||
$client->followRedirects(false);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Part-DB answers an unauthorized request with 401/403 or a redirect (to the login/permission
|
||||
* page) depending on context, so accept any of those — the point is that access is refused.
|
||||
*/
|
||||
private function assertDenied(KernelBrowser $client): void
|
||||
{
|
||||
$code = $client->getResponse()->getStatusCode();
|
||||
$this->assertTrue(
|
||||
$code === 401 || $code === 403 || $client->getResponse()->isRedirect(),
|
||||
"Expected 401/403/redirect for an unauthorized request, got $code"
|
||||
);
|
||||
}
|
||||
|
||||
public function testValueCalculatorPageLoads(): void
|
||||
{
|
||||
$client = $this->loginAdmin();
|
||||
$client->request('GET', '/en/tools/component_image_generator');
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testValueCalculatorPrefillsFromPart(): void
|
||||
{
|
||||
$client = $this->loginAdmin();
|
||||
$client->request('GET', '/en/tools/component_image_generator?part=1');
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testBulkGenerateWithoutSelection(): void
|
||||
{
|
||||
$client = $this->loginAdmin();
|
||||
$client->request('GET', '/en/tools/bulk_generate_images');
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testBulkGenerateClassifiesAResistor(): void
|
||||
{
|
||||
$client = $this->loginAdmin();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$part = $em->find(Part::class, 1);
|
||||
if ($part === null) {
|
||||
$this->markTestSkipped('Fixture part #1 not found.');
|
||||
}
|
||||
//Make the part look like a resistor without a picture so it becomes a candidate and the
|
||||
//classification + EDA-suggestion code path runs.
|
||||
$part->setName('Resistor 10kΩ 0.25W 1% blue body 50ppm');
|
||||
$part->setMasterPictureAttachment(null);
|
||||
$em->flush();
|
||||
|
||||
$client->request('GET', '/en/tools/bulk_generate_images?ids=1');
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testBulkGenerateOverwriteMode(): void
|
||||
{
|
||||
$client = $this->loginAdmin();
|
||||
$client->request('GET', '/en/tools/bulk_generate_images?ids=1&overwrite=1');
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testGenerateImageAttachesPicture(): void
|
||||
{
|
||||
$client = $this->loginAdmin();
|
||||
$row = $this->resistorCandidateRow($client);
|
||||
//The row carries the per-part generate endpoint and its CSRF token (as the JS uses them).
|
||||
$client->request('POST', (string) $row->attr('data-endpoint'), [
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#000"/></svg>',
|
||||
'name' => 'Test generated image',
|
||||
'preview' => '1',
|
||||
'_token' => (string) $row->attr('data-csrf'),
|
||||
], [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$payload = json_decode((string) $client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($payload);
|
||||
self::assertTrue($payload['success'] ?? false, 'Expected {success: true} from the generate-image endpoint.');
|
||||
|
||||
//The picture must actually be attached and (preview=1) set as the master picture.
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$em->clear();
|
||||
$part = $em->find(Part::class, 1);
|
||||
self::assertNotNull($part->getMasterPictureAttachment(), 'Generated image should be set as the master picture.');
|
||||
}
|
||||
|
||||
public function testGenerateImageRejectsInvalidCsrf(): void
|
||||
{
|
||||
$client = $this->loginAdmin();
|
||||
$row = $this->resistorCandidateRow($client);
|
||||
$client->request('POST', (string) $row->attr('data-endpoint'), [
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>',
|
||||
'name' => 'Should be rejected',
|
||||
'_token' => 'definitely-not-a-valid-token',
|
||||
], [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']);
|
||||
|
||||
$this->assertDenied($client);
|
||||
}
|
||||
|
||||
public function testValueCalculatorDeniedWithoutPermission(): void
|
||||
{
|
||||
$client = $this->loginAs('noread');
|
||||
$client->request('GET', '/en/tools/component_image_generator');
|
||||
$this->assertDenied($client);
|
||||
}
|
||||
|
||||
public function testBulkGenerateDeniedWithoutPermission(): void
|
||||
{
|
||||
$client = $this->loginAs('noread');
|
||||
$client->request('GET', '/en/tools/bulk_generate_images?ids=1');
|
||||
$this->assertDenied($client);
|
||||
}
|
||||
|
||||
public function testGenerateImageDeniedWithoutEditPermission(): void
|
||||
{
|
||||
$client = $this->loginAs('noread');
|
||||
$client->request('POST', '/en/part/1/generate_image', [
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>',
|
||||
'_token' => 'irrelevant-edit-is-checked-first',
|
||||
], [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']);
|
||||
$this->assertDenied($client);
|
||||
}
|
||||
|
||||
public function testSetEdaWritesKicadFields(): void
|
||||
{
|
||||
$client = $this->loginAdmin();
|
||||
$row = $this->resistorCandidateRow($client);
|
||||
$client->request('POST', (string) $row->attr('data-eda-endpoint'), [
|
||||
'kicad_symbol' => 'Device:R',
|
||||
'reference_prefix' => 'R',
|
||||
'kicad_footprint' => 'Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal',
|
||||
'_token' => (string) $row->attr('data-eda-csrf'),
|
||||
], [], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$payload = json_decode((string) $client->getResponse()->getContent(), true);
|
||||
self::assertTrue($payload['success'] ?? false, 'Expected {success: true} from the set-eda endpoint.');
|
||||
|
||||
//The EDA fields must actually be written to the part.
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$em->clear();
|
||||
$eda = $em->find(Part::class, 1)->getEdaInfo();
|
||||
self::assertSame('Device:R', $eda->getKicadSymbol());
|
||||
self::assertSame('R', $eda->getReferencePrefix());
|
||||
self::assertSame('Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal', $eda->getKicadFootprint());
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames fixture part #1 into a pictureless resistor and returns its candidate row from the
|
||||
* bulk review page — which carries the generate/set-eda endpoints and their CSRF tokens.
|
||||
*/
|
||||
private function resistorCandidateRow(KernelBrowser $client): Crawler
|
||||
{
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$part = $em->find(Part::class, 1);
|
||||
if ($part === null) {
|
||||
$this->markTestSkipped('Fixture part #1 not found.');
|
||||
}
|
||||
$part->setName('Resistor 10kΩ 0.25W 1% blue body');
|
||||
$part->setMasterPictureAttachment(null);
|
||||
$em->flush();
|
||||
|
||||
$crawler = $client->request('GET', '/en/tools/bulk_generate_images?ids=1');
|
||||
self::assertResponseIsSuccessful();
|
||||
$row = $crawler->filter('tr[data-csrf]')->first();
|
||||
self::assertGreaterThan(0, $row->count(), 'Expected a classified candidate row on the bulk review page.');
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
462
tests/Services/Tools/ComponentValueGuesserTest.php
Normal file
462
tests/Services/Tools/ComponentValueGuesserTest.php
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-server).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Services\Tools;
|
||||
|
||||
use App\Entity\Parameters\PartParameter;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Tools\ComponentValueGuesser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the classification/parsing logic behind the value calculator and bulk image
|
||||
* generator. The guesser has no dependencies, so the tests construct plain Part entities in memory.
|
||||
*/
|
||||
class ComponentValueGuesserTest extends TestCase
|
||||
{
|
||||
private ComponentValueGuesser $guesser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->guesser = new ComponentValueGuesser();
|
||||
}
|
||||
|
||||
private function part(string $name, ?string $description = null): Part
|
||||
{
|
||||
$part = new Part();
|
||||
$part->setName($name);
|
||||
if ($description !== null) {
|
||||
$part->setDescription($description);
|
||||
}
|
||||
|
||||
return $part;
|
||||
}
|
||||
|
||||
private function partWithParameter(string $paramName, float $value, string $unit): Part
|
||||
{
|
||||
$part = new Part();
|
||||
$part->setName('Some part');
|
||||
$param = new PartParameter();
|
||||
$param->setName($paramName);
|
||||
$param->setValueTypical($value);
|
||||
$param->setUnit($unit);
|
||||
$part->addParameter($param);
|
||||
|
||||
return $part;
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider resistorValueProvider
|
||||
*/
|
||||
public function testResistorValueFromName(string $name, float $expectedOhms): void
|
||||
{
|
||||
[$ohms, $farads] = $this->guesser->extractValue($this->part($name));
|
||||
self::assertNull($farads, "Expected no capacitance for '$name'");
|
||||
self::assertNotNull($ohms, "Expected a resistance for '$name'");
|
||||
self::assertEqualsWithDelta($expectedOhms, $ohms, $expectedOhms * 1e-9 + 1e-9);
|
||||
}
|
||||
|
||||
public static function resistorValueProvider(): \Generator
|
||||
{
|
||||
yield 'plain ohm with space' => ['100 Ω', 100.0];
|
||||
yield 'plain ohm no space' => ['470Ω', 470.0];
|
||||
yield 'R notation' => ['470R', 470.0];
|
||||
yield 'kilo with space (regression: Ω is a PCRE word char under /u)' => ['1 kΩ', 1000.0];
|
||||
yield 'kilo no space' => ['10kΩ', 10000.0];
|
||||
yield 'mega with space' => ['1 MΩ', 1_000_000.0];
|
||||
yield 'decimal kilo' => ['4.7 kΩ', 4700.0];
|
||||
yield 'RKM kilo' => ['4k7', 4700.0];
|
||||
yield 'RKM mega' => ['2M2', 2_200_000.0];
|
||||
yield 'bare magnitude letter' => ['10k', 10000.0];
|
||||
yield 'realistic imported name' => ['Resistor 10 kΩ 0.25W 1% Metal Film', 10000.0];
|
||||
yield 'realistic mega name' => ['Resistor 1 MΩ 0.25W 1% Metal Film', 1_000_000.0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider capacitorValueProvider
|
||||
*/
|
||||
public function testCapacitorValueFromName(string $name, float $expectedFarads): void
|
||||
{
|
||||
[$ohms, $farads] = $this->guesser->extractValue($this->part($name));
|
||||
self::assertNull($ohms, "Expected no resistance for '$name'");
|
||||
self::assertNotNull($farads, "Expected a capacitance for '$name'");
|
||||
self::assertEqualsWithDelta($expectedFarads, $farads, $expectedFarads * 1e-6);
|
||||
}
|
||||
|
||||
public static function capacitorValueProvider(): \Generator
|
||||
{
|
||||
yield 'nanofarad' => ['10nF', 10e-9];
|
||||
yield 'microfarad greek mu' => ['0.1µF', 0.1e-6];
|
||||
yield 'picofarad' => ['100pF', 100e-12];
|
||||
yield 'RKM nano' => ['4n7', 4.7e-9];
|
||||
yield 'RKM pico' => ['2p2', 2.2e-12];
|
||||
yield 'named ceramic cap' => ['Ceramic capacitor 100nF', 100e-9];
|
||||
}
|
||||
|
||||
public function testValueFromResistanceParameter(): void
|
||||
{
|
||||
$part = $this->partWithParameter('Resistance', 4.7, 'kΩ');
|
||||
[$ohms, $farads] = $this->guesser->extractValue($part);
|
||||
self::assertNull($farads);
|
||||
self::assertEqualsWithDelta(4700.0, $ohms, 1e-6);
|
||||
}
|
||||
|
||||
public function testValueFromCapacitanceParameter(): void
|
||||
{
|
||||
$part = $this->partWithParameter('Capacitance', 100.0, 'nF');
|
||||
[$ohms, $farads] = $this->guesser->extractValue($part);
|
||||
self::assertNull($ohms);
|
||||
self::assertEqualsWithDelta(100e-9, $farads, 1e-15);
|
||||
}
|
||||
|
||||
public function testClassifiesThroughHoleResistor(): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('Resistor 10 kΩ 0.25W 1% blue body'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('resistor', $guess['type']);
|
||||
self::assertEqualsWithDelta(10000.0, $guess['value'], 1e-6);
|
||||
self::assertSame(0.25, $guess['power']);
|
||||
self::assertSame('±1%', $guess['tolerance']);
|
||||
self::assertSame('#2f6db0', $guess['color']);
|
||||
}
|
||||
|
||||
public function testClassifiesSmdResistorFromPackage(): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('Resistor 4.7 kΩ 0805 1% SMD'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('smd_resistor', $guess['type']);
|
||||
self::assertSame('0805', $guess['package']);
|
||||
}
|
||||
|
||||
public function testClassifiesCapacitor(): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('Ceramic capacitor 100nF 50V'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('capacitor', $guess['type']);
|
||||
self::assertSame(50, $guess['voltage']);
|
||||
}
|
||||
|
||||
public function testClassifiesSmdCapacitorFromPackage(): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('MLCC capacitor 100nF 0805 X7R'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('smd_capacitor', $guess['type']);
|
||||
self::assertSame('0805', $guess['package']);
|
||||
self::assertEqualsWithDelta(100e-9, $guess['value'], 1e-15);
|
||||
|
||||
$eda = $this->guesser->edaSuggestion($guess);
|
||||
self::assertSame('Device:C', $eda['symbol']);
|
||||
self::assertSame('Capacitor_SMD:C_0805_2012Metric', $eda['footprint']);
|
||||
}
|
||||
|
||||
public function testUnclassifiableReturnsNull(): void
|
||||
{
|
||||
self::assertNull($this->guesser->guess($this->part('Arduino Uno R3 development board')));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider powerProvider
|
||||
*/
|
||||
public function testDetectPower(string $name, float $expected): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part($name));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame($expected, $guess['power']);
|
||||
}
|
||||
|
||||
public static function powerProvider(): \Generator
|
||||
{
|
||||
yield 'decimal watt' => ['Resistor 1k 0.25W', 0.25];
|
||||
yield 'fractional watt' => ['Resistor 1k 1/4W', 0.25];
|
||||
yield 'half watt spaced' => ['Resistor 1k 0.5 W', 0.5];
|
||||
yield 'one watt' => ['Resistor 1k 1W', 1.0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider ppmProvider
|
||||
*/
|
||||
public function testDetectPpm(string $name, ?int $expected): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part($name));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame($expected, $guess['ppm']);
|
||||
}
|
||||
|
||||
public static function ppmProvider(): \Generator
|
||||
{
|
||||
yield 'plain ppm' => ['Resistor 1k 50ppm', 50];
|
||||
yield 'ppm per celsius' => ['Resistor 1k 100 ppm/°C', 100];
|
||||
yield 'no ppm' => ['Resistor 1k', null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider toleranceProvider
|
||||
*/
|
||||
public function testDetectTolerance(string $name, ?string $expected): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part($name));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame($expected, $guess['tolerance']);
|
||||
}
|
||||
|
||||
public static function toleranceProvider(): \Generator
|
||||
{
|
||||
yield 'plus-minus percent' => ['Resistor 1k ±5%', '±5%'];
|
||||
yield 'bare percent' => ['Resistor 1k 1%', '±1%'];
|
||||
yield 'sub-percent' => ['Resistor 1k 0.1%', '±0.1%'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider colorProvider
|
||||
*/
|
||||
public function testDetectBodyColor(string $description, ?string $expected): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('Resistor 1k', $description));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame($expected, $guess['color']);
|
||||
}
|
||||
|
||||
public static function colorProvider(): \Generator
|
||||
{
|
||||
yield 'blue body' => ['blue body metal film', '#2f6db0'];
|
||||
yield 'green' => ['green body', '#2e7d4f'];
|
||||
yield 'no colour word' => ['axial resistor', null];
|
||||
yield 'colour word inside another word is ignored' => ['tantalum resistor', null];
|
||||
}
|
||||
|
||||
public function testEdaSuggestionForThroughHoleResistor(): void
|
||||
{
|
||||
$eda = $this->guesser->edaSuggestion(['type' => 'resistor', 'package' => null, 'pitch' => null, 'diameter' => null]);
|
||||
self::assertSame('Device:R', $eda['symbol']);
|
||||
self::assertSame('R', $eda['reference']);
|
||||
self::assertStringContainsString('Resistor_THT:R_Axial', (string) $eda['footprint']);
|
||||
}
|
||||
|
||||
public function testEdaSuggestionForSmdResistor(): void
|
||||
{
|
||||
$eda = $this->guesser->edaSuggestion(['type' => 'smd_resistor', 'package' => '0805', 'pitch' => null, 'diameter' => null]);
|
||||
self::assertSame('Device:R', $eda['symbol']);
|
||||
self::assertSame('Resistor_SMD:R_0805_2012Metric', $eda['footprint']);
|
||||
}
|
||||
|
||||
public function testEdaSuggestionForCapacitorUsesPitch(): void
|
||||
{
|
||||
$eda = $this->guesser->edaSuggestion(['type' => 'capacitor', 'package' => null, 'pitch' => 5.08, 'diameter' => 5.0]);
|
||||
self::assertSame('Device:C', $eda['symbol']);
|
||||
self::assertSame('C', $eda['reference']);
|
||||
self::assertStringContainsString('P5.00mm', (string) $eda['footprint']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider inductorValueProvider
|
||||
*/
|
||||
public function testClassifiesInductor(string $name, float $expectedHenries): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part($name));
|
||||
self::assertNotNull($guess, "Expected '$name' to classify");
|
||||
self::assertSame('inductor', $guess['type']);
|
||||
self::assertEqualsWithDelta($expectedHenries, $guess['value'], $expectedHenries * 1e-6);
|
||||
}
|
||||
|
||||
public static function inductorValueProvider(): \Generator
|
||||
{
|
||||
yield 'microhenry µ' => ['Inductor 100µH', 100e-6];
|
||||
yield 'microhenry u' => ['Choke 4.7uH', 4.7e-6];
|
||||
yield 'millihenry' => ['Coil 10mH', 10e-3];
|
||||
yield 'nanohenry' => ['100nH inductor', 100e-9];
|
||||
yield 'henry' => ['1H filter choke', 1.0];
|
||||
}
|
||||
|
||||
public function testInductanceFromParameter(): void
|
||||
{
|
||||
$part = $this->partWithParameter('Inductance', 100.0, 'µH');
|
||||
$guess = $this->guesser->guess($part);
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('inductor', $guess['type']);
|
||||
self::assertEqualsWithDelta(100e-6, $guess['value'], 1e-12);
|
||||
}
|
||||
|
||||
public function testMegahertzIsNotMistakenForInductance(): void
|
||||
{
|
||||
//"100MHz" must not parse as 100 mH — the (?![a-zA-Z0-9]) guard prevents it.
|
||||
self::assertNull($this->guesser->guess($this->part('Crystal oscillator 100MHz')));
|
||||
}
|
||||
|
||||
public function testEdaSuggestionForInductor(): void
|
||||
{
|
||||
$eda = $this->guesser->edaSuggestion(['type' => 'inductor', 'package' => null, 'pitch' => null, 'diameter' => null]);
|
||||
self::assertSame('Device:L', $eda['symbol']);
|
||||
self::assertSame('L', $eda['reference']);
|
||||
self::assertNull($eda['footprint']);
|
||||
}
|
||||
|
||||
public function testClassifiesSmdInductorFromPackage(): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('Inductor 10µH 0805 SMD'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('smd_inductor', $guess['type']);
|
||||
self::assertSame('0805', $guess['package']);
|
||||
self::assertEqualsWithDelta(10e-6, $guess['value'], 1e-12);
|
||||
}
|
||||
|
||||
public function testEdaSuggestionForSmdInductor(): void
|
||||
{
|
||||
$eda = $this->guesser->edaSuggestion(['type' => 'smd_inductor', 'package' => '1210', 'pitch' => null, 'diameter' => null]);
|
||||
self::assertSame('Device:L', $eda['symbol']);
|
||||
self::assertSame('L', $eda['reference']);
|
||||
self::assertSame('Inductor_SMD:L_1210_3225Metric', $eda['footprint']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider diodeProvider
|
||||
*/
|
||||
public function testClassifiesDiode(string $name, string $expectedSubtype): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part($name));
|
||||
self::assertNotNull($guess, "Expected '$name' to classify");
|
||||
self::assertSame('diode', $guess['type']);
|
||||
self::assertSame($expectedSubtype, $guess['subtype']);
|
||||
}
|
||||
|
||||
public static function diodeProvider(): \Generator
|
||||
{
|
||||
yield 'led word' => ['LED red 5mm 20mA', 'led'];
|
||||
yield 'light emitting' => ['Light-emitting diode green', 'led'];
|
||||
yield 'zener word' => ['Zener diode 5.1V', 'zener'];
|
||||
yield 'zener BZX family' => ['BZX55C5V1', 'zener'];
|
||||
yield 'zener 1N47xx' => ['1N4733A', 'zener'];
|
||||
yield 'schottky word' => ['Schottky barrier diode', 'schottky'];
|
||||
yield 'schottky BAT family' => ['BAT54', 'schottky'];
|
||||
yield 'schottky 1N58xx' => ['1N5819', 'schottky'];
|
||||
yield 'tvs word' => ['TVS diode array', 'tvs'];
|
||||
yield 'tvs SMBJ family' => ['SMBJ15A', 'tvs'];
|
||||
yield 'generic rectifier' => ['Rectifier diode', 'diode'];
|
||||
yield '1N4148 small signal' => ['1N4148 switching', 'diode'];
|
||||
yield '1N4007 rectifier' => ['1N4007', 'diode'];
|
||||
yield 'BAV family' => ['BAV99 dual', 'diode'];
|
||||
}
|
||||
|
||||
public function testLedUsesEmissionColor(): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('LED blue 5mm'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('diode', $guess['type']);
|
||||
self::assertSame('led', $guess['subtype']);
|
||||
self::assertSame('#2f6db0', $guess['color']);
|
||||
}
|
||||
|
||||
public function testZenerCarriesVoltage(): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('Zener diode 5.1V 0.5W'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('zener', $guess['subtype']);
|
||||
self::assertSame(5, $guess['voltage']);
|
||||
}
|
||||
|
||||
public function testResistorForLedStaysResistor(): void
|
||||
{
|
||||
//"220R" yields a resistance, which is classified before the diode fallback ever runs.
|
||||
$guess = $this->guesser->guess($this->part('220R resistor for LED indicator'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('resistor', $guess['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider diodeEdaProvider
|
||||
*/
|
||||
public function testEdaSuggestionForDiode(string $subtype, string $expectedSymbol): void
|
||||
{
|
||||
$eda = $this->guesser->edaSuggestion(['type' => 'diode', 'subtype' => $subtype, 'package' => null, 'pitch' => null, 'diameter' => null]);
|
||||
self::assertSame($expectedSymbol, $eda['symbol']);
|
||||
self::assertSame('D', $eda['reference']);
|
||||
self::assertNull($eda['footprint']);
|
||||
}
|
||||
|
||||
public static function diodeEdaProvider(): \Generator
|
||||
{
|
||||
yield 'generic' => ['diode', 'Device:D'];
|
||||
yield 'led' => ['led', 'Device:LED'];
|
||||
yield 'zener' => ['zener', 'Device:D_Zener'];
|
||||
yield 'schottky' => ['schottky', 'Device:D_Schottky'];
|
||||
yield 'tvs' => ['tvs', 'Device:D_TVS'];
|
||||
}
|
||||
|
||||
public function testEdaSuggestionForSmdDiodeFootprint(): void
|
||||
{
|
||||
$eda = $this->guesser->edaSuggestion(['type' => 'diode', 'subtype' => 'diode', 'package' => '0805', 'pitch' => null, 'diameter' => null]);
|
||||
self::assertSame('Diode_SMD:D_0805_2012Metric', $eda['footprint']);
|
||||
}
|
||||
|
||||
public function testEdaSuggestionForSmdLedFootprint(): void
|
||||
{
|
||||
$eda = $this->guesser->edaSuggestion(['type' => 'diode', 'subtype' => 'led', 'package' => '0805', 'pitch' => null, 'diameter' => null]);
|
||||
self::assertSame('LED_SMD:LED_0805_2012Metric', $eda['footprint']);
|
||||
}
|
||||
|
||||
public function testDetectsThtDiodePackageAndMarking(): void
|
||||
{
|
||||
//Real-world case: importing a 1N400x rectifier kit, whose names spell out the THT package.
|
||||
$guess = $this->guesser->guess($this->part('1N4001 Rectifier Diode 1A 50V DO-41'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('diode', $guess['type']);
|
||||
self::assertSame('diode', $guess['subtype']);
|
||||
self::assertSame('DO-41', $guess['package']);
|
||||
self::assertSame('1N4001', $guess['marking']);
|
||||
|
||||
$eda = $this->guesser->edaSuggestion($guess);
|
||||
self::assertSame('Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal', $eda['footprint']);
|
||||
}
|
||||
|
||||
public function testDetectsSotSchottkyPackage(): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('BAT54 Schottky diode SOT-23'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('schottky', $guess['subtype']);
|
||||
self::assertSame('SOT-23', $guess['package']);
|
||||
self::assertSame('BAT54', $guess['marking']);
|
||||
|
||||
$eda = $this->guesser->edaSuggestion($guess);
|
||||
self::assertSame('Diode_SMD:D_SOT-23', $eda['footprint']);
|
||||
}
|
||||
|
||||
public function testDetectsLedDomeSizeFootprint(): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('LED red 5mm diffused'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('led', $guess['subtype']);
|
||||
self::assertSame('5MM', $guess['package']);
|
||||
//LEDs aren't normally printed with a part number.
|
||||
self::assertNull($guess['marking']);
|
||||
|
||||
$eda = $this->guesser->edaSuggestion($guess);
|
||||
self::assertSame('LED_THT:LED_D5.0mm', $eda['footprint']);
|
||||
}
|
||||
|
||||
public function testMarkingNullWhenNoRecognisablePartNumber(): void
|
||||
{
|
||||
$guess = $this->guesser->guess($this->part('Generic rectifier diode'));
|
||||
self::assertNotNull($guess);
|
||||
self::assertSame('diode', $guess['subtype']);
|
||||
self::assertNull($guess['marking']);
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,126 @@
|
|||
<target>Go!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_band" name="tools.value_calc.band">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.band</source>
|
||||
<target>band</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_band_digit" name="tools.value_calc.resistor.band_digit">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.band_digit</source>
|
||||
<target>Digit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_band_multiplier" name="tools.value_calc.resistor.band_multiplier">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.band_multiplier</source>
|
||||
<target>Multiplier</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_band_tolerance" name="tools.value_calc.resistor.band_tolerance">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.band_tolerance</source>
|
||||
<target>Tolerance</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_band_temp" name="tools.value_calc.resistor.band_temp">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.band_temp</source>
|
||||
<target>Temp. coeff.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_black" name="tools.value_calc.color.black">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.black</source>
|
||||
<target>Black</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_brown" name="tools.value_calc.color.brown">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.brown</source>
|
||||
<target>Brown</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_red" name="tools.value_calc.color.red">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.red</source>
|
||||
<target>Red</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_orange" name="tools.value_calc.color.orange">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.orange</source>
|
||||
<target>Orange</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_yellow" name="tools.value_calc.color.yellow">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.yellow</source>
|
||||
<target>Yellow</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_green" name="tools.value_calc.color.green">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.green</source>
|
||||
<target>Green</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_blue" name="tools.value_calc.color.blue">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.blue</source>
|
||||
<target>Blue</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_violet" name="tools.value_calc.color.violet">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.violet</source>
|
||||
<target>Violet</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_grey" name="tools.value_calc.color.grey">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.grey</source>
|
||||
<target>Grey</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_white" name="tools.value_calc.color.white">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.white</source>
|
||||
<target>White</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_gold" name="tools.value_calc.color.gold">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.gold</source>
|
||||
<target>Gold</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_color_silver" name="tools.value_calc.color.silver">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.color.silver</source>
|
||||
<target>Silver</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_invalid" name="tools.value_calc.invalid_input">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.invalid_input</source>
|
||||
<target>Invalid input</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_tol" name="tools.value_calc.tolerance">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.tolerance</source>
|
||||
<target>Tolerance</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_attach_nothing" name="tools.value_calc.attach.nothing">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.attach.nothing</source>
|
||||
<target>Please generate an image first.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8d38e7538" name="user.password_strength.crack_time">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.crack_time</source>
|
||||
|
|
|
|||
|
|
@ -12995,6 +12995,684 @@ Buerklin-API Authentication server:
|
|||
<target>Last stocktake</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_perm" name="perm.tools.component_image_generator">
|
||||
<segment state="translated">
|
||||
<source>perm.tools.component_image_generator</source>
|
||||
<target>Component image generator</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_title" name="tools.value_calc.title">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.title</source>
|
||||
<target>Component image generator</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_title" name="tools.bulk_gen.title">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.title</source>
|
||||
<target>Bulk generate component images</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_intro" name="tools.bulk_gen.intro">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.intro</source>
|
||||
<target>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.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_pick" name="tools.bulk_gen.pick_location">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.pick_location</source>
|
||||
<target>— Select a storage location —</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_load" name="tools.bulk_gen.load">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.load</source>
|
||||
<target>Load parts</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_none" name="tools.bulk_gen.none">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.none</source>
|
||||
<target>None of the %count% selected part(s) could be classified as a resistor, SMD resistor or capacitor without an existing picture.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_found" name="tools.bulk_gen.found">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.found</source>
|
||||
<target>part(s) ready to illustrate.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_part" name="tools.bulk_gen.part">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.part</source>
|
||||
<target>Part</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_type" name="tools.bulk_gen.type">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.type</source>
|
||||
<target>Detected type</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_value" name="tools.bulk_gen.value">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.value</source>
|
||||
<target>Value</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_wip" name="tools.bulk_gen.wip">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.wip</source>
|
||||
<target>Review the detected types and values here — generating and attaching the pictures is the next step.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_nosel" name="tools.bulk_gen.no_selection">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.no_selection</source>
|
||||
<target>No parts were selected. Select parts in a list and choose the "Generate component images" action.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_skipped" name="tools.bulk_gen.skipped">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.skipped</source>
|
||||
<target>skipped: already have a picture or not classifiable</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_preview" name="tools.bulk_gen.preview">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.preview</source>
|
||||
<target>Preview</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_appearance" name="tools.bulk_gen.appearance">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.appearance</source>
|
||||
<target>Picture appearance</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_apply_all" name="tools.bulk_gen.apply_all">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.apply_all</source>
|
||||
<target>Apply to all rows</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_back" name="tools.bulk_gen.back">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.back</source>
|
||||
<target>Back to parts</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_with_picture" name="tools.bulk_gen.with_picture">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.with_picture</source>
|
||||
<target>%count% selected part(s) already have a picture and were not listed.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_regenerate" name="tools.bulk_gen.regenerate">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.regenerate</source>
|
||||
<target>Re-generate / overwrite those %count%</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_overwrite_mode" name="tools.bulk_gen.overwrite_mode">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.overwrite_mode</source>
|
||||
<target>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.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_has_picture_hint" name="tools.bulk_gen.has_picture_hint">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.has_picture_hint</source>
|
||||
<target>This part already has a picture. Generating sets the new image as the preview and replaces any earlier generated image; uploaded photos are kept.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_overwrite_exit" name="tools.bulk_gen.overwrite_exit">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.overwrite_exit</source>
|
||||
<target>Only parts without a picture</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_has_picture" name="tools.bulk_gen.has_picture">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.has_picture</source>
|
||||
<target>has picture</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_title" name="tools.bulk_gen.help.title">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.title</source>
|
||||
<target>How to get the most auto-filled — what to put in a part's details</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_intro" name="tools.bulk_gen.help.intro">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.intro</source>
|
||||
<target>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.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_col_field" name="tools.bulk_gen.help.col_field">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.col_field</source>
|
||||
<target>Field</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_col_param" name="tools.bulk_gen.help.col_param">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.col_param</source>
|
||||
<target>Add a parameter named…</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_col_text" name="tools.bulk_gen.help.col_text">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.col_text</source>
|
||||
<target>…or write in the name / description</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_value" name="tools.bulk_gen.help.value">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.value</source>
|
||||
<target>Value (required to classify)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_voltage" name="tools.bulk_gen.help.voltage">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.voltage</source>
|
||||
<target>Rated voltage (capacitors)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_tolerance" name="tools.bulk_gen.help.tolerance">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.tolerance</source>
|
||||
<target>Tolerance</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_pitch" name="tools.bulk_gen.help.pitch">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.pitch</source>
|
||||
<target>Lead pitch (capacitors)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_diameter" name="tools.bulk_gen.help.diameter">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.diameter</source>
|
||||
<target>Body diameter (capacitors)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_smd" name="tools.bulk_gen.help.smd">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.smd</source>
|
||||
<target>SMD size (resistors → footprint)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_help_footprint_col" name="tools.bulk_gen.help.footprint_col">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.help.footprint_col</source>
|
||||
<target>the assigned footprint</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_kicad" name="tools.bulk_gen.kicad">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.kicad</source>
|
||||
<target>KiCad</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_write_eda" name="tools.bulk_gen.write_eda">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.write_eda</source>
|
||||
<target>Write KiCad settings to checked parts</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_eda_written" name="tools.bulk_gen.eda_written">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.eda_written</source>
|
||||
<target>EDA settings written</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_footprint_ph" name="tools.bulk_gen.footprint_ph">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.footprint_ph</source>
|
||||
<target>Footprint (optional)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_reference" name="tools.bulk_gen.reference">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.reference</source>
|
||||
<target>Reference prefix</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_kicad_hint" name="tools.bulk_gen.kicad_hint">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.kicad_hint</source>
|
||||
<target>Suggested values — edit any field before writing.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_genattach" name="tools.bulk_gen.generate_attach">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.generate_attach</source>
|
||||
<target>Attach pictures to the checked parts</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_attached" name="tools.bulk_gen.attached">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.attached</source>
|
||||
<target>attached</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bulkgen_failed" name="tools.bulk_gen.failed">
|
||||
<segment state="translated">
|
||||
<source>tools.bulk_gen.failed</source>
|
||||
<target>failed</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="plaction_group_images" name="part_list.action.group.images">
|
||||
<segment state="translated">
|
||||
<source>part_list.action.group.images</source>
|
||||
<target>Images</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="plaction_gen_images" name="part_list.action.generate_images">
|
||||
<segment state="translated">
|
||||
<source>part_list.action.generate_images</source>
|
||||
<target>Generate component images</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_explanation" name="tools.value_calc.explanation">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.explanation</source>
|
||||
<target>Decode and visualize the value of common components — colour bands (resistors, inductors), capacitor codes and SMD chip markings. Read a part by picking its bands/code, or enter a value to generate the matching bands/code and a picture you can attach to the part.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_bodycolor" name="tools.value_calc.body_color">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body_color</source>
|
||||
<target>Body color</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_appearance" name="tools.value_calc.appearance">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.appearance</source>
|
||||
<target>Picture appearance (for the generated image)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_beige" name="tools.value_calc.body.beige">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.beige</source>
|
||||
<target>Beige</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_blue" name="tools.value_calc.body.blue">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.blue</source>
|
||||
<target>Blue</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_green" name="tools.value_calc.body.green">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.green</source>
|
||||
<target>Green</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_lightblue" name="tools.value_calc.body.lightblue">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.lightblue</source>
|
||||
<target>Light blue</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_tan" name="tools.value_calc.body.tan">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.tan</source>
|
||||
<target>Tan</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_red" name="tools.value_calc.body.red">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.red</source>
|
||||
<target>Red</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_purple" name="tools.value_calc.body.purple">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.purple</source>
|
||||
<target>Purple</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_black" name="tools.value_calc.body.black">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.black</source>
|
||||
<target>Black</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_grey" name="tools.value_calc.body.grey">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.grey</source>
|
||||
<target>Grey</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_white" name="tools.value_calc.body.white">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.white</source>
|
||||
<target>White</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_r_title" name="tools.value_calc.resistor.title">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.title</source>
|
||||
<target>Resistor</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_l_title" name="tools.value_calc.inductor.title">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.inductor.title</source>
|
||||
<target>Inductor</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_l_result" name="tools.value_calc.inductor.result">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.inductor.result</source>
|
||||
<target>Inductance</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_l_intro" name="tools.value_calc.inductor.intro">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.inductor.intro</source>
|
||||
<target>Molded axial inductors use the same colour-band code as resistors, but the value is read in microhenries (µH). Pick the band colours, or type a value to set them.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_l_from_value" name="tools.value_calc.inductor.from_value">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.inductor.from_value</source>
|
||||
<target>Set from value</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_l_from_value_help" name="tools.value_calc.inductor.from_value_help">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.inductor.from_value_help</source>
|
||||
<target>Enter an inductance to colour the bands automatically. A bare number is read as µH (e.g. 100 = 100µH); you can also write 100µH, 10mH or 470nH.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_d_title" name="tools.value_calc.diode.title">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.diode.title</source>
|
||||
<target>Diode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_smdind_title" name="tools.value_calc.smd_inductor.title">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.smd_inductor.title</source>
|
||||
<target>SMD inductor</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_smdind_intro" name="tools.value_calc.smd_inductor.intro">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.smd_inductor.intro</source>
|
||||
<target>Molded/shielded SMD power inductor — enter the inductance (e.g. 100µH, 10mH) to see its printed µH code.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_smdcap_title" name="tools.value_calc.smd_capacitor.title">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.smd_capacitor.title</source>
|
||||
<target>SMD capacitor</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_smdcap_intro" name="tools.value_calc.smd_capacitor.intro">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.smd_capacitor.intro</source>
|
||||
<target>Surface-mount MLCC capacitors are almost always unmarked, so there's nothing to decode — just enter the value and package to generate a picture you can attach to the part.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_body_brown" name="tools.value_calc.body.brown">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.body.brown</source>
|
||||
<target>Brown</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_c_title" name="tools.value_calc.capacitor.title">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.capacitor.title</source>
|
||||
<target>Capacitor</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_s_title" name="tools.value_calc.smd.title">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.smd.title</source>
|
||||
<target>SMD resistor</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_r_bands" name="tools.value_calc.resistor.bands">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.bands</source>
|
||||
<target>Number of bands</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_r_bands4" name="tools.value_calc.resistor.bands_4">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.bands_4</source>
|
||||
<target>4 bands</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_r_bands5" name="tools.value_calc.resistor.bands_5">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.bands_5</source>
|
||||
<target>5 bands</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_r_bands6" name="tools.value_calc.resistor.bands_6">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.bands_6</source>
|
||||
<target>6 bands</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_attach_context" name="tools.value_calc.attach.context">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.attach.context</source>
|
||||
<target>The generated image can be attached to part "%part%". Pick a component below and click "Attach to part".</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_attach_preview" name="tools.value_calc.attach.as_preview">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.attach.as_preview</source>
|
||||
<target>Use as preview image</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_attach_button" name="tools.value_calc.attach.button">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.attach.button</source>
|
||||
<target>Attach to part</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_attach_generate" name="tools.value_calc.attach.generate_button">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.attach.generate_button</source>
|
||||
<target>Generate component image</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_attach_generate_hint" name="tools.value_calc.attach.generate_hint">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.attach.generate_hint</source>
|
||||
<target>Generate a resistor, capacitor or SMD picture and attach it to this part.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_gen_flash_success" name="part.generate_image.flash.success">
|
||||
<segment state="translated">
|
||||
<source>part.generate_image.flash.success</source>
|
||||
<target>Generated image was attached to the part.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_gen_flash_invalid" name="part.generate_image.flash.invalid">
|
||||
<segment state="translated">
|
||||
<source>part.generate_image.flash.invalid</source>
|
||||
<target>No valid image was generated. Please try again.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_size" name="tools.value_calc.size">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.size</source>
|
||||
<target>Size</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_r_power" name="tools.value_calc.resistor.power">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.power</source>
|
||||
<target>Power rating</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_smd_package" name="tools.value_calc.smd.package">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.smd.package</source>
|
||||
<target>Package size</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_cap_diameter" name="tools.value_calc.cap.diameter">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.cap.diameter</source>
|
||||
<target>Diameter</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_cap_pitch" name="tools.value_calc.cap.pitch">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.cap.pitch</source>
|
||||
<target>Pitch</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_cap_voltage" name="tools.value_calc.cap.voltage">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.cap.voltage</source>
|
||||
<target>Voltage</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_cap_shape" name="tools.value_calc.cap.shape">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.cap.shape</source>
|
||||
<target>Shape</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_cap_shape_disc" name="tools.value_calc.cap.shape.disc">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.cap.shape.disc</source>
|
||||
<target>Disc</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_cap_shape_blob" name="tools.value_calc.cap.shape.blob">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.cap.shape.blob</source>
|
||||
<target>Blob (MLCC)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_cap_lead" name="tools.value_calc.cap.lead">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.cap.lead</source>
|
||||
<target>Leads</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_cap_lead_short" name="tools.value_calc.cap.lead.short">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.cap.lead.short</source>
|
||||
<target>Short</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_cap_lead_medium" name="tools.value_calc.cap.lead.medium">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.cap.lead.medium</source>
|
||||
<target>Medium</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_cap_lead_long" name="tools.value_calc.cap.lead.long">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.cap.lead.long</source>
|
||||
<target>Long</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_r_result" name="tools.value_calc.resistor.result">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.result</source>
|
||||
<target>Resistance</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_r_fromvalhelp" name="tools.value_calc.resistor.from_value_help">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.from_value_help</source>
|
||||
<target>Enter a resistance value to generate the color bands. You can use suffixes like k, M and the RKM notation (e.g. 4k7).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_r_fromval" name="tools.value_calc.resistor.from_value">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.from_value</source>
|
||||
<target>Value to color code</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_r_apply" name="tools.value_calc.resistor.apply_value">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.resistor.apply_value</source>
|
||||
<target>Generate bands</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_c_intro" name="tools.value_calc.capacitor.intro">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.capacitor.intro</source>
|
||||
<target>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).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_s_onchiphelp" name="tools.value_calc.smd.on_chip_help">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.smd.on_chip_help</source>
|
||||
<target>Edit any field to update the others. Use the radio to pick which code is printed on the chip.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_f_value" name="tools.value_calc.field.value">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.field.value</source>
|
||||
<target>Value</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_f_code" name="tools.value_calc.field.code">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.field.code</source>
|
||||
<target>Code</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_f_tol" name="tools.value_calc.field.tolerance">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.field.tolerance</source>
|
||||
<target>Tolerance</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_f_power" name="tools.value_calc.field.power">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.field.power</source>
|
||||
<target>Power</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_f_ppm" name="tools.value_calc.field.ppm">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.field.ppm</source>
|
||||
<target>Temp. coeff. (ppm/K)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_f_code3" name="tools.value_calc.field.code3">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.field.code3</source>
|
||||
<target>3-digit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_f_code4" name="tools.value_calc.field.code4">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.field.code4</source>
|
||||
<target>4-digit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_f_eia96" name="tools.value_calc.field.eia96">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.field.eia96</source>
|
||||
<target>EIA-96</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vcalc_f_onchip" name="tools.value_calc.field.on_chip">
|
||||
<segment state="translated">
|
||||
<source>tools.value_calc.field.on_chip</source>
|
||||
<target>Printed on the chip</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GNWhoTW" name="part.table.eda_reference">
|
||||
<segment state="translated">
|
||||
<source>part.table.eda_reference</source>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue