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

592 lines
23 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.
/**
* 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;
}