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
+591
View File
@@ -0,0 +1,591 @@
/**
* Perçages face panneaux dessus/fond — alignés sur la vue A des plans (cuttingEngine)
* et sur les commentaires HOLE du G-code (cncGenerator).
*/
import {
CONNECTORS,
PANEL,
CARCASS_FASTENERS,
getCarcassFerrureDowelDrillings,
getHingeCountForDoor,
getHingeCupPositions,
HINGE,
tourillonFaceDrillDepthMm,
DRAWER_REINFORCEMENT,
DRAWER_RENFORT_PANEL_IXCONNECT
} from './manufacturing.js';
/** Perçage ferrure en face — Ø8 mm (même diamètre que tourillon / Ixconnect face) */
export const CARCASS_FERRURE_FACE_DIAMETER_MM = 8;
/** Montants / séparateurs : rangées Ixconnect jonction dessus·fond — retrait depuis bords haut/bas (mm, origine Y = bas pièce en G-code). */
export const IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM = 23;
/** Séparateurs verticaux : second jeu T–IX–T symétrique depuis le bord arrière (profondeur), si largeur brut le permet. */
export const IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM = 200;
export function mapJunctionXAcrossWidthMm(widthMm, xmm) {
const edgeInset = PANEL.structure * 0.5;
const clamped = Math.max(0, Math.min(widthMm, Number(xmm) || 0));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
}
/** Même logique que `getCarcassDowelStationsForPlan` dans cuttingEngine.js */
export function getCarcassDowelStationsForPlan(cf, edgeLengthMm) {
const L = Math.max(1, Number(edgeLengthMm) || 0);
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
let positions = cf.positionsAlongJointMm;
if (!positions?.length && cf.dowelStations?.length) {
positions = cf.dowelStations.map((s) => s.centerMm).filter((v) => Number.isFinite(Number(v)));
}
return getCarcassFerrureDowelDrillings(positions || [], L, off);
}
/**
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
*/
export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const m = piece?.machining || {};
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
if (!['dessus', 'fond'].includes(piece.elementType || '')) return out;
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
/** Largeur « caisse » (finie) pour repères jonctions — peut être < widthMm (brut CNC). */
const refW =
Number(piece?.widthMm) > 0 ? Math.min(widthMm, Number(piece.widthMm)) : widthMm;
const edgeTolMm = 0.5;
const cf = m.carcassFasteners;
if (cf && (cf.dowelStations?.length || cf.positionsAlongJointMm?.length)) {
const edgeL = Math.max(1, Number(cf.edgeLengthMm) || heightMm);
const stations = getCarcassDowelStationsForPlan(cf, edgeL);
const jax = cf.junctionAxesAcrossWidthMm?.length ? cf.junctionAxesAcrossWidthMm : [0, refW];
const dTour = Number(cf.dowelDiameter || CARCASS_FASTENERS.dowel.diameter);
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const zFerr = Number(cf.ferrureDepthInThicknessMm ?? CARCASS_FASTENERS.ferrureDepthInThicknessMm);
const zFerrEdge = Number(cf.ferrureDepthAlongLengthMm ?? 46);
for (const xmm of jax) {
const rawX = clampX(xmm);
const isEnd = rawX <= edgeTolMm || rawX >= refW - edgeTolMm;
const endSide = rawX <= refW / 2 ? 'left' : 'right';
const x = clampX(mapJunctionXAcrossWidthMm(widthMm, xmm));
for (const st of stations) {
const along = st.holesAlongMm || [];
for (let hi = 0; hi < along.length; hi++) {
const alongMm = along[hi];
const yMm = clampY(alongMm);
const isTourillon = hi !== 1;
if (isTourillon) {
out.push({
xMm: x,
yMm,
diameterMm: dTour,
depthMm: zTour,
type: 'tourillon',
face: isEnd ? 'end' : 'face',
...(isEnd ? { end: endSide } : {})
});
} else {
/* Même perçage ferrure (Ø/prof) ; type ixconnect pour G-code / 3D : violet + long sur chant montant, comme renfort. */
out.push({
xMm: x,
yMm,
diameterMm: CARCASS_FERRURE_FACE_DIAMETER_MM,
depthMm: isEnd ? zFerrEdge : zFerr,
type: 'ixconnect',
face: isEnd ? 'end' : 'face',
...(isEnd ? { end: endSide } : {})
});
/* Trou Inbus : même règle que connecteur T–IX–T (un par Ixconnect ferrure). Sans ceci, les jeux côté rainure
* (souvent issus de ferrures caisse seules) n’ont pas de trou violet Inbus en plan / G-code / 3D. */
{
const lock = CONNECTORS.ixconnect.lockScrewInbus;
const dLock = Number(lock?.diameter) || 6;
const zLock = Number(lock?.depth) || 9;
const epsMm = 0.1;
let xInbus = clampX(x);
if (isEnd) {
const depthAlongMm = Math.min(zFerrEdge, widthMm * 0.48, 80);
xInbus =
endSide === 'left'
? depthAlongMm / 2 + epsMm
: widthMm - depthAlongMm / 2 - epsMm;
xInbus = clampX(xInbus);
}
out.push({
xMm: xInbus,
yMm,
diameterMm: dLock,
depthMm: zLock,
type: 'ixconnect_inbus',
face: 'face'
});
}
}
}
}
}
}
const ch = m.connectorHoles;
const joints = ch?.ixconnectJointsAlongDepth;
if (joints?.length) {
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
const zFace = Number(ch.faceHole?.depth || CONNECTORS.ixconnect.faceHole.depth || 12);
const dEdge = Number(ch.edgeHole?.diameter || CONNECTORS.ixconnect.edgeHole.diameter || 8);
const zEdge = Number(ch.edgeHole?.depth || CONNECTORS.ixconnect.edgeHole.depth || 45);
const zTourEdge = Math.min(zTour, 16);
for (const j of joints) {
/* isEnd doit utiliser l’axe nominal (repère caisse), pas clampX avant : si brut CNC < largeur finie,
* clampX(axe droit) recule sous refW−ε et la jonction droite n’est plus « extrémité » → un seul Inbus. */
const axisNominal = Number(j.axisAcrossWidthMm) || 0;
const rawAcross = clampX(axisNominal);
const isEnd =
axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm;
const endSide = rawAcross <= refW / 2 ? 'left' : 'right';
const x = isEnd
? rawAcross <= edgeTolMm
? 0
: refW
: clampX(mapJunctionXAcrossWidthMm(widthMm, j.axisAcrossWidthMm));
const seq = [
{ axis: j.tourillon1Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' },
{ axis: j.ixconnectAxis, d: isEnd ? dEdge : dFace, z: isEnd ? zEdge : zFace, type: 'ixconnect' },
{ axis: j.tourillon2Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' }
];
for (const h of seq) {
const pos = Number(h.axis);
const yMm = clampY(pos);
out.push({
xMm: x,
yMm,
diameterMm: h.d,
depthMm: h.z,
type: h.type,
face: isEnd ? 'end' : 'face',
...(isEnd ? { end: endSide } : {})
});
}
/* Trou de serrage Inbus : un par Ixconnect — même (Y) que l’axe Ixconnect ; X aligné sur le centre du
* perçage tel qu’affiché en 3D (même formule que Cnc3DViewer pour FACE=end : min(prof chant, 48 % largeur, 80 mm) / 2).
* Sur jonction face : X = x du trio. Sur extrémité (montant) : entrée toujours face grande (perpendiculaire),
* mais X ne peut pas rester 0/refW sinon le cylindre est sur le bord au lieu du centre du violet sur chant. */
{
const lock = CONNECTORS.ixconnect.lockScrewInbus;
const dLock = Number(lock?.diameter) || 6;
const zLock = Number(lock?.depth) || 9;
const yIx = clampY(Number(j.ixconnectAxis));
const epsMm = 0.1;
let xInbus = clampX(x);
if (isEnd) {
const depthAlongMm = Math.min(zEdge, widthMm * 0.48, 80);
xInbus =
endSide === 'left'
? depthAlongMm / 2 + epsMm
: widthMm - depthAlongMm / 2 - epsMm;
xInbus = clampX(xInbus);
}
out.push({
xMm: xInbus,
yMm: yIx,
diameterMm: dLock,
depthMm: zLock,
type: 'ixconnect_inbus',
face: 'face'
});
}
}
}
return out;
}
/**
* Rayons mobiles (Étagères amovibles) — système SP 15/8/5
* - Perçage face (dessous non visible): Ø20 × 15, axe à 9.5 mm du bord (gauche/droite)
* - Perçage sur chant (épaisseur, contre côtés): Ø8 × 37, positionné à 6 mm de la face inférieure
* - Positionnement en profondeur (Système 32):
* - Depuis bord avant: 32 mm (37 axe caisse - 5 mm retrait étagère)
* - Depuis bord arrière: 37 mm
*
* Note: le plan 2D (X,Y) ne porte pas l'offset en épaisseur (6 mm) pour le perçage chant ; on l’encode
* comme perçage "end" pour la visualisation et l’annotation G-code.
*
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
*/
export function buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const et = (piece?.elementType || '');
if (!['etageres', 'rayonFixe'].includes(et)) return out;
const m = piece?.machining || {};
const sp = m?.mobileShelfSystem;
const isFixed = et === 'rayonFixe';
if (!sp || (sp.model !== 'sp_15_8_5' && sp.model !== 'sp_15_8_5_face_only')) return out;
const W = Math.max(1, Number(widthMm) || 1);
const D = Math.max(1, Number(heightMm) || 1);
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(D, Number(v) || 0));
const axisFromSide = Number(sp.faceHole?.axisFromSideMm ?? 9.5);
const dFace = Number(sp.faceHole?.diameterMm ?? 20);
const zFace = Number(sp.faceHole?.depthMm ?? 15);
const dEdge = Number(sp.edgeHole?.diameterMm ?? 8);
const zEdge = Number(sp.edgeHole?.depthMm ?? 37);
const yFront = clampY(Number(sp.positions?.fromFrontMm ?? 32));
const yBack = clampY(D - Number(sp.positions?.fromBackMm ?? 37));
const yPos = [yFront, yBack].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
const xLeft = clampX(axisFromSide);
const xRight = clampX(W - axisFromSide);
const xSides = [xLeft, xRight].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
for (const yMm of yPos) {
// Perçage face dessous (non visible): 2 côtés (gauche/droite)
for (const xMm of xSides) {
out.push({
xMm,
yMm,
diameterMm: dFace,
depthMm: zFace,
type: 'shelf_mobile_face',
face: 'face'
});
}
// Perçage chant côté gauche/droit (rayons mobiles uniquement)
if (!isFixed) {
out.push({
xMm: 0,
yMm,
diameterMm: dEdge,
depthMm: zEdge,
type: 'shelf_mobile_edge',
face: 'end',
end: 'left'
});
out.push({
xMm: W,
yMm,
diameterMm: dEdge,
depthMm: zEdge,
type: 'shelf_mobile_edge',
face: 'end',
end: 'right'
});
}
}
return out;
}
/**
* Renforts tiroir 25 mm — 4× Ixconnect SC 8/25 aux coins (50 mm des bords), repère Y depuis bord haut.
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
*/
function coteInnerFaceHasRenfortHoles(faceBlock) {
return Array.isArray(faceBlock?.holes) && faceBlock.holes.length > 0;
}
/**
* Pièce « côté » seul (gauche ou droit) : une seule face intérieure est présente dans `machining`.
* Pièce qty 2 : gauche = partIndex 0, droit = 1 lorsque les deux faces existent.
*/
function resolveCoteDrawerRenfortInnerFace(cotes, partIndex) {
const L = cotes?.leftInnerFace;
const R = cotes?.rightInnerFace;
const hasL = coteInnerFaceHasRenfortHoles(L);
const hasR = coteInnerFaceHasRenfortHoles(R);
if (hasL && hasR) {
return partIndex === 0 ? L : R;
}
if (hasL) return L;
if (hasR) return R;
return null;
}
/**
* Montants / séparateurs : Ixconnect SC 8/25 zone renfort tiroirs cachés (repère X avant, Y bas).
* @param {number} [partIndex] — pour cotes: 0 = montant gauche, 1 = droit lorsque les deux sont sur la même ligne ; pour séparateurs: index du séparateur
*/
export function buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex = 0) {
const et = piece?.elementType || '';
const m = piece?.machining?.drawerRenfortPanelIxconnect;
if (!m) return [];
const d = Number(m.faceHole?.diameter ?? DRAWER_RENFORT_PANEL_IXCONNECT.faceHoleDiameterMm) || 8;
const z = Number(m.faceHole?.depth ?? DRAWER_RENFORT_PANEL_IXCONNECT.faceHoleDepthMm) || 12;
const out = [];
if (et === 'cotes' && m.cotes) {
const face = resolveCoteDrawerRenfortInnerFace(m.cotes, partIndex);
const holes = face?.holes;
if (!holes?.length) return [];
for (const h of holes) {
out.push({
xMm: Number(h.xMm) || 0,
yMm: Number(h.yMm) || 0,
diameterMm: d,
depthMm: z,
type: 'ixconnect_drawer_renfort_panel',
face: 'face'
});
}
return out;
}
if (et === 'panneauxInterieurs' && Array.isArray(m.separators)) {
const sep = m.separators[partIndex];
if (!sep) return [];
/** Vers module d’indice bas : une grande face du panneau ; vers module haut : l’autre (épaisseur). */
const pushFace = (block, tag, faceKey) => {
if (!block?.holes?.length) return;
for (const h of block.holes) {
out.push({
xMm: Number(h.xMm) || 0,
yMm: Number(h.yMm) || 0,
diameterMm: d,
depthMm: z,
type: `ixconnect_drawer_renfort_${tag}`,
face: faceKey
});
}
};
pushFace(sep.faceTowardLowerModule, 'sep_lower', 'face');
pushFace(sep.faceTowardHigherModule, 'sep_higher', 'back');
return out;
}
return [];
}
export function buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm) {
if ((piece?.elementType || '') !== 'renfortsTiroir') return [];
const ix = piece?.machining?.drawerReinforcement?.ixconnectFixing;
if (!ix?.holes?.length) return [];
const d = Number(ix.holeDiameterMm ?? DRAWER_REINFORCEMENT.ixconnectHoleDiameterMm) || 8;
const z = Number(ix.depthInReinforcementMm ?? DRAWER_REINFORCEMENT.ixconnectDepthInReinforcementMm) || 13;
return ix.holes.map((h) => ({
xMm: Number(h.xMm) || 0,
yMm: Number(h.yMm) || 0,
diameterMm: d,
depthMm: z,
type: 'ixconnect_drawer_reinforcement',
face: 'face'
}));
}
/**
* Montants : perçages face (axe épaisseur) Ixconnect + tourillons, 2 rangées haut/bas.
* Séparateurs verticaux : perçages en about sur les petits côtés d’extrémité (faces w×ép. aux deux bouts de la longueur),
* axe le long de Z (hauteur brut), pas depuis les grands chants profondeur (X).
* X = centres depuis bord avant profondeur ; Y G-code = 0 (bord bas) ou H (bord haut) — repère contour brut dans le plan pièce.
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
*/
export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const et = piece?.elementType || '';
if (!['cotes', 'panneauxInterieurs'].includes(et)) return out;
const ch = piece?.machining?.connectorHoles;
const p = ch?.jointPattern;
if (!p) return out;
const H = Math.max(1, Number(heightMm) || 1);
const W = Math.max(1, Number(widthMm) || 1);
const inset = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM;
if (H < 2 * inset + 5) return out;
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
const zFace = Number(ch.faceHole?.depth || CONNECTORS.ixconnect.faceHole.depth || 12);
const dEdge = Number(ch.edgeHole?.diameter || CONNECTORS.ixconnect.edgeHole.diameter || 8);
const zIxEdge = Number(ch.edgeHole?.depth || CONNECTORS.ixconnect.edgeHole.depth || 45);
const zTourEdge = Math.min(tourillonFaceDrillDepthMm(thicknessMm), 15);
const yRows = [inset, H - inset];
const t1 = Number(p.tourillon1Axis);
const t2 = Number(p.tourillon2Axis);
const ix = Number(p.ixconnectAxis);
const mirrorBack =
W >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1;
if (et === 'panneauxInterieurs') {
const seqFront = [
{ axis: t1, d: dTour, z: zTourEdge, type: 'tourillon' },
{ axis: ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
{ axis: t2, d: dTour, z: zTourEdge, type: 'tourillon' }
];
const seqBack = mirrorBack
? [
{ axis: W - t2, d: dTour, z: zTourEdge, type: 'tourillon' },
{ axis: W - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
{ axis: W - t1, d: dTour, z: zTourEdge, type: 'tourillon' }
]
: null;
const pushEndRow = (yMm, seq, endSide) => {
const cy = clampY(yMm);
for (const h of seq) {
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
out.push({
xMm,
yMm: cy,
diameterMm: h.d,
depthMm: h.z,
type: h.type,
face: 'end',
end: endSide
});
}
};
/* Y = 0 / H : extrémités longueur du brut (petits côtés w×ép.), pas un inset sur la grande face. */
pushEndRow(0, seqFront, 'bottom');
if (seqBack) pushEndRow(0, seqBack, 'bottom');
pushEndRow(H, seqFront, 'top');
if (seqBack) pushEndRow(H, seqBack, 'top');
return out;
}
/* cotes : face */
const seqFront = [
{ axis: t1, d: dTour, z: zTour, type: 'tourillon' },
{ axis: ix, d: dFace, z: zFace, type: 'ixconnect' },
{ axis: t2, d: dTour, z: zTour, type: 'tourillon' }
];
const seqBack = mirrorBack
? [
{ axis: W - t2, d: dTour, z: zTour, type: 'tourillon' },
{ axis: W - ix, d: dFace, z: zFace, type: 'ixconnect' },
{ axis: W - t1, d: dTour, z: zTour, type: 'tourillon' }
]
: null;
const pushFaceRow = (yMm, seq) => {
const cy = clampY(yMm);
for (const h of seq) {
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
out.push({
xMm,
yMm: cy,
diameterMm: h.d,
depthMm: h.z,
type: h.type,
face: 'face'
});
}
};
for (const yMm of yRows) {
pushFaceRow(yMm, seqFront);
if (seqBack) pushFaceRow(yMm, seqBack);
}
return out;
}
/**
* Habillages de finition Premium — perçages SC 8/25 (Ixconnect) stockés dans `piece.machining.finishHabillage.holes`.
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
*/
export function buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const fh = piece?.machining?.finishHabillage;
const holes = fh?.holes;
if (!Array.isArray(holes) || holes.length === 0) return out;
const W = Math.max(1, Number(widthMm) || 1);
const H = Math.max(1, Number(heightMm) || 1);
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
for (const h of holes) {
if (!h) continue;
out.push({
xMm: clampX(h.xMm),
yMm: clampY(h.yMm),
diameterMm: Number(h.diameterMm) || Number(fh?.faceHole?.diameter) || 8,
depthMm: Number(h.depthMm) || Number(fh?.faceHole?.depth) || 12.5,
type: h.type || 'ixconnect',
face: h.face || 'face',
...(h.end ? { end: h.end } : {})
});
}
return out;
}
/**
* Portes : cuvettes charnières (Blum) + 2 perçages Ø8 par cuvette (45 mm c/c, centres à 9,5 mm du centre cuvette vers l’intérieur).
* Positions (depuis haut) : axe 103 mm et grille System 32 (voir getHingeCupPositions).
* Repère plan: x = largeur gauche→droite ; y = hauteur depuis bas.
*
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
*/
export function buildDoorHingeCupHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
if ((piece?.elementType || '') !== 'portes') return out;
const W = Math.max(1, Number(widthMm) || 1);
const H = Math.max(1, Number(heightMm) || 1);
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
const hc = piece?.machining?.hingeCups;
if (!hc) return out;
const diameterMm = Number(hc.diameter) || 35;
const depthMm = Number(hc.cupDepth) || 12;
const screwDiameterMm = Number(hc.screwDiameterMm ?? HINGE.cupScrewDiameter) || 8;
const screwDepthMm = Number(hc.screwDepthMm ?? HINGE.cupScrewDepth) || 13;
const screwSpacingMm = Number(hc.screwSpacingMm ?? HINGE.cupScrewSpacing) || 45;
const screwOffsetMm = Number(hc.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter) || 9.5;
const halfScrewSpacing = screwSpacingMm / 2;
// Default Blum inset for cup center (~22mm from edge) unless explicitly set upstream.
const axisFromEdgeMm = Number(hc.axisFromEdgeMm ?? hc.axisFromSideMm ?? hc.axisFromEdge ?? 22);
// Hinge side hint (viewer-only). If absent, keep a consistent default.
const hingeSide = piece?.doorPlanHints?.hingeSide === 'right' ? 'right' : 'left';
const xMm = hingeSide === 'right' ? clampX(W - axisFromEdgeMm) : clampX(axisFromEdgeMm);
const dxInterior = hingeSide === 'right' ? -screwOffsetMm : screwOffsetMm;
const count = Math.max(2, Number(hc.count) || getHingeCountForDoor({ heightMm: H }));
const positionsFromTop =
Array.isArray(hc.positionsFromTop) && hc.positionsFromTop.length
? hc.positionsFromTop.map((v) => Number(v)).filter((v) => Number.isFinite(v))
: getHingeCupPositions(H, count);
for (const fromTop of positionsFromTop) {
const yMm = clampY(H - fromTop); // convert "from top" to plan Y from bottom
out.push({
xMm,
yMm,
diameterMm,
depthMm,
type: 'hinge_cup',
face: 'face'
});
const xScrew = clampX(xMm + dxInterior);
for (const dy of [-halfScrewSpacing, halfScrewSpacing]) {
out.push({
xMm: xScrew,
yMm: clampY(yMm + dy),
diameterMm: screwDiameterMm,
depthMm: screwDepthMm,
type: 'hinge_screw',
face: 'face'
});
}
}
return out;
}