Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Read [README.md](README.md) first for what the app does and what the controls me

- **The part is modelled the way it prints, not the way it is used.** `z = 0` is the face the model is glued to and sits on the build plate; `z = height` is the base's underside, the face that meets a magnetised tray, and it is the top of the model as built. That is what keeps every overhang pointing up so nothing needs supports, and it is why the app opens looking at the underside. Get this backwards and every conclusion about magnets inverts: bores open at `z = height`, and `floorThickness` is nowhere near a magnet.
- **The recess exists to hold magnets**, so the model holds itself down on a magnetised surface. Nothing is basing material, there is no topper, and no filler flows anywhere. Earlier versions of these docs claimed otherwise.
- **`src/geometry` stays DOM-free.** It runs in a worker, in Node tests and in the samples script. No `window`, no `document`, no fetch. Fonts and the WASM module are passed in by the caller.
- **`src/core/geometry` stays DOM-free.** It runs in a worker, in Node tests and in the samples script. No `window`, no `document`, no fetch. Fonts and the WASM module are passed in by the caller.
- **Every Manifold object must be handed to `own()`** in `buildBase`. WASM memory is not garbage collected, and the `finally` block is the only thing freeing it. Chained calls create intermediates — each one needs owning.
- **The body loft assumes a convex footprint.** It is built as the convex hull of the outline offset at each height of the edge profile, which is exact for circles, ovals, pills, rects and polygons. A concave shape would need a real loft instead.
- **Never leave two surfaces exactly tangent.** Ribs deliberately run 0.4mm into the wall: stopping them flush left a single shared point, which stayed manifold in Manifold's own topology but pinched into a non-manifold edge in any tool that welds vertices by position, slicers included. `base.test.ts` asserts no two vertices share a position.
Expand All @@ -24,7 +24,7 @@ Read [README.md](README.md) first for what the app does and what the controls me
- **The edge profile must leave wall beside the recess floor.** The well is cut from the full-size inner outline while the outer loft is inset near the build plate. If that inset reaches the well outline where the floor begins, the cut severs the rim and only the ribs join it back. `maxProfileSize` samples the same profile steps as the loft, the UI caps against it, and `buildBase` rejects callers that bypass the control.
- **Every magnet boss sits on a rib.** That is the invariant `base.test.ts` asserts across every preset, and the counts are chosen to hold it: a ring gets one spoke each, and a row gets an even count, because every magnet in a row shares the long axis so one spoke down it passes through all of them. `ribAngles` takes its phase from a boss bearing so the alignment is exact. It used to fall out of both series using the same `π/2 + 2πi/n`, which meant it held only when the counts happened to match, and flipped as the size changed. A rib on a boss gussets its root, prints as one connected feature instead of an island, and collects the clear floor into a few wide gaps, which is what lets the size label stay large. Phase makes almost no difference to floor stiffness — a boss is a 7mm inclusion in a plate tens of millimetres across.
- **Automatic magnet counts combine holding demand with stability.** `automaticMagnetCount` starts from the existing 5×2mm balanced recommendation, scales demand by the practical `diameter² × thickness` strength proxy, applies a minimum of three magnets for a ring or two for a row, then rounds up to a supported balanced count. This assumes ordinary neodymium magnets; it is a recommendation, not a pull-force claim. A `magnetCounts` entry is a per-footprint manual override, while an absent entry means automatic. Every automatic count must appear in `MAGNET_CHOICES` and every rib count in `RIB_CHOICES`; tests assert both.
- **Label bearings come from the gaps between everything solid**, ribs and bosses together, widest first. Bisecting the ribs alone worked only while ribs and bosses shared a bearing; the moment they did not, a rib bisector aimed straight at a boss. Bases and holders both use `src/geometry/label.ts`, so changes to fitting rules affect both generators.
- **Label bearings come from the gaps between everything solid**, ribs and bosses together, widest first. Bisecting the ribs alone worked only while ribs and bosses shared a bearing; the moment they did not, a rib bisector aimed straight at a boss. Bases and holders both use `src/core/geometry/label.ts`, so changes to fitting rules affect both generators.
- **The size label must never disappear silently.** If it cannot be placed it is simply absent from the model, with no error, so changes to `fitLabel` need a test that a cramped base still gains volume from its label. Custom label text is different: someone typed it, so text that cannot be placed throws and blocks the build instead. The input accepts only `LABEL_CHARACTERS`, which a test checks against the bundled Oswald, up to `LABEL_MAX_LENGTH`. The text is base-only and survives size changes; filenames always name the size, never the label.
- **Magnets are flush with the face that meets the tray, whatever their thickness.** The bore is cut down from `z = height` by `magnets.thickness + magnets.depthClearance`, leaving optional room for adhesive behind a magnet held flush during assembly. Anchoring the bore at `floorThickness` instead looks like it puts the magnet "on the floor", but that floor is the opposite face of the base, so it strands the magnet as far from the tray as the geometry allows. `base.test.ts` asserts the bore's ceiling is `z = height` at every thickness.
- **Painting trays are interleaved magnet grids, not miniature holders.** Rows and columns define the primary pockets, with one offset pocket at the centre of every four adjacent positions. The diagonal pockets give mixed base sizes more placement options; do not pack miniature footprints or add surrounding recesses.
Expand All @@ -33,7 +33,7 @@ Read [README.md](README.md) first for what the app does and what the controls me
- **Tokens print face-up, the opposite of bases.** `z = 0` is the table face on the build plate and positive relief raises text and images from `z = thickness`, while negative relief engraves them down from that face, leaving at least 1mm of material beneath the artwork. Zero relief leaves a plain body. Engraving never changes `tokenHeight`; only raised artwork adds to the body thickness. The edge treatment sits on the top edge. Text wraps onto whichever line break gives the largest cap height up to the requested one, and text that would fall below `MIN_TOKEN_TEXT_HEIGHT` blocks the build rather than shrinking into something unprintable. With both an image and text, the image takes the band above `IMAGE_TEXT_SPLIT` and the text the band below.
- **Token sizes are measured across the flats; base hex sizes are not.** A token's `size` is a round's diameter, a square's side, and a hex's flat-to-flat distance, because that is what a hex grid or a ruler laid edge to edge measures. Hex bases stay across-corners so they drop into the space of the matching round. Hex tokens sit flat edge up so text gets the widest band, and the dimension leaders show the real across-corners × across-flats extents. Text and image fitting test each rectangle against the inset face outline's width at its top and bottom edges, which is exact only because every face is convex and mirrored about the y axis — never fit against the bounding box, whose corners overhang a round or hex face.
- **Token images are silhouettes, never heightmaps.** `loadTokenImage` downsamples an upload to a 256px greyscale grid composited over white, and only that grid is saved in the workspace. `traceSilhouette` turns it into even-odd loops with marching squares, and `buildToken` opens the result by the nozzle width so no sub-0.4mm sliver reaches the slicer. The finished face is simplified before extrusion: offsetting can leave coincident points that pinch into non-manifold edges once vertices are welded.
- **Text fills non-zero; traced images fill even-odd.** Oswald's glyphs overlap their neighbours (`ÏÏ`, `fï`, `gj`), and an even-odd fill cuts every overlap out as a hole, so every text section is built with `GLYPH_FILL` from `src/geometry/text.ts`. Image silhouettes keep `'EvenOdd'`, because `traceSilhouette` promises nothing about winding.
- **Text fills non-zero; traced images fill even-odd.** Oswald's glyphs overlap their neighbours (`ÏÏ`, `fï`, `gj`), and an even-odd fill cuts every overlap out as a hole, so every text section is built with `GLYPH_FILL` from `src/core/geometry/text.ts`. Image silhouettes keep `'EvenOdd'`, because `traceSilhouette` promises nothing about winding.
- **Sizes are exact.** A 28.5mm base reads `28.5` everywhere — size label, footer, filename. Never round a size for display; `trimNumber` drops trailing zeros without rounding.
- **Exports always use a 1µm chord tolerance for circular geometry.** `exportSegmentsFor` derives the segment count from the diameter and round edge profiles inherit the tolerance implied by that count. The preview deliberately uses `previewSegmentsFor` and a lighter mesh; `buildMesh` runs an isolated high-quality build when either export button is pressed. Do not restore a quality control or a fixed export segment count: either makes exported tolerance depend on the size again.
- **Batch entries differ from the current base only in footprint.** `batchBaseConfig` resizes the current base with `resized` and runs it through `synchronizeWorkspace`, the same path as typing that size, so per-footprint magnet overrides and automatic counts apply; the current footprint exports the current config untouched. Every entry builds through `buildMesh` with `exportSegmentsFor`, like a single export. Batch 3MFs keep one object per size and place each copy as an instance, shelf-packed by `packPlates` onto as many build plates as it takes without overlap.
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ When a changeset reaches `main`, CI updates `package.json` and `CHANGELOG.md`, t

