Skip to main content

Inputs

A custom feature's inputs are the values it declares in Feature.with({ … }). Each entry is one row of its dialog, in the order they are listed. A value is either typed in (type: a length, a whole number, a choice, a toggle, a text) or picked in the viewport (picker: a face, an edge, an axis, a connector, …).

Two fields of an entry do different jobs:

  • What the value is (type or picker, and its limits) decides what buildTool() gets and what the statement writes.
  • How its row looks (display, suffix, hint, the layout) is presentation only. Two features that differ only in looks write the same statement.

The page follows two features. A vent grid cuts rows of round holes into a panel: numbers, a checkbox, picture cards and a laid-out dialog. A fastener hole shows choices that depend on each other, a grid of pictures and a pick of several kinds.

The vent grid​

Editing a vent grid: the dialog's rows, the drawing beside it, the red preview of the holes and the statement under it
  1. 1
    Panel face
    A face picker: { picker: 'face', surface: 'planar' }.
  2. 2
    Pattern
    A choice shown as picture cards: { type: 'radio', display: 'cards' }.
  3. 3
    Rows and Columns
    Two whole numbers with − / + buttons, side by side: display: 'stepper' in a layout row.
  4. 4
    Pitch
    A length on a slider, with a unit and a line of help: display: 'slider', suffix, hint.
  5. 5
    Hole diameter
    A plain length.
  6. 6
    Depth
    A section holding a checkbox, and a row shown only while the checkbox is off (visibleWhen).
  7. 7
    The drawing beside the dialog
    An image the dialog shows beside its rows: the feature's aside.
  8. 8
    The statement
    What Apply writes: the pick, and only the values changed from their defaults.

Its inputs, as the feature declares them:

export class VentGrid extends Feature.with({
face: { picker: 'face', label: 'Panel face', surface: 'planar' },
pattern: {
type: 'radio',
display: 'cards',
columns: 2,
options: [
{ label: 'Grid', value: 'grid', image: './vent-grid/grid.svg' },
{ label: 'Staggered', value: 'staggered', image: './vent-grid/staggered.svg' },
],
},
rows: { type: 'integer', display: 'stepper', default: 4, min: 1, max: 40 },
columns: { type: 'integer', display: 'stepper', default: 6, min: 1, max: 40 },
pitch: { type: 'length', display: 'slider', default: 8, min: 2, max: 30, suffix: 'mm', hint: 'Centre to centre' },
diameter: { type: 'length', label: 'Hole diameter', default: 5, min: 0.5 },
through: { type: 'boolean', display: 'checkbox', label: 'Through all', default: true },
depth: { type: 'length', default: 2, min: 0.1, visibleWhen: { through: false } },
}) {
The whole vent grid
vent-grid.feature.js
// A grid of round vent holes cut into a panel, centred on a planar face.
import { Feature, defineFeature } from 'fluidcad/sdk';
import { Primitives, ShapeOps } from 'fluidcad/sdk/kernel';
import { Matrix4, Plane } from 'fluidcad/math';

/** Long enough to cut through any panel. */
const THROUGH = 1000;

export class VentGrid extends Feature.with({
// A pick: one planar face, clicked in the viewport.
face: { picker: 'face', label: 'Panel face', surface: 'planar' },
// A choice shown as two picture cards.
pattern: {
type: 'radio',
display: 'cards',
columns: 2,
options: [
{ label: 'Grid', value: 'grid', image: './vent-grid/grid.svg' },
{ label: 'Staggered', value: 'staggered', image: './vent-grid/staggered.svg' },
],
},
// Whole numbers with − / + buttons, kept between min and max.
rows: { type: 'integer', display: 'stepper', default: 4, min: 1, max: 40 },
columns: { type: 'integer', display: 'stepper', default: 6, min: 1, max: 40 },
// A length on a slider, with its unit and a line of help under it.
pitch: { type: 'length', display: 'slider', default: 8, min: 2, max: 30, suffix: 'mm', hint: 'Centre to centre' },
diameter: { type: 'length', label: 'Hole diameter', default: 5, min: 0.5 },
// A checkbox, and a row that shows only while it is off.
through: { type: 'boolean', display: 'checkbox', label: 'Through all', default: true },
depth: { type: 'length', default: 2, min: 0.1, visibleWhen: { through: false } },
}) {
static id = 'ventGrid';

buildTool() {
const { pattern, rows, columns, pitch, diameter, through, depth } = this.values;
if (!(diameter < pitch)) {
throw new Error(`vent grid: the holes (${diameter}) must be narrower than the pitch (${pitch})`);
}
const faces = this.facesOf(this.values.face);
if (faces.length !== 1) {
throw new Error(`vent grid: pick one planar face (got ${faces.length} faces)`);
}
const plane = faces[0].getPlane();
const origin = plane.projectPoint(faces[0].center());
const frame = Matrix4.fromBasis(plane.xDirection, plane.yDirection, plane.normal, origin);
const length = through ? THROUGH : depth;

// One cylinder per hole, laid out on the XY plane round the origin and
// sunk below z = 0, then placed on the face: one statement, one row.
const tools = [];
for (let row = 0; row < rows; row++) {
// A staggered grid shifts every other row by half a pitch, both ways
// from the centre so the whole grid stays centred.
const shift = pattern === 'staggered' ? (row % 2 === 0 ? -pitch / 4 : pitch / 4) : 0;
for (let column = 0; column < columns; column++) {
const x = (column - (columns - 1) / 2) * pitch + shift;
const y = (row - (rows - 1) / 2) * pitch;
const hole = Primitives.makeCylinder(diameter / 2, length);
tools.push(ShapeOps.transform(hole, frame.multiply(Matrix4.fromTranslation(x, y, -length))));
hole.dispose();
}
}

// The holes start on the face and grow into the panel: their rims on the
// face become the feature's start edges, so grid.startEdges() chamfers them.
this.setBuckets({ startPlane: new Plane(origin, plane.xDirection, plane.normal.negate()) });
return tools;
}
}

export const ventGrid = defineFeature(VentGrid, {
label: 'Vent grid',
description: 'A grid of round vent holes cut into a panel',
icon: './vent-grid.svg',
group: 'create',
// It only cuts: no Add / New tabs, and no .remove() to write.
operations: ['remove'],
// The preview is cheap to build: draw it from the start, with no toggle.
ghost: 'always',
call: ['face', { pattern: 'pattern', rows: 'rows', columns: 'columns', pitch: 'pitch', diameter: 'diameter', through: 'through', depth: 'depth' }],
// Rows and columns side by side, the depth rows under a heading, and a
// drawing of what the pitch and the diameter measure beside the dialog.
width: 'wide',
layout: [
'face',
'pattern',
{ row: ['rows', 'columns'] },
'pitch',
'diameter',
{ section: 'Depth', items: ['through', 'depth'] },
],
aside: [{ image: './vent-grid/dims.svg', alt: 'The pitch, centre to centre, and the hole diameter' }],
});
A lid that uses it
lid.part.js
import { part, sketch, line, extrude, chamfer } from "fluidcad/core";
import { coincident, horizontal, vertical, distance, fix } from "fluidcad/constraints";
import { ventGrid } from "./vent-grid.feature.js";

// A 120 × 80 × 3 enclosure lid with a staggered vent grid in the middle.
export const lid = part("Lid", () => {
sketch("xy", () => {
const b = line([-61, -41], [59, -39]);
const r = line([59, -39], [61, 41]);
const t = line([61, 41], [-59, 39]);
const l = line([-59, 39], [-61, -41]);
coincident(b.end(), r.start());
coincident(r.end(), t.start());
coincident(t.end(), l.start());
coincident(l.end(), b.start());
horizontal(b);
vertical(r);
horizontal(t);
vertical(l);
fix(b.start(), [-60, -40]);
distance(b.start(), b.end(), 120);
distance(r.start(), r.end(), 80);
});
const panel = extrude(3);

// 5 rows of 9 holes, every other row shifted half a pitch.
const grid = ventGrid(panel.endFaces(), { pattern: "staggered", rows: 5, columns: 9, pitch: 9 });
// The rims the holes left on the top face: the feature's start edges.
chamfer(0.5, grid.startEdges());
});

Fields every input takes​

FieldMeaning
labelThe row's caption. Without one, the name made readable: holeDiameter reads Hole diameter.
descriptionThe row's tooltip.
hintA line of small text under the row, always on show: Centre to centre.
defaultWhat a row left alone holds, and what a call that leaves the value out gets. Pickers take none: their value is what the user picks.
defaultsDefaults that follow other values: [{ when: { type: 'wall' }, value: 720 }]. See Defaults that follow another value.
optionalThe call may leave the value out. Without a default, the feature then gets undefined.
visibleWhenWhen the row shows. See Rows that show only when they matter.
group, tabA heading or a tab the row sits under: the short way to lay out a dialog.
asideA drawing the dialog shows beside its rows while this row has the focus. See A drawing per row.

A misspelt field is refused where the file loads, naming the fields the input takes: unknown field 'defualt' for a length value — its fields are …. The feature is greyed in the Custom dropdown until it is fixed.

Numbers​

typeThe feature getsNotes
lengtha numberIn the model file's unit. A default is a plain number in that unit.
anglea numberIn degrees. The label reads Start angle (°).
integera whole numberA call that passes 2.5 fails to compile.
numbera numberAnything else: a ratio, a count of turns.

min and max bound the value. A model whose call passes a value outside them fails to compile at that call, naming the value and the bound. How the row looks:

FieldRow
(none)A plain input. It takes expressions and the part's variables, as the built-in dialogs do: pitch * 2, wallThickness.
display: 'slider'A track above the input, for a value worth exploring. Needs min and max. step sets the track's step: 1 for an integer, else a hundredth of the range.
display: 'stepper'− and + buttons beside the input, stepping by step (1 without one). For a number or an integer, not a length or an angle.
suffix: 'mm'A short unit at the end of the input, up to 8 characters. On an angle it replaces the (°).
// A bolt circle's inputs: a count, a diameter and a starting angle.
count: { type: 'integer', label: 'Holes', display: 'stepper', default: 4, min: 1, max: 36 },
circle: { type: 'length', label: 'Circle diameter', default: 32, min: 0 },
start: { type: 'angle', label: 'Start angle', default: 45 },

Give every size a sensible default. A number row with no default starts empty, and the dialog won't apply until it is filled: Height is required.

Toggles​

through: { type: 'boolean', display: 'checkbox', label: 'Through all', default: true },

A boolean is a switch, or a checkbox with display: 'checkbox'. Without a default it is off, and the feature gets false.

Choices​

A choice offers a list of options. Each option has a label the dialog shows and a value, a string or a number, which is what the feature gets and the statement writes. With no default, the choice starts at its first option.

Field of an optionMeaning
label, valueWhat the dialog shows, and what the feature gets. Required.
imageA picture of the option, next to the feature file: './vent-grid/grid.svg'. See Pictures.
descriptionA line under the label.
groupA heading the option is listed under.
keywordsMore words a search finds it by: ['inch'] for the imperial sizes.
whenThe other values it is offered for. See Options that follow another value.

A few choices, all on show: radio​

A radio shows every option at once. Choose it for two to five choices the user should see together:

displayRow
'list'Stacked, one per line. The default.
'inline'On one line.
'buttons'A segmented row of buttons, for two or three short labels: Metric / Imperial.
'cards'Picture tiles, for choices told apart by their shape: Grid / Staggered. columns sets 2 to 6 per line.
standard: {
type: 'radio',
display: 'buttons',
options: [{ label: 'Metric', value: 'iso' }, { label: 'Imperial', value: 'unc' }],
},

Many choices: select​

A select is a dropdown. With display: 'grid', the dropdown opens on a grid of the options' pictures (columns, 2 to 6). search: true adds a search box that matches labels, descriptions and keywords. Reach for it beyond a dozen options.

size: {
type: 'select',
label: 'Screw',
default: 'm3',
display: 'grid',
columns: 3,
search: true,
options: [
{ label: 'M3', value: 'm3', image: './fastener/m3.svg', description: '3.4 mm clearance' },
{ label: 'M4', value: 'm4', image: './fastener/m4.svg', description: '4.5 mm clearance' },
{ label: 'M5', value: 'm5', image: './fastener/m5.svg', description: '5.5 mm clearance' },
],
},

A select is also the choice a parameter can drive. Buttons and cards can't take one.

Options that follow another value​

An option's when names the other values it is offered for. The fastener hole's screws follow its standard: the metric sizes for Metric, the numbered sizes for Imperial.

size: {
type: 'select',
label: 'Screw',
default: 'm3',
display: 'grid',
columns: 3,
search: true,
options: [
{ label: 'M3', value: 'm3', image: './fastener/m3.svg', when: { standard: 'iso' } },
{ label: 'M4', value: 'm4', image: './fastener/m4.svg', when: { standard: 'iso' } },
{ label: 'M5', value: 'm5', image: './fastener/m5.svg', when: { standard: 'iso' } },
{ label: '#4', value: 'n4', image: './fastener/n4.svg', when: { standard: 'unc' }, keywords: ['inch'] },
{ label: '#6', value: 'n6', image: './fastener/n6.svg', when: { standard: 'unc' }, keywords: ['inch'] },
],
},

The Screw grid offers M3, M4 and M5 under Metric, and #4 and #6 under Imperial

  • Switching to Imperial moves the screw from M4, no longer offered, to #4, the first one that is. The statement writes standard: 'unc', size: 'n4'.
  • when takes one value or a list ({ standard: ['iso', 'din'] }). Every value it names must match.
  • For a table of sizes, where one option comes with several combinations, when takes a list of such objects, and the option is offered while any one of them matches: when: [{ shape: 'single', height: 40 }, { shape: 'double', height: [20, 80] }].
  • The default must be offered while every other value is at its own default. The declaration is refused otherwise.
  • when narrows the dialog only. A call written by hand may pass any declared option, so buildTool() must handle every one.

Prefer one choice whose options follow another value over two choices swapped in and out with visibleWhen.

Several at once: multiple​

// The sides of an enclosure left open.
open: {
type: 'select',
multiple: true,
label: 'Open sides',
options: [
{ label: 'Front', value: 'front' },
{ label: 'Back', value: 'back' },
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
],
default: [],
max: 3,
},

A select with multiple: true holds a list of options. The dropdown shows a check mark per chosen option, and the row reads the chosen labels, N selected past two, or None. The feature always gets a list, in the order the call wrote it, [] for none: this.values.open.includes('front'). min and max count the options chosen. The statement writes an array literal: open: ['front', 'left'].

Text​

// A part number kept with the feature.
marking: { type: 'text', optional: true, placeholder: 'A2-70', maxLength: 12, pattern: '[A-Za-z0-9-]*', hint: 'Letters, digits and dashes' },

A text is a string. placeholder is what the empty input shows, maxLength the most characters, and pattern a regular expression the whole text must match. A text that breaks either blocks Apply with the reason, and a call that passes one fails to compile. multiline: true gives a text of several lines: Enter starts a new line, and Ctrl/Cmd+Enter applies.

What a row left alone gives​

A row left alone always builds what it shows. A call that leaves a value out gets the same thing:

DeclaredThe row showsThe feature gets
any input with a defaultthe defaultthe default
any input with defaultsthe first whose when holds, else the default — and it follows the other rows until the user changes itthe same, from the call's other values
boolean, no defaultofffalse
select or radio, no defaultthe first option offeredthat option's value
select or radio, no default, optional: truenothing chosenundefined
select with multiple: truethe default, else Nonethe default, else []
a slider, no defaultits minmin
a number or a text, no defaultan empty rowrequired: Apply says Height is required., and a call without it fails to compile
the same, optional: truean empty rowundefined

The dialog writes only the values the user changed, so a statement stays short: ventGrid(panel.endFaces(), { pitch: 9 }).

Defaults that follow another value​

defaults gives a value defaults that depend on the other values. A cabinet's height follows its type:

type: {
type: 'radio', display: 'buttons', default: 'base',
options: [{ label: 'Base', value: 'base' }, { label: 'Wall', value: 'wall' }, { label: 'Wardrobe', value: 'wardrobe' }],
},
height: {
type: 'length', default: 900, min: 300, max: 3000,
defaults: [
{ when: { type: 'wall' }, value: 720 },
{ when: { type: 'wardrobe' }, value: 2400 },
],
},
  • The first entry whose when holds is the default. The plain default applies when none holds, so the value must have one (a boolean, a select and a radio always do).
  • when reads as an option's when: one value or a list ({ type: ['wall', 'wardrobe'] }), every value it names matching, or a list of such objects, any one matching.
  • A when names typed-in values only, and only values that declare no defaults of their own: one level, no chains. The declaration is refused otherwise, naming the entry: defaults[0]: 'when' names 'depth', which declares 'defaults' itself.
  • Each value is checked as a call's value is: its type, min and max, one of the options. On a choice whose options carry a when, each value must be offered where its when holds.
  • In the dialog, a row the user has not touched follows its default: pick Wall and the height shows 720. A row the user changed keeps its value.
  • A call that leaves the value out gets the default its other values give it: cabinet({ type: 'wall' }) is 720 high. The dialog leaves a value set to that default out of the statement, so changing the type later still moves the height.

Rows that show only when they matter​

visibleWhen shows a row only while other values hold what it names. The vent grid's depth only matters for a blind cut:

through: { type: 'boolean', display: 'checkbox', label: 'Through all', default: true },
depth: { type: 'length', default: 2, min: 0.1, visibleWhen: { through: false } },
  • Every value it names must match. A value may also match one of a list: { size: ['m3', 'm4'] }.
  • When one object can't say it, give a list of objects: the row shows while any one of them holds. A cabinet's handle row shows for a wardrobe with doors, or for any base cabinet: visibleWhen: [{ type: 'wardrobe', doors: true }, { type: 'base' }].
  • A hidden row writes nothing. Its value still reaches the feature: depth is 2 while the cut goes through, and buildTool() decides whether to read it.
  • It may name a picked value, with a condition on what is picked: picked: true, a count of picks, the surface of the picked faces ('planar', 'cylinder', 'cone', 'sphere', 'torus'), or not: { … } for the opposite.
at: { picker: ['face', 'axis'], optional: true },
// The hub's size shows unless a shaft (a cylindrical face) is picked.
hubDiameter: { type: 'length', default: 20, visibleWhen: { at: { not: { surface: 'cylinder' } } } },

Picks​

A picked value is clicked in the viewport, or chosen from a History row. The dialog writes what a model would write by hand: panel.endFaces(), 'z', bolt.instance(1).

pickerThe dialog takesIt writesbuildTool() reads it with
facefacese.endFaces(), select(face()…)facesOf()
edgeedges; with facesAsEdges: true, a face stands for its edgese.endEdges(0)edgesOf()
axisa world axis, an axis(), a straight edge, a connector'z', ax, axis(e.sideEdges(0)), boltaxisOf()
planean origin plane, a plane(), a planar face'xy', p, plane(e.endFaces())planeOf()
sketcha sketch's outline, or its rowthe sketch's variableshapesOf()
solida face or edge of a solid, or the row that made itthat statement's variableshapesOf()
featurea feature's row; with types: ['hole'], a face or edge such a feature madeits variableshapesOf(), framesOf(), propertiesOf()
connectora connector, or a copy of onebolt, bolt.instance(2)frameOf(), pointOf()
pointa vertex: a sketch point, a solid's corners.geometries.a, e.endEdges(0).end()frameOf(), pointOf()
anchora face or an edge, then one of the points it suggestse.endFaces().center()frameOf(), pointOf()

More fields narrow what the dialog takes:

  • A list of picks: multiple: true, or max: 3 for at most three. The feature always gets a list, [] for none. ordered: true lets the user drag the picks into order. An axis or a plane is always one pick.
  • What kind: surface: 'planar' | 'cylinder' | 'cone' | 'torus' on a face, curve: 'line' | 'circle' | 'arc' on an edge. The dialog refuses anything else with the reason (On face: the picked face is not planar). A call written by hand is not checked: check in buildTool() when it matters.
  • A sketch: hasGeometry: true refuses an empty one. implicitDefault: true lets the call leave it out.
  • An anchor: anchors: ['center'] suggests the centres only ('center', 'start', 'end', 'offset').

A pick a call may leave out (optional: true) can come first and still be skipped: a call goes straight to its options, gear({ teeth: 24 }).

Several kinds in one pick​

picker takes a list of kinds. The fastener hole goes in at a connector, a sketch point or the centre of a face:

at: { picker: ['connector', 'point', 'anchor'], anchors: ['center'], label: 'At' },

Each click goes to the one kind that takes it, so the kinds must take different clicks. ['face', 'edge'] and ['solid', 'connector'] work. ['face', 'anchor'] is refused, because both take a click on a face. frameOf() reads any of the three the same way, and isLocation() tells a connector, point or anchor from the other kinds.

Laying out the dialog​

A dialog with a handful of rows needs no layout: the rows follow the declaration. A value's group puts it under a heading, and its tab on a tab:

count: { type: 'integer', label: 'Holes', default: 4, min: 1, tab: 'Pattern' },
circle: { type: 'length', label: 'Circle diameter', default: 32, tab: 'Pattern' },
through: { type: 'boolean', label: 'Through all', default: true, tab: 'Hole', group: 'Depth' },
depth: { type: 'length', default: 3, tab: 'Hole', group: 'Depth', visibleWhen: { through: false } },

When rows should sit side by side, fold away, or come with notes and drawings, give defineFeature() a layout. The vent grid's:

width: 'wide',
layout: [
'face',
'pattern',
{ row: ['rows', 'columns'] },
'pitch',
'diameter',
{ section: 'Depth', items: ['through', 'depth'] },
],
aside: [{ image: './vent-grid/dims.svg', alt: 'The pitch, centre to centre, and the hole diameter' }],
ItemWhat it places
'name'A value's row.
{ row: ['a', 'b'] }Two to four typed-in values side by side. widths: [2, 1] shares the width. A picked value takes a full row.
{ section: 'Title', items: [...] }Rows under a heading. collapsed: true opens it folded, for rows rarely changed.
{ tabs: [{ label, items }, …] }Two tabs or more, at the top level, once.
{ note: 'text' }A line of text. tone: 'warning' marks it.
{ image: './x.svg' }A picture between the rows. height in pixels, 16 to 480.
{ divider: true }A rule between rows.
  • Every value is placed exactly once. A value left out is refused (the value 'depth' has no place in the layout), and so is a value's own group or tab.
  • Every item takes a visibleWhen. The fastener hole shows a note only for a counterbore: { note: 'The recess is sized for the head of the screw.', visibleWhen: { head: 'counterbore' } }.
  • width: 'wide' or 'wider' opens a wider dialog. aside shows notes and pictures in a card beside the rows, here what the pitch and the diameter measure.
  • A row never cuts a label short: a cell that doesn't fit moves onto a line of its own. Pair narrow inputs, such as two numbers, and give buttons or a dropdown a line to themselves.

A drawing per row​

A value can bring its own aside, shown in the card in place of the feature's while its row has the focus. Use it for a value the user may not know, such as a gear's module or pressure angle:

module: {
type: 'length',
default: 2,
aside: [
{ image: './gear/module.svg', alt: 'The circular pitch p = π · m', height: 200 },
{ note: 'The module is the pitch diameter over the number of teeth.' },
],
},

The card is 200 pixels wide inside. In a narrow window, where the dialog becomes a sheet, only the feature's own aside shows.

Pictures​

Icons, option pictures and dialog drawings are files next to the feature file, named by a relative path: './fastener/m4.svg', never ../ or a URL. SVG, PNG, JPEG and WebP all work. Only the files the description names are served.

They are shown as pictures, so they follow the icon rules. currentColor draws black, so use fixed colours that read in both themes: the toolbar's greys and blues, or a mid-tone grey such as #8a94a6 for line art.

WhereSizeDraw
Cards and a grid dropdownA square as wide as its cell, about 80 pixelsA square picture that fills the square: a section, a pattern, a part seen from the side.
A list option24 pixelsOne bold shape.
A buttons option16 pixelsA glyph, or no picture.
The aside card200 pixels wideA dimension drawing, with its labels.

The pictures in the captures on this page follow these rules. The vent grid's cards are its two patterns, seen from above. The fastener's screws are drawn to one scale, so the sizes compare at a glance, and its heads are sections through the part, with the cut's walls in blue.

The fastener hole​

The fastener hole cuts a screw's clearance hole, with a recess for the head when asked. The built-in Hole does this and more: the fastener is here for its dialog. Buttons pick the standard, a grid of pictures the screw, cards the head, and a slider the depth. A note shows for a counterbore.

Editing a fastener hole: Metric and Imperial buttons, the Screw dropdown, Plain and Counterbore cards, the note and the Depth slider, with the hole&#39;s preview

The whole fastener hole
fastener.feature.js
// A clearance hole for a screw, sunk into the material at a connector, a point
// or the centre of a face, with a recess for the screw's head.
import { Feature, defineFeature } from 'fluidcad/sdk';
import { Primitives, ShapeOps } from 'fluidcad/sdk/kernel';
import { Matrix4 } from 'fluidcad/math';

/** Each screw's clearance hole, head diameter and head height, in mm. */
const SCREWS = {
m3: { clearance: 3.4, head: 6.5, headHeight: 3 },
m4: { clearance: 4.5, head: 8, headHeight: 4 },
m5: { clearance: 5.5, head: 10, headHeight: 5 },
n4: { clearance: 3.3, head: 6, headHeight: 2.8 },
n6: { clearance: 3.9, head: 7.5, headHeight: 3.5 },
};

export class Fastener extends Feature.with({
// A pick of three kinds: a connector, a sketch point or the centre of a face.
at: { picker: ['connector', 'point', 'anchor'], anchors: ['center'], label: 'At' },
// Two short choices, side by side as buttons.
standard: {
type: 'radio',
display: 'buttons',
options: [{ label: 'Metric', value: 'iso' }, { label: 'Imperial', value: 'unc' }],
},
// Many choices told apart by their shape: a grid of pictures, searchable,
// offering only the screws of the standard chosen above (`when`).
size: {
type: 'select',
label: 'Screw',
default: 'm3',
display: 'grid',
columns: 3,
search: true,
options: [
{ label: 'M3', value: 'm3', image: './fastener/m3.svg', description: '3.4 mm clearance', when: { standard: 'iso' } },
{ label: 'M4', value: 'm4', image: './fastener/m4.svg', description: '4.5 mm clearance', when: { standard: 'iso' } },
{ label: 'M5', value: 'm5', image: './fastener/m5.svg', description: '5.5 mm clearance', when: { standard: 'iso' } },
{ label: '#4', value: 'n4', image: './fastener/n4.svg', description: '3.3 mm clearance', when: { standard: 'unc' }, keywords: ['inch'] },
{ label: '#6', value: 'n6', image: './fastener/n6.svg', description: '3.9 mm clearance', when: { standard: 'unc' }, keywords: ['inch'] },
],
},
// A few choices, all worth seeing at once: picture cards.
head: {
type: 'radio',
display: 'cards',
columns: 2,
options: [
{ label: 'Plain', value: 'plain', image: './fastener/plain.svg', description: 'Nothing for the head' },
{ label: 'Counterbore', value: 'counterbore', image: './fastener/counterbore.svg', description: 'A recess the head sinks into' },
],
},
depth: { type: 'length', default: 10, min: 1, max: 50, display: 'slider', suffix: 'mm', hint: 'Measured from the surface' },
}) {
static id = 'fastener';

buildTool() {
const { size, head, depth } = this.values;
const screw = SCREWS[size];
const recess = head === 'counterbore' ? screw.headHeight : 0;
if (!(depth > recess)) {
throw new Error(`fastener: the hole (${depth}) must be deeper than the recess for its head (${recess})`);
}
// Where the screw goes in: origin on the surface, Z out of the material.
const frame = this.frameOf(this.values.at);
const place = Matrix4.fromBasis(frame.xDirection, frame.yDirection, frame.normal, frame.origin);

// Each cylinder stands on the XY plane; sink it below z = 0, then place it.
/** @param {number} radius @param {number} length */
const sunk = (radius, length) => {
const cylinder = Primitives.makeCylinder(radius, length);
const placed = ShapeOps.transform(cylinder, place.multiply(Matrix4.fromTranslation(0, 0, -length)));
cylinder.dispose();
return placed;
};
const tools = [sunk(screw.clearance / 2, depth)];
if (recess > 0) {
tools.push(sunk(screw.head / 2, recess));
}
return tools;
}
}

export const fastener = defineFeature(Fastener, {
label: 'Fastener hole',
description: 'A screw clearance hole, plain or counterbored',
icon: './fastener.svg',
group: 'create',
operations: ['remove'],
call: ['at', { standard: 'standard', size: 'size', head: 'head', depth: 'depth' }],
layout: [
'at',
'standard',
'size',
'head',
{ note: 'The recess is sized for the head of the screw.', visibleWhen: { head: 'counterbore' } },
'depth',
],
});
A cover plate that uses it
cover.part.js
import { part, sketch, line, extrude } from "fluidcad/core";
import { coincident, horizontal, vertical, distance, fix } from "fluidcad/constraints";
import { fastener } from "./fastener.feature.js";

// A 60 × 40 × 8 cover plate with a counterbored M4 hole at its centre.
export const cover = part("Cover plate", () => {
sketch("xy", () => {
const b = line([-31, -21], [29, -19]);
const r = line([29, -19], [31, 21]);
const t = line([31, 21], [-29, 19]);
const l = line([-29, 19], [-31, -21]);
coincident(b.end(), r.start());
coincident(r.end(), t.start());
coincident(t.end(), l.start());
coincident(l.end(), b.start());
horizontal(b);
vertical(r);
horizontal(t);
vertical(l);
fix(b.start(), [-30, -20]);
distance(b.start(), b.end(), 60);
distance(r.start(), r.end(), 40);
});
const plate = extrude(8);

// At the centre of the top face; 12 deep cuts right through the 8 mm plate.
fastener(plate.endFaces().center(), { size: "m4", head: "counterbore", depth: 12 });
});

The call​

call in defineFeature() maps a model's arguments to the inputs. In order:

  1. Values passed by position: 'face'.
  2. At most one object of options, each key naming a value: { rows: 'rows', pitch: 'pitch' }. A key may differ from the value's name: { dia: 'diameter' }.
  3. At most one rest value, a list of picks spread over the last arguments: '...faces'.
callA model writes
['face', { height: 'height', outer: 'outer', hole: 'hole' }]standoff(pad.endFaces(), { height: 12 })
['around', { count: 'count', circle: 'circle' }]boltCircle('z', { count: 6 })
['at', { teeth: 'teeth' }], with at optionalgear(seat.endFaces(), { teeth: 14 }), gear({ teeth: 24 }), gear()
['angle', '...faces']draft(5, e.sideFaces(), e2.sideFaces())
  • Every value appears in call exactly once.
  • Follow the built-ins: what is picked comes first, by position, and sizes go in the object of options.
  • A call that doesn't fit fails to compile, naming the fix: standoff(): unknown option 'hieght' — the options are 'height', 'outer' and 'hole'. In the editor the arguments are typed, so the misspelt option is marked as you write.
  • The dialog edits a statement whose options are written as an object literal, with choices and texts as plain literals. A statement written another way still runs, but its History row opens as not editable: options held in a variable ({ ...size }), or a choice given as an expression (size: screw).