# Tutorial · Materiales del taller

Tutorial paso a paso del **catálogo de materiales** de Despiece: el
chip de la fila plana, el picker (grid de tiles + secciones
*Proyecto · Recientes · Catálogo*), el form **Otro** con toggle
**Color | Textura**, el tab *Materiales del taller* en Configuración
(catálogo built-in + customs + recientes + form con el mismo toggle),
y cómo alimenta al Nest / BOM / Pedido / Etiquetas.

El modelo de datos es un **string de taller** canónico
`"Melamina MDP · Petiribí"` (tipo + simil) por pieza, persistido en
`parts.materials?: Record<partId, string>`. El catálogo vive en
`localStorage[despiece.materials.v1]` con *recent* (MRU 30),
*favorites* y *custom* (≤ 80). Matching por
`normalizeMaterialKey` (trim + colapsar whitespace + lowercase). Cada
material puede tener apariencia **color** (un hex) o **textura**
(un `textureId` cuyo blob vive en IndexedDB v4 store
`materialImages`), nunca ambos — la regla XOR vive en
`effectiveAppearance()`.

---

## 1. Cargar el modelo y el chip de material

![Vista general con el chip de material en una fila](img/01-overview-materiales.jpg)

Al importar el STEP/IGES/BREP/glTF/GLB, la sidebar izquierda muestra el
árbol jerárquico. En la lista plana, cada pieza expone un chip de
material (selector `.sb-material-btn`) en la misma línea que el de rol,
veta y cantos.

Internamente el chip vive en
`src/ui/product-tree/row-chips.ts:buildMaterialButton`. Lee
`part.material`, consulta `swatchForMaterial()` para el color, y
renderiza un dot del color del catálogo. Si no hay material asignado,
el chip queda con el ícono de la paleta en gris (sin dot). Si el
material resuelto tiene apariencia *texture*, el dot se reemplaza por
un thumbnail 16×16 que tira de `materialImages` (vía
`getMaterialImageBlob`).

## 2. Picker: grid de tiles + secciones

![Picker de materiales](img/02-picker-materiales.jpg)

Click en el chip de material abre el picker (handler en
`src/ui/material-picker.ts:openMaterialPicker`). Es un **sheet** de
680 px de ancho con:

- **Chrome sticky** arriba (no hace scroll): título + banner bulk
  (cuando aplica) + input de filtro + close.
- **Body** scrollable: tile grid + secciones *Proyecto · Recientes ·
  Catálogo (agrupado por `BoardKind`)* + botón **+ Otro…** que
  expande un form debajo.

El grid usa `grid-template-columns: repeat(auto-fill, minmax(104px, 1fr))`
con `gap: 10px` (`src/ui/material-picker/list.css`). Cada tile es
`.mat-picker-chip--tile` (mínimo 92 px de alto) y arranca con un
preview 48×48 + label. El preview aplica la regla **XOR** (texto +
símbolo): si la def resuelve a apariencia `texture` y tiene
`textureId`, se renderiza un `<img class="mat-picker-preview--texture">`
cuyo `src` se hidrata async vía `getMaterialImageBlob`. Si no, se
pinta un `<span class="mat-picker-preview--color" style="background:#…">`
con el hex. Ver `chipPreviewHtml()` en
`src/ui/material-picker-html.ts:25`.

Las secciones se arman con `listMaterialChoices()`:

| Sección | Origen | Límite |
|---------|--------|--------|
| **Proyecto** | `projectMaterialsFromParts(parts)` — los materiales únicos ya asignados en el modelo cargado. | sin límite explícito |
| **Recientes** | `localStorage[despiece.materials.v1].recent` (MRU). | 30 (`MAX_RECENT`) |
| **Catálogo** | `listMaterialDefs()` = customs + 26 built-ins (Faplac-style) no overridden. | 80 customs (`MAX_CUSTOM`) + 26 built-ins |

El buscador (sticky arriba) usa `applyFilter()` que hace
`includes(q)` en `value` y `textContent`; las secciones que no
matchean se ocultan solas (`[data-mat-section][hidden]`) vía el
propagador de `host.querySelectorAll`.

> Click afuera (scrim) o `Esc` lo cierra sin aplicar nada (`null`).
> El handler en `apply-material.ts:pickAndApplyMaterial` detecta esa
> cancelación y no toca el modelo.

## 3. *Otro*: crear material al vuelo con Color | Textura

![Form Otro con toggle Color|Textura](img/02b-otro-form.jpg)

Al final del picker hay un botón `+ Otro…` (`.mat-picker-other-toggle`).
Click lo expande y muestra el form inline, **idéntico al de Settings
→ Materiales**:

| Campo | Selector | Notas |
|-------|----------|-------|
| Tipo de tablero | `.mat-picker-kind` | uno de los 7 `BoardKind` built-in; `melamina_mdp` por default. |
| Simil / acabado | `.mat-picker-input` | texto, máx 80 chars, trim + colapsar whitespace, no vacío. |
| **Apariencia** | `.mat-picker-active-type` | toggle **Color \| Textura** — XOR (ver `effectiveAppearance()`). Sólo se muestra el bloque activo. |
| Color (si Color) | `.mat-picker-color` | color picker HTML5, default `#cccccc`. |
| Textura (si Textura) | `.mat-picker-texture-file` | file input PNG/JPEG/WEBP. Al subir, se guarda como blob en IndexedDB y se muestra preview. |
| Botones | `[data-kind="apply-other"]` / `cancel-other` | aplican o cancelan. |

`applyOther()` (en `src/ui/material-picker-other.ts:142`) llama a
`addCustomMaterial({kind, finish, swatch, textureId, activeType})` y
al success cierra el picker con la nueva def ya en *Catálogo* y
*Recientes*. Si el simil matchea un built-in, el form rechaza con
*Ese material ya está en el catálogo fijo* (`addCustomMaterial`
devuelve `{ok: false, error: 'duplicate'}`).

> El mismo form vive en **Configuración → Materiales** (paso 7);
> cualquier custom creado desde el picker aparece inmediatamente en
> el catálogo y viceversa.

## 4. Asignar a una pieza

![Asignar material a una pieza individual](img/03-row-chip-materiales.jpg)

El flujo de una sola pieza vive en
`src/ui/apply-material.ts:pickAndApplyMaterial`:

```ts
// Click en el chip →
const next = await openMaterialPicker({
  current:           primary.material ?? '',
  projectMaterials:  projectMaterialsFromParts(parts),
  applyCount:        targets.length,
});
if (next === null) return;        // cancelado
applyMaterialToIds(targets, next);
```

`applyMaterialToIds` arma **un solo** entry de history
(`withHistory('set-material')`), así un undo deshace toda la
asignación de una. Internamente `setPartMaterial(id, material)`
actualiza `state.model.parts[i].material` (string) y dispara la
re-render del árbol. Si la def tiene apariencia `texture`,
`applyMaterialToMesh` corre async (`fire-and-forget`) para repintar
el mesh con la textura del catálogo.

El picker también llama `pushRecentMaterial(display)` al cierre con
éxito — el material elegido sube al tope de *Recientes* en la
próxima apertura.

## 5. Asignar a toda la selección

![Picker con banner "Se aplicará a 4 piezas seleccionadas"](img/04-bulk-materiales.jpg)

Con ≥ 2 piezas seleccionadas (Shift+click para añadir, click
sencillo para reemplazar, Ctrl/Cmd+click para toggle), click en
cualquiera de sus chips abre el picker **una sola vez** con un banner
arriba que dice *«Se aplicará a N piezas seleccionadas»*. El resultado
se aplica a las N en una sola entrada de history. Equivalente:
botón derecho del sidebar › «Material…».

El mismo `pickAndApplyMaterial` detecta si la pieza clickeada es
parte de la selección múltiple y abre el picker con todos los ids
seleccionados como target:

```ts
const selected = state.selectedPartIds.filter(...);
const targets =
  selected.length > 1 && selected.includes(primaryId)
    ? selected
    : [primaryId];
// picker.applyCount = targets.length → muestra "Aplicar a N piezas"
```

Si tu selección es de un color distinto al de la primera pieza, el
picker muestra el `current` del primary (no un mixto) — pero el
material que elijas reemplaza los N strings en una sola operación
atómica de history.

> Para limpiar el material de la selección, botón derecho › «Limpiar
> material». Internamente `clearMaterialFromSelection` setea
> `part.material = ''` en una sola entrada de history.

## 6. Configuración → Materiales

![Tab Materiales de Configuración](img/05-settings-materiales.jpg)

El botón *Configuración* de la topbar (icono engranaje) abre el panel
lateral. La primera sección del tab **Materiales del taller** (id
`#stg-materials`) está implementada en
`src/ui/settings-panel/materials-ui.ts` y se renderiza con el HTML
de `html-section-materials.ts:buildMaterialsSection`.

El tab tiene cuatro zonas:

| Zona | Selector | Función |
|------|----------|---------|
| **Hint** | `#stg-materials > .stg-hint` | Recordatorio: tipo + simil + color **o** textura (con sample Faplac). |
| **+ Agregar material** | `#stg-mat-add` | Abre el form inline (`#stg-mat-form`) oculto por default (ver paso 7). |
| **Lista** | `#stg-mat-list` | Cada fila: swatch 16 + simil + tipo + acciones. Customs con `✎`/`×` + badge opcional *editado* (override). Built-ins con badge *fijo* + ícono `▦` si tienen textura. |
| **Restaurar defaults** | `#stg-mat-restore` | Pide confirmación y vuelve a sembrar `custom=[]` con sólo los built-ins. |
| **Recientes** | `#stg-mat-recent` | MRU 30 (chips clickeables). *Vaciar* los borra todos (`clearRecentMaterials`). |

> Los customs persisten en `localStorage[despiece.materials.v1]`
> (`version: 3`) y se incluyen en **Configuración → Exportar**
> (zip/json). El *logo* del taller también se incluye ahí. Importar
> otro equipo no pisa los customs locales — solo añade.

## 7. Catálogo built-in Faplac-style

![Lista de materiales built-in](img/06-builtin-materiales.jpg)

`BUILTIN_MATERIAL_DEFS` en `src/materials/catalog.ts` lleva 26
entradas:

| Tipo | Built-ins | Role default |
|------|-----------|---------------|
| `melamina_mdp` | 11 (Blanco TX / mate / brillo / Gris perla / lino / cemento / Negro / Roble / Nogal / Petiribí / Wengue) | `face` |
| `melamina_mdf` | 4 (Blanco TX / Gris lino / Nogal / Roble) | `face` |
| `mdf_crudo` | 1 (Crudo) | `face` |
| `fondo` | 2 (Blanco / Negro) | `fondo` |
| `maciza_pino_fk` | 2 (Listonado 18 / 24 mm) | `core` |
| `maciza_eucalipto` | 2 (Listonado 18 / 24 mm) | `core` |
| `maciza_paraiso` | 2 (Listonado 18 / 24 mm) | `core` |

Los built-ins viven en el código (`src/materials/catalog.ts`), no en
localStorage — son **inamovibles**. Si querés un simil custom (*Roble
español*, *Blanco nórdico*, etc.), agregalo como custom (paso 8) o
desde el *Otro* del picker (paso 3). El badge *fijo* en la fila lo
deja claro.

## 8. Crear un material custom (con toggle Color|Textura)

![Form de creación de material](img/07-form-materiales.jpg)

**+ Agregar material** abre el form inline con cinco campos:

| Campo | Selector | Validación |
|-------|----------|------------|
| Tipo de tablero | `#stg-mat-kind` | uno de los 7 `BoardKind` built-in; `melamina_mdp` por default. |
| Simil / acabado | `#stg-mat-finish` | texto, máx 80 chars, trim + colapsar whitespace, no vacío. |
| **Apariencia** | `#stg-mat-active-type` | toggle **Color \| Textura** — XOR (ver `effectiveAppearance()` en `src/materials/types.ts`). Sólo el bloque activo es visible. |
| Color (si Color) | `#stg-mat-swatch` | color picker HTML5, default `#cccccc`; se normaliza a hex6 minúsculas. |
| Textura (si Textura) | `#stg-mat-texture-pick` / `#stg-mat-texture` | file input PNG/JPEG/WEBP. Sube y guarda blob en IndexedDB (v4 store `materialImages`). Preview en `#stg-mat-texture-preview`. Botón *Quitar* si ya hay textura. |
| Costo $/m² (opcional) | `#stg-mat-cost` | número decimal, ≥ 0; vacío = sin costo (no bloquea). |

**Guardar** llama a
`addCustomMaterial({kind, finish, swatch, costPerM2, textureId, activeType})`
en `src/materials/store.ts`. La función devuelve
`{ok:false, error:'duplicate' | 'empty' | 'limit'}` si algo no
cierra (límite 80 customs; duplicado por `kind+finish`
case-insensitive; simil vacío).

> El toggle Color|Textura es **XOR**: al cambiar de Color a
> Textura, el swatch se descarta (`st.swatch` queda con el default)
> y aparece la dropzone; al revés, el `textureId` se descarta y
> vuelve el color picker. Esto lo enforce
> `applyActiveTypeUi()` en
> `src/ui/settings-panel/materials-ui-form.ts:46`.

> Si intentás guardar un simil que matchea un built-in Faplac (ej.
> *Blanco TX* en `melamina_mdp`), el form rechaza con *Ese material
> ya está en el catálogo fijo* (chequeo `isBuiltinMaterialName`).

## 9. Renombrar / borrar custom

![Editar y borrar materiales custom](img/08-edit-delete-materiales.jpg)

Dos botones por fila custom:

- **✎** (`data-mat-edit`) → `showForm(root, def)`; el form se abre
  con todos los campos pre-poblados y `#stg-mat-edit-id` con el id
  del def. **Guardar** en este caso llama
  `updateCustomMaterial(id, patch)`, que valida duplicate / empty y
  vuelve a persistir.
- **×** (`data-mat-del`) → `removeCustomMaterial(id)`: saca del
  catálogo y remapea cualquier entrada de `recent` o `favorites`
  que apuntaba al label viejo.

Lo importante: **al renombrar** (cambiar *simil* o *tipo*) el sistema
reescribe automáticamente todas las piezas del modelo cargado que
tenían el label anterior. Esto evita huérfanos silenciosos:

```ts
// src/ui/apply-material.ts
if (r.previousName !== r.def.name) {
  const n = rewriteMaterialsInLoadedModel(r.previousName, r.def.name);
  if (n > 0) {
    setTransient(`${n} piezas actualizadas con el material renombrado`, 3000);
  }
}
```

> El rename entra como una sola entrada de *history* (no dos
> separadas), así un undo revierte tanto el catálogo como las N
> piezas en un paso.

## 10. Recientes y persistencia

![Sección de recientes](img/09-recent-materiales.jpg)

El catálogo se persiste en `localStorage` bajo la clave
`despiece.materials.v1` con la forma:

```ts
interface MaterialCatalogV3 {
  version:   3;
  recent:    string[];             // MRU 30, string canonico "Melamina MDP · Petiribí"
  favorites: string[];             // reservado (sin UI v1)
  custom:    MaterialDef[];        // hasta 80, editable
}
```

Las texturas (cuando hay customs con `activeType: 'texture'`) viven
en IndexedDB store `materialImages` (key = `textureId`,
value = `Blob`). El store usa el v4 schema definido en
`src/materials/image-store.ts`.

La función `pushRecentMaterial(display)` en
`src/materials/store.ts` hace lo siguiente:

1. Trim + colapsar whitespace al display string.
2. Buscar key lowercase en `recent`; si existe, sacarlo.
3. Insertar al tope; truncar a `MAX_RECENT = 30`.
4. Persistir.

**Vaciar** (`#stg-mat-clear-recent`) llama a
`clearRecentMaterials` que setea `recent: []`. No toca `favorites`
ni `custom`.

> Como *favorites* es un campo reservado del schema pero todavía sin
> UI, los customs son la forma principal de «fijar» un material:
> aparece siempre en el picker y no se va con *Vaciar recientes*.

## 11. Integración con Nest · BOM · Etiquetas

![Hoja de atajos](img/10-atajos-materiales.jpg)

El material asignado a cada pieza fluye hacia abajo de tres formas
distintas. Es importante entender las tres para no mezclar colores en
el nest sin querer:

| Downstream | Comportamiento | Módulo |
|------------|----------------|--------|
| **Nest** (multi-restart) | `partsToNestItems` agrupa por *material × espesor* y crea tableros separados por color. La pieza sin material va a *Sin material* y queda suelta. | `src/nesting/pack-async.ts` |
| **BOM** (CSV / XLSX / PDF) | Agrupa piezas por material + espesor + dimensiones; incluye columna `modelo` con el label canónico. | `src/export-bom.ts` |
| **Pedido de materiales** (PDF / CSV) | Si el catálogo tiene `costPerM2`, lo usa para calcular costo total por material. Sin costo → columna vacía. | `src/export-purchase.ts` |
| **Etiquetas PDF** (cantos) | Header de la etiqueta incluye `material` del catálogo (lookup `canonicalizeMaterial`). | `src/export-labels.ts` |

Atajos del flujo de materiales:

| Atajo | Acción |
|-------|--------|
| Click en chip de material | Abrir picker (1 pieza). |
| Shift+click en filas de la lista | Multi-selección (añadir). |
| RMB en chip / fila | Menú contextual con *Material…* y *Limpiar material*. |
| `F` / `Home` | Fit / Fit + iso del viewport 3D. |
| `?` / `F1` | Abrir la hoja de atajos completa. |

En Mac el modificador de sistema es `⌘`; en otros sistemas `Ctrl`.

---

## Ver también

- [Tutorial · Cantos y auto-cantos](cantos-y-auto-cantos.html)
- [Tutorial · Roles y auto-roles](roles-y-auto-roles.html)
- [Tutorial · Optimización de corte](optimizacion-corte.html)
- [Tutorial · Plano de taller](plano-de-taller.html)
- Spec
  [`2026-07-15-material-purchase-labels.md`](../superpowers/specs/2026-07-15-material-purchase-labels.md)
  — modelo de material + lista de compra + etiquetas.
