Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-05-28 07:20:38 +02:00
co-authored by Cursor
parent 6923c319a1
commit 6c0d7e47b8
81 changed files with 21780 additions and 2986 deletions
+583 -183
View File
@@ -1,170 +1,113 @@
/**
* Plan Generator - Generate 2D assembly and parts plans in SVG format
* Also handles PDF export
* 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 ──────────────────────────────────────────────────────────
/**
* Generate SVG for assembly view (front elevation)
* Draw System 32 hole column (vertical series of holes)
*/
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType, installationType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.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; }
</style>`;
// Title
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${widthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Module separators
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length - 1; i++) {
currentX += modules[i].widthMm * DRAWING_SCALE;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
}
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" />`;
}
// Doors
if (doorType === 'sliding') {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = (widthMm / panelCount) * DRAWING_SCALE;
for (let i = 0; i < panelCount; i++) {
svg += `<rect x="${offsetX + i * panelWidth * 0.95 + 5}" y="${offsetY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * DRAWING_SCALE}" class="door" />`;
}
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 {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
let currentX = offsetX;
const items = modules.length > 0 ? modules : (config.blocks || []);
for (let i = 0; i < blockCount; i++) {
const blockWidth = items[i]?.widthMm || (widthMm / blockCount);
svg += `<rect x="${currentX + 3}" y="${offsetY + 3}" width="${blockWidth * DRAWING_SCALE - 6}" height="${(bodyHeight - 6) * DRAWING_SCALE}" class="door" />`;
currentX += blockWidth * DRAWING_SCALE;
}
svg += `<line x1="${x}" y1="${y + grooveOffset}" x2="${x + length}" y2="${y + grooveOffset}" class="groove" />`;
}
// Dimension lines
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, widthMm * DRAWING_SCALE, `${widthMm} mm`, 'horizontal');
svg += generateDimensionLine(offsetX - 20, offsetY, bodyHeight * DRAWING_SCALE, `${bodyHeight} mm`, 'vertical');
svg += generateDimensionLine(offsetX - 40, offsetY, heightMm * DRAWING_SCALE, `${heightMm} mm (total)`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for side view
*/
export function generateSidePlan(config) {
const { heightMm, depthMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = depthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${depthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${depthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Dimension
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, depthMm * DRAWING_SCALE, `${depthMm} mm`, 'horizontal');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for top view
*/
export function generateTopPlan(config, modules = []) {
const { widthMm, depthMm } = config;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = depthMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.label { font-family: Arial, sans-serif; font-size: 8px; fill: #666; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Body outline
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${depthMm * DRAWING_SCALE}" class="body" />`;
// Module separators and labels
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length; i++) {
const module = modules[i];
const moduleWidth = module.widthMm * DRAWING_SCALE;
// Label
svg += `<text x="${currentX + moduleWidth / 2}" y="${offsetY + depthMm * DRAWING_SCALE / 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="${offsetY}" x2="${currentX}" y2="${offsetY + depthMm * DRAWING_SCALE}" class="separator" />`;
}
}
}
svg += '</svg>';
return svg;
}
@@ -173,25 +116,366 @@ export function generateTopPlan(config, modules = []) {
*/
function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
let svg = '';
if (direction === 'horizontal') {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x + length}" y2="${y}" class="dimension" />`;
// Arrows
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" />`;
// Label
svg += `<text x="${x + length / 2}" y="${y + LABEL_OFFSET}" class="dim-text" text-anchor="middle">${label}</text>`;
} else {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x}" y2="${y + length}" class="dimension" />`;
// Arrows
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" />`;
// Label (rotated)
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;
}
@@ -203,8 +487,10 @@ export function generateAllPlans(config, modules = [], colors = {}) {
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,
@@ -220,14 +506,14 @@ export function generateAllPlans(config, modules = [], colors = {}) {
/**
* Generate HTML page with all plans for PDF export
*/
export function generatePDFHtml(config, modules = [], cuttingList = null) {
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</title>
<title>Plans Armoire — APEX 4.0</title>
<style>
@page { size: A4; margin: 15mm; }
body { font-family: Arial, sans-serif; font-size: 11px; }
@@ -244,15 +530,19 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
.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">Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
<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">
@@ -264,25 +554,53 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<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 += `
@@ -293,11 +611,11 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<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 plaqué:</span><strong>${cuttingList.summary.edgeBandingMl} ml</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>
@@ -310,11 +628,15 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<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>
@@ -323,36 +645,114 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<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="4">Sous-total</th>
<th>${group.pieceCount}</th>
<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,
export default {
generateAssemblyPlan,
generateSidePlan,
generateTopPlan,
generateDetailPlan,
generateAllPlans,
generatePDFHtml
generatePDFHtml
};