Files
apex/server/services/planGenerator.js
T
lejayandCursor 6c0d7e47b8 v2.0
Co-authored-by: Cursor <[email protected]>
2026-05-28 07:20:38 +02:00

759 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Plan Generator - Generate 2D assembly and parts plans in SVG format
* Includes APEX 4.0 manufacturing details: System 32 drilling, hinge
* positions, Ixconnect connectors, back panel groove, and reinforcements.
* Also handles PDF export.
*/
import {
PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS,
getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE,
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING
} from '../../shared/manufacturing.js';
// Constants for drawing
const DRAWING_SCALE = 0.1; // 1mm = 0.1px
const MARGIN = 50;
const LABEL_OFFSET = 15;
const ARROW_SIZE = 8;
// ── Shared SVG styles ────────────────────────────────────────────────
const SHARED_STYLES = `
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.inner { fill: none; stroke: #666; stroke-width: 0.5; }
.dimension { fill: none; stroke: #0066cc; stroke-width: 0.5; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
.label { font-family: Arial, sans-serif; font-size: 9px; fill: #333; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.door { fill: #d4c4a4; stroke: #333; stroke-width: 1; opacity: 0.8; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
.s32-hole { fill: none; stroke: #e63946; stroke-width: 0.4; }
.hinge-marker { fill: #ff6b35; stroke: #333; stroke-width: 0.3; }
.groove { fill: none; stroke: #2a9d8f; stroke-width: 1; stroke-dasharray: 2,2; }
.connector { fill: #6c5ce7; stroke: #333; stroke-width: 0.3; }
.renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; }
.detail-label { font-family: Arial, sans-serif; font-size: 7px; fill: #666; }
.fh-groove { fill: none; stroke: #0d9488; stroke-width: 1; stroke-dasharray: 3, 2; }
`;
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Draw System 32 hole column (vertical series of holes)
*/
function drawSystem32Holes(x, topY, panelHeightMm, scale) {
let svg = '';
const usable = panelHeightMm - SYSTEM32.firstHoleOffset;
const count = Math.max(0, Math.floor(usable / SYSTEM32.pitch) + 1);
const r = (SYSTEM32.holeDiameter / 2) * scale;
for (let i = 0; i < count; i++) {
const yOffset = (SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch) * scale;
svg += `<circle cx="${x}" cy="${topY + yOffset}" r="${r}" class="s32-hole" />`;
}
return svg;
}
/**
* Draw hinge position markers on a door plan (côté charnières)
*/
function drawHingeMarkers(doorX, doorY, doorWidthMm, doorHeightMm, scale, hingeSide = 'left') {
let svg = '';
const markerH = 6;
const markerW = 3;
const hingeCount = getHingeCountForDoor({ heightMm: doorHeightMm });
const positions = getHingeCupPositions(doorHeightMm, hingeCount);
const doorW = doorWidthMm * scale;
const cupInset = Math.min(22 * scale, doorW * 0.13);
const hingeLineX = hingeSide === 'right' ? doorX + doorW - cupInset : doorX + cupInset;
for (const posMm of positions) {
const y = doorY + posMm * scale;
svg += `<rect x="${hingeLineX - markerW / 2}" y="${y - markerH / 2}" width="${markerW}" height="${markerH}" class="hinge-marker" />`;
}
return svg;
}
/**
* Rainure poignée toute hauteur — côté opposé aux charnières, retrait H/B (HANDLE.fullHeightTopBottomOffset)
*/
function drawFullHeightHandleGrooveElevation(doorX, doorY, doorWidthMm, doorHeightMm, scale, hingeSide) {
const off = HANDLE.fullHeightTopBottomOffset;
const gw = HANDLE.fullHeightGroove.width;
const doorW = doorWidthMm * scale;
const axisX = hingeSide === 'right'
? doorX + (gw / 2) * scale
: doorX + doorW - (gw / 2) * scale;
const y1 = doorY + off * scale;
const y2 = doorY + doorHeightMm * scale - off * scale;
let svg = `<line x1="${axisX}" y1="${y1}" x2="${axisX}" y2="${y2}" class="fh-groove" />`;
const tx = axisX + (hingeSide === 'right' ? 5 : -5);
const anchor = hingeSide === 'right' ? 'start' : 'end';
svg += `<text x="${tx}" y="${doorY + doorHeightMm * scale * 0.45}" class="detail-label" text-anchor="${anchor}" fill="#0d9488">Rainure poignée TH (${gw} mm) · ${off} mm H/B</text>`;
return svg;
}
/**
* Draw back panel groove indicator (dashed line near panel edge).
* Per updated spec: groove at 16mm from panel edge.
*/
function drawGrooveIndicator(x, y, length, direction, scale) {
let svg = '';
const grooveOffset = GROOVE.back.axisFromPanelEdge * scale;
if (direction === 'vertical') {
svg += `<line x1="${x + grooveOffset}" y1="${y}" x2="${x + grooveOffset}" y2="${y + length}" class="groove" />`;
} else {
svg += `<line x1="${x}" y1="${y + grooveOffset}" x2="${x + length}" y2="${y + grooveOffset}" class="groove" />`;
}
return svg;
}
/**
* Generate a dimension line with arrows and text
*/
function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
let svg = '';
if (direction === 'horizontal') {
svg += `<line x1="${x}" y1="${y}" x2="${x + length}" y2="${y}" class="dimension" />`;
svg += `<polygon points="${x},${y} ${x + ARROW_SIZE},${y - 3} ${x + ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
svg += `<polygon points="${x + length},${y} ${x + length - ARROW_SIZE},${y - 3} ${x + length - ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
svg += `<text x="${x + length / 2}" y="${y + LABEL_OFFSET}" class="dim-text" text-anchor="middle">${label}</text>`;
} else {
svg += `<line x1="${x}" y1="${y}" x2="${x}" y2="${y + length}" class="dimension" />`;
svg += `<polygon points="${x},${y} ${x - 3},${y + ARROW_SIZE} ${x + 3},${y + ARROW_SIZE}" fill="#0066cc" />`;
svg += `<polygon points="${x},${y + length} ${x - 3},${y + length - ARROW_SIZE} ${x + 3},${y + length - ARROW_SIZE}" fill="#0066cc" />`;
svg += `<text x="${x - LABEL_OFFSET}" y="${y + length / 2}" class="dim-text" text-anchor="middle" transform="rotate(-90 ${x - LABEL_OFFSET} ${y + length / 2})">${label}</text>`;
}
return svg;
}
// ── Plan generators ──────────────────────────────────────────────────
/**
* Generate SVG for assembly view (front elevation)
*/
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const S = DRAWING_SCALE;
const svgWidth = widthMm * S + MARGIN * 2;
const svgHeight = heightMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
// Title
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Plinth
const plinthY = oY + bodyHeight * S;
svg += `<rect x="${oX}" y="${plinthY}" width="${widthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
// Body
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`;
// Module separators
if (modules.length > 0) {
let currentX = oX;
for (let i = 0; i < modules.length - 1; i++) {
currentX += modules[i].widthMm * S;
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
}
}
// System 32 holes on left and right side panels
svg += drawSystem32Holes(oX + PANEL.structure * S / 2, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
svg += drawSystem32Holes(oX + widthMm * S - PANEL.structure * S / 2, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
// Doors
if (doorType === 'sliding') {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = (widthMm / panelCount) * S;
for (let i = 0; i < panelCount; i++) {
svg += `<rect x="${oX + i * panelWidth * 0.95 + 5}" y="${oY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * S}" class="door" />`;
}
} else {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
let currentX = oX;
const items = modules.length > 0 ? modules : (config.blocks || []);
const hw = config.hardware || {};
const fullHeightHandle = hw.handleType === 'full_height';
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
const gapMm = 2;
const doorHmm = bodyHeight - 4;
for (let i = 0; i < blockCount; i++) {
const bw = items[i]?.widthMm || (widthMm / blockCount);
const doorWmm = Math.max(100, Math.floor(widthMm / blockCount) - 4 - widthDeductionMm);
const modulePx = bw * S;
const doorWPx = doorWmm * S;
const doorX = currentX + (modulePx - doorWPx) / 2;
const doorY = oY + gapMm * S;
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
const hingeSide = getDoorHingeSide(i, blockCount, hw);
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
if (fullHeightHandle) {
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
}
currentX += modulePx;
}
}
// Reinforcements (Forteresse 16 mm):
// width = armoire width - 2×19 (entre côtés),
// one at top + one at bottom (offset 19 mm),
// third one centered if wardrobe height > 1500 mm.
const renfortX = oX + PANEL.structure * S;
const renfortW = Math.max(0, (widthMm - PANEL.structure * 2) * S);
const renfortH = PANEL.reinforcement * S;
const topRenfortY = oY + REINFORCEMENT.topBottomOffsetMm * S;
const bottomRenfortY = plinthY - REINFORCEMENT.topBottomOffsetMm * S - renfortH;
const reinforcementYs = [topRenfortY, bottomRenfortY];
if (heightMm > REINFORCEMENT.thirdRequiredAboveHeightMm) {
reinforcementYs.push(oY + (bodyHeight * S) / 2 - renfortH / 2);
}
for (const y of reinforcementYs) {
svg += `<rect x="${renfortX}" y="${y}" width="${renfortW}" height="${renfortH}" class="renfort" />`;
}
svg += `<text x="${oX + (widthMm * S) / 2}" y="${svgHeight - 10}" class="detail-label" text-anchor="middle" fill="#6b5344" font-size="7.5">Renforts Forteresse (16 mm) : fixation par montant — ${REINFORCEMENT_MACHINING.connectorModel} au centre + 2× tourillons Ø${REINFORCEMENT_MACHINING.tourillonDiameter} à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords · axes sur plan pièce « Renforts »</text>`;
// Dimension lines
svg += generateDimensionLine(oX, plinthY + plinthHeightMm * S + 20, widthMm * S, `${widthMm} mm`, 'horizontal');
svg += generateDimensionLine(oX - 20, oY, bodyHeight * S, `${bodyHeight} mm`, 'vertical');
svg += generateDimensionLine(oX - 40, oY, heightMm * S, `${heightMm} mm (total)`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for side view — includes groove indicator and System 32 holes
*/
export function generateSidePlan(config) {
const { heightMm, depthMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const S = DRAWING_SCALE;
const svgWidth = depthMm * S + MARGIN * 2;
const svgHeight = heightMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Plinth
const plinthY = oY + bodyHeight * S;
svg += `<rect x="${oX}" y="${plinthY}" width="${depthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
// Body
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`;
// Back panel (8mm) shown as a thin rectangle at the back
const backX = oX + (depthMm - PANEL.back) * S;
svg += `<rect x="${backX}" y="${oY + PANEL.structure * S}" width="${PANEL.back * S}" height="${(bodyHeight - PANEL.structure * 2) * S}" fill="#d4c4a4" stroke="#666" stroke-width="0.5" />`;
svg += `<text x="${backX - 2}" y="${oY + bodyHeight * S / 2}" class="detail-label" text-anchor="end">Dos ${PANEL.back}mm</text>`;
// Groove indicator (dashed green line at 16mm from panel edge)
const grooveAxisX = oX + (depthMm - GROOVE.back.axisFromPanelEdge) * S;
svg += `<line x1="${grooveAxisX}" y1="${oY + PANEL.structure * S}" x2="${grooveAxisX}" y2="${plinthY - PANEL.structure * S}" class="groove" />`;
svg += `<text x="${grooveAxisX - 3}" y="${oY + bodyHeight * S * 0.3}" class="detail-label" text-anchor="end" fill="#2a9d8f">Rainure ${GROOVE.back.width}×${GROOVE.back.depth} (16mm du bord)</text>`;
// System 32 holes along the side panel
svg += drawSystem32Holes(oX + 37 * S, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
svg += `<text x="${oX + 37 * S}" y="${oY - 5}" class="detail-label" text-anchor="middle" fill="#e63946">System 32</text>`;
// Dimension lines
svg += generateDimensionLine(oX, plinthY + plinthHeightMm * S + 20, depthMm * S, `${depthMm} mm`, 'horizontal');
svg += generateDimensionLine(oX - 20, oY, bodyHeight * S, `${bodyHeight} mm`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for top view — includes groove line for back panel
*/
export function generateTopPlan(config, modules = []) {
const { widthMm, depthMm } = config;
const S = DRAWING_SCALE;
const svgWidth = widthMm * S + MARGIN * 2;
const svgHeight = depthMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Body outline
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${depthMm * S}" class="body" />`;
// Back panel groove line (horizontal dashed line near panel edge)
const grooveY = oY + (depthMm - PANEL.structure) * S;
svg += `<line x1="${oX + PANEL.structure * S}" y1="${grooveY}" x2="${oX + (widthMm - PANEL.structure) * S}" y2="${grooveY}" class="groove" />`;
svg += `<text x="${oX + widthMm * S + 5}" y="${grooveY + 3}" class="detail-label" fill="#2a9d8f">Rainure dos</text>`;
// Module separators and labels
if (modules.length > 0) {
let currentX = oX;
for (let i = 0; i < modules.length; i++) {
const module = modules[i];
const moduleWidth = module.widthMm * S;
// Label
svg += `<text x="${currentX + moduleWidth / 2}" y="${oY + depthMm * S / 2}" class="label" text-anchor="middle">${module.moduleCode || `M${i + 1}`}</text>`;
// Separator
if (i < modules.length - 1) {
currentX += moduleWidth;
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${oY + depthMm * S}" class="separator" />`;
}
}
}
svg += '</svg>';
return svg;
}
/**
* Generate SVG detail plan — zoomed view of a side panel showing
* System 32 drilling, connector holes, groove, and edge banding
*/
export function generateDetailPlan(config) {
const { depthMm, heightMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const innerWidth = config.widthMm - (PANEL.structure * 2);
const innerDepth = depthMm - PANEL.structure;
// Zoomed scale for detail view (3× normal)
const DS = 0.3;
// Show top 600mm of a side panel (enough for connectors, first S32 holes, groove)
const detailHeightMm = Math.min(600, bodyHeight);
const detailWidthMm = depthMm;
// Extra space on the right for shelf drilling inset
const svgWidth = detailWidthMm * DS + MARGIN * 2 + 260;
const svgHeight = detailHeightMm * DS + MARGIN * 2 + 40;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}
.edge-band { fill: #ff9f1c; stroke: none; opacity: 0.6; }
.callout { font-family: Arial, sans-serif; font-size: 8px; fill: #333; }
.callout-line { stroke: #999; stroke-width: 0.3; fill: none; }
.shelf-outline { fill: #f9fafb; stroke: #111827; stroke-width: 0.8; }
.shelf-face-hole { fill: none; stroke: #ea580c; stroke-width: 0.55; }
.shelf-edge-hole { fill: none; stroke: #7c3aed; stroke-width: 0.55; stroke-dasharray: 1.2 1.2; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="20" class="title" text-anchor="middle">Détail — Côté (vue intérieure)</text>`;
const oX = MARGIN + 40;
const oY = MARGIN + 20;
// Panel outline
svg += `<rect x="${oX}" y="${oY}" width="${detailWidthMm * DS}" height="${detailHeightMm * DS}" class="body" stroke-width="2" />`;
// Edge banding (2mm ABS on all 4 edges)
const eb = EDGE_BANDING.abs * DS;
svg += `<rect x="${oX}" y="${oY}" width="${detailWidthMm * DS}" height="${eb}" class="edge-band" />`; // top
svg += `<rect x="${oX}" y="${oY + detailHeightMm * DS - eb}" width="${detailWidthMm * DS}" height="${eb}" class="edge-band" />`; // bottom
svg += `<rect x="${oX}" y="${oY}" width="${eb}" height="${detailHeightMm * DS}" class="edge-band" />`; // left
svg += `<rect x="${oX + detailWidthMm * DS - eb}" y="${oY}" width="${eb}" height="${detailHeightMm * DS}" class="edge-band" />`; // right
// Back panel groove — 16mm from panel edge
const grooveAxisOffset = (detailWidthMm - GROOVE.back.axisFromPanelEdge) * DS;
const grooveX = oX + grooveAxisOffset - (GROOVE.back.width / 2) * DS;
const grooveW = GROOVE.back.width * DS;
svg += `<rect x="${grooveX}" y="${oY + 5}" width="${grooveW}" height="${detailHeightMm * DS - 10}" fill="#2a9d8f" opacity="0.3" stroke="#2a9d8f" stroke-width="0.5" />`;
// Callout
svg += `<line x1="${grooveX + grooveW}" y1="${oY + 30}" x2="${grooveX + grooveW + 25}" y2="${oY + 20}" class="callout-line" />`;
svg += `<text x="${grooveX + grooveW + 27}" y="${oY + 22}" class="callout">Rainure ${GROOVE.back.width}×${GROOVE.back.depth} mm</text>`;
svg += `<text x="${grooveX + grooveW + 27}" y="${oY + 31}" class="callout">Position à ${GROOVE.back.axisFromPanelEdge} mm du bord</text>`;
// System 32 holes (front row) — blind holes, depth 12mm
const s32X = oX + 37 * DS; // 37mm from front edge
const usable = detailHeightMm - SYSTEM32.firstHoleOffset * 2; // first AND last at 51mm
const holeCount = Math.max(0, Math.floor(usable / SYSTEM32.pitch) + 1);
const r = (SYSTEM32.holeDiameter / 2) * DS;
for (let i = 0; i < Math.min(holeCount, 15); i++) {
const holeY = oY + (SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch) * DS;
svg += `<circle cx="${s32X}" cy="${holeY}" r="${r}" class="s32-hole" />`;
}
// Callout for System 32
svg += `<line x1="${s32X}" y1="${oY + SYSTEM32.firstHoleOffset * DS}" x2="${oX - 15}" y2="${oY + SYSTEM32.firstHoleOffset * DS - 10}" class="callout-line" />`;
svg += `<text x="${oX - 17}" y="${oY + SYSTEM32.firstHoleOffset * DS - 12}" class="callout" text-anchor="end">System 32 (borgne)</text>`;
svg += `<text x="${oX - 17}" y="${oY + SYSTEM32.firstHoleOffset * DS - 3}" class="callout" text-anchor="end">Ø${SYSTEM32.holeDiameter} / prof. ${SYSTEM32.holeDepth} / pas ${SYSTEM32.pitch}</text>`;
// First hole offset dimension
svg += generateDimensionLine(oX + detailWidthMm * DS + 10, oY, SYSTEM32.firstHoleOffset * DS, `${SYSTEM32.firstHoleOffset}`, 'vertical');
// Ixconnect connector holes (top edge) — extrémité d’arête: T@20 → IX@50 → T@80 (vue détail côté)
const conn = CONNECTORS.ixconnect;
const pattern = CONNECTORS.jointPattern;
// Show 3 holes: tourillon@20, ixconnect@50, tourillon@80 from left edge (arête extrémité)
const jT1X = oX + pattern.tourillon1Axis * DS;
const jIXX = oX + pattern.ixconnectAxis * DS;
const jT2X = oX + pattern.tourillon2Axis * DS;
const tourR = (CONNECTORS.tourillonDiameter / 2) * DS;
const ixEdgeR = (conn.edgeHole.diameter / 2) * DS;
// Edge holes on top edge
svg += `<circle cx="${jT1X}" cy="${oY}" r="${tourR}" class="connector" opacity="0.5" />`; // tourillon 1
svg += `<circle cx="${jIXX}" cy="${oY}" r="${ixEdgeR}" class="connector" />`; // Ixconnect
svg += `<circle cx="${jT2X}" cy="${oY}" r="${tourR}" class="connector" opacity="0.5" />`; // tourillon 2
// Face holes (Ixconnect only — Ø8×12mm)
svg += `<circle cx="${jIXX}" cy="${oY + conn.faceHole.depth * DS}" r="${(conn.faceHole.diameter / 2) * DS}" class="connector" />`;
// Callout
svg += `<line x1="${jT2X + 5}" y1="${oY}" x2="${jT2X + 30}" y2="${oY - 15}" class="callout-line" />`;
svg += `<text x="${jT2X + 32}" y="${oY - 22}" class="callout">Ixconnect ${conn.model}</text>`;
svg += `<text x="${jT2X + 32}" y="${oY - 13}" class="callout">About: Ø${conn.edgeHole.diameter}×${conn.edgeHole.depth} / Face: Ø${conn.faceHole.diameter}×${conn.faceHole.depth}</text>`;
svg += `<text x="${jT2X + 32}" y="${oY - 4}" class="callout">Pattern: T@${pattern.tourillon1Axis} → IX@${pattern.ixconnectAxis} → T@${pattern.tourillon2Axis}</text>`;
// Panel thickness label
svg += `<text x="${oX + detailWidthMm * DS / 2}" y="${oY + detailHeightMm * DS + 15}" class="callout" text-anchor="middle">Épaisseur: ${PANEL.structure} mm — Chant ABS ${EDGE_BANDING.abs} mm</text>`;
// --- Inset: Rayons mobiles (Étagères amovibles) — SP 15/8/5 ---
// Dimensions de découpe : L.int - 2 mm / P.int - 5 mm
const shelfWmm = Math.max(80, innerWidth - 2);
const shelfDmm = Math.max(80, innerDepth - 5);
const insetMaxWpx = 170;
const insetMaxHpx = 190;
const insetScale = Math.min(insetMaxWpx / shelfWmm, insetMaxHpx / shelfDmm);
const insetX = oX + detailWidthMm * DS + 60;
const insetY = oY + 40;
const insetW = shelfWmm * insetScale;
const insetH = shelfDmm * insetScale;
svg += `<text x="${insetX + insetW / 2}" y="${insetY - 16}" class="callout" text-anchor="middle">Rayons mobiles — SP 15/8/5 (dessous)</text>`;
svg += `<rect x="${insetX}" y="${insetY}" width="${insetW}" height="${insetH}" class="shelf-outline" />`;
// Hole positions: from front 32mm; from back 37mm
const yFrontMm = 32;
const yBackMm = shelfDmm - 37;
const yPosMm = [yFrontMm, yBackMm].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
// Face holes: Ø20 axis 9.5mm from left/right
const xSideMm = 9.5;
const xPosMm = [xSideMm, shelfWmm - xSideMm].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
const rFace = (20 / 2) * insetScale;
const rEdge = (8 / 2) * insetScale;
for (const yMm of yPosMm) {
const cy = insetY + yMm * insetScale;
// Face holes (underside) — left/right
for (const xMm of xPosMm) {
const cx = insetX + xMm * insetScale;
svg += `<circle cx="${cx}" cy="${cy}" r="${rFace}" class="shelf-face-hole" />`;
}
// Edge holes (against side panels) — left/right edge
svg += `<circle cx="${insetX}" cy="${cy}" r="${rEdge}" class="shelf-edge-hole" />`;
svg += `<circle cx="${insetX + insetW}" cy="${cy}" r="${rEdge}" class="shelf-edge-hole" />`;
}
// Callouts
svg += `<line x1="${insetX + insetW}" y1="${insetY + 10}" x2="${insetX + insetW + 22}" y2="${insetY}" class="callout-line" />`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 2}" class="callout">Face: Ø20×15 — axe 9.5 mm</text>`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 12}" class="callout">Chant: Ø8×37 — à 6 mm du dessous</text>`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 22}" class="callout">Avant: 32 mm · Arrière: 37 mm</text>`;
svg += '</svg>';
return svg;
}
/**
* Generate all plans for a configuration
*/
export function generateAllPlans(config, modules = [], colors = {}) {
return {
assembly: generateAssemblyPlan(config, modules),
side: generateSidePlan(config),
top: generateTopPlan(config, modules),
detail: generateDetailPlan(config),
metadata: {
generatedAt: new Date().toISOString(),
specs: 'APEX 4.0',
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
installationType: config.installationType,
doorType: config.doorType,
moduleCount: modules.length
}
}
};
}
/**
* Generate HTML page with all plans for PDF export
*/
export function generatePDFHtml(config, modules = [], cuttingList = null, packingList = null) {
const plans = generateAllPlans(config, modules);
let html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Plans Armoire — APEX 4.0</title>
<style>
@page { size: A4; margin: 15mm; }
body { font-family: Arial, sans-serif; font-size: 11px; }
.page { page-break-after: always; padding: 20px; }
.header { border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
.title { font-size: 20px; font-weight: bold; margin: 0; }
.subtitle { font-size: 12px; color: #666; margin: 5px 0 0; }
.plan-container { margin: 20px 0; text-align: center; }
.plan-title { font-size: 14px; font-weight: bold; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
th { background: #f5f5f5; font-weight: bold; }
.summary { background: #f0f7ff; padding: 15px; border-radius: 8px; margin: 20px 0; }
.summary h3 { margin: 0 0 10px; }
.specs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.spec-item { display: flex; justify-content: space-between; }
.mfg-specs { background: #fff8e7; padding: 12px; border-radius: 6px; margin: 15px 0; border-left: 4px solid #c9a96e; }
.mfg-specs h4 { margin: 0 0 8px; color: #8b7355; }
.mfg-specs ul { margin: 0; padding-left: 18px; }
.mfg-specs li { margin: 2px 0; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1 class="title">Plans de fabrication - Armoire sur mesure</h1>
<p class="subtitle">APEX 4.0 — Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
</div>
<div class="summary">
<h3>Spécifications</h3>
<div class="specs">
<div class="spec-item"><span>Largeur:</span><strong>${config.widthMm} mm</strong></div>
<div class="spec-item"><span>Hauteur:</span><strong>${config.heightMm} mm</strong></div>
<div class="spec-item"><span>Profondeur:</span><strong>${config.depthMm} mm</strong></div>
<div class="spec-item"><span>Plinthe:</span><strong>${config.plinthHeightMm} mm</strong></div>
<div class="spec-item"><span>Type:</span><strong>${config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</strong></div>
<div class="spec-item"><span>Portes:</span><strong>${config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</strong></div>
</div>
</div>
<div class="mfg-specs">
<h4>Normes de fabrication APEX 4.0</h4>
<ul>
<li>Structure ${PANEL.structure} mm / Renforts ${PANEL.reinforcement} mm / Dos ${PANEL.back} mm</li>
<li>Chants ABS ${EDGE_BANDING.abs} mm visible — Dos: ABS sur ${EDGE_BANDING.backPanelSides} chants</li>
<li>Rainure dos ${GROOVE.back.width}×${GROOVE.back.depth} mm — position à ${GROOVE.back.axisFromPanelEdge} mm du bord</li>
<li>System 32 côtés : Ø${SYSTEM32.holeDiameter} mm borgne, prof. ${SYSTEM32.holeDepth} mm, pas ${SYSTEM32.pitch} mm, offset ${SYSTEM32.firstHoleOffset} mm</li>
<li>System 32 séparateurs : Ø${SYSTEM32_SEPARATORS.holeDiameter} mm traversant, offset ${SYSTEM32_SEPARATORS.firstHoleOffset} mm</li>
<li>Connecteurs ${CONNECTORS.ixconnect.model} (structure) : about Ø${CONNECTORS.ixconnect.edgeHole.diameter}×${CONNECTORS.ixconnect.edgeHole.depth} / face Ø${CONNECTORS.ixconnect.faceHole.diameter}×${CONNECTORS.ixconnect.faceHole.depth}</li>
<li>Connecteurs ${CONNECTORS.ixconnectFinish.model} (finitions) : face Ø${CONNECTORS.ixconnectFinish.faceHole.diameter}×${CONNECTORS.ixconnectFinish.faceHole.depth}, axes ${CONNECTORS.ixconnectFinish.axes.back}/${CONNECTORS.ixconnectFinish.axes.front} mm</li>
<li>Ixconnect: extrémités d’arête T@${CONNECTORS.jointPattern.tourillon1Axis} → IX@${CONNECTORS.jointPattern.ixconnectAxis} → T@${CONNECTORS.jointPattern.tourillon2Axis} mm (depuis bord avant) · jonctions internes dessus/fond: IX au milieu profondeur + T ±${CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm} mm — faces séparateurs verticaux sans Ixconnect</li>
<li>Renforts : 1× ${REINFORCEMENT_MACHINING.connectorModel} au centre + 2× tourillons Ø${REINFORCEMENT_MACHINING.tourillonDiameter} à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords (axes selon largeur renfort)</li>
<li>Charnières ${HINGE.model} : axe à ${HINGE.axisOffset} mm du haut et du bas (réversible)</li>
<li>Matrice charnières: ≤900mm=2, 901-1600=3, 1601-2000=4, 2001-2400=5, 2401-2600=6</li>
<li>Étagères : ${SHELF_MACHINING.tourillonCount}× tourillons Ø${SHELF_MACHINING.tourillonDiameter} — ${SHELF_MACHINING.shelvesPerStratum} rayons/strate · Rayons mobiles SP 15/8/5 : face Ø20×15 (axe 9.5) + chant Ø8×37 (à 6 du dessous) · avant 32 / arrière 37</li>
<li>Plinthe : ${PLINTH.model} (pieds ${PLINTH.footHeight} mm + clips)</li>
<li>Dessus/Fond : ABS ${EDGE_BANDING.abs} mm avant ET arrière</li>
${config.hardware?.handleType === 'full_height' ? `<li>Poignée toute hauteur : déduction ${HANDLE.fullHeightWidthDeductionMm} mm en largeur porte ; rainure ${HANDLE.fullHeightGroove.width}×${HANDLE.fullHeightGroove.depth} mm côté opposé aux charnières ; retrait mécanique ${HANDLE.fullHeightTopBottomOffset} mm haut et bas</li>` : ''}
</ul>
</div>
<div class="plan-container">
<div class="plan-title">Vue de face (Élévation)</div>
${plans.assembly}
</div>
</div>
<div class="page">
<div class="plan-container">
<div class="plan-title">Vue de côté</div>
${plans.side}
</div>
<div class="plan-container">
<div class="plan-title">Vue de dessus</div>
${plans.top}
</div>
</div>
<div class="page">
<div class="plan-container">
<div class="plan-title">Détail constructif — Côté</div>
${plans.detail}
</div>
</div>`;
// Cutting list page
if (cuttingList) {
html += `
<div class="page">
<h2>Liste de débitage</h2>
<div class="summary">
<h3>Résumé</h3>
<div class="specs">
<div class="spec-item"><span>Nombre de pièces:</span><strong>${cuttingList.summary.totalPieces}</strong></div>
<div class="spec-item"><span>Surface totale:</span><strong>${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²</strong></div>
<div class="spec-item"><span>Chant ABS 2mm:</span><strong>${cuttingList.summary.edgeBandingMl} ml</strong></div>
<div class="spec-item"><span>Finitions:</span><strong>${cuttingList.summary.finishCount}</strong></div>
</div>
</div>`;
for (const group of cuttingList.byFinish) {
html += `
<h3>Finition: ${group.finishLabel}</h3>
<table>
<thead>
<tr>
<th>Description</th>
<th>Largeur</th>
<th>Hauteur</th>
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
<th>Usinage</th>
</tr>
</thead>
<tbody>`;
for (const piece of group.pieces) {
const machStr = piece.machining
? Object.keys(piece.machining).join(', ')
: '-';
html += `
<tr>
<td>${piece.description}</td>
<td>${piece.widthMm} mm</td>
<td>${piece.heightMm} mm</td>
<td>${piece.thicknessMm} mm</td>
<td>${piece.quantity}</td>
<td>${piece.surfaceM2.toFixed(3)} m²</td>
<td>${machStr}</td>
</tr>`;
}
html += `
</tbody>
<tfoot>
<tr>
<th colspan="5">Sous-total</th>
<th>${group.pieceCount} pcs</th>
<th>${group.totalSurfaceM2.toFixed(2)} m²</th>
</tr>
</tfoot>
</table>`;
}
html += `
</div>`;
// Piece-by-piece machining plan pages
for (const piece of (cuttingList.pieces || [])) {
if (!piece.piecePlanSvg) continue;
html += `
<div class="page">
<h2>Plan pièce - ${piece.description}</h2>
<p style="margin:0 0 8px;color:#555;">
Fini: ${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm
&nbsp;|&nbsp;
Brut CNC: ${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm
&nbsp;|&nbsp;
Qté: ${piece.quantity}
</p>
<div class="plan-container">
${piece.piecePlanSvg}
</div>
</div>`;
}
}
// Packing list page (layer-based per APEX 4.0)
if (packingList && packingList.boxes && packingList.boxes.length > 0) {
html += `
<div class="page">
<h2>Liste de colisage — Stratégie par niveaux (APEX 4.0)</h2>
<div class="summary">
<h3>Résumé</h3>
<div class="specs">
<div class="spec-item"><span>Nombre de colis:</span><strong>${packingList.totalBoxes}</strong></div>
<div class="spec-item"><span>Poids max/colis:</span><strong>${packingList.maxWeightKg} kg</strong></div>
<div class="spec-item"><span>Colis le plus lourd:</span><strong>${packingList.heaviestBoxKg.toFixed(1)} kg</strong></div>
<div class="spec-item"><span>Poids total:</span><strong>${packingList.totalWeightKg.toFixed(1)} kg</strong></div>
</div>
</div>`;
// Layer summary table
if (packingList.layerSummary) {
const ls = packingList.layerSummary;
html += `
<table>
<thead><tr><th>Niveau</th><th>Contenu</th><th>Colis</th><th>Pièces</th></tr></thead>
<tbody>
<tr><td>1</td><td>${ls.level1.label}</td><td>${ls.level1.boxes}</td><td>${ls.level1.items}</td></tr>
<tr><td>2</td><td>${ls.level2.label}</td><td>${ls.level2.boxes}</td><td>${ls.level2.items}</td></tr>
<tr><td>3</td><td>${ls.level3.label}</td><td>${ls.level3.boxes}</td><td>${ls.level3.items}</td></tr>
</tbody>
</table>`;
}
for (const box of packingList.boxes) {
html += `
<h3>Colis ${box.id}${box.level ? ` — ${box.level}` : ''} — ${box.totalWeightKg.toFixed(1)} kg</h3>
<table>
<thead>
<tr><th>Pièce</th><th>Dim. (L×H×Ép.)</th><th>Qté</th><th>Poids</th></tr>
</thead>
<tbody>`;
for (const item of box.pieces) {
html += `
<tr>
<td>${item.description}</td>
<td>${item.widthMm}×${item.heightMm}×${item.thicknessMm} mm</td>
<td>${item.quantity}</td>
<td>${item.weightKg.toFixed(1)} kg</td>
</tr>`;
}
html += `
</tbody>
</table>`;
}
html += `
</div>`;
}
html += `
</body>
</html>`;
return html;
}
export default {
generateAssemblyPlan,
generateSidePlan,
generateTopPlan,
generateDetailPlan,
generateAllPlans,
generatePDFHtml
};