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
15 changes: 15 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash -c 'CHANGED=$(git -C \"$CLAUDE_PROJECT_DIR\" diff --name-only HEAD 2>/dev/null; git -C \"$CLAUDE_PROJECT_DIR\" diff --name-only --cached 2>/dev/null) && echo \"$CHANGED\" | grep -qE \"^main/opengeometry(-three)?/(src|examples-vite|tests)/\" || exit 0; cd \"$CLAUDE_PROJECT_DIR\" && echo \"[hook] running cargo fmt --check\" && cargo fmt --check --manifest-path main/opengeometry/Cargo.toml && echo \"[hook] running cargo check\" && cargo check --manifest-path main/opengeometry/Cargo.toml --quiet && echo \"[hook] running npm run lint:check\" && npm run --silent lint:check && echo \"[hook] gates passed\"'"
}
]
}
]
}
}
108 changes: 108 additions & 0 deletions .claude/skills/brep-invariants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
name: brep-invariants
description: Use when editing files under `main/opengeometry/src/brep/`, when modifying primitives that build BRep (`primitives/`, `operations/extrude.rs`, `operations/sweep.rs`, `booleans/`), or when applying transforms to existing BRep (`spatial/placement.rs`). Fires on questions about halfedge twin pairing, loop chains, winding order, or "do I need to update X when I update Y in the BRep?".
---

# B-Rep Invariants

B-Rep (Boundary Representation) is the canonical geometry representation in this kernel.
Every primitive, operation, and boolean produces or modifies a `Brep` struct. The
triangulated mesh shown in Three.js is *derived* from BRep; BRep is not derived from the
mesh.

If you violate the invariants below, downstream code (projection, boolean, export, scene
serialization) will produce silent garbage or panic in unrelated places.

## Topology

```
Brep
├── vertices: Vec<Vertex> (3D positions, unique IDs)
├── edges: Vec<Edge> (one per pair of vertices, owns 2 halfedges)
├── faces: Vec<Face> (one outer Loop + 0..N inner Loops, normal, winding)
├── wires: Vec<Wire> (open polylines not bounded by faces)
├── shells: Vec<Shell> (face collections — solid or open)
└── id

HalfEdge (lives inside Edge)
├── start vertex
├── twin → opposite-direction halfedge (same Edge)
├── next → next halfedge in the Loop (cyclic)
└── face → the Face whose Loop contains this halfedge
```

## Invariants you must preserve

1. **Twin pairing is total.** Every HalfEdge has exactly one twin, and `twin(twin(h)) == h`.
When you add an Edge, you add two HalfEdges; never one.
2. **Loop chains are cyclic.** Following `next` around a Loop returns to the starting
HalfEdge. No dangling `next` pointers, no premature termination.
3. **Outer loops are CCW; inner (hole) loops are CW.** Winding determines which side is
"inside." Use `operations::windingsort` helpers — don't eyeball it.
4. **Face normal matches loop winding.** If you flip a loop's winding, recompute the
normal. Mismatched normal/winding breaks projection (silhouette extraction) and STL
export (wrong-facing triangles).
5. **A Vertex referenced by an Edge must exist in `Brep::vertices`.** Same for HalfEdge →
Edge, Face → Loop, Loop → HalfEdge. The `BrepBuilder` enforces this; manual
construction must not bypass the builder.
6. **Shells reference Faces; they do not own them.** Mutating a Face changes the geometry
for every Shell that includes it.

## Placement transforms — the open question

`Placement3D` (`spatial/placement.rs`) currently applies translation + rotation + uniform
scale by **updating vertex positions** in the BRep and the geometry buffer.

This is sufficient when:
- Topology is unchanged (same vertices, same edges, same faces)
- The transform is rigid or uniform-scale (preserves co-planarity, preserves loop winding)