## Layout

- `src/geometry` — DOM-free geometry, presets, profile sampling, and STL/3MF exporters shared by the worker, Node tests, and sample script.
- `src/core/geometry` — DOM-free geometry, presets, profile sampling, and STL/3MF exporters shared by the worker, Node tests, and sample script.
- `src/worker` — the mesh-building worker and its config-in/mesh-out protocol.
- `src/components` — app-specific controls, drawing annotations, and the three.js viewer.
- `src/components/panels` — one settings panel per generator; `src/App.tsx` owns the workspace state and passes each panel what it edits.
Expand All @@ -54,7 +54,7 @@ When a changeset reaches `main`, CI updates `package.json` and `CHANGELOG.md`, t

## Geometry model

`src/geometry` stays free of browser APIs. Fonts and the Manifold WASM module are supplied by the caller, which keeps the builder identical in the browser, worker, tests, and sample exporter.
`src/core/geometry` stays free of browser APIs. Fonts and the Manifold WASM module are supplied by the caller, which keeps the builder identical in the browser, worker, tests, and sample exporter.

The body is the convex hull of the footprint offset at each height of the edge profile. This is exact for the supported convex shapes. Concave footprints need a different loft and are outside the current model.

Expand All @@ -76,4 +76,4 @@ Production setup and verification live in the [Cloudflare deployment guide](docs

## Shared infrastructure checks

The shared Oxlint domain preset restricts Node and framework imports in `src/geometry`; test files can still load real geometry fixtures. `.oxlintrc.json` is the active lint configuration. `ras changesets check` validates existing Changesets without requiring a release note for every change. The PostHog wiring test imports the real application entrypoint and verifies the provider configuration passed by the app.
The shared Oxlint domain preset restricts Node and framework imports in `src/core/geometry`; test files can still load real geometry fixtures. `.oxlintrc.json` is the active lint configuration. `ras changesets check` validates existing Changesets without requiring a release note for every change. The PostHog wiring test imports the real application entrypoint and verifies the provider configuration passed by the app.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"manifold-3d": "^3.5.4",
"opentype.js": "^2.0.0",
"posthog-js": "^1.435.6",
"ras-stack": "^3.0.0",
"ras-stack": "^4.0.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"three": "^0.186.1"
Expand All @@ -61,7 +61,7 @@
"@vitejs/plugin-react": "^6.1.1",
"oxfmt": "^0.71.0",
"oxlint": "^1.86.0",
"ras-stack-config": "^1.0.0",
"ras-stack-config": "^3.0.0",
"shadcn": "^4.21.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.15",
Expand Down
31 changes: 17 additions & 14 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ allowBuilds:
esbuild: true

minimumReleaseAgeExclude:
- ras-stack-config@1.0.0
- ras-stack@3.0.0
- ras-stack-config@3.0.0
- ras-stack@4.0.0
14 changes: 7 additions & 7 deletions scripts/exportSamples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import type { Font } from 'opentype.js'
import { buildBase } from '../src/geometry/base'
import { toStl } from '../src/geometry/exporters'
import { loadManifold } from '../src/geometry/manifold'
import { buildMovementTray, defaultMovementTrayConfig, movementTrayName } from '../src/geometry/movementTray'
import { baseName } from '../src/geometry/outline'
import { buildBase } from '../src/core/geometry/base'
import { toStl } from '../src/core/geometry/exporters'
import { loadManifold } from '../src/core/geometry/manifold'
import { buildMovementTray, defaultMovementTrayConfig, movementTrayName } from '../src/core/geometry/movementTray'
import { baseName } from '../src/core/geometry/outline'
import {
buildPaintingHandle,
buildPaintingTray,
defaultPaintingTrayConfig,
paintingHandleConfig,
paintingHandleName,
paintingTrayName,
} from '../src/geometry/paintingTray'
import { OVAL_SIZES, presetFor, ROUND_SIZES } from '../src/geometry/presets'
} from '../src/core/geometry/paintingTray'
import { OVAL_SIZES, presetFor, ROUND_SIZES } from '../src/core/geometry/presets'

