import { JscadOperation } from 'jscad-planner'; import { BaseSolver, BasePipelineSolver, PipelineStep } from '@tscircuit/solver-utils'; import { GraphicsObject } from 'graphics-debug'; /** * An outward face of the enclosure, named by the axis its outward normal points * along. `z_pos` is the outward face of the closing part (the lid, for an * `fdm.box`); `z_neg` is the outward face of the floor. * * Named directions (`left`, `top`, ...) are deliberately avoided here: an * enclosure face can plausibly be +/-Y or +/-Z, and `top` already means +Y as an * `insertion_direction` and +Z as a PCB layer. Core's `BoardWall` uses the same * six names, so converting a board wall to a face is an identity. */ type EnclosureFace = "x_pos" | "x_neg" | "y_pos" | "y_neg" | "z_pos" | "z_neg"; interface EnclosureBoardInput { /** Board width along X, in millimetres. */ width: number; /** Board height along Y, in millimetres. */ height: number; /** Board thickness along Z, in millimetres. */ thickness: number; } /** * An aperture's `width`/`height`/`depth` are measured in the frame of the face * it pierces, never in board or enclosure axes. They are the same three words * `EnclosureMechanicalInput` uses for the box's own X/Y/Z spans, but they belong * to a different object and the face is what fixes which axes they mean: * * | Face | `width` | `height` | `depth` | * | --- | --- | --- | --- | * | `x_pos`, `x_neg` | Y | Z | X | * | `y_pos`, `y_neg` | X | Z | Y | * | `z_pos`, `z_neg` | part-local, see below | part-local | Z | * * So on any side face `height` is the vertical dimension and `width` runs along * the wall; the two are the face-tangent axes in the order given by * `getFaceTangentAxes`, and `depth` is the face normal. * * On the two horizontal faces the tangent pair is board X and Y only when the * part is unrotated. A part on the lid or the floor can sit at any rotation, so * the opening turns with it -- see `rotation`. */ interface CommonEnclosureApertureInput { /** * Initial Cartesian face for the aperture. With no continuous axis this is * the final face. When `apertureAxisDirection` is present on a side opening, * resolution selects the first enclosure wall reached by that ray; this value * breaks an exact corner tie. */ face: EnclosureFace; /** * How far the opening is turned within the face it pierces, in degrees * counter-clockwise, about the face normal. * * Only meaningful on `z_pos`/`z_neg`. A board rotation is a rotation about Z, * so the part of it that *rolls* an opening is its component about the face * normal: 1 on the horizontal faces, whose normal is Z, and exactly 0 on the * four side walls, whose normals lie in the board plane. A part sitting on * the lid or the floor can be placed at any rotation, and its opening has to * turn with it -- otherwise a rotated rectangular connector gets a cutout * still squared to board X/Y. * * Adapters should pass the part's own rotation for every face and let this be * resolved; a side wall genuinely has no roll to apply, and the part's * rotation is accounted for there at an earlier stage, by rotating the * footprint's insertion direction to pick which wall the aperture belongs in. * * One case is currently invisible rather than absent: moving a part to the * bottom layer is a 180 degree rotation about Y, which for the +/-Y walls IS a * roll about the face normal. Every aperture shape today -- rect, pill, circle * -- is centrally symmetric, so a 180 degree roll maps the opening onto * itself. An asymmetric shape (a keyed or D-shaped opening) would make it * matter, and would need the flip folded in here. */ rotation?: number; /** * Continuous outward axis of the part's enclosure interaction, expressed as * a unit **direction** in the board's right-handed XYZ frame (+Z above the * board). It receives rotation and layer orientation but no translation; * units therefore do not apply. * * On a side face the enclosure measures this vector against the selected * face's outward normal to obtain the signed incidence angle. This must remain * separate from `face`: the face is the nearest quantized Cartesian choice, * while this vector preserves the physical angle within that choice. In * particular, reconstructing the vector from `rotation` is ambiguous at * exactly +/-45 degrees and is wrong when the footprint's local direction is * not the assumed axis. * * Optional for adapters that do not know the part's interaction direction; * absent means the part is treated as square to its selected face. */ apertureAxisDirection?: { x: number; y: number; z: number; }; /** * A point on the interaction axis in board coordinates, relative to the board * center. With `apertureAxisDirection`, this must be the stable datum the part * rotates around; the enclosure intersects that ray with its first wall. With * no continuous axis, the point is projected orthogonally onto `face` and its * two tangent coordinates center the aperture. */ center: { x: number; y: number; }; /** * Which board surface the part is mounted on. Sets the datum for * `heightDimensionOffset`, and for a vertical interaction it is also the face the * aperture exits through. * * Defaults to `"top"`. */ boardSide?: "top" | "bottom"; /** * Move the opening's **center** across the face it pierces, along the same * two axes its `width` and `height` are measured in. Both may be negative. * * Sharing the frame with the dimensions is the point: on a side face * `heightDimensionOffset` runs the way `height` does, and on a horizontal face * both follow the part's own rotation, exactly as the opening itself does. * There is no separate Z quantity to reason about, which is what * `zExtentAboveBoard` used to be -- a name that only made sense on the four * walls, since on the lid and the floor the opening does not move in Z at all. * * Zero means "wherever the part puts it", which is usually right: * * - Side faces: centered on the part's body above the board, taken from * `componentBody.aboveBoardHeight`. An opening therefore lines up with the * connector it serves without anyone computing a height. Where a part has no * measured bounds this falls back to half the opening's own height, which * rests its lower edge on the mounting surface. * - Horizontal faces: centered on the part's own position. * * `heightDimensionOffset` is measured **outward** from the mounting surface on * a side face -- up for a top-mounted part, down for a bottom-mounted one -- * so, like the default it shifts, it describes the part rather than where the * part was placed. The same authored number stays correct on either side of * the board. */ widthDimensionOffset?: number; /** See `widthDimensionOffset`. */ heightDimensionOffset?: number; /** Extra clearance applied on every edge. */ margin?: number; /** * Opening size along the normal of the face -- how far the cut is projected * inboard, measured in the direction the part pokes through. * * This is the third aperture dimension, not a board-Z measurement: on a side * face it runs horizontally, along X or Y. The vertical dimension of a side * aperture is `height`. * * The cut is projected this far inboard of the face so that nothing *inside* * the enclosure fouls the part. Cutting only through the face itself leaves the * lid lip (which sits just inboard of the side walls) intact, so a connector * deep enough to reach it would be obstructed by a feature the opening never * touched. Over-projecting is harmless: inboard of the wall and lip there is * only cavity. * * Defaults to 0, i.e. cut through the face only. */ depth?: number; /** * Physical envelope of the part behind this aperture, supplied by an upstream * adapter when `depth` is not authored. * * The enclosure layer projects it onto the face normal (see * `getComponentBodyFaceExtent`), so the adapter reports facts about the part * and this package keeps ownership of what those facts mean for a cut. */ componentBody?: EnclosureComponentBody; } interface RectEnclosureApertureInput extends CommonEnclosureApertureInput { shape: "rect"; width: number; height: number; } interface PillEnclosureApertureInput extends CommonEnclosureApertureInput { shape: "pill"; width: number; height: number; } interface CircleEnclosureApertureInput extends CommonEnclosureApertureInput { shape: "circle"; radius: number; } type EnclosureApertureInput = RectEnclosureApertureInput | PillEnclosureApertureInput | CircleEnclosureApertureInput; interface EnclosureMechanicalInput { board: EnclosureBoardInput; /** Outside X dimension. Inferred when omitted. */ width?: number; /** Outside Y dimension. Inferred when omitted. */ height?: number; /** Outside Z dimension. Inferred when omitted. */ depth?: number; /** Side wall thickness. Defaults to 2 mm. */ wallThickness?: number; /** Floor thickness. Defaults to wallThickness. */ floorThickness?: number; /** Horizontal clearance between each board edge and the inside wall. */ boardClearance?: number; apertures?: EnclosureApertureInput[]; } interface ResolvedEnclosureDimensions { width: number; height: number; depth: number; wallThickness: number; floorThickness: number; boardClearance: number; } /** * An aperture after resolution: every optional and every fallback in * `EnclosureApertureInput` has been decided. Geometry stages consume this and * must not re-derive placement from the authored aperture. */ interface ResolvedEnclosureAperturePlacement { /** The authored aperture this placement was resolved from. */ aperture: EnclosureApertureInput; face: EnclosureFace; /** * Aperture center in enclosure-local coordinates, already projected onto the * face plane. Geometry stages translate to this and must not re-derive it. */ center: { x: number; y: number; z: number; }; /** * How far inboard of the face the cut is projected, so features inside the * enclosure (the lid lip today, mounting bosses later) cannot obstruct the * part. Resolved from the aperture's `depth`. */ inwardProjection: number; /** * How far off square the part meets this face, in degrees. * * The cutting tool is turned by it about board Z, so its depth axis follows * the part's real mating axis and the wall receives the true oblique section. * Zero on the horizontal faces and for any part square to its wall. */ incidenceDegrees?: number; /** Margin-inflated opening size across the face's first tangent axis. */ width: number; /** Margin-inflated opening size across the face's second tangent axis. */ height: number; /** * Rotation of the opening within its face, in degrees. Zero on the side * faces, which do not turn -- see `EnclosureApertureInput.rotation`. */ rotation: number; } /** * Process-independent resolved enclosure problem. `lib/fdm/` extends this with * construction-specific fields; a future CNC or sheet-metal pipeline would do * the same while reusing this resolution. */ interface ResolvedEnclosureInput { board: EnclosureBoardInput; apertures: ResolvedEnclosureAperturePlacement[]; } interface ResolvedEnclosureAperture { aperture: EnclosureApertureInput; width: number; height: number; cutDepth: number; jscadPlan: JscadOperation; } /** * Normalized physical envelope of the part behind an aperture. * * This is deliberately *not* a renderer bounding box. Nothing upstream loads a * mesh, so a true min/max box would either be a lie or force the enclosure layer * to depend on geometry loading. What an adapter can honestly supply is: * * - the authored body extent in the part's own frame (`size`), * - how the part is turned on the board (`rotation`), and * - the board-frame footprint it occupies (`footprint`). * * The enclosure layer turns those facts into a face-relative depth. Keeping the * projection here rather than in the adapter means every consumer -- FDM today, * CNC or sheet metal later, plus assembly/DRC -- reads the same envelope and * applies its own policy to it. */ interface EnclosureComponentBody { /** * Body extent in the part's own, unrotated frame. * * `z` is the full model extent, so it spans whatever hangs *below* the board * as well. Prefer `aboveBoardHeight` when it is present; `size.z` is the * fallback for parts whose model bounds were never measured, and is only used * where over-reporting is harmless. */ size?: { x: number; y: number; z?: number; }; /** * How far the part reaches above the board surface, in millimetres. * * This is the honest Z extent for anything the enclosure must clear. It is * derived by the adapter from the model's measured bounds and the point of * the model placed on the board surface, so unlike `size.z` it excludes pins * and through-board shells. */ aboveBoardHeight?: number; /** Rotation of the part about the board normal, in degrees. */ rotation?: number; /** * Axis-aligned board-frame extents of the part's footprint. Used as the floor * for a projection, since pad fans and courtyards can reach further inboard * than the body itself. */ footprint?: { width: number; height: number; }; } /** * How far the part reaches along the normal of the face it pokes through. * * Prefers the authored body extent, because that is the physical part. A * footprint is only where the pads and silkscreen are, and a through-board * connector shell commonly overhangs it by a lot: the prefab USB-A has a 19mm * deep body over a 10mm footprint, so sizing a cut from the footprint alone left * the opening stopping barely half way along the connector. The footprint is * still taken as a floor, since it can be the larger of the two. * * On a horizontal face the normal is Z, so the extent is how far the part * reaches above the board. `aboveBoardHeight` is that number honestly, derived * from the model's measured bounds about the board surface, and is what should * be supplied. Reporting a Z extent at all is what lets a part in a corner * relieve the side walls it presses against. A footprint has no Z extent, so * there is no floor to apply. * * `size.z` is only a fallback for parts whose model was never measured, and it * is a poor one: it spans the pins and any through-board shell, so it * over-reports the part's reach. Prefer measuring the model; a caller relying on * this fallback for a tall through-hole part should authorise the depth * explicitly instead. * * Note this is an extent in the PART's frame, measured from the board it stands * on -- not a depth. On a horizontal face the two datums are a whole cavity * apart, so `getDerivedApertureDepth` converts before anything cuts with it. * Using this number raw as a lid depth is what once drove a cut into the floor. */ declare const getComponentBodyFaceExtent: ({ body, face, }: { body: EnclosureComponentBody | undefined; face: EnclosureFace; }) => number | undefined; /** * The part's reach above the board, if it was measured. * * Deliberately does NOT fall back to `size.z`: that spans the pins and any * through-board shell, so centering an opening on it would sit the opening too * high. A caller with no measured bounds is better served by its own fallback. */ declare const getComponentBodyAboveBoardHeight: (body: EnclosureComponentBody | undefined) => number | undefined; type Axis = "x" | "y" | "z"; declare const ENCLOSURE_FACES: readonly ["x_pos", "x_neg", "y_pos", "y_neg", "z_pos", "z_neg"]; /** * Whether a value is one of the six faces. * * Worth having because the retired names fail *quietly* otherwise: the helpers * below read the axis and sign off the name, so a stale `"top"` yields axis * `"t"` and a negative sign, and the solver goes on to place a malformed side * wall rather than throwing. Anything crossing the package boundary as a face * should be checked here first. */ declare const isEnclosureFace: (value: unknown) => value is EnclosureFace; declare const assertEnclosureFace: (value: unknown, context?: string) => EnclosureFace; /** * Face names encode their own geometry (`_`), so the normal axis and * sign are read off the name rather than looked up. A table here could disagree * with the names; a projection cannot. */ /** The axis an aperture cuts along on each face. */ declare const getFaceNormalAxis: (face: EnclosureFace) => Axis; /** * Which way the face's outward normal points along its axis. `y_pos` is +Y, * agreeing with `insertion_direction`, whose `from_top` is the footprint's +Y. */ declare const getFaceNormalSign: (face: EnclosureFace) => 1 | -1; /** * The two axes tangent to a face. An aperture center is free in these and * pinned to the face plane in the normal axis. */ declare const getFaceTangentAxes: (face: EnclosureFace) => [Axis, Axis]; declare const isHorizontalFace: (face: EnclosureFace) => boolean; /** * Position of an axis in an `[x, y, z]` tuple. * * Named because the mapping is otherwise written out as a nested ternary at * each use, which reads as a decision rather than as the indexing it is. */ declare const getAxisIndex: (axis: Axis) => 0 | 1 | 2; /** * The enclosure's OUTER extent along an axis. * * `width`/`height`/`depth` name the three spans in the enclosure's own frame, * which is a different vocabulary from the face-local `aperture*` dimensions. * This is the one place the two meet, so callers can ask "how far does the box * go along the axis this face is tangent to" instead of re-deriving it with a * face comparison each time. */ declare const getEnclosureSpanAlongAxis: (dimensions: { width: number; height: number; depth: number; }, axis: Axis) => number; /** * Builds the complete through-face subtraction for one already-resolved * aperture. Shape, clearance, and face orientation live with the aperture rather * than in the enclosure-shell planner; placement is read from the resolved * problem and never re-derived here. */ declare const createApertureCutoutPlan: ({ placement, faceThickness, booleanTolerance, }: { placement: ResolvedEnclosureAperturePlacement; /** * Thickness of the material this face is made of -- a side wall, the lid top * plate, or the base floor. The construction layer decides which. */ faceThickness: number; /** Slop so the tool breaks cleanly through both surfaces of the face. */ booleanTolerance: number; }) => ResolvedEnclosureAperture; /** * Process-independent description of the vertical stack of an enclosed-board * assembly. It deliberately says nothing about how the shells are produced, so * this layer can move to `@tscircuit/assembly` unchanged. */ interface EnclosureAssemblyFrameInput { /** Material below the mounted board, measured from the outside bottom. */ floorThickness: number; /** Gap between the inside floor and the board bottom surface. */ standoffHeight: number; /** Board thickness. */ boardThickness: number; /** Distance from the outside top down to the parting plane. */ seamOffsetFromTop: number; /** Outside height of the assembled product. */ totalHeight: number; } /** * The planes fixed by the stack below the seam. These depend only on how the * board is mounted, never on how tall the enclosure is, which is what lets the * overall height be *derived* from them. */ interface EnclosureBoardPlanes { /** Top surface of the base floor. */ floorTopZ: number; /** PCB bottom surface in enclosure-local coordinates. */ boardBottomZ: number; /** PCB top surface in enclosure-local coordinates. */ boardTopZ: number; } interface EnclosureAssemblyFrame extends EnclosureBoardPlanes { /** Base/lid parting plane. */ seamZ: number; /** Outside top surface of the assembled enclosure. */ totalHeight: number; } /** * Resolve the vertical planes of an enclosed-board assembly. * * This module must not import from `lib/enclosure/` or `lib/fdm/`: the assembly * layer sits at the bottom of the dependency order so it can be extracted into * a generic `@tscircuit/assembly` package later. Process-specific callers map * their own dimensions onto `EnclosureAssemblyFrameInput` (see * `lib/fdm/resolve-fdm-enclosure-frame.ts`). */ declare const resolveEnclosureBoardPlanes: ({ floorThickness, standoffHeight, boardThickness, }: Pick) => EnclosureBoardPlanes; declare const resolveEnclosureAssemblyFrame: ({ seamOffsetFromTop, totalHeight, ...stack }: EnclosureAssemblyFrameInput) => EnclosureAssemblyFrame; /** * FDM process/design-rule profile. * * Rules are injectable rather than scattered through geometry code, so a * different printer, material, or fit class can be expressed as data instead of * edits to the shell and aperture builders. * * The first group are fallbacks for dimensions the author may set per * enclosure. The second group are process constants that are not authored. */ interface FdmDesignRules { /** Side-wall thickness when the caller does not specify one. */ wallThickness: number; /** Horizontal gap from each board edge to the inside wall. */ boardClearance: number; /** Lid top-plate thickness. */ lidThickness: number; /** Gap from the inside floor to the PCB bottom. */ standoffHeight: number; /** Empty vertical space above the PCB. */ topHeadroom: number; /** Depth of the friction-fit lip below the lid. */ lidLipDepth: number; /** * Slop added to a subtraction tool so it pokes past the surface it is meant * to break through. Only ever applied to faces that are supposed to open: * extending a cut past a face that defines a wall thickness silently changes * that thickness. */ booleanTolerance: number; /** Total diametral gap between the lid lip and the base cavity wall. */ slidingFitClearance: number; /** Lip wall thickness as a fraction of the side-wall thickness. */ lipWallThicknessRatio: number; /** Upper bound on lip wall thickness regardless of the ratio. */ lipWallThicknessMax: number; } declare const DEFAULT_FDM_DESIGN_RULES: FdmDesignRules; interface CreateFdmEnclosureInput extends EnclosureMechanicalInput { /** Lid top-plate thickness. Defaults to wallThickness. */ lidThickness?: number; /** Gap between the inside floor and PCB bottom. Defaults to 4 mm. */ standoffHeight?: number; /** * Empty vertical space above the PCB top surface, not above the tallest * component -- only parts owning an aperture report a height at all. * * Omitted, the depth is inferred and grows to clear every side-wall aperture. * Given, it is taken literally and apertures do not affect the depth. Defaults * to 6 mm when nothing else forces the box taller. */ topHeadroom?: number; /** Depth of the friction-fit lip below the lid. Defaults to 4 mm. */ lidLipDepth?: number; /** Overrides for the FDM design-rule profile. */ fdmRules?: Partial; } interface ResolvedFdmEnclosureDimensions extends ResolvedEnclosureDimensions { lidThickness: number; standoffHeight: number; topHeadroom: number; lidLipDepth: number; } /** * The fully-decided FDM enclosure problem. Produced once by * `resolveFdmEnclosureProblem`; every pipeline stage reads only from this, so * no stage re-applies a default, a fallback, or a validation rule. */ interface ResolvedFdmEnclosureInput extends ResolvedEnclosureInput { construction: "fdm_box"; dimensions: ResolvedFdmEnclosureDimensions; rules: FdmDesignRules; frame: EnclosureAssemblyFrame; } interface FdmEnclosurePart { id: "base" | "lid"; jscadPlan: JscadOperation; } /** Blank shells before aperture cutouts are applied. */ interface FdmEnclosureShellPlans { basePlan: JscadOperation; lidPlan: JscadOperation; } interface ComposedFdmEnclosurePlans { parts: FdmEnclosurePart[]; assembledPlan: JscadOperation; } interface CreateFdmEnclosureOutput { dimensions: ResolvedFdmEnclosureDimensions; frame: EnclosureAssemblyFrame; parts: FdmEnclosurePart[]; apertures: ResolvedEnclosureAperture[]; jscadPlan: JscadOperation; } declare const assertApertureFitsEnclosure: ({ face, center, width, height, dimensions, prefix, }: { face: EnclosureFace; /** Already projected onto the face plane. */ center: { x: number; y: number; z: number; }; /** Margin-inflated opening size across the first tangent axis. */ width: number; /** Margin-inflated opening size across the second tangent axis. */ height: number; dimensions: ResolvedFdmEnclosureDimensions; /** Caller-facing path of the aperture, e.g. `apertures[2]`. */ prefix: string; }) => void; declare class ComposeFdmEnclosureSolver extends BaseSolver { private readonly params; enclosurePlans?: ComposedFdmEnclosurePlans; constructor(params: { resolved: ResolvedFdmEnclosureInput; shellPlans: FdmEnclosureShellPlans; apertureCutouts: ResolvedEnclosureAperture[]; }); _step(): void; getOutput(): ComposedFdmEnclosurePlans; getConstructorParams(): [typeof this.params]; visualize(): GraphicsObject; } declare class CreateFdmApertureCutoutsSolver extends BaseSolver { private readonly params; apertureCutouts: ResolvedEnclosureAperture[]; constructor(params: { resolved: ResolvedFdmEnclosureInput; }); _step(): void; computeProgress(): number; getOutput(): ResolvedEnclosureAperture[]; getConstructorParams(): [typeof this.params]; visualize(): GraphicsObject; } declare class CreateFdmEnclosureShellSolver extends BaseSolver { private readonly params; shellPlans?: FdmEnclosureShellPlans; constructor(params: { resolved: ResolvedFdmEnclosureInput; }); _step(): void; getOutput(): FdmEnclosureShellPlans; getConstructorParams(): [typeof this.params]; visualize(): GraphicsObject; } /** * How deep to cut for an aperture whose depth was not authored, in the datum * the face measures depth in. * * `getComponentBodyFaceExtent` answers a different question -- how far the part * reaches along the face normal, measured from the board it is mounted on -- and * the two datums only coincide on a side face, where the wall and the part both * start at roughly the board plane. On a horizontal face they do not coincide at * all: an inward projection begins at the plate's INNER surface, while the * part's reach is measured from the board surface, which sits a cavity away. * * Using the reach raw is what cut the floor. A 15mm pushbutton on a 19.35mm box * produced a 15mm cut measured down from the lid's outer face, ending at * z = 4.35 -- past the cavity and 0.15mm into a floor plate spanning 0..2, * leaving a circular pocket in the bottom of the box. The part never went near * the floor; only the number did. * * Stated as a span in one frame, the cut runs from the plane the part is * mounted on up to whichever is higher, the top of the part or the outer face * of the plate: * * from mountZ * to max(mountZ + aboveBoardHeight, plateOuterZ) * * Both ends matter. The upper `max` is why a part far shorter than the cavity * still gets a hole clean through the plate. The lower end is why a part far * taller than the box does not reach the plate at the other end: below the * mounting plane the part cannot be, so no material there can foul it. * * The cutting primitive already spans the complete plate thickness. Its * `inwardProjection` is appended beyond the inner surface, so the derived value * is only the cavity span from that inner surface to `mountZ`. Including the * plate a second time overreaches by one plate thickness and can cut the far * shell when a board sits close to it. * * This is not the depth capping that was removed. That truncated what an author * had explicitly asked for, silently cutting a shallower hole than requested; * an authored depth still renders exactly as drawn, on every face, and can still * be used to reach the far shell deliberately. This only decides what to derive * when nothing was authored. */ declare const getDerivedApertureDepth: ({ face, boardSide, componentBody, frame, }: { face: EnclosureFace; boardSide?: "top" | "bottom"; componentBody: EnclosureComponentBody | undefined; frame: EnclosureAssemblyFrame; }) => number | undefined; /** * How far inboard of a face an aperture must be cut. * * Cutting only through the face leaves everything *behind* it intact, and the * split shell puts material there: the lid lip hangs just inboard of the base * side walls, so a connector deep enough to reach it would be blocked by a * feature the opening never touched. * * The projection is therefore the greater of: * - how deep the opening is (`depth`), so the whole part is clear; * - how far the shell's own inboard features reach. * * `width` and `height` size the opening in the face itself; * `depth` is what reaches into whatever *other* walls lie along the * normal. A large lid opening in a corner, for instance, is bounded in X and Y * by its width and height, and relieves the two side walls it overlaps down to * its depth. * * An authored depth is rendered as authored, on every face. Nothing is capped * to the cavity: a deep enough opening reaches the shell on the far side and * takes material out of it, exactly as drawn. That is a real risk on a * horizontal face, where the far shell is only millimetres away -- an authored * 15mm through the lid will punch through the base floor beneath it. * * A *derived* depth cannot do that, because it is derived in this face's own * datum and stops at the plane the part is mounted on (see * `getDerivedApertureDepth`). The distinction is the point: the part's reach * above the board is not a depth from the lid, and treating it as one is what * put a circular pocket in the floor under a pushbutton. * * Capping used to blur this, and blurred it inconsistently: the four side faces * were never capped, so the same authored number meant "as drawn" on a wall and * "as much as fits" on the lid. Silently cutting a shallower hole than asked * for is its own defect -- the part fouls the shell and the model gives no sign * why. The depth an author writes is still the depth they get, and a box too * shallow to contain a part is fixed by making the box deeper (see * `resolve-fdm-enclosure-dimensions.ts`, which grows the depth to clear the * apertures when `topHeadroom` was not authored) rather than by quietly * truncating the cut. */ declare const getFdmApertureInwardProjection: ({ face, depth, dimensions, rules, }: { face: EnclosureFace; depth?: number; dimensions: ResolvedFdmEnclosureDimensions; rules: FdmDesignRules; }) => number; /** * Which wall of the split shell a given face is made of. A side face is a * printed side wall; `top` is the lid top plate and `bottom` is the base floor, * so an aperture through either only has to clear that plate. */ declare const getFdmFaceThickness: (face: EnclosureFace, dimensions: ResolvedFdmEnclosureDimensions) => number; /** * Z of a side-face aperture center. * * `heightDatum` is where the part puts the opening -- the middle of its body * above the board -- and `heightDimensionOffset` moves it from there. Both are * measured *outward* from the part's own mounting surface, so a part authored * once is correct on either board side: a top-mounted connector measures up from * the board top, a bottom-mounted one down from the board bottom. */ declare const resolveApertureCenterZ: ({ boardSide, heightDatum, heightDimensionOffset, boardTopZ, boardBottomZ, }: { boardSide: "top" | "bottom"; heightDatum: number; heightDimensionOffset: number; boardTopZ: number; boardBottomZ: number; }) => number; /** * Place an aperture center on its face. * * An authored aperture gives a point on the BOARD and a height above its * mounting surface; a cut needs a point in the enclosure. The two tangent * coordinates carry over, and the coordinate along the face normal is replaced * by the face's own plane -- the mid-surface of the material being pierced, so * the cutting prism straddles it. Callers therefore never decide which axis a * given face pins. */ declare const resolveApertureCenter: ({ face, boardCenter, centerZ, widthDimensionOffset, heightDimensionOffset, rotation, incidenceDegrees, dimensions, frame, }: { face: EnclosureFace; boardCenter: { x: number; y: number; }; centerZ: number; /** Across the face, in the same frame as the opening's `width`. */ widthDimensionOffset: number; /** * Across the face, in the same frame as the opening's `height`. Already folded * into `centerZ` on a side face, so it is only applied here on the horizontal * pair, where "height" is an in-plane axis rather than board Z. */ heightDimensionOffset: number; /** In-face rotation of the opening, in degrees. Zero on the side faces. */ rotation: number; /** * How far off square the part meets a side wall, in degrees. Zero on the * horizontal faces and for a part that meets its wall head on. */ incidenceDegrees?: number; dimensions: ResolvedFdmEnclosureDimensions; frame: EnclosureAssemblyFrame; }) => { x: number; y: number; z: number; }; /** * Select the first side wall intersected by the part's continuous outward axis. * * `origin` is a point in enclosure-local/board-centred XY millimetres; * `apertureAxisDirection` is a unitless board-space direction. The component * centre is the stable origin because it is the datum the body itself rotates * around. Selection is performed against the cavity's inner planes: that is the * first enclosure material encountered by a ray leaving a part inside the box. * * Quantizing direction alone switches walls at 45 degrees regardless of where * the part sits. Near a corner that can select a wall the physical axis reaches * only after passing through another one. The actual ray instead changes faces * exactly where it crosses the corner, preserving a continuous opening path. * The caller's quantized face breaks an exact-time tie. */ declare const resolveFirstFaceAlongApertureAxis: ({ face, origin, apertureAxisDirection, dimensions, }: { face: EnclosureFace; origin: { x: number; y: number; }; apertureAxisDirection?: { x: number; y: number; z: number; }; dimensions: ResolvedEnclosureDimensions; }) => EnclosureFace; /** * Signed angle from a selected wall's outward normal to the part's actual * interaction axis, measured counter-clockwise in board XY. * * Both inputs are directions in the board's right-handed frame (+Z above the * board), not points, and carry no translation or units. `face` is the nearest * quantized Cartesian choice; `apertureAxisDirection` retains the continuous * vector that led to that choice. Measuring one against the other is crucial: * recovering a residual from the component's rotation alone loses the * footprint's local axis and disagrees with face selection at exactly +/-45 * degrees, making the cutter lean 90 degrees toward the wrong wall. * * Zero on horizontal faces. A rotation about board Z is a roll in the lid/floor * plane, not an approach angle, and is handled separately as aperture rotation. * An absent vector also means zero so low-information adapters retain the * historical square-to-wall cut rather than guessing an axis. */ declare const getApertureIncidenceDegrees: ({ face, apertureAxisDirection, }: { face: EnclosureFace; apertureAxisDirection?: { x: number; y: number; z: number; }; }) => number; /** * Where the part's mating axis actually crosses the wall, as an offset along the * wall from the part's own position. * * Projecting the part's centre straight onto the wall is only right when it * meets the wall square. Leaning by `incidence`, the axis travels * `distanceToWall / cos(incidence)` to reach the plane and lands * `distanceToWall * tan(incidence)` further along it -- 0.8mm for a part 1.4mm * off the wall at 30 degrees, which is most of a 3.5mm jack's radius. * * Signed along the face's tangent, taken as the outward normal turned 90 degrees * counter-clockwise, so the same expression serves all four walls. */ declare const getObliqueTangentShift: ({ face, incidenceDegrees, distanceToWall, }: { face: EnclosureFace; incidenceDegrees: number; /** Along the outward normal, from the part's point to the wall's mid-plane. */ distanceToWall: number; }) => number; /** * Decide every dimension of the box. * * `topHeadroom` is the one lever an author has over how tall the box is, so * whether apertures are allowed to affect the depth turns on whether they * authored it: * * - **Authored** (or an explicit `depth`): taken literally, apertures ignored. * Growing the box to contain every opening would silently override the very * number the author set -- the depth would land on `tallestApertureTop + * margin` no matter what `topHeadroom` said, so lowering it would do nothing * and a part could never deliberately poke through the lid. * - **Defaulted**: there is no intent to override, and the useful default is a * box that actually works, so the depth grows until the lid and its lip clear * every side-face aperture (see `getApertureClearanceDepth`). Otherwise the * common case -- a connector taller than the default headroom -- produces an * opening straddling the parting seam, which no cable can enter. * * Note what this does NOT promise: clearance over components. Only parts that * own an aperture report their envelope, so an arbitrary tall capacitor is * invisible here. `topHeadroom` is clearance above the board, not above the * tallest part, and its documentation says so. */ declare const resolveFdmEnclosureDimensions: ({ input, rules, }: { input: CreateFdmEnclosureInput; rules: FdmDesignRules; }) => ResolvedFdmEnclosureDimensions; /** * Map resolved FDM box dimensions onto the process-independent assembly frame. * The FDM lid top plate is what sits above the seam, so its thickness is the * generic `seamOffsetFromTop`. */ declare const resolveFdmEnclosureFrame: ({ board, dimensions, }: { board: EnclosureBoardInput; dimensions: ResolvedFdmEnclosureDimensions; }) => EnclosureAssemblyFrame; /** * Turn an authored FDM enclosure request into a fully-decided problem. * * This is the single place where defaults are applied, fallbacks are resolved, * and the request is validated. Everything downstream is pure construction: no * pipeline stage may read `CreateFdmEnclosureInput` or re-derive a value that * appears in `ResolvedFdmEnclosureInput`. * * It reads as the order the decisions actually depend on each other: * * 1. authored input is checked for what can be judged on its own terms; * 2. the box is sized, which needs no aperture (see * `resolve-fdm-enclosure-dimensions.ts` for why apertures deliberately do * not grow it); * 3. the frame fixes the vertical planes of the assembly; * 4. each aperture is placed against that frame, checked to be on the box, and * given its inward projection. */ declare const resolveFdmEnclosureProblem: (input: CreateFdmEnclosureInput) => ResolvedFdmEnclosureInput; /** * Pipeline stage wrapper around the pure `resolveFdmEnclosureProblem`. It keeps * resolution visible as a step in the solver debugger; the logic itself is a * plain function so a caller can pre-resolve a problem and replay it. */ declare class ResolveFdmEnclosureProblemSolver extends BaseSolver { private readonly input; resolved?: ResolvedFdmEnclosureInput; constructor(input: CreateFdmEnclosureInput); _step(): void; getOutput(): ResolvedFdmEnclosureInput; getConstructorParams(): [CreateFdmEnclosureInput]; visualize(): GraphicsObject; } declare const visualizeFdmEnclosure: ({ title, resolved, processedApertureCount, }: { title: string; resolved: ResolvedFdmEnclosureInput; processedApertureCount?: number; }) => GraphicsObject; declare const getApertureDimensions: (aperture: EnclosureApertureInput) => { width: number; height: number; }; /** * How far above the mounting surface a side-face opening sits before any * authored offset, measured to the opening's CENTER. * * The part decides: an opening is centered on the body it serves, so it lines up * with the connector without anyone computing a height. That needs the part's * true reach above the board, which `componentBody.aboveBoardHeight` supplies * from measured model bounds. * * Where a part has no measured bounds there is nothing to center on, so this * falls back to half the opening's own height -- which rests its lower edge on * the mounting surface. That is the older behaviour, kept because it is a * reasonable guess and because it keeps parts without CAD models working. */ declare const getApertureHeightDatum: (aperture: EnclosureApertureInput) => number; /** * Check one authored aperture for values that are wrong on their own terms. * * Only what can be judged without an enclosure: a radius must be positive * whatever box it is cut into. Whether the opening actually FITS is a different * question, needs resolved dimensions, and lives in * `lib/fdm/assert-aperture-fits-enclosure.ts`. */ declare const validateApertureInput: (aperture: EnclosureApertureInput, index: number) => void; declare const createFdmEnclosure: (input: CreateFdmEnclosureInput) => CreateFdmEnclosureOutput; /** * Resolution happens once, in the first stage. Every later stage receives only * the `ResolvedFdmEnclosureInput` it produced, so no construction stage applies * a default, resolves a fallback, or re-runs a validation rule. */ declare class CreateFdmEnclosureSolver extends BasePipelineSolver { resolveFdmEnclosureProblemSolver?: ResolveFdmEnclosureProblemSolver; createFdmEnclosureShellSolver?: CreateFdmEnclosureShellSolver; createFdmApertureCutoutsSolver?: CreateFdmApertureCutoutsSolver; composeFdmEnclosureSolver?: ComposeFdmEnclosureSolver; private getResolvedProblem; pipelineDef: PipelineStep[]; getConstructorParams(): [CreateFdmEnclosureInput]; getOutput(): CreateFdmEnclosureOutput; initialVisualize(): GraphicsObject | null; finalVisualize(): GraphicsObject; } export { type Axis, type CircleEnclosureApertureInput, ComposeFdmEnclosureSolver, type ComposedFdmEnclosurePlans, CreateFdmApertureCutoutsSolver, type CreateFdmEnclosureInput, type CreateFdmEnclosureOutput, CreateFdmEnclosureShellSolver, CreateFdmEnclosureSolver, DEFAULT_FDM_DESIGN_RULES, ENCLOSURE_FACES, type EnclosureApertureInput, type EnclosureAssemblyFrame, type EnclosureAssemblyFrameInput, type EnclosureBoardInput, type EnclosureBoardPlanes, type EnclosureComponentBody, type EnclosureFace, type EnclosureMechanicalInput, type FdmDesignRules, type FdmEnclosurePart, type FdmEnclosureShellPlans, type PillEnclosureApertureInput, type RectEnclosureApertureInput, ResolveFdmEnclosureProblemSolver, type ResolvedEnclosureAperture, type ResolvedEnclosureAperturePlacement, type ResolvedEnclosureDimensions, type ResolvedEnclosureInput, type ResolvedFdmEnclosureDimensions, type ResolvedFdmEnclosureInput, assertApertureFitsEnclosure, assertEnclosureFace, createApertureCutoutPlan, createFdmEnclosure, getApertureDimensions, getApertureHeightDatum, getApertureIncidenceDegrees, getAxisIndex, getComponentBodyAboveBoardHeight, getComponentBodyFaceExtent, getDerivedApertureDepth, getEnclosureSpanAlongAxis, getFaceNormalAxis, getFaceNormalSign, getFaceTangentAxes, getFdmApertureInwardProjection, getFdmFaceThickness, getObliqueTangentShift, isEnclosureFace, isHorizontalFace, resolveApertureCenter, resolveApertureCenterZ, resolveEnclosureAssemblyFrame, resolveEnclosureBoardPlanes, resolveFdmEnclosureDimensions, resolveFdmEnclosureFrame, resolveFdmEnclosureProblem, resolveFirstFaceAlongApertureAxis, validateApertureInput, visualizeFdmEnclosure };