It may be insufficient when:
- The transform is non-uniform scale (could break face planarity if vertices shift
off-plane — though this kernel currently rejects non-uniform scale at the API level)
- Edge classifications have been pre-cached (e.g., `EdgeClass` from technical-drawing
projection) — those caches do not auto-invalidate
- Halfedge `start` / `next` chains are recomputed elsewhere from positions rather than
topology — they must not be

**Action when adding a new transform code path:** check whether the transform changes
topology. If it does (e.g., a future "mirror with face-flip" operation), update
halfedges, loops, normals, and any cached projections in addition to vertex positions.
If it does not, vertex updates are sufficient. Document which case applies in a
comment at the call site.

## When you add a new primitive

Use `BrepBuilder` (in `brep/builder.rs`). The builder validates twin pairing and loop
closure on `build()`. Bypassing it produces "valid until you try to use it" BReps.

Pattern:
1. Add vertices.
2. Add edges referencing vertex IDs (creates twin halfedges).
3. Build loops by chaining halfedges; mark outer vs. inner.
4. Build faces from loops; compute normal from outer loop winding.
5. (If solid) build shells from faces.
6. Call `build()` — it returns `Result<Brep, BrepError>`. Don't `unwrap()` in
wasm-exposed paths.

## Tests to run after touching BRep

```bash
cargo test --manifest-path main/opengeometry/Cargo.toml -q
# Watch specifically for:
# rectangle_generates_face_loop_without_duplicate_halfedges
# sphere_geometry_and_outline_are_non_empty
# sphere_segment_inputs_are_clamped
```

If you added a new primitive, add a smoke test to `tests/primitives_smoke.rs` that
asserts:
- BRep is non-empty (`vertices.len() > 0`, `faces.len() > 0` for solid primitives)
- The geometry buffer triangulates without panic
- Outline extraction (if applicable) returns at least one segment

## Booleans

Boolean operations go through the `boolmesh` crate via `booleans/`. The output is a
freshly-built BRep, not a mutation of an input. Do not assume vertex/edge IDs persist
across a boolean.
116 changes: 116 additions & 0 deletions .claude/skills/scene-snapshot-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
name: scene-snapshot-rules
description: Use when working with `OGSceneManager` or the new `OGEntityRegistry`, when implementing or calling projection (`projectTo2DCamera`, `projectTo2DLines`, `projectCurrentToViews`), when implementing or calling export (`exportSceneToStl`, `exportSceneToStep`, `exportSceneToIfc`, native PDF), or when a user reports "my scene shows old geometry after I edited the shape." Also fires on questions about the lifecycle of BRep snapshots inside the scene.
---

# Scene & Snapshot Rules

`OGSceneManager` (and its successor `OGEntityRegistry`) is the bridge between live
wrapper objects in the TS layer and batch operations in the Rust kernel — projection,
export, multi-entity rendering. The scene is **a registry of serialized BRep snapshots**,
not a live view of the wrapper objects.

This is the most common source of "why does my export show the old geometry?" bugs.

## Mental model

```
TS wrapper object (Cuboid, Polygon, ...) OGSceneManager (Rust, WASM)
├── live BRep, mutable ├── snapshot of BRep #1 — frozen at insertion
├── live placement ├── snapshot of BRep #2 — frozen at insertion
└── re-emits geometry on setConfig() └── ...
```

`addBrepEntityToScene(sceneId, entityId, kind, brepJson)` copies the BRep JSON into the
scene. After that call, the wrapper object and the scene are independent. Editing the
wrapper does **not** update the scene.

## Push updates explicitly

If you change a wrapper after inserting it and want the scene to reflect it, you must
push the new snapshot:

```ts
// Pattern (verify exact method names with `git grep` — APIs are mid-rename)
const updatedJson = toBrepSerialized(wall); // see helper below
manager.replaceBrepEntityInScene(sceneId, "wall-1", "Cuboid", updatedJson);
// or
manager.refreshBrepEntityInScene(sceneId, "wall-1"); // pull from a registered source
```