const FONT_PATH = 'src/assets/fonts/oswald-700.woff'

Expand Down
16 changes: 8 additions & 8 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,20 @@ import { ButtonGroup } from '@/components/ui/button-group'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { TitleBlock } from '@/components/TitleBlock'
import { Viewer } from '@/components/Viewer'
import { holderLayout, holderName, holderPlan, maxHolderMagnetThickness } from '@/geometry/holder'
import { baseName, footprint, isElongated } from '@/geometry/outline'
import { footprintKey, SIZES_BY_SHAPE } from '@/geometry/presets'
import { movementTrayHeight, movementTrayLayout, movementTrayName } from '@/geometry/movementTray'
import { maxTokenEdgeSize, tokenFootprint, tokenHeight, tokenName } from '@/geometry/token'
import { holderLayout, holderName, holderPlan, maxHolderMagnetThickness } from '@/core/geometry/holder'
import { baseName, footprint, isElongated } from '@/core/geometry/outline'
import { footprintKey, SIZES_BY_SHAPE } from '@/core/geometry/presets'
import { movementTrayHeight, movementTrayLayout, movementTrayName } from '@/core/geometry/movementTray'
import { maxTokenEdgeSize, tokenFootprint, tokenHeight, tokenName } from '@/core/geometry/token'
import {
paintingHandleAxisCenter,
paintingTrayAssemblyHeight,
paintingTrayAssemblyMinZ,
paintingTrayLayout,
paintingTrayName,
} from '@/geometry/paintingTray'
import { stemMaximumDiameter, stemName, stemOverallHeight } from '@/geometry/stem'
import type { BaseConfig, FlightStemConfig, TokenConfig, HolderConfig, MovementTrayConfig, PaintingTrayConfig } from '@/geometry/types'
} from '@/core/geometry/paintingTray'
import { stemMaximumDiameter, stemName, stemOverallHeight } from '@/core/geometry/stem'
import type { BaseConfig, FlightStemConfig, TokenConfig, HolderConfig, MovementTrayConfig, PaintingTrayConfig } from '@/core/geometry/types'
import { loadTokenImage } from '@/lib/tokenImage'
import { batchBaseConfig, batchName } from '@/lib/batch'
import { useExport } from '@/lib/useExport'
Expand Down
Loading
Loading