Herrajes (Pedido · Catálogo · Reglas)
Tutorial paso a paso del panel de Herrajes de Despiece:
armar el pedido con su catálogo de SKUs y un motor de reglas
que mapea rol de pieza × intervalo mm a cantidad automática.
Se abre desde el botón Herraje de la topbar (no tiene
atajo de teclado propio — la callback onHerraje solo
está cableada al #tb-herraje y al item de menú
correspondiente).
despiece.herrajeria.v1 con 29 SKUs default +
customs, despiece.hardware-rules.v1 con 7 reglas
default). El pedido por proyecto está en
parts.hardwareList con líneas
{skuCode, qty, origin: 'manual' | 'auto'}.
Aplicar reglas regenera solo las líneas auto; las
manual quedan intactas.
§ 0 Modelo mental: tres capas para responder "¿cuántas bisagras tengo que pedir?"
Antes de meternos en la UI, vale la pena tener clara la pregunta de negocio. Cuando un taller importa un modelo con 12 puertas y 6 estantes y abre Excel paralelo, lo que está haciendo a mano es:
- Recorrer las puertas una por una, medir la altura y ver en qué tramo cae (2 bisagras hasta 1500 mm, 3 hasta 2200 mm, 4 por encima) para saber cuántas bisagras lleva cada una.
- Sumar un tirador por puerta, sumar 4 pines por estante, etc.
El módulo de Herrajes hace exactamente eso, pero con datos configurables en vez de a ojo. Hay tres capas independientes:
┌─────────────────────────────────┐ ┌──────────────────────────────────┐
│ Catálogo (localStorage taller) │ │ Reglas (localStorage taller) │
│ HerrajeItem.code/name/cost/cat │ │ HardwareRule.role × [min,max) │
│ │ │ → sku × qtyPerPart │
└────────────┬────────────────────┘ └─────────────┬────────────────────┘
│ │
└──────────────────┬───────────────────┘
▼
applyHardwareRules() ← parts.partRole + dims
│
▼
parts.hardwareList: HardwareLine[]
│
┌───────────┬───────────┼───────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
Pedido Etiquetas Presupuesto Export CSV Export XLSX
- El catálogo es la fuente de verdad de qué existe y a qué precio (los SKUs de tu proveedor).
- Las reglas son la fuente de verdad de qué cantidad va por pieza (tu saber hacer de taller, parametrizable).
- El pedido (
parts.hardwareList) es la materialización en este proyecto: la suma de(catálogo × reglas)para cada pieza, más cualquier línea manual que hayas agregado.
Las tres capas viven en lugares distintos (catálogo y reglas son del taller; pedido es del proyecto). Eso permite que cambies un precio en el catálogo y se recalculen subtotales sin tocar reglas, y que cambies una regla y se regenere el pedido sin tocar el catálogo.
01 Cargar el modelo y abrir Herraje
Al importar el STEP/IGES/BREP/glTF/GLB, el botón Herraje de la topbar se habilita. La primera vez que abrís Herraje en este proyecto, el panel se inicializa con:
- Pedido vacío (hardwareList sin líneas).
- Catálogo con 29 SKUs precargados de fábrica (
DEFAULT_HERRAJERIA_ITEMSensrc/herrajerie/catalog-defaults.ts). - Reglas con 7 reglas default (
defaultHardwareRules()ensrc/herrajerie/rules-defaults.ts).
loadHerrajeriaCatalog()
y loadHardwareRules() leen localStorage; si está
vacío, devuelven los defaults sin red ni sincronización. Una
vez cargado, todo lo que edites ahí se persiste en este
navegador.
El panel es full-screen modal con shell
.herraje-sheet y tres vistas segmentadas
Pedido · Catálogo · Reglas. Las vistas son exclusivas —
cambiar de tab destruye y re-monta el contenido de la vista target
(en vez de tenerlas apiladas).
02 Vista Pedido: lista del proyecto
La vista Pedido muestra state.project.hardwareList
con columnas SKU · Nombre · Cant · Unidad · Costo unit · Subtotal ·
Notas. La toolbar tiene:
| Botón / selector | Acción |
|---|---|
#herraje-list-search | Buscador live con autocomplete contra el catálogo (código + nombre). |
data-action="add" | + Línea: inserta una fila nueva vacía para que escribas el SKU manualmente. |
data-action="apply-rules" | Aplicar reglas (paso 3). |
data-action="groupby-flat" | "groupby-assembly" | Switch Plano / Por mueble (subtotal por assembly). En Por mueble, las filas de una línea auto repartida entre dos o más muebles son de sólo lectura. |
Cada línea tiene una provenance badge:
auto (lila / teal, según el tema) indica que la línea
fue generada por aplicar reglas; manual (gris)
indica que la escribiste a mano. Las dos conviven en la misma
tabla sin separarse.
Arriba de la tabla puede aparecer un aviso: el listado guardado ya
no coincide con las reglas o las piezas actuales. Es el recordatorio
de volver a apretar Aplicar reglas para regenerar las líneas
auto.
2.1 · Anatomía de una HardwareLine
Detrás del row visible hay un objeto HardwareLine
con estos campos:
| Campo | Tipo | Origen | Quién lo edita |
|---|---|---|---|
id | uuid v4 | generado al crear | inmutable |
skuCode | FK string al HerrajeItem.code | catálogo (FK) o tipeado | manual al crear |
qty | number ≥ 1 | manual o auto | manual + Aplicar reglas |
origin | 'manual' | 'auto' | sistema | sólo Apply lo cambia |
notes | string opcional | manual | manual de la línea |
unitCost | number opcional (override) | manual | manual de la línea |
auto no es decorativo:
es el dato que usa applyHardwareRules para saber
qué líneas descartar antes de regenerar (apply-rules.ts:112-116:
existing.filter(l => l.origin !== 'auto')). Si
ves un row auto mal en tu CSV de proveedor, la
solución no es borrarlo a mano — es ajustar la regla, volver
a aplicar y dejar que el motor lo regenere.
03 Aplicar reglas — el motor de 7 fases
auto a partir de las piezas del modelo + las reglas del catálogo. Líneas manual se preservan intactas.
El botón llama runApplyHardwareRules()
(src/herrajerie/run-apply.ts:24-49) que termina en
applyHardwareRules(...) en
src/herrajerie/apply-rules.ts. El motor recorre 7
fases, cada una con un rol claro.
3.1 · Clamp del multiplicador (apply-rules.ts:108-110)
const qtyMul = quantityMultiplier && Number.isFinite(quantityMultiplier) && quantityMultiplier > 1 ? Math.floor(quantityMultiplier) : 1;
Defensivo: aunque el caller pase 0, NaN,
2.7 o negativo, el motor trata todo lo "no es
entero ≥ 2" como 1 (sin escalado). Esto preserva la compat con
proyectos y tests pre-multiplier (ver spec
2026-07-17-herraje-multiplier.md).
3.2 · Loop exterior: por cada pieza (apply-rules.ts:132-182)
Filtros en orden, cortocircuitando en cuanto uno falla:
visibleOnly && part.object?.visible === false→ skip (respeta el flag de Settings → Export).role = part.partRole ?? 'none'; si es'none'o vacío → skip (la pieza no aporta nada al herraje).
3.3 · Cómputo de X (apply-rules.ts:138)
const x = boardDimensions(
part.dimXmm, part.dimYmm, part.dimZmm
).x;
x es la cara mayor del AABB de la pieza
(boardDimensions() en
src/export-bom/board-dimensions.ts). Una puerta
600×1800×18 → X=1800, Y=600. Justificación de diseño:
las reglas de bisagras son por altura de puerta, y X
resulta ser la dimensión mayor de la cara. Limitación
documentada: la spec v2 sólo cubre X; una futura v3 podría
cubrir Y (ancho) y Z (espesor).
3.4 · Loop interior: por cada regla (apply-rules.ts:142-179)
Para cada pieza, se iteran las reglas aplicando filtros en orden:
!rule.enabled→ skip. Las reglas deshabilitadas ni aportan qty ni participan en el overlap check (sólo "reservan" rangos futuros).role !== rule.role→ skip. La pieza no es del rol que la regla matchea.seenSku.has(rule.skuCode)→ skip. Defensa runtime: si dos reglas enabled matchean el mismo SKU en la misma pieza (no debería pasar si la validación de solapes está OK), gana la primera en orden del array.!inRange(x, rule.minMm, rule.maxMm)→ skip. Intervalo half-open[min, max)(semi-abierto:minMm ≤ x < maxMm).!knownSkuCodes.has(rule.skuCode)→skippedOrphanSku++, continuar. Una regla que apunta a un SKU borrado del catálogo no rompe la corrida, sólo se cuenta aparte.
Por qué intervalos semi-abiertos [0, 1500) ∪ [1500, 2200) ∪ [2200, ∞)
Una pieza de X=1500 mm cae en la regla [1500, 2200),
NO en [0, 1500). Si los rangos fueran
cerrados en ambos lados ([0, 1500] ∪ [1500, 2200]),
los bordes se solaparían en 1500 y la validación rechazaría las
reglas por overlap. Definición formal en
rules-interval.ts:7-11:
export function inRange(x, minMm, maxMm) { if (minMm !== undefined && x < minMm) return false; if (maxMm !== undefined && x >= maxMm) return false; return true; }
El helper rangesOverlap() (rules-interval.ts:17-27)
usa la condición aLo < bHi && bLo < aHi
para detectar solapes; los adyacentes (que tocan en el extremo
compartido) NO se consideran solapados gracias al
half-open.
3.5 · Acumulación (apply-rules-match.ts + apply-rules-emit.ts)
Si todos los filtros pasan:
// apply-rules-match.ts (1) stats.pieces++; partsBySku.set( rule.skuCode, (partsBySku.get(rule.skuCode) ?? 0) + 1 ); seenSku.add(rule.skuCode); matchedThisPart = true; // apply-rules-emit.ts (2) const groups = Math.floor(stats.pieces / (rule.perPieces ?? 1)); if (groups > 0) qtyBySku.set( rule.skuCode, (qtyBySku.get(rule.skuCode) ?? 0) + groups * rule.qtyPerPart * qtyMul );
qtyMul) entra acá —
escala el cómputo, NO la regla. La regla sigue diciendo
"2 bisagras por puerta"; lo que cambia es el factor de la obra.
Con perPieces la suma no es por pieza: el motor
cuenta primero cuántas piezas matchea cada regla y sólo después
emite floor(piezas / perPieces) × qtyPerPart × qtyMul.
Un resto incompleto no emite nada, y un SKU que queda en 0 no emite
línea. Son dos pases —apply-rules-match.ts cuenta,
apply-rules-emit.ts emite— porque la cantidad no existe
hasta terminar de recorrer el modelo.
3.6 · Breakdown por ensamblaje (opt-in, apply-rules.ts:165-178)
Cuando withAssemblyBreakdown: true y
partAncestors están poblados (cómputo en
computeAssemblyGroups + tree-builder +
findAncestorAssemblyIds):
- Leaf-only: la pieza se cuenta UNA sola vez, atribuida al asmId más profundo (
asmIds[length-1]). Un cajón dentro de COCINA no duplica en COCINA. - Piezas ausentes del mapa se ignoran para breakdown (igual contribuyen a
qtyBySkuglobal).
3.7 · Reemplazo selectivo (apply-rules.ts:112-116, 184-196)
const kept = qtyMul > 1 ? existing.filter(l => l.origin !== 'auto') .map(l => ({ ...l, qty: l.qty * qtyMul })) : existing.filter(l => l.origin !== 'auto'); const autoLines = []; for (const [skuCode, qty] of qtyBySku) { autoLines.push({ id: uuid(), skuCode, qty, origin: 'auto', notes: `auto · ${n} piezas`, }); } return { nextList: [...kept, ...autoLines], summary };
nextList = [...manual (× qtyMul), ...autoLines (× qtyMul)].
Las líneas manual no se tocan salvo por el
escalado del multiplicador. Las auto se descartan
y se regeneran limpias en cada Apply. Re-aplicar no duplica
auto — siempre una línea por SKU, no por pieza.
3.8 · Summary devuelto (apply-rules.ts:198-223)
partsMatched → piezas que aportaron ≥1 regla
linesAuto → líneas auto emitidas (1 por SKU)
qtyTotal → suma qty de las auto (post-multiplier)
skippedOrphanSku → matches omitidos por SKU fuera de catálogo
byAssembly? → opt-in; Map<skuCode, Map<asmId, { partIds, qty }>>
3.9 · Worked example end-to-end
Proyecto CocinaV9: 3 puertas con role
puerta, ninguna asignada a mano aún.
pieza A: 600×500×18 → X=600 → rule-puerta-bis-0 [0,1500) → BIS-35 × 2 pieza B: 1800×600×18 → X=1800 → rule-puerta-bis-1 [1500,2200) → BIS-35 × 3 pieza C: 2400×900×18 → X=2400 → rule-puerta-bis-2 [2200,+∞) → BIS-35 × 4 + cada puerta también matchea rule-puerta-tir (sin rango, cualquier alto) → TIR-J × 1
Aplicar reglas (multiplier=1) produce:
partsMatched: 3
linesAuto: 2 (BIS-35 + TIR-J)
qtyTotal: 12 (9 + 3)
skippedOrphanSku: 0
state.project.hardwareList =
{ skuCode: 'BIS-35', qty: 9, origin: 'auto', notes: 'auto · 3 piezas' }
{ skuCode: 'TIR-J', qty: 3, origin: 'auto', notes: 'auto · 3 piezas' }
Si activás herrajeMultiplierEnabled +
projectMultiplier=3 (mismo proyecto, "3 cocinas
iguales"), el mismo input produce qtyTotal: 36 con
las mismas 2 líneas, sin editar reglas.
04 Vista Catálogo: 29 SKUs precargados
La vista Catálogo muestra
loadHerrajeriaCatalog() con las 9 categorías:
| Categoría | Built-ins | Ejemplos |
|---|---|---|
bisagra | 5 | BIS-35, BIS-PREMIUM, BIS-PIV, BIS-VIDRIO-LO, TIP-ON |
corredera | 6 | COR-TEL-30/35/40/45/50, COR-TEL-CUSTOM |
tirador | 4 | TIR-J, TIR-U, TIR-EMB, TIR-BOTON |
union | 4 | GRP-UNI, GRP-CONF, TAR-DOWEL, TORN-30 |
soporte | 3 | PIN-EST-5, PIN-EST-7, SOP-PERCH |
cerradura | 2 | LOCK-CAJ, LOCK-PTA-EMB |
pata | 2 | PATA-REG, RUEDA-GIR |
kit | 1 | KIT-COR (riel + 2 roldanas) |
otro | 2 | PERCHERO-TUB, ZAPATERO (los dos a medida) |
Los tres últimos (SOP-PERCH, PERCHERO-TUB,
ZAPATERO) entraron para que
add_clothes_rail y add_shoe_rack no emitan
códigos que no existen en el catálogo.
Las 9 categorías viven en
src/herrajerie/catalog-defs.ts:HERRAJE_CATEGORY_DEFS
con sus aliases por locale. Las 3 unidades son
unidad | par | juego. El CRUD es inline (celda
por celda con autocomplete) — no hay modal de edición.
code es slug-like, mayúsculas, guiones
(la normalización vive en normalizeCode() en
types.ts:136-138: trim + uppercase + colapsa
espacios a -). Es la FK que las reglas y el pedido
referencian — si lo cambiás, tenés que remapear las reglas y
líneas a mano.
Con el SKU en uso por el proyecto abierto, el code es
el único campo bloqueado: precio, nombre, categoría, unidad,
familia, notas y medidas se editan igual. Al cambiar el
cost, las líneas sin override propio toman el precio
nuevo; las que tienen override lo conservan. Borrar el SKU sí sigue
bloqueado mientras haya líneas usándolo.
4.1 · Por qué catálogo ≠ reglas
El catálogo es la fuente de verdad de precios: cambiar
cost de BIS-35 recalcula subtotales
en todo el proyecto automáticamente, sin tocar las reglas. Las
reglas son la fuente de verdad de cantidades: cambiar
una regla regenera líneas auto sin tocar el
catálogo. Mantener estas dos dimensiones separadas es lo que
permite ajustar el precio de tu proveedor sin invalidar tu
conocimiento de taller.
05 Roles sugeridos por SKU
none). Togglea y el commit es inmediato (fila existente) o marca la lista para la fila nueva.
Cada HerrajeItem carga un array roles: string[]
con los roles sugeridos (qué roles de pieza típicamente
usan este herraje). Por ejemplo, BIS-35 arranca con
['puerta'], PIN-EST-5 con
['estante'], PATA-REG con ['base'].
El catálogo herraje es la fuente de verdad para el popover:
los roles son los mismos del módulo Roles (built-ins + customs de
getRoleDefs()). El sentinel 'none' se filtra.
5.1 · Distinción clave: sugerencia vs dato real
Tres campos con nombres similares pero responsabilidades distintas:
HerrajeItem.roles: ['puerta'] ← sugerencia VISUAL (catálogo) part.partRole: 'puerta' ← dato REAL de la pieza (modelo) HardwareRule.role: 'puerta' ← filtro de la regla (regla) El motor lee part.partRole (apply-rules.ts:135) El catálogo sugiere. (UI badge "sugerido para puerta") La pieza decide. (Shift+B, edición manual, auto-detect) La regla filtra. (overlap check, half-open interval)
part.partRole de cada pieza. Los roles
del catálogo son sólo sugerencia visual para que el
taller sepa "este herraje va con piezas de rol X". Si tu
pieza tiene rol puerta y existe la regla
(puerta, BIS-35, 0–1500 mm, 2 u.), la pieza suma 2 a
BIS-35 al aplicar reglas.
06 Vista Reglas: rol → SKU × intervalo mm
La vista Reglas muestra
loadHardwareRules() con 7 reglas default del taller
(definidas en src/herrajerie/rules-defaults.ts):
El campo x (eje mayor de la pieza en mm) cae en
cada regla vía inRange(x, minMm, maxMm). Los bordes
son semi-abiertos (minMm <= x < maxMm) y
minMm/maxMm son opcionales: sin
minMm el match arranca en 0; sin maxMm
el match va a infinito. La tercera regla puerta
(≥ 2200 mm) tiene minMm: 2200 y
maxMm indefinido — cubre cualquier pieza arriba de
2200 mm.
6.1 · Función conceptual de una regla
Si tenemos que reducir mentalmente "Aplicar reglas" a una sola función, es:
f(role, X) → qty del SKU
↑ ↑
│ └─ boardDimensions(p).x (cara mayor)
└───── part.partRole ?? 'none'
Una regla es una fila de la "tabla de despacho": si el rol
matchea y el alto cae en el rango, suma qtyPerPart
al acumulador del SKU. La pieza pasa por todas las reglas
activas; cada SKU que matchea suma una vez por pieza
(no se duplica si dos reglas enabled solapan, ver §3.4 punto 3).
6.2 · Cobertura de las 7 default y por qué esos números
- Bisagras por rango de altura: una puerta típica de placard en AR PyME lleva 2 bisagras hasta 1500 mm (puerta de bajo-mesada o alacena), 3 entre 1500 y 2200 mm (puerta alta de placard), 4 a partir de 2200 mm (puerta de piso a techo, vestidor). Los tres rangos semi-abiertos cubren
[0, +∞)sin solapar. - Tirador universal: 1 por puerta, sin importar alto. Una regla sin
minMm/maxMmmatchea cualquier X. - Estantes: 4 pines por estante (soporte estándar de melamina). Sin rango.
- Base: 4 patas por base (mueble de piso). Sin rango.
- Correderas de cajón: 1 par por cajón, no por lateral. La regla de
lateral_cajonllevaCant / pieza1 yCada N piezas2, así los dos laterales de un cajón piden un solo par y un lateral suelto no pide nada.
6.3 · Cómo agregar una regla
Para "puerta alta de cocina, lleva 5 bisagras a partir de 2600 mm", crear:
role: 'puerta' sku: 'BIS-35' minMm: 2600 maxMm: (vacío = ∞) qty: 5
La cobertura [0, ∞) de las 3 default queda:
[0, 1500) ∪ [1500, 2200) ∪ [2200, +∞). La nueva
[2600, +∞) se solapa con la última
puerta BIS-35 ([2200, +∞)); al guardar,
la validación va a disparar overlap salvo que
deshabilites una de las dos. Diseñá reglas disjuntas
(half-open adyacentes o none).
07 Crear regla custom
+ Nueva regla agrega una fila con id vacío
(addHerrajeriaRule() en
src/herrajerie/rules-store.ts). La fila draft está
forzada a enabled: false hasta completar rol y
SKU; el resto se puede dejar incompleto mientras tipeás.
Cuando completás los campos y guardás,
validateHardwareRules corre antes de persistir
(rules-validate.ts:140-151):
| Código de error | Cuándo | Ejemplo disparador |
|---|---|---|
invalid_id | El id está vacío o sólo whitespace. | Draft recién agregado antes de tocar nada |
invalid_role | Rol no está en el catálogo de roles conocidos (built-ins + customs), o es 'none'. | rol puert (typo, falta la a) |
unknown_sku | El SKU no está en el catálogo de herrajes del taller. | BIS-99 que el taller borró del catálogo |
invalid_qty | qtyPerPart no es entero 1–999. | 2.5 (decimal), 0 (en regla enabled), 1000 (fuera de rango) |
invalid_per_pieces | perPieces no es entero ≥ 1. | 0, 2.5, -1 |
invalid_min / invalid_max | Borde negativo o no finito. | minMm: -5, maxMm: NaN |
invalid_range | maxMm ≤ minMm. | min: 600, max: 600 (punto único) |
overlap | Dos reglas enabled con mismo (rol, sku) y rangos solapados. | [0, 600) + [500, 700) con mismo rol+sku |
forceWriteHardwareRules en
rules-store.ts:131) para que no se pierdan al
recargar. Sólo se bloquea enabled; si tenés 6 reglas
OK y agregás una draft rota, no se pierden las 6 — sólo la
nueva queda enabled: false hasta que la
termines. Esto permite agregar una fila "placeholder" sin
invalidar el resto del taller.
08 Multiplicador de obra (×N cocinas)
settings.herrajeMultiplierEnabled + projectMultiplier (1–99) escala TODAS las líneas (manual + auto) sin tocar las reglas. Útil para cotizar un lote de N cocinas iguales sin duplicar el modelo.
El multiplicador de obra vive en dos settings:
settings.herrajeMultiplierEnabled— toggle en Settings → Herraje / stock (defaultfalse).settings.projectMultiplier— entero 1–99 (default 1).
Cuando el flag está activo,
runApplyHardwareRules escala la qty de todas
las líneas resultantes (manual + auto) por el multiplicador. La
separación conceptual es clave:
SIN multiplier ×3 CON multiplier ×3 ───────────────────── ───────────────────── qtyBySku = Σ qtyPerPart qtyBySku = Σ qtyPerPart × 3 manual[i].qty = N manual[i].qty = N × 3 rule.qtyPerPart = 2 rule.qtyPerPart = 2 (sin tocar)
// apply-rules.ts (con multiplier) const qtyMul = quantityMultiplier && quantityMultiplier > 1 ? Math.floor(quantityMultiplier) : 1; const kept = qtyMul > 1 ? existing.filter(l => l.origin !== 'auto') .map(l => ({ ...l, qty: l.qty * qtyMul })) : existing.filter(l => l.origin !== 'auto'); // también: qtyBySku acumula con rule.qtyPerPart * qtyMul
NO escala qtyPerPart de las reglas — la regla
sigue siendo «por pieza». Lo que se escala es el cómputo. Así
un cambio posterior en el multiplicador (ej. pasar de ×2 a ×3)
no rompe las reglas custom del taller.
2026-07-17-herraje-multiplier.md) fija el default
en false para preservar el comportamiento
histórico (proyectos y tests existentes NO multiplican).
Cuando agregás projectMultiplier>1 a un
proyecto viejo, el herraje también se multiplica sólo si
activás explícitamente el flag en Settings.
09 Settings: Export / Import JSON · library share
herrajeria.json con catálogo + recientes. Importar JSON lo reemplaza. Compartir enlace… encodea el catálogo a base64+envelope despiece-library (cap 65 KB) para pasarlo por WhatsApp.
Hay tres niveles de I/O para el herraje:
| Nivel | Salida | Comando |
|---|---|---|
| Per-panel JSON | herrajeria.json (catálogo + recientes) | Catálogo → Exportar JSON / Importar JSON |
| Library share | URL con catálogo encodeado (despiece-library envelope v1, gzip+base64) | Catálogo → ⋯ → Compartir enlace / Pegar enlace |
| Configuración I/O | config.zip con TODO (catálogo + reglas + fotos + materiales + roles + máquinas + stock + glue-up + lineales) | Settings → Exportar / Importar |
El library share tiene cap 65 KB por la limitación de URL —
si tu catálogo supera eso, va como .zip con las fotos
adjuntas. Cada SKU puede tener una foto en
localStorage[despiece.herraje-images.v1] que se incluye
en el I/O.
9.1 · Mapa de persistencia por capa
| Dato | Dónde | Bump PROJECT_FORMAT_VERSION |
|---|---|---|
| Catálogo de herrajes | localStorage[despiece.herrajeria.v1] | N/A |
| Reglas de taller | localStorage[despiece.hardware-rules.v1] | N/A |
| Fotos de herrajes | localStorage[despiece.herraje-images.v1] | N/A |
Settings (incl. herrajeMultiplierEnabled) | localStorage[despiece.settings.v1] | N/A |
Pedido del proyecto (parts.hardwareList[]) | .despiece (per-proyecto) | no (soft) |
parts.hardwareRulesMode / hardwareRules | .despiece (legacy, soft) | no |
herrajeriaCatalog),
las reglas (hardwareRules) y la lista de herraje
(hardwareList) no bumpan
PROJECT_FORMAT_VERSION — son soft fields que se
migran con sane defaults si el modelo tiene un valor legacy.
10 Atajos de teclado
Help (botón de la topbar o ?) abre la hoja completa. Herraje no tiene atajo propio — todo va por la UI.
Atajos del flujo de herraje:
| Atajo | Acción |
|---|---|
| Click en #tb-herraje | Abrir / cerrar el panel de Herraje (toggleHerrajePanel). |
| Esc | Cerrar el panel (no bloquea si hay un export en curso). |
| En el buscador | Type-ahead con autocomplete; Enter agrega el primer resultado o el SKU tipeado. |
| Click en SKU de catálogo | Selecciona + resalta su fila (drag & inline edit). |
| Click en Aplicar reglas | Regenera las líneas auto (sin tocar las manual). |
| ? / F1 | Abrir la hoja de atajos completa. |
| F / Home | Fit / Fit + iso del viewport 3D. |
En Mac el modificador de sistema es ⌘; en otros sistemas Ctrl.
11 Por conjunto (assembly breakdown)
Además del modo Plano, el switch groupby-assembly
en la toolbar de Pedido abre la vista Por mueble: las
líneas auto se reparten por ensamblaje del product
tree y muestran subtotal por grupo.
Para obras con varios muebles (ej. una cocina con bajo-mesada + alacena + cajonera), este modo te dice qué bisagras van a la alacena y cuáles al bajo-mesada:
- Filas de sólo lectura: si una línea
autose reparte entre dos o más muebles, sus filas acá no se editan — las varias filas comparten una sola línea guardada, y editar una sobrescribía la línea entera. Editala o quitala en el modo Plano. - Construcción:
computeAssemblyGroups(src/herrajerie/assembly-grouping.ts:36-84) arma el product tree víabuildTree(m.model, state.assemblyNames.names)y mapea cada pieza a supartAncestors: partId → [root...leaf]. - Atribución leaf-only: la pieza se cuenta UNA sola vez, atribuida al asmId más profundo. El sub-conjunto
cajónqueda en "cajón", no en "Mueble cocina". Ver spec2026-07-17-herrajerie-group-by-assembly.md. - Subtotales por grupo:
AssemblyGroup.subtotal = Σ(rows.subtotal)se precomputa para no recalcular en cada render. - Manual siempre aparte: las líneas
origin: 'manual'caen en el grupo(Manual)(MANUAL_GROUP_ID), separadas de las auto, en orden estable (manual al final, el resto alfabético por label). - Multiplier: el breakdown
(sku, asm) → qtytambién escala porprojectMultipliercuandoherrajeMultiplierEnabledestá on.
12 Pipeline integrado
El herraje no termina en el panel: alimenta varios consumidores
aguas abajo, todos leyendo state.project.hardwareList
o sus derivados:
| Consumidor | Qué lee | Para qué |
|---|---|---|
Pedido de materiales (hardwareCost) | buildHardwareRows(list, catalog) | Suma de subtotales herraje en el pedido al proveedor |
Etiquetas (label-section) | state.project.hardwareList agrupado por (sku, asm) | Breakdown por mueble en etiquetas |
| Presupuesto al cliente | state.project.hardwareList con unitCost | Sección "Herraje" del PDF branded |
| Export CSV heredado | filas planas del listado | Descarga CSV |
| Export XLSX | filas + estilos por categoría | Planilla editable |
| Export PDF | filas agrupadas por categoría | Pedido al proveedor |
runApplyHardwareRules pre-escala las qty (manual +
auto), todos los consumidores leen qty ya multiplicada — no
requieren enterarse del flag. Cambiar
herrajeMultiplierEnabled retroactivo requiere
re-aplicar reglas para que las auto vuelvan a escalar; las
manuales se editan a mano (decisión de spec).
Ver también
- Tutorial · Cantos y auto-cantos
- Tutorial · Roles y auto-roles
- Tutorial · Optimización de corte
- Tutorial · Plano de taller
- Tutorial · Materiales del taller
- Spec 2026-07-16-bom-herrajerie-simple.md — modelo de datos del herraje + BOM (v1, manual).
- Spec 2026-07-17-hardware-role-rules.md — motor de reglas rol × intervalo → SKU × qty (v2, auto-derivación).
- Spec 2026-07-17-herrajerie-group-by-assembly.md — breakdown por mueble.
- Spec 2026-07-17-herraje-multiplier.md — multiplicador de obra.
- Spec 2026-07-18-herraje-library-share.md — library share URL.