### Serialization helper

Use the BRep accessor precedence chain:

```ts
function toBrepSerialized(source: unknown): string {
const get = (k: string) =>
source && typeof (source as any)[k] === "function" ? (source as any)[k]() : null;

const value = get("getBrepSerialized") ?? get("getBrepData") ?? get("getBrep") ?? source;
return typeof value === "string" ? value : JSON.stringify(value);
}
```

Note: `Polygon.getBrepData()` already returns serialized JSON — passing it through
`JSON.stringify` again would double-encode. The helper above handles this because the
`typeof value === "string"` branch short-circuits.

## Projection

`projectTo2DCamera(...)` and `projectTo2DLines(...)` (and the in-flight
`projectCurrentToViews(...)` for batched multi-view) operate on the snapshots stored in
the scene. They do **not** read from wrapper objects.

If the projection result looks wrong:
1. Confirm the snapshot is current (replace/refresh, then re-project).
2. Confirm the camera parameters JSON is what you expect.
3. Confirm `HlrOptions` matches the desired silhouette/hidden-line behavior.

## Edge classification (in-flight)

The technical-drawing PDF flow adds `EdgeClass` (VisibleOutline / VisibleCrease / Hidden /
SectionCut) to projected segments via `ClassifiedSegment`. If you are implementing a new
projection path, prefer emitting `ClassifiedSegment` rather than unclassified `Segment2D`,
so downstream PDF/SVG/DXF emitters can apply ISO 128 line weights.

Spec: `knowledge/technical-drawing-pdf-export.md`.

## Export

Export functions (`exportSceneToStl`, `exportSceneToStep`, `exportSceneToIfc`) iterate the
snapshots. Same rule: snapshots, not wrappers.

Native PDF (`pdf.rs`) is gated behind `cfg(not(target_arch = "wasm32"))`. It uses the
`printpdf` crate, which does not compile to WASM. Browser PDF is **not** supported in
this kernel — see the spec for the planned downstream package.

## When to use a single direct BRep helper instead

For one-off operations on a single shape (`exportBrepToStl`, `exportBrepToStep`, etc.),
skip `OGSceneManager` and call the direct helper. The scene manager is only worth its
overhead when you have multiple entities and want batched projection or multi-entity
export.

## OGSceneManager vs OGEntityRegistry

Both currently live in `main/opengeometry/src/scenegraph.rs`. The new `OGEntityRegistry`
is the API direction for the technical-drawing work; `OGSceneManager` remains the stable
public name for the moment. Verify exact symbols with `git grep` before recommending one
over the other in new code.

## Verification

When changing scene/projection/export code:
1. Run `cargo test` — unit tests cover snapshot round-tripping.
2. Manually run the projection example:
```bash
cargo run --manifest-path main/opengeometry/Cargo.toml \
--example scenegraph_projection -- ./out/scenegraph_projection.pdf
```
3. Or dump JSON for inspection:
```bash
cargo run --manifest-path main/opengeometry/Cargo.toml \
--example scenegraph_projection_dump_json -- ./out/projection_dump
jq . ./out/projection_dump_scene2d.json
```
81 changes: 81 additions & 0 deletions .claude/skills/wasm-build-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
name: wasm-build-flow
description: Use when debugging build failures, stale WASM, "module not found" errors from `pkg/`, mismatched type definitions in `dist/`, or when the dev server / examples behave as if Rust changes didn't take effect. Also fires when the user asks how the build pipeline works end-to-end.
---

# WASM Build Flow

The build has three stages and a packaging step. Skipping a stage or running them out of
order is the most common source of "I edited Rust and nothing changed" bugs.

## Pipeline

```
main/opengeometry/src/*.rs
│ wasm-pack build --target web
main/opengeometry/pkg/ ← generated; never edit by hand
├── opengeometry_bg.wasm
├── opengeometry.js (wasm-bindgen JS glue)
└── opengeometry.d.ts (TS types for the WASM exports)
│ rollup -c rollup.config.js (entry: main/opengeometry-three/index.ts)
dist/index.js ← bundled TS wrapper, three.js external
│ scripts/prepare-dist.mjs
dist/ ← publishable NPM bundle
├── index.js
├── opengeometry_bg.wasm (copied to root for default wasmURL)
├── opengeometry/pkg/opengeometry.{js,d.ts}
└── package.json (subpath exports)
```

## Commands

```bash
npm run build # All three stages + prepare-dist (the canonical command)
npm run build-core # Stage 1: Rust → WASM (also builds native release)
npm run build-three # Stage 2: Rollup TS bundle
npm run prepare-dist # Stage 3: copy WASM, rewrite imports, write dist/package.json
```

**Order is enforced inside `npm run build`.** Calling `build-three` without a fresh
`pkg/` produces stale `.d.ts` mismatches that surface as type errors in the bundle.

## Common failure modes

| Symptom | Cause | Fix |
|---|---|---|
| Type error referencing a symbol you just added in Rust | `pkg/` is stale | `npm run build-core`, then re-bundle |
| `Module not found: opengeometry/pkg/opengeometry` in dist | `prepare-dist.mjs` did not run | `npm run prepare-dist` |
| WASM file 404 in browser | App points at wrong `wasmURL` | Pass `new URL("node_modules/opengeometry/opengeometry_bg.wasm", import.meta.url).href` |
| `wasm-pack: command not found` | Tool not installed | `brew install wasm-pack` (macOS) or `cargo install wasm-pack` |
| `cargo check` passes but bundle fails | `cfg(target_arch = "wasm32")` gating mismatch | Confirm the symbol is wasm-exposed; check the `cfg(not(...))` blocks |
| Imports from `../../../opengeometry/pkg/...` in published dist | `prepare-dist.mjs` import-rewrite step regression | Inspect `scripts/prepare-dist.mjs`, verify regex still matches |

## What never to do

- Edit anything under `main/opengeometry/pkg/`. It is regenerated by `wasm-pack`.
- Edit anything under `dist/` directly. It is regenerated by the build.
- Add `wasm32` conditional logic without verifying that both build targets still produce
working artifacts (`wasm-pack build` AND `cargo build --release`).
- Bundle Three.js into `dist/`. It is a peer dependency; Rollup excludes `three` and
`three/*` by design.

## Verification after a build change

```bash
npm run build
ls -la dist/ # Must contain index.js, opengeometry_bg.wasm
node -e "console.log(require('./dist/package.json').exports)" # Confirm subpath exports
```

If the diff touched `wasm-pack` flags, `rollup.config.js`, or `scripts/prepare-dist.mjs`,
also test consumption from the example app:

```bash
npm --prefix main/opengeometry-three run dev-example-three
# Open the catalog; check that primitives render, no console errors
```
3 changes: 3 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copilot Instructions

All agent guidance lives in [AGENTS.md](../AGENTS.md). Read that first.
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Lint TypeScript
run: npm run lint:check

- name: Run build
run: npm run build

Expand All @@ -188,9 +191,8 @@ jobs:
exit 1
fi

- name: Run tests (if available)
- name: Run tests
run: npm test
continue-on-error: true

- name: Final NPM existence check
id: final-npm-check
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,16 @@ main/opengeometry/pkg/
.DS_Store
.vscode/
settings.json
# Track .claude/settings.json (team hook config); keep .claude/settings.local.json ignored.
!.claude/settings.json
.claude/settings.local.json
.claude/scheduled_tasks.lock

# AI and documentation files
.requests/*.md
.github/*.md
# Track Copilot's instruction file (redirect to AGENTS.md).
!.github/copilot-instructions.md

# CAD export artifacts
*.pdf
Expand Down
Loading
Loading