diff --git a/.gitignore b/.gitignore index 3008b7b..e0b80d0 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,8 @@ main/opengeometry/pkg/ .vscode/ settings.json -# Copilot +# AI and documentation files +# .requests/*.md .github/*.md # CAD export artifacts diff --git a/.requests/kernel-asks-report.md b/.requests/kernel-asks-report.md new file mode 100644 index 0000000..8ecccd6 --- /dev/null +++ b/.requests/kernel-asks-report.md @@ -0,0 +1,187 @@ +# Kernel Ask Report for Production B-Rep Editor + +Date: 2026-03-21 +Kernel audited: `../OpenGeometry-Kernel/main/opengeometry` and `../OpenGeometry-Kernel/main/opengeometry-three` + +--- + +## 1) Recheck Summary + +The kernel has a solid editable B-Rep baseline and currently passes editor tests, including: + +- face push/pull +- face/edge/vertex move +- topology render data +- edit result payload control flags + +Confirmed by local test run: + +- `cargo test editor::tests -- --nocapture` +- Result: `7 passed, 0 failed` + +--- + +## 2) What Is Already Good Enough + +## 2.1 Editable operations exist and are wired to JS/TS wrappers + +- Rust exports: + - `pushPullFace`, `moveFace`, `moveEdge`, `moveVertex` + - [mod.rs](/Users/vishwajeetmane/Work/OpenGeometry/OpenGeometry-Kernel/main/opengeometry/src/editor/mod.rs#L202) +- TS wrapper mirrors these methods: + - [editor.ts](/Users/vishwajeetmane/Work/OpenGeometry/OpenGeometry-Kernel/main/opengeometry-three/src/operations/editor.ts#L198) + +## 2.2 Polygon can enter editable B-Rep pipeline + +- `Polygon` exposes B-Rep payload: + - `getBrepData()` + - [polygon.ts](/Users/vishwajeetmane/Work/OpenGeometry/OpenGeometry-Kernel/main/opengeometry-three/src/shapes/polygon.ts#L469) +- `createEditableBrepEntity()` accepts `getBrepData/getBrepSerialized`: + - [editor.ts](/Users/vishwajeetmane/Work/OpenGeometry/OpenGeometry-Kernel/main/opengeometry-three/src/operations/editor.ts#L251) + +## 2.3 Topology remap payload exists + +- `topology_changed` and `topology_remap` are returned in edit results: + - [mod.rs](/Users/vishwajeetmane/Work/OpenGeometry/OpenGeometry-Kernel/main/opengeometry/src/editor/mod.rs#L359) + +--- + +## 3) Production Gaps and Kernel Asks + +## P0 (Blockers for robust CAD-grade behavior) + +### P0.1 Face extrude operation (true topology-creating push/pull) + +Current `push_pull_face_internal` moves existing face vertices along normal, then recomputes normals: + +- [edits.rs](/Users/vishwajeetmane/Work/OpenGeometry/OpenGeometry-Kernel/main/opengeometry/src/editor/edits.rs#L27) + +This is not sufficient for planar polygon "push/pull to solid" workflows expected in CAD. + +Ask: + +- Add `extrudeFace(face_id, distance, options)` on editable entity. +- It must create proper side faces + cap/bottom topology where applicable. +- Return standard `BrepEditResult` with topology changes. + +Acceptance: + +- extruding a single-face polygon creates a watertight prism-like shell (unless explicitly open-surface mode). +- face/edge/vertex IDs remap correctly after extrusion. + +### P0.2 Real semantic topology remap from actual edits + +Current remap builder used in edit result is domain default mapping: + +- [remap.rs](/Users/vishwajeetmane/Work/OpenGeometry/OpenGeometry-Kernel/main/opengeometry/src/editor/remap.rs#L106) + +This mostly maps `old_id -> same_id if still present`, or deleted. +`split/merged` support is currently synthetic via helper tests, not emitted by real topology-changing ops. + +Ask: + +- Replace default ID-overlap remap with edit-aware remap generation for each topology-changing operation. +- Ensure `status` reflects real behavior (`split`, `merged`, `deleted`, `created`, `unchanged`). +- Ensure `primary_id` is deterministic and useful for selection continuity. + +Acceptance: + +- topology-changing ops produce non-identity remap entries in real edit tests. +- remap is deterministic across repeated identical operation sequences. + +### P0.3 Created-ID reporting contract + +`TopologyRemapStatus` includes `Created`, but current remap structure is `old_id -> new_ids`; it has no first-class entries for purely new IDs. + +Ask: + +- Add explicit created-feature reporting, either: + - a `created_ids` section per domain, or + - a remap schema that can encode created entries without `old_id`. +- Keep backward compatibility or version payload (`topology_remap_v2`) if needed. + +Acceptance: + +- editor can select newly created face/edge/vertex from result payload without heuristic scan. + +## P1 (Strongly recommended for v1 UX quality) + +### P1.1 Topology-edit operations for 2D/polygon workflows + +Needed for direct polygon/ polyline-like editing ergonomics: + +- `insertVertexOnEdge(edge_id, t_or_position, options)` +- `removeVertex(vertex_id, options)` with validity guards +- optional: `splitEdge(edge_id, t, options)` + +Acceptance: + +- polygon point insertion/removal works through kernel ops. +- remap and validity are returned consistently. + +### P1.2 Constraint-aware edit operations + +Current move ops are free-vector transforms and can unintentionally break intended constraints (coplanar editing, axis constraints). + +Ask: + +- Add operation variants/flags: + - plane constrained + - axis constrained + - preserve coplanarity for selected contexts + +Acceptance: + +- same operation with constraints yields deterministic constrained geometry without UI-side post-fix hacks. + +### P1.3 Capability discovery API + +Editor needs to know what controls to show/hide per entity/feature. + +Ask: + +- Add `getEditCapabilities()` at entity and/or feature level: + - e.g. `canPushPullFace`, `canExtrudeFace`, `canMoveEdge`, `canInsertVertex`, etc. + +Acceptance: + +- UI does not expose invalid operations and avoids trial-and-error calls. + +## P2 (Scale/performance and robustness) + +### P2.1 Delta payload support + +Current result can include full serialized geometry; useful but heavy under repeated drags. + +Ask: + +- Optional changed-domain delta payloads (`changed_faces`, `changed_edges`, etc.) to reduce bandwidth/work. + +### P2.2 Better validity diagnostics taxonomy + +Current validity returns warnings/errors strings. Helpful, but categorization would improve UX. + +Ask: + +- Add machine-readable codes/severity per diagnostic entry. + +--- + +## 4) Explicit Notes About Polygon Editing + +- Polygon edge/vertex dragging is feasible today via editable B-Rep `moveEdge/moveVertex` after creating editable entity from polygon B-Rep. +- Polygon "push/pull" today is geometric face translation, not solid extrusion. +- For AutoCAD/Blender-like expected push/pull on polygon faces, `extrudeFace` is the key missing operation. + +--- + +## 5) Minimal Kernel API Ask Set (If You Want Strict MVP) + +If we only request the minimum to unblock production editor quality: + +1. `extrudeFace(...)` +2. true semantic remap for topology-changing ops +3. created-ID reporting in edit result +4. `insertVertexOnEdge(...)` and `removeVertex(...)` + +Everything else can follow in later iterations. diff --git a/.requests/kernel-brep-editor-handoff.md b/.requests/kernel-brep-editor-handoff.md new file mode 100644 index 0000000..cc4c065 --- /dev/null +++ b/.requests/kernel-brep-editor-handoff.md @@ -0,0 +1,378 @@ +# Kernel Handoff: Requirements For A Proper B-Rep Editor + +## Purpose + +This document defines the kernel-side capabilities needed to build a real B-Rep editor in `@opengeometry/editor-controls`. + +The current package can render and inspect B-Reps, but its editing path is still wrapper-first: + +- It reads geometry through `getBrepSerialized()` +- It mutates host objects through `setConfig(...)` / `setTransformation(...)` +- It rebuilds the B-Rep after each wrapper patch + +That is enough for parametric editing of primitives, but it is not enough for true face/edge/vertex editing. + +The next version of the editor should be kernel-first for 3D editing. + +## Current Constraint + +Today the editor binds to wrapper objects and only knows how to apply option patches. + +Relevant code in this package: + +- `src/adapters/default-adapters.ts` +- `src/core/editor.ts` +- `examples-vite/sandbox.ts` + +Why this blocks a real B-Rep editor: + +- Arbitrary face push/pull is a topology operation, not a config patch. +- After a topology edit, the result may no longer be representable as the same primitive wrapper. +- Curved faces need semantic or kernel-native handling, not ad hoc UI shortcuts. +- Continued interaction needs stable face/edge/vertex identity across rebuilds. + +Example: + +- A cuboid face push/pull can be faked as width/height/depth plus center adjustment. +- A cylinder top face push/pull can map to height. +- A cylinder side face push/pull can map to radius only in the uniform analytic case. +- A partial side-face edit or local face extrusion no longer remains a `Cylinder` primitive. + +This is why the editor cannot become universal by adding more wrapper-specific patches. + +## Kernel Information Already Suggesting The Right Direction + +The kernel architecture notes already describe `Brep` as canonical state and mention `extrude_brep_face`: + +- `../OpenGeometry-Kernel/knowledge/opengeometry-architecture.md` + +That is the right direction. The editor needs this kind of capability exposed cleanly in the JS/TS surface. + +## What The Editor Needs From Kernel + +### 1. A kernel-native editable solid/entity surface + +The editor needs a public JS/TS object that represents an editable B-Rep entity directly, not only primitive wrappers like `Cuboid`, `Cylinder`, `Wedge`, etc. + +Minimum capabilities: + +- return serialized or structured B-Rep +- return current placement / transformation +- apply topology edits +- apply placement edits +- regenerate renderable geometry after edits +- return stable topology ids or a remap after edits + +This can be either: + +- a new generic `EditableBrep` / `BrepEntity` wrapper, or +- the existing wrappers plus a shared low-level kernel edit API they all expose + +The important part is that the editor must not be forced to express every edit as `setConfig(...)`. + +### 2. Stable topology identifiers + +The editor needs stable ids for: + +- shells +- faces +- loops +- edges +- vertices + +Requirements: + +- ids must exist in the public B-Rep payload +- ids must survive no-topology-change edits +- when topology does change, the kernel must return a remap or change report + +Needed result shape: + +```ts +type TopologyId = string | number; + +interface TopologyRemap { + faces?: Record; + edges?: Record; + vertices?: Record; +} +``` + +Without this, the editor cannot keep a selected face or edge alive across edit steps. + +### 3. Public B-Rep inspection helpers + +The editor needs public access to topology and geometric properties without reverse engineering them from triangle meshes. + +Minimum helpers: + +- face centroid +- face normal at representative point +- face surface type +- edge endpoints +- edge curve type +- vertex position +- adjacency: + - face -> loops + - loop -> halfedges / edges / vertices + - edge -> incident faces + - vertex -> connected edges / faces + +Suggested surface: + +```ts +interface BrepInspectionApi { + getFaceInfo(entity: EditableBrepEntity, faceId: TopologyId): FaceInfo; + getEdgeInfo(entity: EditableBrepEntity, edgeId: TopologyId): EdgeInfo; + getVertexInfo(entity: EditableBrepEntity, vertexId: TopologyId): VertexInfo; +} +``` + +### 4. Kernel edit operations for 3D topology editing + +The editor needs actual kernel operations, not only primitive parameter mutation. + +Minimum phase-1 operations: + +- push/pull face along local face normal +- offset or translate face +- move vertex +- move edge + +At minimum, the face operation must work for planar and analytic faces and must return a valid edited solid. + +Suggested surface: + +```ts +interface BrepEditApi { + pushPullFace(entity: EditableBrepEntity, faceId: TopologyId, distance: number, options?: FaceEditOptions): BrepEditResult; + moveFace(entity: EditableBrepEntity, faceId: TopologyId, translation: Vec3, options?: FaceEditOptions): BrepEditResult; + moveEdge(entity: EditableBrepEntity, edgeId: TopologyId, translation: Vec3, options?: EdgeEditOptions): BrepEditResult; + moveVertex(entity: EditableBrepEntity, vertexId: TopologyId, translation: Vec3, options?: VertexEditOptions): BrepEditResult; +} +``` + +If the kernel already has an internal `extrude_brep_face` path, expose that through a stable JS/TS API. + +### 5. Result payloads that the editor can consume incrementally + +Every edit result should return: + +- updated B-Rep +- updated placement +- updated renderable geometry if available +- topology remap +- validity / healing report +- whether the result is still representable as the original primitive type + +Suggested shape: + +```ts +interface BrepEditResult { + entity: EditableBrepEntity; + brepSerialized: string; + geometrySerialized?: string; + outlineGeometrySerialized?: string; + topologyRemap?: TopologyRemap; + validity: { + ok: boolean; + healed?: boolean; + warnings?: string[]; + errors?: string[]; + }; + representation: { + kind: "primitive" | "generic_brep"; + primitiveType?: "cuboid" | "cylinder" | "sphere" | "wedge" | "opening" | "sweep"; + }; +} +``` + +### 6. Clear behavior for primitive preservation vs promotion + +The editor needs a deterministic answer to this question: + +When a topology edit no longer matches a primitive wrapper, what happens? + +Required behavior: + +- if the edited result is still exactly representable as the same primitive, keep that primitive representation +- otherwise promote to a generic B-Rep entity + +Examples: + +- cuboid top-face push/pull: can stay `Cuboid` +- cylinder top-face push/pull: can stay `Cylinder` +- cylinder side-face uniform radial edit: can stay `Cylinder` +- local face edit that breaks primitive invariants: must become generic B-Rep + +This promotion behavior is necessary for a universal editor. + +### 7. Placement separated from local B-Rep + +The editor needs a clear separation between: + +- local shape B-Rep +- world placement / transformation + +This is especially important for: + +- rotated editing +- local face normals vs world face normals +- stable topological ids +- future instancing / scenegraph support + +The editor does not need `OGSceneManager` projection, but it does need reliable local/world transforms. + +### 8. Face/edge/vertex geometry suitable for picking and overlays + +The editor can project B-Rep itself, but for 3D picking and gizmos it needs kernel data that maps visible geometry back to topology ids. + +Useful deliverables: + +- triangulated face buffers with face ids preserved per triangle +- edge render buffers with edge ids preserved per segment +- vertex positions with ids + +Suggested surface: + +```ts +interface TopologyRenderData { + faces: Array<{ + faceId: TopologyId; + positions: Float32Array; + indices: Uint32Array | Uint16Array; + }>; + edges: Array<{ + edgeId: TopologyId; + positions: Float32Array; + }>; + vertices: Array<{ + vertexId: TopologyId; + position: Vec3; + }>; +} +``` + +This avoids editor-side guessing about which rendered triangle belongs to which face. + +### 9. Validity and healing feedback + +The editor needs to know if an edit: + +- succeeded +- was auto-healed +- produced warnings +- failed because the requested edit is impossible + +This must be part of the public result contract, not hidden in logs. + +### 10. Undo-friendly deterministic operations + +Kernel edit operations must be deterministic and serializable enough for editor undo/redo. + +The editor can manage history, but it needs operations whose outputs are: + +- repeatable +- stable enough to reapply +- free of hidden mutable global state + +## Minimum Public API Request + +If the kernel team wants the smallest useful surface, this is the minimum request: + +```ts +interface EditableBrepEntity { + getId(): string; + getBrepSerialized(): string; + getPlacement(): ObjectTransformation; + setPlacement(transform: ObjectTransformation): void; + getTopologyRenderData(): TopologyRenderData; + getFaceInfo(faceId: TopologyId): FaceInfo; + getEdgeInfo(edgeId: TopologyId): EdgeInfo; + getVertexInfo(vertexId: TopologyId): VertexInfo; + pushPullFace(faceId: TopologyId, distance: number, options?: FaceEditOptions): BrepEditResult; + moveEdge(edgeId: TopologyId, delta: Vec3, options?: EdgeEditOptions): BrepEditResult; + moveVertex(vertexId: TopologyId, delta: Vec3, options?: VertexEditOptions): BrepEditResult; +} +``` + +## What The Editor Does Not Need From Kernel + +The editor package does not need these kernel-side deliverables for this phase: + +- 2D scene projection from `OGSceneManager` +- editor UI widgets +- SVG rendering +- camera controls +- browser event handling + +The editor will handle: + +- orthographic SVG projection +- 2D snap logic +- 2D/3D selection state +- handles and gizmos +- history stack +- multi-view coordination + +## Acceptance Criteria For The Kernel Work + +The kernel-side delivery is sufficient when all of the following are possible through the public JS/TS API: + +1. Select a cuboid face by stable `faceId`, push/pull it, and continue editing the same face across drag steps. +2. Select a cylinder top face and push/pull it as height change. +3. Select a cylinder side face and perform the kernel-defined valid edit: + either preserve `Cylinder` if analytic constraints still hold, or promote to generic B-Rep. +4. Select a face on a non-primitive edited solid and continue push/pull without falling back to primitive-only logic. +5. Select an edge by stable `edgeId` and move it. +6. Select a vertex by stable `vertexId` and move it. +7. Receive a topology remap or stable ids after every operation. +8. Receive explicit validity/healing feedback after every operation. + +## Suggested Implementation Order + +### Phase 1 + +- expose stable topology ids in public B-Rep payload +- expose `pushPullFace(...)` +- expose face triangulation / edge / vertex id mapping +- expose validity report +- support cuboid and cylinder through the same public edit contract + +### Phase 2 + +- expose `moveEdge(...)` +- expose `moveVertex(...)` +- add topology remap details +- add primitive preservation vs promotion reporting + +### Phase 3 + +- extend the same contract to wedge, opening, sweep, boolean results, and generic edited solids + +## Direct Instruction For The Kernel Agent + +Build the public JS/TS kernel surface required for a kernel-first B-Rep editor. + +Do not optimize for wrapper-only editing. + +The target is not "make cuboid and cylinder demos easier." The target is: + +- a stable editable B-Rep entity +- stable topology ids +- public face/edge/vertex inspection +- public face/edge/vertex edit operations +- deterministic edit results with remap and validity reporting + +If an internal kernel capability already exists, expose it cleanly instead of adding more wrapper-specific shortcuts. + +## What The Editor Agent Will Do After Kernel Delivery + +Once this kernel surface exists and is republished through npm, the editor package will be rebuilt around: + +- B-Rep-first 3D selection +- face/edge/vertex gizmos bound to topology ids +- true push/pull +- generic solid editing after primitive promotion +- consistent multi-view SVG + 3D editing from the same kernel entity + diff --git a/.requests/kernel-topology-remap-request.md b/.requests/kernel-topology-remap-request.md new file mode 100644 index 0000000..ba602ef --- /dev/null +++ b/.requests/kernel-topology-remap-request.md @@ -0,0 +1,132 @@ +# Kernel Request: Non-Identity Topology Remap Contract For B-Rep Editor Continuity + +## Context + +`@opengeometry/editor-controls` is now using kernel-native B-Rep editing (`OGEditableBrepEntity`) for face/edge/vertex editing. + +Current gap: + +- `BrepEditResult.topology_remap` is currently identity-only via `identity_topology_remap(...)`. +- Reference: `main/opengeometry/src/editor/mod.rs` around `serialize_edit_result` (`topology_remap: Some(identity_topology_remap(&self.local_brep))`). + +This is sufficient for topology-preserving edits, but not for topology-changing edits. + +## Why This Blocks A Proper Editor + +When topology changes (split/merge/delete/create), identity remap breaks editor continuity: + +- Active face/edge/vertex selection cannot be tracked reliably across drag frames. +- Push/pull and edge/vertex drag can jump, deselect, or bind to stale IDs. +- Undo/redo selection restoration becomes inconsistent. +- Overlay handles and hit targets cannot rebind deterministically. + +## Request (Required API Behavior) + +For each edit operation (`pushPullFace`, `moveFace`, `moveEdge`, `moveVertex`), return a real remap that represents what changed. + +### Required fields in `BrepEditResult` + +Keep existing fields. Add these semantics (or equivalent shape): + +1. `topology_changed: boolean` +2. `topology_remap` with per-domain mapping for `faces`, `edges`, `vertices` +3. Non-1:1 mapping support: +- unchanged: old -> [same] +- split: old -> [new1, new2, ...] +- deleted: old -> [] +- merge: multiple old -> [sameNew] +4. `primary_id` per old id for editor continuity when mapping is 1:N. +5. Deterministic/stable IDs for untouched topology. + +If you prefer a new field name (for backward compatibility), add `topology_remap_v2` and keep old `topology_remap` for now. + +## Suggested JSON Shape + +```json +{ + "topology_changed": true, + "topology_remap_v2": { + "faces": [ + { "old_id": 12, "new_ids": [31, 32], "primary_id": 31, "status": "split" }, + { "old_id": 9, "new_ids": [], "primary_id": null, "status": "deleted" }, + { "old_id": 7, "new_ids": [7], "primary_id": 7, "status": "unchanged" } + ], + "edges": [], + "vertices": [] + } +} +``` + +Status enum can be: + +- `unchanged` +- `split` +- `merged` +- `deleted` +- `created` (optional if represented via created lists) + +## Minimal Acceptable First Version + +If full generic remap is not ready immediately, minimum needed to unblock editor: + +1. Accurate remap for the edited domain: +- face ops: face remap must be correct +- edge ops: edge remap must be correct +- vertex ops: vertex remap must be correct +2. `topology_changed` flag +3. `primary_id` (or equivalent) for edited element continuity + +Then expand to full face+edge+vertex remap in follow-up. + +## Determinism Rules + +1. Same input state + same operation -> same output IDs/remap. +2. IDs for untouched entities must remain stable. +3. Remap must always reference IDs in the returned post-edit B-Rep. + +## Operation-Level Expectations + +1. `pushPullFace(faceId, ...)` +- If face survives: map old face to surviving face (`primary_id`). +- If face splits: map to all new faces with a stable `primary_id`. +- If deleted/consumed: map to empty and mark deleted. + +2. `moveEdge(edgeId, ...)` +- Return edge remap at minimum; include affected faces/vertices when changed. + +3. `moveVertex(vertexId, ...)` +- Return vertex remap at minimum; include affected edges/faces when changed. + +4. `moveFace(faceId, ...)` +- Same expectations as push/pull for face continuity + collateral topology. + +## Acceptance Criteria For Kernel Delivery + +1. After any topology-changing edit, editor can keep one deterministic active target using remap `primary_id`. +2. Editor can rebuild overlays by remapping all tracked selected IDs (face/edge/vertex). +3. Undo/redo rebinds selected topology IDs without stale references. +4. Cylinder/cuboid primitive-preserving paths still return stable identity mapping when topology does not change. +5. Non-primitive edited solids return correct remap after subsequent edits. + +## Test Cases Requested In Kernel + +1. Face split case: +- one face old id maps to 2+ new face ids; `primary_id` present. +2. Deletion/consumption case: +- old id maps to empty. +3. Merge case: +- multiple old ids map to one new id. +4. Topology-preserving case: +- identity mapping for unaffected IDs. +5. Determinism: +- repeated same operation produces identical remap and IDs. + +## Compatibility Notes + +- Keep existing `BrepEditResult` payload shape stable. +- Introduce new remap field additively if needed. +- Avoid breaking current `opengeometry-three` parsing; additive changes only. + +## Priority + +High. This is the remaining kernel contract gap preventing production-grade B-Rep editing continuity in the editor package. diff --git a/AGENTS.md b/AGENTS.md index 0cc46e5..bcf0b69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,20 @@ Minimum expectations: - why - residual risk +### Repository Workflows and Commands (Current) + +Use commands that match touched areas: + +- Rust core (`main/opengeometry`): `cargo fmt --check`, `cargo check --examples`, `cargo test -q` +- Root validation: `npm test` (runs Rust tests + Rust example tests via `--manifest-path main/opengeometry/Cargo.toml`) +- Root build: `npm run build-core`, `npm run build-three`, `npm run build` +- Dist preparation: `npm run prepare-dist` (or `npm run copy-wasm`) +- Three.js package examples (`main/opengeometry-three`): `npm --prefix main/opengeometry-three run dev-example-three`, `npm --prefix main/opengeometry-three run build-example-three`, `npm --prefix main/opengeometry-three run preview-example-three` +- TypeScript linting (`main/opengeometry-three/src`): `npm run lint:check` (or `npm run lint` when fixes are intended) + +TODO: +- Confirm and document a local release dry-run workflow (CI currently uses `npm ci`, `npm run build`, and `npm test` in `.github/workflows/release.yml`). + ## Change Management - Prefer incremental commits with focused intent. diff --git a/AI-DOCs/2026-03-22-parametric-freeform-editor-handoff.md b/AI-DOCs/2026-03-22-parametric-freeform-editor-handoff.md new file mode 100644 index 0000000..343cd9d --- /dev/null +++ b/AI-DOCs/2026-03-22-parametric-freeform-editor-handoff.md @@ -0,0 +1,61 @@ +# Parametric and Freeform Editor Handoff + +Date: 2026-03-22 + +## What Changed + +- Reframed native OpenGeometry wrappers as `parametric` objects edited through `getConfig` / `setConfig` and `getPlacement` / `setPlacement`. +- Renamed the public direct-edit surface from `EditableBrep` terminology to `freeform` terminology: + - wasm export is now `OGFreeformGeometry` + - `opengeometry-three` exports `FreeformGeometry`, `createFreeformGeometry()`, and `FreeformEditResult` +- Promoted freeform into a first-class module: + - Rust implementation now lives under `main/opengeometry/src/freeform/` + - TypeScript wrapper now lives under `main/opengeometry-three/src/freeform/` + - `main/opengeometry/src/editor/` remains as a compatibility re-export rather than the primary home +- Added `getEditCapabilities()` and `toFreeform()` across native wrapper types in `opengeometry-three`. +- Added `BooleanResult.toFreeform()` so boolean outputs can be turned into editable freeform geometry without extra adapter code. +- Added `cutFace(faceId, startEdgeId, startT, endEdgeId, endT)` to the freeform kernel and TS wrapper for single-face cuts that split only the selected face. +- Added `loopCut(edgeId, t)` to the freeform kernel and TS wrapper for closed quad edge rings. +- Updated the Vite editor example so: + - `Cut One Freeform Side Face` demonstrates a Forma-style single-face cut on one cuboid side + - `Loop Cut Freeform Side Ring` demonstrates a real kernel-backed loop cut on the converted cuboid + - the older split-and-move workaround is no longer the main path for adding a visible face cut + - the orange preview now draws topology edges as well as outline edges so coplanar cuts remain visible +- Split the TypeScript editor surface into smaller files under `main/opengeometry-three/src/operations/editor/`. +- Added a reusable scene example and a Vite example showing parametric edits first and explicit freeform conversion second. + +## Why It Changed + +`editor-controls` needs a clear two-mode model: + +- `parametric` mode for config and placement changes on native objects +- `freeform` mode for direct face/edge/vertex editing after explicit conversion + +This keeps object-mode interaction logic in the GUI package while preserving one robust direct-edit engine in the kernel. + +## How to Test Locally + +Run from repository root: + +1. `cargo fmt --check --manifest-path main/opengeometry/Cargo.toml` +2. `cargo check --examples --manifest-path main/opengeometry/Cargo.toml` +3. `cargo test -q --manifest-path main/opengeometry/Cargo.toml` +4. `npm test` +5. `npm run build-three` +6. `npm --prefix main/opengeometry-three run build-example-three` + +## Backward-Compatibility Notes + +- This is intentionally breaking for the editing API surface. +- Public `EditableBrep*` naming is replaced by `freeform` naming. +- The old convenience helpers `describeEditableObject()` and `enterFreeformMode()` were removed to keep the public API thinner. +- Direct BRep editing behavior is preserved; only the product language and integration contract changed. +- Existing imports through `main/opengeometry/src/editor/` and `main/opengeometry-three/src/operations/editor/` continue to work through compatibility re-exports while the dedicated freeform module becomes the primary home. + +## Known Caveats + +- Object-mode handle math is intentionally not implemented in the kernel or `opengeometry-three`; that belongs in `editor-controls`. +- `Opening` continues to use the cuboid kernel under the hood, but now reports itself as `entityType: "opening"` to the TS editing contract. +- The Rust module previously named `render.rs` is now `topology_display.rs` because it prepares topology display payloads rather than performing rendering. +- Boolean outputs convert to freeform with identity placement and baked world-space coordinates, because boolean results currently serialize world BReps rather than a local-BRep-plus-placement pair. +- `cutFace` is currently the right primitive for a Forma-like single-face split. `loopCut` remains intentionally scoped to closed quad edge rings. diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index 09a01f8..da4cfb3 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -514,7 +514,7 @@

Wedge

Operations

-

6 core demos

+

7 core demos

@@ -539,6 +539,17 @@

Polygon Boolean Operations

Open example +
+
+

Parametric and Freeform

+
+

Shows native config and placement edits first, then explicit freeform conversion.

+
+ Parametric + Freeform +
+ Open example +

Offset

diff --git a/main/opengeometry-three/examples-vite/operations/editor-modes.html b/main/opengeometry-three/examples-vite/operations/editor-modes.html new file mode 100644 index 0000000..285a2b7 --- /dev/null +++ b/main/opengeometry-three/examples-vite/operations/editor-modes.html @@ -0,0 +1,692 @@ + + + + OpenGeometry Parametric and Freeform Example + + + + + + +
+ Back + Parametric and Freeform +
+ +
+ + + diff --git a/main/opengeometry-three/index.ts b/main/opengeometry-three/index.ts index 1d85968..672df7f 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -114,6 +114,10 @@ export * from "./src/shapes/index"; * Reusable example builders for quickly wiring demo scenes. */ export * from "./src/examples/index"; +/** + * First-class freeform geometry wrapper around the kernel OGFreeformGeometry API. + */ +export * from "./src/freeform/index"; /** * Kernel-backed modeling operations. */ diff --git a/main/opengeometry-three/src/examples/editor-modes.ts b/main/opengeometry-three/src/examples/editor-modes.ts new file mode 100644 index 0000000..1fd5d69 --- /dev/null +++ b/main/opengeometry-three/src/examples/editor-modes.ts @@ -0,0 +1,98 @@ +import * as THREE from "three"; +import { Vector3 } from "../../../opengeometry/pkg/opengeometry"; +import { + FreeformGeometry, + FreeformEditResult, +} from "../freeform"; +import { Polygon } from "../shapes/polygon"; +import { Cuboid } from "../shapes/cuboid"; + +function topFaceId(freeform: FreeformGeometry): number | null { + let bestFaceId: number | null = null; + let bestY = Number.NEGATIVE_INFINITY; + + for (const face of freeform.getTopologyRenderData().faces) { + const info = freeform.getFaceInfo(face.face_id); + if (info.centroid.y > bestY) { + bestY = info.centroid.y; + bestFaceId = face.face_id; + } + } + + return bestFaceId; +} + +/** + * Demonstrates the intended editing flow for editor-controls: + * parametric config/placement edits first, then explicit freeform conversion. + */ +export function createEditorModesExample(scene: THREE.Scene) { + const polygon = new Polygon({ + vertices: [ + new Vector3(-3.2, 0.0, -0.8), + new Vector3(-2.2, 0.0, 0.4), + new Vector3(-1.0, 0.0, 0.0), + new Vector3(-1.5, 0.0, -1.2), + ], + color: 0x2563eb, + fatOutlines: true, + outlineWidth: 4, + }); + polygon.outline = true; + + const polygonCapabilities = polygon.getEditCapabilities(); + polygon.setConfig({ + vertices: [ + new Vector3(-3.4, 0.0, -0.9), + new Vector3(-2.0, 0.0, 0.6), + new Vector3(-0.8, 0.0, -0.1), + new Vector3(-1.6, 0.0, -1.4), + ], + }); + polygon.setPlacement({ + translation: new Vector3(0.0, 0.0, -0.2), + }); + + const cuboid = new Cuboid({ + center: new Vector3(1.2, 0.8, 0.0), + width: 1.2, + height: 1.4, + depth: 1.0, + color: 0x10b981, + fatOutlines: true, + outlineWidth: 4, + }); + cuboid.outline = true; + + const cuboidCapabilities = cuboid.getEditCapabilities(); + cuboid.setConfig({ + width: 1.6, + height: 1.8, + }); + cuboid.setPlacement({ + translation: new Vector3(0.2, 0.0, 0.0), + rotation: new Vector3(0.0, Math.PI / 9.0, 0.0), + }); + + const freeform = cuboid.toFreeform(`${cuboid.ogid}-freeform`); + const topFace = topFaceId(freeform); + let freeformResult: FreeformEditResult | null = null; + + if (topFace !== null) { + freeformResult = freeform.pushPullFace(topFace, 0.35, { + includeTopologyRemap: true, + }); + } + + scene.add(polygon); + scene.add(cuboid); + + return { + polygon, + cuboid, + polygonCapabilities, + cuboidCapabilities, + freeform, + freeformResult, + }; +} diff --git a/main/opengeometry-three/src/examples/index.ts b/main/opengeometry-three/src/examples/index.ts index 0e40a09..f9befb2 100644 --- a/main/opengeometry-three/src/examples/index.ts +++ b/main/opengeometry-three/src/examples/index.ts @@ -7,3 +7,4 @@ export * from './sweep'; export * from './offset'; export * from './wall-from-offsets'; export * from './booleans'; +export * from './editor-modes'; diff --git a/main/opengeometry-three/src/freeform/geometry.ts b/main/opengeometry-three/src/freeform/geometry.ts new file mode 100644 index 0000000..87c8a22 --- /dev/null +++ b/main/opengeometry-three/src/freeform/geometry.ts @@ -0,0 +1,447 @@ +import { + OGFreeformGeometry, + Vector3, +} from "../../../opengeometry/pkg/opengeometry"; + +import type { + CreateFreeformGeometryOptions, + EdgeInfo, + EditOperationOptions, + FaceInfo, + FreeformEditCapabilities, + FreeformEditResult, + FreeformFeatureEditCapabilities, + FreeformSource, + ObjectTransformation, + TopologyId, + TopologyRenderData, + VertexInfo, +} from "./types"; + +type RawFreeformOperationCapabilities = { + can_push_pull_face: boolean; + can_move_face: boolean; + can_extrude_face: boolean; + can_cut_face: boolean; + can_move_edge: boolean; + can_move_vertex: boolean; + can_insert_vertex_on_edge: boolean; + can_remove_vertex: boolean; + can_split_edge: boolean; + can_loop_cut: boolean; + reasons?: string[]; +}; + +type RawFreeformFeatureEditCapabilities = RawFreeformOperationCapabilities & { + domain: "face" | "edge" | "vertex"; + topology_id: TopologyId; +}; + +function parseJson(payload: string): T { + return JSON.parse(payload) as T; +} + +function toVector3(value: { x: number; y: number; z: number }): Vector3 { + return new Vector3(value.x, value.y, value.z); +} + +function toPlainVector3(value: { x: number; y: number; z: number }) { + return { + x: value.x, + y: value.y, + z: value.z, + }; +} + +function cloneVector( + vector: Vector3 | undefined, + fallback: [number, number, number] +): Vector3 { + return vector?.clone() ?? new Vector3(...fallback); +} + +function normalizePlacement( + placement: ObjectTransformation +): ObjectTransformation { + return { + anchor: toVector3(placement.anchor), + translation: toVector3(placement.translation), + rotation: toVector3(placement.rotation), + scale: toVector3(placement.scale), + }; +} + +function toObjectTransformation( + placement: NonNullable +): ObjectTransformation { + return { + anchor: cloneVector(placement.anchor, [0, 0, 0]), + translation: cloneVector(placement.translation, [0, 0, 0]), + rotation: cloneVector(placement.rotation, [0, 0, 0]), + scale: cloneVector(placement.scale, [1, 1, 1]), + }; +} + +function toPlainObjectTransformation(transform: ObjectTransformation) { + return { + anchor: toPlainVector3(transform.anchor), + translation: toPlainVector3(transform.translation), + rotation: toPlainVector3(transform.rotation), + scale: toPlainVector3(transform.scale), + }; +} + +function normalizeFaceInfo(face: FaceInfo): FaceInfo { + return { + ...face, + centroid: toVector3(face.centroid), + normal: toVector3(face.normal), + }; +} + +function normalizeEdgeInfo(edge: EdgeInfo): EdgeInfo { + return { + ...edge, + start: toVector3(edge.start), + end: toVector3(edge.end), + }; +} + +function normalizeVertexInfo(vertex: VertexInfo): VertexInfo { + return { + ...vertex, + position: toVector3(vertex.position), + }; +} + +function normalizeFeatureCapabilities( + raw: RawFreeformFeatureEditCapabilities +): FreeformFeatureEditCapabilities { + return { + domain: raw.domain, + topologyId: raw.topology_id, + canPushPullFace: raw.can_push_pull_face, + canMoveFace: raw.can_move_face, + canExtrudeFace: raw.can_extrude_face, + canCutFace: raw.can_cut_face, + canMoveEdge: raw.can_move_edge, + canMoveVertex: raw.can_move_vertex, + canInsertVertexOnEdge: raw.can_insert_vertex_on_edge, + canRemoveVertex: raw.can_remove_vertex, + canSplitEdge: raw.can_split_edge, + canLoopCut: raw.can_loop_cut, + reasons: raw.reasons, + }; +} + +function normalizeFreeformCapabilities( + raw: RawFreeformOperationCapabilities +): FreeformEditCapabilities { + return { + editingMode: "freeform", + entityType: "freeform", + editFamily: "freeform", + canEditConfig: false, + canEditPlacement: true, + canConvertToFreeform: false, + canPushPullFace: raw.can_push_pull_face, + canMoveFace: raw.can_move_face, + canExtrudeFace: raw.can_extrude_face, + canCutFace: raw.can_cut_face, + canMoveEdge: raw.can_move_edge, + canMoveVertex: raw.can_move_vertex, + canInsertVertexOnEdge: raw.can_insert_vertex_on_edge, + canRemoveVertex: raw.can_remove_vertex, + canSplitEdge: raw.can_split_edge, + canLoopCut: raw.can_loop_cut, + reasons: raw.reasons, + }; +} + +function normalizeFreeformSerialized(source: FreeformSource): string { + if (typeof source === "string") { + return source; + } + + if (typeof source.getLocalBrepSerialized === "function") { + return source.getLocalBrepSerialized(); + } + + if (typeof source.getLocalBrepData === "function") { + return JSON.stringify(source.getLocalBrepData()); + } + + if (typeof source.getBrepSerialized === "function") { + return source.getBrepSerialized(); + } + + if (typeof source.getBrepData === "function") { + return JSON.stringify(source.getBrepData()); + } + + if (typeof source.getBrep === "function") { + return JSON.stringify(source.getBrep()); + } + + return JSON.stringify(source); +} + +function parseEditResult(payload: string): FreeformEditResult { + const result = parseJson(payload); + return { + ...result, + placement: normalizePlacement(result.placement), + }; +} + +function serializeOptions(options?: EditOperationOptions): string | undefined { + if (!options) { + return undefined; + } + + return JSON.stringify({ + ...options, + constraintAxis: options.constraintAxis + ? toPlainVector3(options.constraintAxis) + : undefined, + constraintPlaneNormal: options.constraintPlaneNormal + ? toPlainVector3(options.constraintPlaneNormal) + : undefined, + }); +} + +export class FreeformGeometry { + private readonly geometry: OGFreeformGeometry; + + constructor(geometry: OGFreeformGeometry) { + this.geometry = geometry; + } + + getId(): string { + return this.geometry.id; + } + + getBrepSerialized(): string { + return this.geometry.getBrepSerialized(); + } + + getLocalBrepSerialized(): string { + return this.geometry.getLocalBrepSerialized(); + } + + getGeometrySerialized(): string { + return this.geometry.getGeometrySerialized(); + } + + getOutlineGeometrySerialized(): string { + return this.geometry.getOutlineGeometrySerialized(); + } + + getPlacement(): ObjectTransformation { + return normalizePlacement( + parseJson(this.geometry.getPlacementSerialized()) + ); + } + + setPlacement(transform: ObjectTransformation): void { + this.geometry.setPlacementSerialized( + JSON.stringify(toPlainObjectTransformation(transform)) + ); + } + + setTransform(translation: Vector3, rotation: Vector3, scale: Vector3): void { + this.geometry.setTransform(translation, rotation, scale); + } + + setTranslation(translation: Vector3): void { + this.geometry.setTranslation(translation); + } + + setRotation(rotation: Vector3): void { + this.geometry.setRotation(rotation); + } + + setScale(scale: Vector3): void { + this.geometry.setScale(scale); + } + + setAnchor(anchor: Vector3): void { + this.geometry.setAnchor(anchor); + } + + getTopologyRenderData(): TopologyRenderData { + return parseJson(this.geometry.getTopologyRenderData()); + } + + getFaceInfo(faceId: TopologyId): FaceInfo { + return normalizeFaceInfo(parseJson(this.geometry.getFaceInfo(faceId))); + } + + getEdgeInfo(edgeId: TopologyId): EdgeInfo { + return normalizeEdgeInfo(parseJson(this.geometry.getEdgeInfo(edgeId))); + } + + getVertexInfo(vertexId: TopologyId): VertexInfo { + return normalizeVertexInfo( + parseJson(this.geometry.getVertexInfo(vertexId)) + ); + } + + getEditCapabilities(): FreeformEditCapabilities { + return normalizeFreeformCapabilities( + parseJson( + this.geometry.getEditCapabilities() + ) + ); + } + + getFaceEditCapabilities(faceId: TopologyId): FreeformFeatureEditCapabilities { + return normalizeFeatureCapabilities( + parseJson( + this.geometry.getFaceEditCapabilities(faceId) + ) + ); + } + + getEdgeEditCapabilities(edgeId: TopologyId): FreeformFeatureEditCapabilities { + return normalizeFeatureCapabilities( + parseJson( + this.geometry.getEdgeEditCapabilities(edgeId) + ) + ); + } + + getVertexEditCapabilities(vertexId: TopologyId): FreeformFeatureEditCapabilities { + return normalizeFeatureCapabilities( + parseJson( + this.geometry.getVertexEditCapabilities(vertexId) + ) + ); + } + + pushPullFace( + faceId: TopologyId, + distance: number, + options?: EditOperationOptions + ): FreeformEditResult { + return parseEditResult( + this.geometry.pushPullFace(faceId, distance, serializeOptions(options)) + ); + } + + extrudeFace( + faceId: TopologyId, + distance: number, + options?: EditOperationOptions + ): FreeformEditResult { + return parseEditResult( + this.geometry.extrudeFace(faceId, distance, serializeOptions(options)) + ); + } + + cutFace( + faceId: TopologyId, + startEdgeId: TopologyId, + startT: number, + endEdgeId: TopologyId, + endT: number, + options?: EditOperationOptions + ): FreeformEditResult { + return parseEditResult( + this.geometry.cutFace( + faceId, + startEdgeId, + startT, + endEdgeId, + endT, + serializeOptions(options) + ) + ); + } + + moveFace( + faceId: TopologyId, + translation: Vector3, + options?: EditOperationOptions + ): FreeformEditResult { + return parseEditResult( + this.geometry.moveFace(faceId, translation, serializeOptions(options)) + ); + } + + moveEdge( + edgeId: TopologyId, + translation: Vector3, + options?: EditOperationOptions + ): FreeformEditResult { + return parseEditResult( + this.geometry.moveEdge(edgeId, translation, serializeOptions(options)) + ); + } + + moveVertex( + vertexId: TopologyId, + translation: Vector3, + options?: EditOperationOptions + ): FreeformEditResult { + return parseEditResult( + this.geometry.moveVertex(vertexId, translation, serializeOptions(options)) + ); + } + + insertVertexOnEdge( + edgeId: TopologyId, + t: number, + options?: EditOperationOptions + ): FreeformEditResult { + return parseEditResult( + this.geometry.insertVertexOnEdge(edgeId, t, serializeOptions(options)) + ); + } + + splitEdge( + edgeId: TopologyId, + t: number, + options?: EditOperationOptions + ): FreeformEditResult { + return parseEditResult( + this.geometry.splitEdge(edgeId, t, serializeOptions(options)) + ); + } + + loopCut( + edgeId: TopologyId, + t: number, + options?: EditOperationOptions + ): FreeformEditResult { + return parseEditResult( + this.geometry.loopCut(edgeId, t, serializeOptions(options)) + ); + } + + removeVertex( + vertexId: TopologyId, + options?: EditOperationOptions + ): FreeformEditResult { + return parseEditResult( + this.geometry.removeVertex(vertexId, serializeOptions(options)) + ); + } +} + +export function createFreeformGeometry( + source: FreeformSource, + options?: CreateFreeformGeometryOptions +): FreeformGeometry { + const id = options?.id ?? `freeform-${Date.now()}`; + const localBrepSerialized = normalizeFreeformSerialized(source); + const geometry = new FreeformGeometry( + new OGFreeformGeometry(id, localBrepSerialized) + ); + + if (options?.placement) { + geometry.setPlacement(toObjectTransformation(options.placement)); + } + + return geometry; +} diff --git a/main/opengeometry-three/src/freeform/index.ts b/main/opengeometry-three/src/freeform/index.ts new file mode 100644 index 0000000..2563d2f --- /dev/null +++ b/main/opengeometry-three/src/freeform/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export { FreeformGeometry, createFreeformGeometry } from "./geometry"; diff --git a/main/opengeometry-three/src/freeform/types.ts b/main/opengeometry-three/src/freeform/types.ts new file mode 100644 index 0000000..ae439e8 --- /dev/null +++ b/main/opengeometry-three/src/freeform/types.ts @@ -0,0 +1,179 @@ +import { Vector3 } from "../../../opengeometry/pkg/opengeometry"; + +export type TopologyId = number; + +export interface FreeformPlacementInput { + anchor?: Vector3; + translation: Vector3; + rotation: Vector3; + scale: Vector3; +} + +export interface ObjectTransformation { + anchor: Vector3; + translation: Vector3; + rotation: Vector3; + scale: Vector3; +} + +export interface FreeformOperationCapabilities { + canPushPullFace: boolean; + canMoveFace: boolean; + canExtrudeFace: boolean; + canCutFace: boolean; + canMoveEdge: boolean; + canMoveVertex: boolean; + canInsertVertexOnEdge: boolean; + canRemoveVertex: boolean; + canSplitEdge: boolean; + canLoopCut: boolean; + reasons?: string[]; +} + +export interface FreeformEditCapabilities extends FreeformOperationCapabilities { + editingMode: "freeform"; + entityType: "freeform"; + editFamily: "freeform"; + canEditConfig: false; + canEditPlacement: true; + canConvertToFreeform: false; +} + +export interface FreeformFeatureEditCapabilities + extends FreeformOperationCapabilities { + domain: "face" | "edge" | "vertex"; + topologyId: TopologyId; +} + +export type TopologyRemapStatus = "unchanged" | "split" | "merged" | "deleted"; + +export interface TopologyRemapEntry { + old_id: TopologyId; + new_ids: TopologyId[]; + primary_id: TopologyId | null; + status: TopologyRemapStatus; +} + +export interface TopologyCreatedIds { + faces: TopologyId[]; + edges: TopologyId[]; + vertices: TopologyId[]; +} + +export interface TopologyRemap { + faces: TopologyRemapEntry[]; + edges: TopologyRemapEntry[]; + vertices: TopologyRemapEntry[]; + created_ids: TopologyCreatedIds; +} + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export interface FreeformDiagnostic { + code: string; + severity: DiagnosticSeverity; + message: string; + domain?: string; + topology_id?: TopologyId; +} + +export interface FreeformValidity { + ok: boolean; + healed?: boolean; + diagnostics: FreeformDiagnostic[]; +} + +export interface FaceInfo { + face_id: TopologyId; + centroid: Vector3; + normal: Vector3; + surface_type: string; + loop_ids: TopologyId[]; + edge_ids: TopologyId[]; + vertex_ids: TopologyId[]; + adjacent_face_ids: TopologyId[]; +} + +export interface EdgeInfo { + edge_id: TopologyId; + curve_type: string; + start_vertex_id: TopologyId; + end_vertex_id: TopologyId; + start: Vector3; + end: Vector3; + incident_face_ids: TopologyId[]; +} + +export interface VertexInfo { + vertex_id: TopologyId; + position: Vector3; + edge_ids: TopologyId[]; + face_ids: TopologyId[]; +} + +export interface TopologyFaceRenderData { + face_id: TopologyId; + positions: number[]; + indices: number[]; +} + +export interface TopologyEdgeRenderData { + edge_id: TopologyId; + positions: number[]; +} + +export interface TopologyVertexRenderData { + vertex_id: TopologyId; + position: Vector3; +} + +export interface TopologyRenderData { + faces: TopologyFaceRenderData[]; + edges: TopologyEdgeRenderData[]; + vertices: TopologyVertexRenderData[]; +} + +export interface FreeformEditResult { + entity_id: string; + brep_serialized?: string; + local_brep_serialized?: string; + geometry_serialized?: string; + outline_geometry_serialized?: string; + topology_changed: boolean; + topology_remap?: TopologyRemap; + changed_faces?: TopologyId[]; + changed_edges?: TopologyId[]; + changed_vertices?: TopologyId[]; + validity: FreeformValidity; + placement: ObjectTransformation; +} + +export interface EditOperationOptions { + includeBrepSerialized?: boolean; + includeLocalBrepSerialized?: boolean; + includeGeometrySerialized?: boolean; + includeOutlineGeometrySerialized?: boolean; + includeTopologyRemap?: boolean; + includeDeltas?: boolean; + constraintAxis?: Vector3; + constraintPlaneNormal?: Vector3; + preserveCoplanarity?: boolean; + constraintFrame?: "local" | "world"; + openSurfaceMode?: boolean; +} + +export interface CreateFreeformGeometryOptions { + id?: string; + placement?: FreeformPlacementInput | ObjectTransformation; +} + +export type FreeformSource = + | string + | Record + | { + getLocalBrepSerialized?: () => string; + getLocalBrepData?: () => unknown; + getBrepSerialized?: () => string; + getBrepData?: () => unknown; + getBrep?: () => unknown; + }; diff --git a/main/opengeometry-three/src/operations/boolean.ts b/main/opengeometry-three/src/operations/boolean.ts index 9a3f4bd..7b4486b 100644 --- a/main/opengeometry-three/src/operations/boolean.ts +++ b/main/opengeometry-three/src/operations/boolean.ts @@ -2,6 +2,11 @@ import * as OGKernel from "../../../opengeometry/pkg/opengeometry"; import * as THREE from "three"; import { toCreasedNormals } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import { + createFreeformGeometry, + type CreateFreeformGeometryOptions, + type FreeformGeometry, +} from "../freeform"; import { getUUID } from "../utils/randomizer"; import { createShapeOutlineMesh, @@ -146,6 +151,17 @@ export class BooleanResult extends THREE.Mesh { return this.brepSerialized; } + /** + * Converts the boolean output into a first-class freeform geometry object so + * it can participate in direct face/edge/vertex editing. + */ + toFreeform(options?: CreateFreeformGeometryOptions): FreeformGeometry { + return createFreeformGeometry(this.getBrepSerialized(), { + id: options?.id ?? this.ogid, + placement: options?.placement, + }); + } + set outline(enable: boolean) { this._outlineEnabled = enable; this.clearOutlineMesh(); diff --git a/main/opengeometry-three/src/operations/editor/freeform.ts b/main/opengeometry-three/src/operations/editor/freeform.ts new file mode 100644 index 0000000..c9fd211 --- /dev/null +++ b/main/opengeometry-three/src/operations/editor/freeform.ts @@ -0,0 +1 @@ +export { FreeformGeometry, createFreeformGeometry } from "../../freeform"; diff --git a/main/opengeometry-three/src/operations/editor/index.ts b/main/opengeometry-three/src/operations/editor/index.ts new file mode 100644 index 0000000..b88a0d2 --- /dev/null +++ b/main/opengeometry-three/src/operations/editor/index.ts @@ -0,0 +1,6 @@ +export * from "./types"; +export { + clonePlacement, + createParametricEditCapabilities, +} from "./parametric"; +export { FreeformGeometry, createFreeformGeometry } from "../../freeform"; diff --git a/main/opengeometry-three/src/operations/editor/parametric.ts b/main/opengeometry-three/src/operations/editor/parametric.ts new file mode 100644 index 0000000..8d2029f --- /dev/null +++ b/main/opengeometry-three/src/operations/editor/parametric.ts @@ -0,0 +1,60 @@ +import { Vector3 } from "../../../../opengeometry/pkg/opengeometry"; + +import type { + ObjectTransformation, + ParametricEditCapabilities, + ParametricEditFamily, + ParametricEntityType, + ParametricPlacement, +} from "./types"; + +function cloneVector( + vector: Vector3 | undefined, + fallback: [number, number, number] +): Vector3 { + return vector?.clone() ?? new Vector3(...fallback); +} + +export function createParametricEditCapabilities( + entityType: ParametricEntityType, + editFamily: ParametricEditFamily +): ParametricEditCapabilities { + return { + editingMode: "parametric", + entityType, + editFamily, + canEditConfig: true, + canEditPlacement: true, + canConvertToFreeform: true, + }; +} + +export function clonePlacement( + placement: Partial +): ParametricPlacement { + return { + translation: cloneVector(placement.translation, [0, 0, 0]), + rotation: cloneVector(placement.rotation, [0, 0, 0]), + scale: cloneVector(placement.scale, [1, 1, 1]), + }; +} + +export function toObjectTransformation( + placement: ParametricPlacement | ObjectTransformation +): ObjectTransformation { + if ("anchor" in placement) { + return { + anchor: cloneVector(placement.anchor, [0, 0, 0]), + translation: cloneVector(placement.translation, [0, 0, 0]), + rotation: cloneVector(placement.rotation, [0, 0, 0]), + scale: cloneVector(placement.scale, [1, 1, 1]), + }; + } + + return { + anchor: new Vector3(0, 0, 0), + translation: cloneVector(placement.translation, [0, 0, 0]), + rotation: cloneVector(placement.rotation, [0, 0, 0]), + scale: cloneVector(placement.scale, [1, 1, 1]), + }; +} diff --git a/main/opengeometry-three/src/operations/editor/types.ts b/main/opengeometry-three/src/operations/editor/types.ts new file mode 100644 index 0000000..881e81d --- /dev/null +++ b/main/opengeometry-three/src/operations/editor/types.ts @@ -0,0 +1,70 @@ +import { Vector3 } from "../../../../opengeometry/pkg/opengeometry"; +export type { + CreateFreeformGeometryOptions, + EdgeInfo, + EditOperationOptions, + FaceInfo, + FreeformDiagnostic, + FreeformEditCapabilities, + FreeformEditResult, + FreeformFeatureEditCapabilities, + FreeformOperationCapabilities, + FreeformSource, + FreeformValidity, + ObjectTransformation, + TopologyCreatedIds, + TopologyEdgeRenderData, + TopologyFaceRenderData, + TopologyId, + TopologyRemap, + TopologyRemapEntry, + TopologyRemapStatus, + TopologyRenderData, + TopologyVertexRenderData, + VertexInfo, +} from "../../freeform/types"; + +export type EditingMode = "parametric" | "freeform"; +export type ParametricEntityType = + | "line" + | "polyline" + | "arc" + | "curve" + | "rectangle" + | "polygon" + | "cuboid" + | "cylinder" + | "sphere" + | "wedge" + | "opening" + | "sweep"; +export type EntityType = ParametricEntityType | "freeform"; +export type EditFamily = + | "profile" + | "curve" + | "box" + | "radial" + | "wedge" + | "sweep" + | "freeform"; +export type ParametricEditFamily = Exclude; + +export interface ParametricPlacement { + translation: Vector3; + rotation: Vector3; + scale: Vector3; +} + +export interface BaseEditCapabilities { + editingMode: EditingMode; + entityType: EntityType; + editFamily: EditFamily; + canEditConfig: boolean; + canEditPlacement: boolean; + canConvertToFreeform: boolean; +} + +export interface ParametricEditCapabilities extends BaseEditCapabilities { + editingMode: "parametric"; + entityType: ParametricEntityType; +} diff --git a/main/opengeometry-three/src/operations/index.ts b/main/opengeometry-three/src/operations/index.ts index f84f488..0a08dfa 100644 --- a/main/opengeometry-three/src/operations/index.ts +++ b/main/opengeometry-three/src/operations/index.ts @@ -2,3 +2,7 @@ * Kernel-backed boolean helpers and renderable result mesh. */ export * from "./boolean"; +/** + * Parametric capability helpers used by editor-controls object mode. + */ +export * from "./editor"; diff --git a/main/opengeometry-three/src/primitives/arc.ts b/main/opengeometry-three/src/primitives/arc.ts index 6bb8bd2..20b0ccf 100644 --- a/main/opengeometry-three/src/primitives/arc.ts +++ b/main/opengeometry-three/src/primitives/arc.ts @@ -4,6 +4,11 @@ import { getUUID } from "../utils/randomizer"; import { Line2 } from 'three/examples/jsm/lines/Line2.js'; import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; // import { IArcOptions } from "../base-types"; export interface IArcOptions { @@ -170,6 +175,25 @@ export class Arc extends THREE.Line { return this.options; } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("arc", "curve"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.arc.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + private getCurrentPositions() { const attribute = this.geometry.getAttribute("position"); if (!attribute || attribute.itemSize !== 3) { diff --git a/main/opengeometry-three/src/primitives/curve.ts b/main/opengeometry-three/src/primitives/curve.ts index 2b49e85..254e749 100644 --- a/main/opengeometry-three/src/primitives/curve.ts +++ b/main/opengeometry-three/src/primitives/curve.ts @@ -1,6 +1,11 @@ import * as THREE from "three"; import { OGCurve, Vector3 } from "../../../opengeometry/pkg/opengeometry"; import { getUUID } from "../utils/randomizer"; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; export interface ICurveOptions { ogid?: string; @@ -101,6 +106,10 @@ export class Curve extends THREE.Line { } } + getConfig() { + return this.options; + } + getAnchor() { const anchor = this.curve.get_anchor(); return new Vector3(anchor.x, anchor.y, anchor.z); @@ -149,6 +158,25 @@ export class Curve extends THREE.Line { this.setPlacement({ scale }); } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("curve", "curve"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.curve.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + private generateGeometry() { const bufferData = Array.from(this.curve.get_geometry_buffer()); diff --git a/main/opengeometry-three/src/primitives/line.ts b/main/opengeometry-three/src/primitives/line.ts index c25c538..b64e861 100644 --- a/main/opengeometry-three/src/primitives/line.ts +++ b/main/opengeometry-three/src/primitives/line.ts @@ -4,6 +4,11 @@ import { getUUID } from "../utils/randomizer"; import { Line2 } from 'three/examples/jsm/lines/Line2.js'; import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; export interface ILineOptions { ogid?: string; @@ -132,6 +137,10 @@ export class Line extends THREE.Line { } } + getConfig() { + return this.options; + } + getAnchor() { const anchor = this.line.get_anchor(); return new Vector3(anchor.x, anchor.y, anchor.z); @@ -180,6 +189,25 @@ export class Line extends THREE.Line { this.setPlacement({ scale }); } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("line", "profile"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.line.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + /** * Every time there are property changes, geometry needs to be discarded and regenerated. * This is to ensure that the geometry is always up-to-date with the current state. diff --git a/main/opengeometry-three/src/primitives/polyline.ts b/main/opengeometry-three/src/primitives/polyline.ts index e954791..0e3303f 100644 --- a/main/opengeometry-three/src/primitives/polyline.ts +++ b/main/opengeometry-three/src/primitives/polyline.ts @@ -4,6 +4,11 @@ import { getUUID } from "../utils/randomizer"; import { Line2 } from 'three/examples/jsm/lines/Line2.js'; import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; export interface IPolylineOptions { ogid?: string; @@ -127,6 +132,10 @@ export class Polyline extends THREE.Line { } } + getConfig() { + return this.options; + } + getAnchor() { const anchor = this.polyline.get_anchor(); return new Vector3(anchor.x, anchor.y, anchor.z); @@ -175,6 +184,25 @@ export class Polyline extends THREE.Line { this.setPlacement({ scale }); } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("polyline", "profile"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.polyline.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + addPoint(point: Vector3) { if (!this.polyline) return; diff --git a/main/opengeometry-three/src/primitives/rectangle.ts b/main/opengeometry-three/src/primitives/rectangle.ts index 95c6f15..c9ab621 100644 --- a/main/opengeometry-three/src/primitives/rectangle.ts +++ b/main/opengeometry-three/src/primitives/rectangle.ts @@ -4,6 +4,11 @@ import { getUUID } from "../utils/randomizer"; import { Line2 } from 'three/examples/jsm/lines/Line2.js'; import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; export interface IRectangleOptions { ogid?: string; @@ -201,6 +206,28 @@ export class Rectangle extends THREE.Line { return this.options; } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("rectangle", "profile"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry( + this.polyLineRectangle.get_local_brep_serialized(), + { + id, + placement: this.getPlacement(), + } + ); + } + private getCurrentPositions() { const attribute = this.geometry.getAttribute("position"); if (!attribute || attribute.itemSize !== 3) { diff --git a/main/opengeometry-three/src/shapes/cuboid.ts b/main/opengeometry-three/src/shapes/cuboid.ts index 787992d..5322d8d 100644 --- a/main/opengeometry-three/src/shapes/cuboid.ts +++ b/main/opengeometry-three/src/shapes/cuboid.ts @@ -8,6 +8,11 @@ import { sanitizeOutlineWidth, ShapeOutlineMesh, } from "./outline-utils"; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; import { subtractShapeOperand } from "./boolean-subtract"; import type { ShapeSubtractOperand, @@ -99,6 +104,14 @@ export class Cuboid extends THREE.Mesh { return this.options; } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + setConfig(options: CuboidConfigUpdate) { const nextOptions = { ...this.options, ...options }; const geometryChanged = @@ -294,6 +307,17 @@ export class Cuboid extends THREE.Mesh { this.setPlacement({ scale }); } + getEditCapabilities() { + return createParametricEditCapabilities("cuboid", "box"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.cuboid.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + private clearOutlineMesh() { if (!this.#outlineMesh) { return; diff --git a/main/opengeometry-three/src/shapes/cylinder.ts b/main/opengeometry-three/src/shapes/cylinder.ts index 6a8363b..4ea1310 100644 --- a/main/opengeometry-three/src/shapes/cylinder.ts +++ b/main/opengeometry-three/src/shapes/cylinder.ts @@ -8,6 +8,11 @@ import { sanitizeOutlineWidth, ShapeOutlineMesh, } from "./outline-utils"; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; import { subtractShapeOperand } from "./boolean-subtract"; import type { ShapeSubtractOperand, @@ -149,6 +154,10 @@ export class Cylinder extends THREE.Mesh { } } + getConfig() { + return this.options; + } + getAnchor() { const anchor = this.cylinder.get_anchor(); return new Vector3(anchor.x, anchor.y, anchor.z); @@ -187,6 +196,25 @@ export class Cylinder extends THREE.Mesh { this.setPlacement({ scale }); } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("cylinder", "radial"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.cylinder.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + cleanGeometry() { this.geometry.dispose(); if (Array.isArray(this.material)) { diff --git a/main/opengeometry-three/src/shapes/opening.ts b/main/opengeometry-three/src/shapes/opening.ts index 7600d87..42643e9 100644 --- a/main/opengeometry-three/src/shapes/opening.ts +++ b/main/opengeometry-three/src/shapes/opening.ts @@ -8,6 +8,11 @@ import { sanitizeOutlineWidth, ShapeOutlineMesh, } from "./outline-utils"; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; import { subtractShapeOperand } from "./boolean-subtract"; import type { ShapeSubtractOperand, @@ -153,6 +158,10 @@ export class Opening extends THREE.Mesh { } } + getConfig() { + return this.options; + } + getAnchor() { const anchor = this.opening.get_anchor(); return new Vector3(anchor.x, anchor.y, anchor.z); @@ -191,6 +200,25 @@ export class Opening extends THREE.Mesh { this.setPlacement({ scale }); } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("opening", "box"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.opening.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + cleanGeometry() { this.geometry.dispose(); if (Array.isArray(this.material)) { diff --git a/main/opengeometry-three/src/shapes/polygon.ts b/main/opengeometry-three/src/shapes/polygon.ts index 4819398..8f1a442 100644 --- a/main/opengeometry-three/src/shapes/polygon.ts +++ b/main/opengeometry-three/src/shapes/polygon.ts @@ -10,6 +10,11 @@ import { setShapeOutlineColor, ShapeOutlineMesh, } from "./outline-utils"; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; import { subtractShapeOperand } from "./boolean-subtract"; import type { ShapeSubtractOperand, @@ -161,6 +166,10 @@ export class Polygon extends THREE.Mesh { } } + getConfig() { + return this.options; + } + getAnchor() { const anchor = this.polygon.get_anchor(); return new Vector3(anchor.x, anchor.y, anchor.z); @@ -209,8 +218,27 @@ export class Polygon extends THREE.Mesh { this.setPlacement({ scale }); } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("polygon", "profile"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.polygon.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + // /** - // * Sets the placement of the polygon in 3D space. + // * Sets the placement of the polygon in 3D space. // * @param x X-coordinate // * @param y Y-coordinate // * @param z Z-coordinate diff --git a/main/opengeometry-three/src/shapes/sphere.ts b/main/opengeometry-three/src/shapes/sphere.ts index ac6cf21..1b2c955 100644 --- a/main/opengeometry-three/src/shapes/sphere.ts +++ b/main/opengeometry-three/src/shapes/sphere.ts @@ -9,6 +9,11 @@ import { sanitizeOutlineWidth, ShapeOutlineMesh, } from "./outline-utils"; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; import { subtractShapeOperand } from "./boolean-subtract"; import type { ShapeSubtractOperand, @@ -49,6 +54,7 @@ interface ISphereKernelInstance { set_transform: (..._args: [Vector3, Vector3, Vector3]) => void; get_geometry_buffer(): Float64Array; get_brep_serialized(): string; + get_local_brep_serialized(): string; get_outline_geometry_buffer(): Float64Array; get_anchor(): Vector3; } @@ -168,6 +174,10 @@ export class Sphere extends THREE.Mesh { } } + getConfig() { + return this.options; + } + getAnchor() { const anchor = this.sphere.get_anchor(); return new Vector3(anchor.x, anchor.y, anchor.z); @@ -206,6 +216,25 @@ export class Sphere extends THREE.Mesh { this.setPlacement({ scale }); } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("sphere", "radial"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.sphere.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + cleanGeometry() { this.geometry.dispose(); if (Array.isArray(this.material)) { diff --git a/main/opengeometry-three/src/shapes/sweep.ts b/main/opengeometry-three/src/shapes/sweep.ts index 275c145..6887c00 100644 --- a/main/opengeometry-three/src/shapes/sweep.ts +++ b/main/opengeometry-three/src/shapes/sweep.ts @@ -9,6 +9,11 @@ import { sanitizeOutlineWidth, ShapeOutlineMesh, } from "./outline-utils"; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; import { subtractShapeOperand } from "./boolean-subtract"; import type { ShapeSubtractOperand, @@ -51,6 +56,7 @@ interface ISweepKernelInstance { reset_anchor: () => void; get_geometry_buffer(): Float64Array; get_brep_serialized(): string; + get_local_brep_serialized(): string; get_outline_geometry_buffer(): Float64Array; get_anchor(): Vector3; } @@ -180,6 +186,10 @@ export class Sweep extends THREE.Mesh { } } + getConfig() { + return this.options; + } + getAnchor() { const anchor = this.sweep.get_anchor(); return new Vector3(anchor.x, anchor.y, anchor.z); @@ -228,6 +238,25 @@ export class Sweep extends THREE.Mesh { this.setPlacement({ scale }); } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("sweep", "sweep"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.sweep.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + cleanGeometry() { this.geometry.dispose(); if (Array.isArray(this.material)) { diff --git a/main/opengeometry-three/src/shapes/wedge.ts b/main/opengeometry-three/src/shapes/wedge.ts index 1a11d11..7a73908 100644 --- a/main/opengeometry-three/src/shapes/wedge.ts +++ b/main/opengeometry-three/src/shapes/wedge.ts @@ -8,6 +8,11 @@ import { sanitizeOutlineWidth, ShapeOutlineMesh, } from "./outline-utils"; +import { + clonePlacement, + createParametricEditCapabilities, +} from "../operations/editor"; +import { createFreeformGeometry } from "../freeform"; import { subtractShapeOperand } from "./boolean-subtract"; import type { ShapeSubtractOperand, @@ -134,6 +139,10 @@ export class Wedge extends THREE.Mesh { } } + getConfig() { + return this.options; + } + getAnchor() { const anchor = this.wedge.get_anchor(); return new Vector3(anchor.x, anchor.y, anchor.z); @@ -172,6 +181,25 @@ export class Wedge extends THREE.Mesh { this.setPlacement({ scale }); } + getPlacement() { + return clonePlacement({ + translation: this.options.translation, + rotation: this.options.rotation, + scale: this.options.scale, + }); + } + + getEditCapabilities() { + return createParametricEditCapabilities("wedge", "wedge"); + } + + toFreeform(id: string = this.ogid) { + return createFreeformGeometry(this.wedge.get_local_brep_serialized(), { + id, + placement: this.getPlacement(), + }); + } + cleanGeometry() { this.geometry.dispose(); if (Array.isArray(this.material)) { diff --git a/main/opengeometry/src/editor/mod.rs b/main/opengeometry/src/editor/mod.rs new file mode 100644 index 0000000..8bd2868 --- /dev/null +++ b/main/opengeometry/src/editor/mod.rs @@ -0,0 +1,3 @@ +//! Backward-compatible re-export for the older `editor` module path. + +pub use crate::freeform::*; diff --git a/main/opengeometry/src/freeform/capabilities.rs b/main/opengeometry/src/freeform/capabilities.rs new file mode 100644 index 0000000..6e5f25a --- /dev/null +++ b/main/opengeometry/src/freeform/capabilities.rs @@ -0,0 +1,289 @@ +use wasm_bindgen::prelude::JsValue; + +use super::edits::collect_closed_quad_edge_ring; +use super::inspection::incident_faces_for_edge; +use super::validation::normalized; +use super::{EditCapabilities, FeatureEditCapabilities, OGFreeformGeometry}; + +impl OGFreeformGeometry { + pub(super) fn build_entity_edit_capabilities(&self) -> EditCapabilities { + let supports_edge_topology_edits = self.supports_any_edge_topology_edits(); + let supports_face_cut = self.supports_any_face_cut(); + let supports_loop_cut = self.supports_any_loop_cut(); + let supports_vertex_removal = self.supports_single_face_topology_edits(); + let can_remove_vertex = supports_vertex_removal && self.single_face_vertex_count() > 3; + + let mut reasons = Vec::new(); + if !supports_edge_topology_edits { + reasons + .push("insert/split require an edge that belongs to at least one face".to_string()); + } + if !supports_vertex_removal { + reasons.push( + "removeVertex currently requires a single-face open surface topology".to_string(), + ); + } + if !supports_face_cut { + reasons.push( + "cutFace currently requires a face without holes and at least four boundary edges" + .to_string(), + ); + } + if !supports_loop_cut { + reasons.push( + "loopCut currently requires a closed quad edge ring on a face-backed solid edge" + .to_string(), + ); + } + + EditCapabilities { + can_push_pull_face: !self.local_brep.faces.is_empty(), + can_move_face: !self.local_brep.faces.is_empty(), + can_extrude_face: self + .local_brep + .faces + .iter() + .any(|face| face.inner_loops.is_empty() && normalized(face.normal).is_some()), + can_cut_face: supports_face_cut, + can_move_edge: !self.local_brep.edges.is_empty(), + can_move_vertex: !self.local_brep.vertices.is_empty(), + can_insert_vertex_on_edge: supports_edge_topology_edits, + can_remove_vertex, + can_split_edge: supports_edge_topology_edits, + can_loop_cut: supports_loop_cut, + reasons, + } + } + + pub(super) fn build_face_edit_capabilities( + &self, + face_id: u32, + ) -> Result { + let Some(face) = self + .local_brep + .faces + .iter() + .find(|candidate| candidate.id == face_id) + else { + return Err(JsValue::from_str(&format!( + "Face {} does not exist", + face_id + ))); + }; + + let can_extrude_face = face.inner_loops.is_empty() && normalized(face.normal).is_some(); + let can_cut_face = self.supports_face_cut_on_face(face_id); + let mut reasons = Vec::new(); + if !can_extrude_face { + reasons.push("extrudeFace currently supports planar faces without holes".to_string()); + } + if !can_cut_face { + reasons.push( + "cutFace currently requires a face without holes and at least four boundary edges" + .to_string(), + ); + } + + Ok(FeatureEditCapabilities { + domain: "face".to_string(), + topology_id: face_id, + can_push_pull_face: true, + can_move_face: true, + can_extrude_face, + can_cut_face, + can_move_edge: false, + can_move_vertex: false, + can_insert_vertex_on_edge: false, + can_remove_vertex: false, + can_split_edge: false, + can_loop_cut: false, + reasons, + }) + } + + pub(super) fn build_edge_edit_capabilities( + &self, + edge_id: u32, + ) -> Result { + if self.local_brep.edges.get(edge_id as usize).is_none() { + return Err(JsValue::from_str(&format!( + "Edge {} does not exist", + edge_id + ))); + } + + let supports_topology_edits = self.supports_face_backed_edge_topology_edits(edge_id); + let supports_loop_cut = self.supports_loop_cut_from_edge(edge_id); + let mut reasons = Vec::new(); + if !supports_topology_edits { + reasons.push( + "insertVertexOnEdge/splitEdge require an edge that belongs to at least one face" + .to_string(), + ); + } + if !supports_loop_cut { + reasons.push( + "loopCut currently requires an edge that belongs to a closed quad edge ring" + .to_string(), + ); + } + + Ok(FeatureEditCapabilities { + domain: "edge".to_string(), + topology_id: edge_id, + can_push_pull_face: false, + can_move_face: false, + can_extrude_face: false, + can_cut_face: false, + can_move_edge: true, + can_move_vertex: false, + can_insert_vertex_on_edge: supports_topology_edits, + can_remove_vertex: false, + can_split_edge: supports_topology_edits, + can_loop_cut: supports_loop_cut, + reasons, + }) + } + + pub(super) fn build_vertex_edit_capabilities( + &self, + vertex_id: u32, + ) -> Result { + if self.local_brep.vertices.get(vertex_id as usize).is_none() { + return Err(JsValue::from_str(&format!( + "Vertex {} does not exist", + vertex_id + ))); + } + + let supports_topology_edits = self.supports_single_face_topology_edits(); + let can_remove_vertex = supports_topology_edits + && self.single_face_vertex_count() > 3 + && self.single_face_contains_vertex(vertex_id); + + let mut reasons = Vec::new(); + if !supports_topology_edits { + reasons.push("removeVertex requires a single-face open surface topology".to_string()); + } else if !can_remove_vertex { + reasons.push( + "removeVertex requires a boundary vertex on a face with at least four vertices" + .to_string(), + ); + } + + Ok(FeatureEditCapabilities { + domain: "vertex".to_string(), + topology_id: vertex_id, + can_push_pull_face: false, + can_move_face: false, + can_extrude_face: false, + can_cut_face: false, + can_move_edge: false, + can_move_vertex: true, + can_insert_vertex_on_edge: false, + can_remove_vertex, + can_split_edge: false, + can_loop_cut: false, + reasons, + }) + } + + pub(super) fn supports_single_face_topology_edits(&self) -> bool { + if self.local_brep.faces.len() != 1 { + return false; + } + + let Some(face) = self.local_brep.faces.first() else { + return false; + }; + + if !face.inner_loops.is_empty() { + return false; + } + + let loop_vertices = self.local_brep.get_loop_vertex_indices(face.outer_loop); + if loop_vertices.len() < 3 { + return false; + } + + for edge in &self.local_brep.edges { + let incident = incident_faces_for_edge(&self.local_brep, edge.id); + if incident.len() > 1 { + return false; + } + } + + true + } + + pub(super) fn supports_any_edge_topology_edits(&self) -> bool { + self.local_brep + .edges + .iter() + .any(|edge| self.supports_face_backed_edge_topology_edits(edge.id)) + } + + pub(super) fn supports_face_backed_edge_topology_edits(&self, edge_id: u32) -> bool { + !incident_faces_for_edge(&self.local_brep, edge_id).is_empty() + } + + pub(super) fn supports_any_face_cut(&self) -> bool { + self.local_brep + .faces + .iter() + .any(|face| self.supports_face_cut_on_face(face.id)) + } + + pub(super) fn supports_face_cut_on_face(&self, face_id: u32) -> bool { + let Some(face) = self + .local_brep + .faces + .iter() + .find(|candidate| candidate.id == face_id) + else { + return false; + }; + + if !face.inner_loops.is_empty() { + return false; + } + + self.local_brep + .get_loop_halfedges(face.outer_loop) + .map(|halfedges| halfedges.len() >= 4) + .unwrap_or(false) + } + + pub(super) fn supports_any_loop_cut(&self) -> bool { + self.local_brep + .edges + .iter() + .any(|edge| self.supports_loop_cut_from_edge(edge.id)) + } + + pub(super) fn supports_loop_cut_from_edge(&self, edge_id: u32) -> bool { + collect_closed_quad_edge_ring(&self.local_brep, edge_id).is_ok() + } + + fn single_face_vertex_count(&self) -> usize { + self.local_brep + .faces + .first() + .map(|face| { + self.local_brep + .get_loop_vertex_indices(face.outer_loop) + .len() + }) + .unwrap_or(0) + } + + fn single_face_contains_vertex(&self, vertex_id: u32) -> bool { + let Some(face) = self.local_brep.faces.first() else { + return false; + }; + + self.local_brep + .get_loop_vertex_indices(face.outer_loop) + .contains(&vertex_id) + } +} diff --git a/main/opengeometry/src/freeform/edits.rs b/main/opengeometry/src/freeform/edits.rs new file mode 100644 index 0000000..b3ce83a --- /dev/null +++ b/main/opengeometry/src/freeform/edits.rs @@ -0,0 +1,1989 @@ +use std::collections::{HashMap, HashSet}; + +use openmaths::Vector3; + +use crate::brep::Brep; +use crate::brep::BrepBuilder; + +use super::inspection::collect_face_vertex_ids; +use super::validation::{is_finite_vector, normalized}; +use super::{ + BrepDiagnostic, ConstraintSettings, EditEffect, OGFreeformGeometry, TopologyChangeJournal, + GEOMETRY_EPSILON, +}; + +#[derive(Clone)] +pub(super) struct LoopCutFaceStep { + pub face_id: u32, + pub input_edge_id: u32, + pub opposite_edge_id: u32, +} + +#[derive(Clone)] +pub(super) struct LoopCutRing { + pub edge_ids: Vec, + pub face_steps: Vec, +} + +impl OGFreeformGeometry { + pub(super) fn push_pull_face_internal( + &mut self, + face_id: u32, + distance: f64, + constraints: &ConstraintSettings, + ) -> Result { + if !distance.is_finite() { + return Err(BrepDiagnostic::error( + "invalid_distance", + "Face push/pull distance must be a finite number", + ) + .with_domain("face", Some(face_id))); + } + + let Some(face) = self + .local_brep + .faces + .iter() + .find(|candidate| candidate.id == face_id) + else { + return Err(BrepDiagnostic::error( + "missing_face", + format!("Face {} does not exist", face_id), + ) + .with_domain("face", Some(face_id))); + }; + + let Some(normal) = normalized(face.normal) else { + return Err(BrepDiagnostic::error( + "invalid_face_normal", + format!("Face {} does not have a valid normal", face_id), + ) + .with_domain("face", Some(face_id))); + }; + + let base_translation = Vector3::new( + normal.x * distance, + normal.y * distance, + normal.z * distance, + ); + self.translate_face_by_vector_internal(face_id, base_translation, constraints) + } + + pub(super) fn translate_face_by_vector_internal( + &mut self, + face_id: u32, + translation: Vector3, + constraints: &ConstraintSettings, + ) -> Result { + if !is_finite_vector(translation) { + return Err(BrepDiagnostic::error( + "invalid_translation", + "Face translation must contain finite numbers", + ) + .with_domain("face", Some(face_id))); + } + + let vertex_ids = { + let Some(face) = self + .local_brep + .faces + .iter() + .find(|candidate| candidate.id == face_id) + else { + return Err(BrepDiagnostic::error( + "missing_face", + format!("Face {} does not exist", face_id), + ) + .with_domain("face", Some(face_id))); + }; + collect_face_vertex_ids(&self.local_brep, face).map_err(|message| { + BrepDiagnostic::error("invalid_face_topology", message) + .with_domain("face", Some(face_id)) + })? + }; + + let (translation, mut diagnostics) = + self.apply_constraints(translation, constraints, "face", face_id)?; + + for vertex_id in &vertex_ids { + let Some(vertex) = self.local_brep.vertices.get_mut(*vertex_id as usize) else { + return Err(BrepDiagnostic::error( + "missing_vertex_reference", + format!("Face {} references missing vertex {}", face_id, vertex_id), + ) + .with_domain("vertex", Some(*vertex_id))); + }; + + vertex.position.x += translation.x; + vertex.position.y += translation.y; + vertex.position.z += translation.z; + } + + self.local_brep.recompute_face_normals(); + + let mut moved_vertices = HashSet::new(); + moved_vertices.extend(vertex_ids.iter().copied()); + let (changed_faces, changed_edges, changed_vertices) = + self.collect_changed_domains_for_vertices(&moved_vertices); + + diagnostics.push( + BrepDiagnostic::info( + "face_translated", + format!("Face {} translated successfully", face_id), + ) + .with_domain("face", Some(face_id)), + ); + + Ok(EditEffect { + diagnostics, + topology_journal: None, + changed_faces, + changed_edges, + changed_vertices, + }) + } + + pub(super) fn move_edge_internal( + &mut self, + edge_id: u32, + translation: Vector3, + constraints: &ConstraintSettings, + ) -> Result { + if !is_finite_vector(translation) { + return Err(BrepDiagnostic::error( + "invalid_translation", + "Edge translation must contain finite numbers", + ) + .with_domain("edge", Some(edge_id))); + } + + let (start_id, end_id) = self.local_brep.get_edge_endpoints(edge_id).ok_or_else(|| { + BrepDiagnostic::error("missing_edge", format!("Edge {} does not exist", edge_id)) + .with_domain("edge", Some(edge_id)) + })?; + + let (translation, diagnostics) = + self.apply_constraints(translation, constraints, "edge", edge_id)?; + + let mut unique_vertices = HashSet::new(); + unique_vertices.insert(start_id); + unique_vertices.insert(end_id); + + for vertex_id in &unique_vertices { + let Some(vertex) = self.local_brep.vertices.get_mut(*vertex_id as usize) else { + return Err(BrepDiagnostic::error( + "missing_vertex_reference", + format!("Edge {} references missing vertex {}", edge_id, vertex_id), + ) + .with_domain("vertex", Some(*vertex_id))); + }; + + vertex.position.x += translation.x; + vertex.position.y += translation.y; + vertex.position.z += translation.z; + } + + self.local_brep.recompute_face_normals(); + + let (changed_faces, changed_edges, changed_vertices) = + self.collect_changed_domains_for_vertices(&unique_vertices); + + Ok(EditEffect { + diagnostics, + topology_journal: None, + changed_faces, + changed_edges, + changed_vertices, + }) + } + + pub(super) fn move_vertex_internal( + &mut self, + vertex_id: u32, + translation: Vector3, + constraints: &ConstraintSettings, + ) -> Result { + if !is_finite_vector(translation) { + return Err(BrepDiagnostic::error( + "invalid_translation", + "Vertex translation must contain finite numbers", + ) + .with_domain("vertex", Some(vertex_id))); + } + + if self.local_brep.vertices.get(vertex_id as usize).is_none() { + return Err(BrepDiagnostic::error( + "missing_vertex", + format!("Vertex {} does not exist", vertex_id), + ) + .with_domain("vertex", Some(vertex_id))); + } + + let (translation, diagnostics) = + self.apply_constraints(translation, constraints, "vertex", vertex_id)?; + + let vertex = self + .local_brep + .vertices + .get_mut(vertex_id as usize) + .expect("vertex existence checked above"); + + vertex.position.x += translation.x; + vertex.position.y += translation.y; + vertex.position.z += translation.z; + + self.local_brep.recompute_face_normals(); + + let mut moved_vertices = HashSet::new(); + moved_vertices.insert(vertex_id); + let (changed_faces, changed_edges, changed_vertices) = + self.collect_changed_domains_for_vertices(&moved_vertices); + + Ok(EditEffect { + diagnostics, + topology_journal: None, + changed_faces, + changed_edges, + changed_vertices, + }) + } + + pub(super) fn extrude_face_internal( + &mut self, + face_id: u32, + distance: f64, + open_surface_mode: bool, + ) -> Result { + if !distance.is_finite() || distance.abs() <= GEOMETRY_EPSILON { + return Err(BrepDiagnostic::error( + "invalid_distance", + "Extrude distance must be a finite non-zero number", + ) + .with_domain("face", Some(face_id))); + } + + let Some(face) = self + .local_brep + .faces + .iter() + .find(|candidate| candidate.id == face_id) + .cloned() + else { + return Err(BrepDiagnostic::error( + "missing_face", + format!("Face {} does not exist", face_id), + ) + .with_domain("face", Some(face_id))); + }; + + if !face.inner_loops.is_empty() { + return Err(BrepDiagnostic::error( + "unsupported_face", + "extrudeFace currently supports faces without holes", + ) + .with_domain("face", Some(face_id))); + } + + if open_surface_mode { + let mut effect = + self.push_pull_face_internal(face_id, distance, &ConstraintSettings::default())?; + effect.diagnostics.push( + BrepDiagnostic::info( + "open_surface_mode", + "openSurfaceMode uses topology-preserving face translation", + ) + .with_domain("face", Some(face_id)), + ); + return Ok(effect); + } + + let loop_vertices = self.local_brep.get_loop_vertex_indices(face.outer_loop); + if loop_vertices.len() < 3 { + return Err(BrepDiagnostic::error( + "invalid_face_topology", + "Face has insufficient vertices for extrusion", + ) + .with_domain("face", Some(face_id))); + } + + let mut boundary_edge_count = 0usize; + for edge in &self.local_brep.edges { + let mut incident_count = 0usize; + if let Some((a, b)) = self.local_brep.get_edge_endpoints(edge.id) { + for index in 0..loop_vertices.len() { + let from = loop_vertices[index]; + let to = loop_vertices[(index + 1) % loop_vertices.len()]; + if undirected_edge_key(a, b) == undirected_edge_key(from, to) { + incident_count = self.incident_face_count(edge.id); + break; + } + } + } + if incident_count == 1 { + boundary_edge_count += 1; + } + } + + if boundary_edge_count == 0 { + let mut effect = + self.push_pull_face_internal(face_id, distance, &ConstraintSettings::default())?; + effect.diagnostics.push( + BrepDiagnostic::info( + "solid_face_extrude", + "Closed-solid face extrusion resolved to push/pull translation", + ) + .with_domain("face", Some(face_id)), + ); + return Ok(effect); + } + + if !self.supports_single_face_topology_edits() { + return Err(BrepDiagnostic::error( + "unsupported_topology", + "extrudeFace currently supports open-surface extrusion on single-face entities", + ) + .with_domain("face", Some(face_id))); + } + + let Some(normal) = normalized(face.normal) else { + return Err(BrepDiagnostic::error( + "invalid_face_normal", + "Face normal is invalid for extrusion", + ) + .with_domain("face", Some(face_id))); + }; + + let old_brep = self.local_brep.clone(); + let old_positions: HashMap = old_brep + .vertices + .iter() + .map(|vertex| (vertex.id, vertex.position)) + .collect(); + + let mut positions = old_brep + .vertices + .iter() + .map(|vertex| vertex.position) + .collect::>(); + + for vertex_id in &loop_vertices { + let Some(vertex) = positions.get_mut(*vertex_id as usize) else { + return Err(BrepDiagnostic::error( + "missing_vertex_reference", + format!("Face {} references missing vertex {}", face_id, vertex_id), + ) + .with_domain("vertex", Some(*vertex_id))); + }; + vertex.x += normal.x * distance; + vertex.y += normal.y * distance; + vertex.z += normal.z * distance; + } + + let mut bottom_vertex_map: HashMap = HashMap::new(); + for vertex_id in &loop_vertices { + let bottom_id = positions.len() as u32; + let Some(original_position) = old_positions.get(vertex_id) else { + return Err(BrepDiagnostic::error( + "missing_vertex_reference", + format!("Vertex {} not found in source face", vertex_id), + ) + .with_domain("vertex", Some(*vertex_id))); + }; + positions.push(*original_position); + bottom_vertex_map.insert(*vertex_id, bottom_id); + } + + let mut builder = BrepBuilder::new(self.local_brep.id); + builder.add_vertices(&positions); + + builder.add_face(&loop_vertices, &[]).map_err(|error| { + BrepDiagnostic::error( + "extrude_failed", + format!("Failed to build extruded top face: {}", error), + ) + .with_domain("face", Some(face_id)) + })?; + + let bottom_loop: Vec = loop_vertices + .iter() + .rev() + .map(|vertex_id| bottom_vertex_map[vertex_id]) + .collect(); + + builder.add_face(&bottom_loop, &[]).map_err(|error| { + BrepDiagnostic::error( + "extrude_failed", + format!("Failed to build extruded bottom face: {}", error), + ) + .with_domain("face", Some(face_id)) + })?; + + for index in 0..loop_vertices.len() { + let top_current = loop_vertices[index]; + let top_next = loop_vertices[(index + 1) % loop_vertices.len()]; + let bottom_current = bottom_vertex_map[&top_current]; + let bottom_next = bottom_vertex_map[&top_next]; + + let side = vec![top_current, bottom_current, bottom_next, top_next]; + builder.add_face(&side, &[]).map_err(|error| { + BrepDiagnostic::error( + "extrude_failed", + format!("Failed to build extruded side face: {}", error), + ) + .with_domain("face", Some(face_id)) + })?; + } + + builder.add_shell_from_all_faces(true).map_err(|error| { + BrepDiagnostic::error( + "extrude_failed", + format!("Failed to build closed shell: {}", error), + ) + .with_domain("face", Some(face_id)) + })?; + + self.local_brep = builder.build().map_err(|error| { + BrepDiagnostic::error( + "extrude_failed", + format!("Failed to finalize extruded topology: {}", error), + ) + .with_domain("face", Some(face_id)) + })?; + + let mut journal = TopologyChangeJournal::default(); + journal.faces.map(face_id, vec![0]); + + let after_edge_lookup = self.edge_lookup_by_endpoints(); + for edge in &old_brep.edges { + if let Some((from, to)) = old_brep.get_edge_endpoints(edge.id) { + let key = undirected_edge_key(from, to); + if let Some(new_edge_id) = after_edge_lookup.get(&key).copied() { + journal.edges.map(edge.id, vec![new_edge_id]); + } + } + } + + let new_face_ids: HashSet = self.local_brep.faces.iter().map(|face| face.id).collect(); + for face in new_face_ids { + if face != 0 { + journal.faces.add_created(face); + } + } + + let mapped_edges: HashSet = + journal.edges.mapping.values().flatten().copied().collect(); + for edge in &self.local_brep.edges { + if !mapped_edges.contains(&edge.id) { + journal.edges.add_created(edge.id); + } + } + + let old_vertex_count = old_brep.vertices.len() as u32; + for vertex_id in old_vertex_count..self.local_brep.vertices.len() as u32 { + journal.vertices.add_created(vertex_id); + } + + let changed_faces = self + .local_brep + .faces + .iter() + .map(|face| face.id) + .collect::>(); + let changed_edges = self + .local_brep + .edges + .iter() + .map(|edge| edge.id) + .collect::>(); + let changed_vertices = self + .local_brep + .vertices + .iter() + .map(|vertex| vertex.id) + .collect::>(); + + Ok(EditEffect { + diagnostics: vec![BrepDiagnostic::info( + "face_extruded", + format!("Face {} extruded successfully", face_id), + ) + .with_domain("face", Some(face_id))], + topology_journal: Some(journal), + changed_faces, + changed_edges, + changed_vertices, + }) + } + + pub(super) fn insert_vertex_on_edge_internal( + &mut self, + edge_id: u32, + t: f64, + ) -> Result { + if !self.supports_face_backed_edge_topology_edits(edge_id) { + return Err(BrepDiagnostic::error( + "unsupported_topology", + "insertVertexOnEdge requires an edge used by at least one face", + ) + .with_domain("edge", Some(edge_id))); + } + + if !t.is_finite() || t <= GEOMETRY_EPSILON || t >= 1.0 - GEOMETRY_EPSILON { + return Err(BrepDiagnostic::error( + "invalid_parameter", + "insertVertexOnEdge expects t in (0, 1)", + ) + .with_domain("edge", Some(edge_id))); + } + + let old_brep = self.local_brep.clone(); + let (from_id, to_id) = old_brep.get_edge_endpoints(edge_id).ok_or_else(|| { + BrepDiagnostic::error("missing_edge", format!("Edge {} does not exist", edge_id)) + .with_domain("edge", Some(edge_id)) + })?; + + let from = old_brep + .vertices + .get(from_id as usize) + .map(|vertex| vertex.position) + .ok_or_else(|| { + BrepDiagnostic::error( + "missing_vertex_reference", + format!( + "Vertex {} referenced by edge {} is missing", + from_id, edge_id + ), + ) + .with_domain("vertex", Some(from_id)) + })?; + + let to = old_brep + .vertices + .get(to_id as usize) + .map(|vertex| vertex.position) + .ok_or_else(|| { + BrepDiagnostic::error( + "missing_vertex_reference", + format!("Vertex {} referenced by edge {} is missing", to_id, edge_id), + ) + .with_domain("vertex", Some(to_id)) + })?; + + let inserted = Vector3::new( + from.x + (to.x - from.x) * t, + from.y + (to.y - from.y) * t, + from.z + (to.z - from.z) * t, + ); + + let mut positions = old_brep + .vertices + .iter() + .map(|vertex| vertex.position) + .collect::>(); + let inserted_vertex_id = positions.len() as u32; + positions.push(inserted); + + let mut affected_faces = Vec::new(); + let updated_faces = self.rebuild_faces_with_inserted_edge_vertex( + &old_brep, + edge_id, + from_id, + to_id, + inserted_vertex_id, + &mut affected_faces, + )?; + + let mut builder = BrepBuilder::new(self.local_brep.id); + builder.add_vertices(&positions); + + for (outer, holes) in &updated_faces { + builder.add_face(outer, holes).map_err(|error| { + BrepDiagnostic::error( + "edge_split_failed", + format!("Failed to rebuild face with inserted vertex: {}", error), + ) + .with_domain("edge", Some(edge_id)) + })?; + } + + for shell in &old_brep.shells { + builder + .add_shell(&shell.faces, shell.is_closed) + .map_err(|error| { + BrepDiagnostic::error( + "edge_split_failed", + format!("Failed to rebuild shell after edge split: {}", error), + ) + .with_domain("edge", Some(edge_id)) + })?; + } + + self.local_brep = builder.build().map_err(|error| { + BrepDiagnostic::error( + "edge_split_failed", + format!("Failed to finalize edge split: {}", error), + ) + .with_domain("edge", Some(edge_id)) + })?; + + let mut journal = TopologyChangeJournal::default(); + for face_id in &affected_faces { + journal.faces.map(*face_id, vec![*face_id]); + } + journal.vertices.add_created(inserted_vertex_id); + + let edge_lookup = self.edge_lookup_by_endpoints(); + for edge in &old_brep.edges { + if edge.id == edge_id { + let first_key = undirected_edge_key(from_id, inserted_vertex_id); + let second_key = undirected_edge_key(inserted_vertex_id, to_id); + + let mut mapped = Vec::new(); + if let Some(id) = edge_lookup.get(&first_key).copied() { + mapped.push(id); + } + if let Some(id) = edge_lookup.get(&second_key).copied() { + mapped.push(id); + } + journal.edges.map(edge.id, mapped); + continue; + } + + if let Some((start, end)) = old_brep.get_edge_endpoints(edge.id) { + let key = undirected_edge_key(start, end); + if let Some(new_edge) = edge_lookup.get(&key).copied() { + journal.edges.map(edge.id, vec![new_edge]); + } + } + } + + let mut changed_faces = affected_faces; + changed_faces.sort_unstable(); + changed_faces.dedup(); + let changed_edges = self.local_brep.edges.iter().map(|edge| edge.id).collect(); + let changed_vertices = vec![from_id, to_id, inserted_vertex_id]; + + Ok(EditEffect { + diagnostics: vec![BrepDiagnostic::info( + "vertex_inserted_on_edge", + format!("Inserted vertex on edge {}", edge_id), + ) + .with_domain("edge", Some(edge_id))], + topology_journal: Some(journal), + changed_faces, + changed_edges, + changed_vertices, + }) + } + + pub(super) fn split_edge_internal( + &mut self, + edge_id: u32, + t: f64, + ) -> Result { + self.insert_vertex_on_edge_internal(edge_id, t) + } + + pub(super) fn cut_face_internal( + &mut self, + face_id: u32, + start_edge_id: u32, + start_t: f64, + end_edge_id: u32, + end_t: f64, + ) -> Result { + if start_edge_id == end_edge_id { + return Err(BrepDiagnostic::error( + "invalid_topology_selection", + "cutFace requires two distinct edges on the target face", + ) + .with_domain("face", Some(face_id))); + } + + for (edge_id, t_value) in [(start_edge_id, start_t), (end_edge_id, end_t)] { + if !t_value.is_finite() + || t_value <= GEOMETRY_EPSILON + || t_value >= 1.0 - GEOMETRY_EPSILON + { + return Err(BrepDiagnostic::error( + "invalid_parameter", + "cutFace expects t in (0, 1)", + ) + .with_domain("edge", Some(edge_id))); + } + } + + if !self.supports_face_cut_on_face(face_id) { + return Err(BrepDiagnostic::error( + "unsupported_topology", + "cutFace currently requires a face without holes and at least four boundary edges", + ) + .with_domain("face", Some(face_id))); + } + + let old_brep = self.local_brep.clone(); + let face = old_brep + .faces + .iter() + .find(|candidate| candidate.id == face_id) + .cloned() + .ok_or_else(|| { + BrepDiagnostic::error("missing_face", format!("Face {} does not exist", face_id)) + .with_domain("face", Some(face_id)) + })?; + + let face_vertices = old_brep.get_loop_vertex_indices(face.outer_loop); + let face_edges = face_outer_loop_edges(&old_brep, face_id)?; + + let start_edge_index = face_edges + .iter() + .position(|candidate| *candidate == start_edge_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "edge_not_on_face", + format!("Edge {} is not part of face {}", start_edge_id, face_id), + ) + .with_domain("edge", Some(start_edge_id)) + })?; + let end_edge_index = face_edges + .iter() + .position(|candidate| *candidate == end_edge_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "edge_not_on_face", + format!("Edge {} is not part of face {}", end_edge_id, face_id), + ) + .with_domain("edge", Some(end_edge_id)) + })?; + + let start_from = face_vertices[start_edge_index]; + let start_to = face_vertices[(start_edge_index + 1) % face_vertices.len()]; + let end_from = face_vertices[end_edge_index]; + let end_to = face_vertices[(end_edge_index + 1) % face_vertices.len()]; + + let start_inserted = interpolate_loop_edge_position( + &old_brep, + start_from, + start_to, + start_t, + start_edge_id, + )?; + let end_inserted = + interpolate_loop_edge_position(&old_brep, end_from, end_to, end_t, end_edge_id)?; + + let mut positions = old_brep + .vertices + .iter() + .map(|vertex| vertex.position) + .collect::>(); + let start_inserted_vertex_id = positions.len() as u32; + positions.push(start_inserted); + let end_inserted_vertex_id = positions.len() as u32; + positions.push(end_inserted); + + let mut primary_faces = Vec::with_capacity(old_brep.faces.len()); + let mut secondary_face_loop = None; + let mut changed_faces = HashSet::new(); + + for candidate_face in &old_brep.faces { + let outer_loop = old_brep.get_loop_vertex_indices(candidate_face.outer_loop); + let (outer_with_start, outer_start_changed) = insert_vertex_into_loop( + &outer_loop, + start_from, + start_to, + start_inserted_vertex_id, + ); + let (outer_with_both, outer_end_changed) = insert_vertex_into_loop( + &outer_with_start, + end_from, + end_to, + end_inserted_vertex_id, + ); + + let mut holes = Vec::with_capacity(candidate_face.inner_loops.len()); + let mut hole_changed = false; + for loop_id in &candidate_face.inner_loops { + let hole_loop = old_brep.get_loop_vertex_indices(*loop_id); + let (hole_with_start, hole_start_changed) = insert_vertex_into_loop( + &hole_loop, + start_from, + start_to, + start_inserted_vertex_id, + ); + let (hole_with_both, hole_end_changed) = insert_vertex_into_loop( + &hole_with_start, + end_from, + end_to, + end_inserted_vertex_id, + ); + hole_changed |= hole_start_changed || hole_end_changed; + holes.push(hole_with_both); + } + + if candidate_face.id == face_id { + if !outer_start_changed || !outer_end_changed { + return Err(BrepDiagnostic::error( + "cut_face_failed", + format!( + "Failed to insert cut points into the boundary of face {}", + face_id + ), + ) + .with_domain("face", Some(face_id))); + } + + let (primary_loop, secondary_loop) = split_loop_between_vertices( + &outer_with_both, + start_inserted_vertex_id, + end_inserted_vertex_id, + face_id, + )?; + + primary_faces.push((primary_loop, Vec::new())); + secondary_face_loop = Some(secondary_loop); + changed_faces.insert(face_id); + continue; + } + + if outer_start_changed || outer_end_changed || hole_changed { + changed_faces.insert(candidate_face.id); + } + + primary_faces.push((outer_with_both, holes)); + } + + let mut builder = BrepBuilder::new(self.local_brep.id); + builder.add_vertices(&positions); + + for (outer, holes) in &primary_faces { + builder.add_face(outer, holes).map_err(|error| { + BrepDiagnostic::error( + "cut_face_failed", + format!("Failed to rebuild face during cutFace: {}", error), + ) + .with_domain("face", Some(face_id)) + })?; + } + + let secondary_face_loop = secondary_face_loop.ok_or_else(|| { + BrepDiagnostic::error( + "cut_face_failed", + "Failed to create the second face produced by cutFace", + ) + .with_domain("face", Some(face_id)) + })?; + let new_face_id = builder + .add_face(&secondary_face_loop, &[]) + .map_err(|error| { + BrepDiagnostic::error( + "cut_face_failed", + format!("Failed to append the second cut face: {}", error), + ) + .with_domain("face", Some(face_id)) + })?; + + for shell in &old_brep.shells { + let mut shell_faces = Vec::new(); + for old_face_id in &shell.faces { + shell_faces.push(*old_face_id); + if *old_face_id == face_id { + shell_faces.push(new_face_id); + } + } + + builder + .add_shell(&shell_faces, shell.is_closed) + .map_err(|error| { + BrepDiagnostic::error( + "cut_face_failed", + format!("Failed to rebuild shell after cutFace: {}", error), + ) + .with_domain("face", Some(face_id)) + })?; + } + + self.local_brep = builder.build().map_err(|error| { + BrepDiagnostic::error( + "cut_face_failed", + format!("Failed to finalize cutFace: {}", error), + ) + .with_domain("face", Some(face_id)) + })?; + + let mut journal = TopologyChangeJournal::default(); + journal.faces.map(face_id, vec![face_id, new_face_id]); + journal.faces.add_created(new_face_id); + journal.vertices.add_created(start_inserted_vertex_id); + journal.vertices.add_created(end_inserted_vertex_id); + + let edge_lookup = self.edge_lookup_by_endpoints(); + let mut changed_edges = HashSet::new(); + + for edge in &old_brep.edges { + let Some((from, to)) = old_brep.get_edge_endpoints(edge.id) else { + continue; + }; + + let mapped = if edge.id == start_edge_id || edge.id == end_edge_id { + let inserted_vertex_id = if edge.id == start_edge_id { + start_inserted_vertex_id + } else { + end_inserted_vertex_id + }; + + let mut split_edges = Vec::new(); + let first_key = undirected_edge_key(from, inserted_vertex_id); + let second_key = undirected_edge_key(inserted_vertex_id, to); + + if let Some(mapped_id) = edge_lookup.get(&first_key).copied() { + split_edges.push(mapped_id); + } + if let Some(mapped_id) = edge_lookup.get(&second_key).copied() { + split_edges.push(mapped_id); + } + + changed_edges.extend(split_edges.iter().copied()); + split_edges + } else { + let key = undirected_edge_key(from, to); + edge_lookup + .get(&key) + .copied() + .map(|mapped_id| vec![mapped_id]) + .unwrap_or_default() + }; + + journal.edges.map(edge.id, mapped); + } + + let connecting_edge_key = + undirected_edge_key(start_inserted_vertex_id, end_inserted_vertex_id); + let connecting_edge_id = + edge_lookup + .get(&connecting_edge_key) + .copied() + .ok_or_else(|| { + BrepDiagnostic::error( + "cut_face_failed", + "cutFace did not produce the new connecting edge", + ) + .with_domain("face", Some(face_id)) + })?; + journal.edges.add_created(connecting_edge_id); + changed_edges.insert(connecting_edge_id); + + changed_faces.insert(new_face_id); + + let mut changed_faces = changed_faces.into_iter().collect::>(); + changed_faces.sort_unstable(); + + let mut changed_edges = changed_edges.into_iter().collect::>(); + changed_edges.sort_unstable(); + + let changed_vertices = vec![start_inserted_vertex_id, end_inserted_vertex_id]; + + Ok(EditEffect { + diagnostics: vec![BrepDiagnostic::info( + "face_cut_applied", + format!( + "Cut face {} between edges {} and {}", + face_id, start_edge_id, end_edge_id + ), + ) + .with_domain("face", Some(face_id))], + topology_journal: Some(journal), + changed_faces, + changed_edges, + changed_vertices, + }) + } + + pub(super) fn loop_cut_edge_ring_internal( + &mut self, + edge_id: u32, + t: f64, + ) -> Result { + if !t.is_finite() || t <= GEOMETRY_EPSILON || t >= 1.0 - GEOMETRY_EPSILON { + return Err( + BrepDiagnostic::error("invalid_parameter", "loopCut expects t in (0, 1)") + .with_domain("edge", Some(edge_id)), + ); + } + + let old_brep = self.local_brep.clone(); + let ring = collect_closed_quad_edge_ring(&old_brep, edge_id)?; + + let (reference_from, reference_to) = + old_brep.get_edge_endpoints(edge_id).ok_or_else(|| { + BrepDiagnostic::error("missing_edge", format!("Edge {} does not exist", edge_id)) + .with_domain("edge", Some(edge_id)) + })?; + + let reference_direction = old_brep + .vertices + .get(reference_to as usize) + .zip(old_brep.vertices.get(reference_from as usize)) + .and_then(|(to, from)| { + normalized(Vector3::new( + to.position.x - from.position.x, + to.position.y - from.position.y, + to.position.z - from.position.z, + )) + }); + + let mut positions = old_brep + .vertices + .iter() + .map(|vertex| vertex.position) + .collect::>(); + let mut inserted_vertex_by_edge = HashMap::new(); + + for ring_edge_id in &ring.edge_ids { + let (from_id, to_id) = old_brep.get_edge_endpoints(*ring_edge_id).ok_or_else(|| { + BrepDiagnostic::error( + "missing_edge", + format!("Edge {} does not exist", ring_edge_id), + ) + .with_domain("edge", Some(*ring_edge_id)) + })?; + + let from = old_brep + .vertices + .get(from_id as usize) + .map(|vertex| vertex.position) + .ok_or_else(|| { + BrepDiagnostic::error( + "missing_vertex_reference", + format!( + "Vertex {} referenced by edge {} is missing", + from_id, ring_edge_id + ), + ) + .with_domain("vertex", Some(from_id)) + })?; + let to = old_brep + .vertices + .get(to_id as usize) + .map(|vertex| vertex.position) + .ok_or_else(|| { + BrepDiagnostic::error( + "missing_vertex_reference", + format!( + "Vertex {} referenced by edge {} is missing", + to_id, ring_edge_id + ), + ) + .with_domain("vertex", Some(to_id)) + })?; + + let edge_direction = + normalized(Vector3::new(to.x - from.x, to.y - from.y, to.z - from.z)); + let local_t = + if let (Some(reference), Some(direction)) = (reference_direction, edge_direction) { + if reference.dot(&direction) < 0.0 { + 1.0 - t + } else { + t + } + } else { + t + }; + + let inserted = Vector3::new( + from.x + (to.x - from.x) * local_t, + from.y + (to.y - from.y) * local_t, + from.z + (to.z - from.z) * local_t, + ); + + let inserted_vertex_id = positions.len() as u32; + positions.push(inserted); + inserted_vertex_by_edge.insert(*ring_edge_id, inserted_vertex_id); + } + + let face_step_by_id = ring + .face_steps + .iter() + .map(|step| (step.face_id, step)) + .collect::>(); + + let mut primary_faces = Vec::with_capacity(old_brep.faces.len()); + let mut extra_face_specs = Vec::<(u32, Vec)>::new(); + + for face in &old_brep.faces { + let outer_loop = old_brep.get_loop_vertex_indices(face.outer_loop); + + if let Some(step) = face_step_by_id.get(&face.id) { + let inserted_a = *inserted_vertex_by_edge + .get(&step.input_edge_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "loop_cut_failed", + format!("Missing inserted vertex for edge {}", step.input_edge_id), + ) + .with_domain("edge", Some(step.input_edge_id)) + })?; + let inserted_b = *inserted_vertex_by_edge + .get(&step.opposite_edge_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "loop_cut_failed", + format!("Missing inserted vertex for edge {}", step.opposite_edge_id), + ) + .with_domain("edge", Some(step.opposite_edge_id)) + })?; + + let (input_from, input_to) = old_brep + .get_edge_endpoints(step.input_edge_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "missing_edge", + format!("Edge {} does not exist", step.input_edge_id), + ) + .with_domain("edge", Some(step.input_edge_id)) + })?; + let (opposite_from, opposite_to) = old_brep + .get_edge_endpoints(step.opposite_edge_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "missing_edge", + format!("Edge {} does not exist", step.opposite_edge_id), + ) + .with_domain("edge", Some(step.opposite_edge_id)) + })?; + + let (with_first, inserted_first) = + insert_vertex_into_loop(&outer_loop, input_from, input_to, inserted_a); + let (with_both, inserted_second) = + insert_vertex_into_loop(&with_first, opposite_from, opposite_to, inserted_b); + + if !inserted_first || !inserted_second { + return Err(BrepDiagnostic::error( + "loop_cut_failed", + format!( + "Failed to insert loop-cut vertices into face {} boundary", + face.id + ), + ) + .with_domain("face", Some(face.id))); + } + + let (primary_loop, secondary_loop) = + split_loop_between_vertices(&with_both, inserted_a, inserted_b, face.id)?; + + primary_faces.push((primary_loop, Vec::new())); + extra_face_specs.push((face.id, secondary_loop)); + continue; + } + + let holes = face + .inner_loops + .iter() + .map(|loop_id| old_brep.get_loop_vertex_indices(*loop_id)) + .collect::>(); + primary_faces.push((outer_loop, holes)); + } + + let mut builder = BrepBuilder::new(self.local_brep.id); + builder.add_vertices(&positions); + + for (outer, holes) in &primary_faces { + builder.add_face(outer, holes).map_err(|error| { + BrepDiagnostic::error( + "loop_cut_failed", + format!("Failed to rebuild primary face during loop cut: {}", error), + ) + .with_domain("edge", Some(edge_id)) + })?; + } + + let old_face_count = old_brep.faces.len() as u32; + let mut extra_face_id_by_old = HashMap::new(); + + for (index, (old_face_id, outer)) in extra_face_specs.iter().enumerate() { + builder.add_face(outer, &[]).map_err(|error| { + BrepDiagnostic::error( + "loop_cut_failed", + format!("Failed to rebuild split face during loop cut: {}", error), + ) + .with_domain("face", Some(*old_face_id)) + })?; + extra_face_id_by_old.insert(*old_face_id, old_face_count + index as u32); + } + + for shell in &old_brep.shells { + let mut shell_faces = Vec::new(); + for old_face_id in &shell.faces { + shell_faces.push(*old_face_id); + if let Some(extra_face_id) = extra_face_id_by_old.get(old_face_id) { + shell_faces.push(*extra_face_id); + } + } + + builder + .add_shell(&shell_faces, shell.is_closed) + .map_err(|error| { + BrepDiagnostic::error( + "loop_cut_failed", + format!("Failed to rebuild shell after loop cut: {}", error), + ) + .with_domain("edge", Some(edge_id)) + })?; + } + + self.local_brep = builder.build().map_err(|error| { + BrepDiagnostic::error( + "loop_cut_failed", + format!("Failed to finalize loop cut: {}", error), + ) + .with_domain("edge", Some(edge_id)) + })?; + + let mut journal = TopologyChangeJournal::default(); + for (old_face_id, extra_face_id) in &extra_face_id_by_old { + journal + .faces + .map(*old_face_id, vec![*old_face_id, *extra_face_id]); + journal.faces.add_created(*extra_face_id); + } + + for inserted_vertex_id in inserted_vertex_by_edge.values() { + journal.vertices.add_created(*inserted_vertex_id); + } + + let edge_lookup = self.edge_lookup_by_endpoints(); + let mut changed_edges = HashSet::new(); + + for edge in &old_brep.edges { + let Some((from, to)) = old_brep.get_edge_endpoints(edge.id) else { + continue; + }; + + let mapped = if let Some(inserted_vertex_id) = inserted_vertex_by_edge.get(&edge.id) { + let mut split_edges = Vec::new(); + let first_key = undirected_edge_key(from, *inserted_vertex_id); + let second_key = undirected_edge_key(*inserted_vertex_id, to); + + if let Some(id) = edge_lookup.get(&first_key).copied() { + split_edges.push(id); + } + if let Some(id) = edge_lookup.get(&second_key).copied() { + split_edges.push(id); + } + + split_edges + } else { + let key = undirected_edge_key(from, to); + edge_lookup + .get(&key) + .copied() + .map(|id| vec![id]) + .unwrap_or_default() + }; + + changed_edges.extend(mapped.iter().copied()); + journal.edges.map(edge.id, mapped); + } + + for step in &ring.face_steps { + let inserted_a = inserted_vertex_by_edge[&step.input_edge_id]; + let inserted_b = inserted_vertex_by_edge[&step.opposite_edge_id]; + let key = undirected_edge_key(inserted_a, inserted_b); + if let Some(new_edge_id) = edge_lookup.get(&key).copied() { + journal.edges.add_created(new_edge_id); + changed_edges.insert(new_edge_id); + } + } + + let mut changed_faces = ring + .face_steps + .iter() + .map(|step| step.face_id) + .collect::>(); + changed_faces.extend(extra_face_id_by_old.values().copied()); + changed_faces.sort_unstable(); + changed_faces.dedup(); + + let mut changed_edges = changed_edges.into_iter().collect::>(); + changed_edges.sort_unstable(); + + let mut changed_vertices = inserted_vertex_by_edge + .values() + .copied() + .collect::>(); + changed_vertices.sort_unstable(); + changed_vertices.dedup(); + + Ok(EditEffect { + diagnostics: vec![BrepDiagnostic::info( + "loop_cut_applied", + format!( + "Applied loop cut across {} quad faces starting from edge {}", + ring.face_steps.len(), + edge_id + ), + ) + .with_domain("edge", Some(edge_id))], + topology_journal: Some(journal), + changed_faces, + changed_edges, + changed_vertices, + }) + } + + pub(super) fn remove_vertex_internal( + &mut self, + vertex_id: u32, + ) -> Result { + if !self.supports_single_face_topology_edits() { + return Err(BrepDiagnostic::error( + "unsupported_topology", + "removeVertex requires a single-face open surface", + ) + .with_domain("vertex", Some(vertex_id))); + } + + let old_brep = self.local_brep.clone(); + let face = old_brep + .faces + .first() + .expect("single-face topology checked above"); + let mut loop_vertices = old_brep.get_loop_vertex_indices(face.outer_loop); + if loop_vertices.len() <= 3 { + return Err(BrepDiagnostic::error( + "insufficient_vertices", + "removeVertex requires a face with at least four vertices", + ) + .with_domain("vertex", Some(vertex_id))); + } + + let loop_edges = self.single_face_loop_edges(&old_brep)?; + + let Some(vertex_index) = loop_vertices + .iter() + .position(|candidate| *candidate == vertex_id) + else { + return Err(BrepDiagnostic::error( + "vertex_not_on_boundary", + format!("Vertex {} is not part of the editable boundary", vertex_id), + ) + .with_domain("vertex", Some(vertex_id))); + }; + + let previous_index = if vertex_index == 0 { + loop_vertices.len() - 1 + } else { + vertex_index - 1 + }; + let previous_vertex = loop_vertices[previous_index]; + let next_vertex = loop_vertices[(vertex_index + 1) % loop_vertices.len()]; + + let previous_edge = loop_edges[previous_index]; + let next_edge = loop_edges[vertex_index]; + + loop_vertices.remove(vertex_index); + + let positions = old_brep + .vertices + .iter() + .map(|vertex| vertex.position) + .collect::>(); + + let mut builder = BrepBuilder::new(self.local_brep.id); + builder.add_vertices(&positions); + builder.add_face(&loop_vertices, &[]).map_err(|error| { + BrepDiagnostic::error( + "vertex_remove_failed", + format!( + "Failed to rebuild face without vertex {}: {}", + vertex_id, error + ), + ) + .with_domain("vertex", Some(vertex_id)) + })?; + + self.local_brep = builder.build().map_err(|error| { + BrepDiagnostic::error( + "vertex_remove_failed", + format!("Failed to finalize vertex removal: {}", error), + ) + .with_domain("vertex", Some(vertex_id)) + })?; + + let mut journal = TopologyChangeJournal::default(); + journal.faces.map(face.id, vec![0]); + journal.vertices.map(vertex_id, Vec::new()); + + let edge_lookup = self.edge_lookup_by_endpoints(); + let merged_key = undirected_edge_key(previous_vertex, next_vertex); + let merged_edge = edge_lookup.get(&merged_key).copied(); + + for edge in &old_brep.edges { + if edge.id == previous_edge || edge.id == next_edge { + journal + .edges + .map(edge.id, merged_edge.map(|id| vec![id]).unwrap_or_default()); + continue; + } + + if let Some((start, end)) = old_brep.get_edge_endpoints(edge.id) { + let key = undirected_edge_key(start, end); + if let Some(new_edge) = edge_lookup.get(&key).copied() { + journal.edges.map(edge.id, vec![new_edge]); + } + } + } + + let changed_faces = vec![0]; + let changed_edges = self.local_brep.edges.iter().map(|edge| edge.id).collect(); + let changed_vertices = vec![previous_vertex, vertex_id, next_vertex]; + + Ok(EditEffect { + diagnostics: vec![BrepDiagnostic::info( + "vertex_removed", + format!("Removed vertex {} from boundary", vertex_id), + ) + .with_domain("vertex", Some(vertex_id))], + topology_journal: Some(journal), + changed_faces, + changed_edges, + changed_vertices, + }) + } + + fn apply_constraints( + &self, + incoming: Vector3, + constraints: &ConstraintSettings, + domain: &str, + topology_id: u32, + ) -> Result<(Vector3, Vec), BrepDiagnostic> { + let mut translation = incoming; + let mut diagnostics = Vec::new(); + + if !is_finite_vector(incoming) { + return Err(BrepDiagnostic::error( + "invalid_translation", + "Translation contains non-finite numbers", + ) + .with_domain(domain, Some(topology_id))); + } + + if constraints.axis.is_some() && constraints.plane_normal.is_some() { + return Err(BrepDiagnostic::error( + "conflicting_constraints", + "Specify either axis or plane constraint, not both", + ) + .with_domain(domain, Some(topology_id))); + } + + if constraints.constraint_frame == "world" { + diagnostics.push( + BrepDiagnostic::warning( + "world_frame_not_supported", + "World-frame constraints are currently interpreted in local space", + ) + .with_domain(domain, Some(topology_id)), + ); + } + + if let Some(axis) = constraints.axis { + let Some(axis_normalized) = normalized(axis) else { + return Err(BrepDiagnostic::error( + "invalid_axis_constraint", + "Axis constraint must have non-zero length", + ) + .with_domain(domain, Some(topology_id))); + }; + + let projection = translation.dot(&axis_normalized); + translation = Vector3::new( + axis_normalized.x * projection, + axis_normalized.y * projection, + axis_normalized.z * projection, + ); + } + + if let Some(plane_normal) = constraints.plane_normal { + let Some(plane_normalized) = normalized(plane_normal) else { + return Err(BrepDiagnostic::error( + "invalid_plane_constraint", + "Plane normal constraint must have non-zero length", + ) + .with_domain(domain, Some(topology_id))); + }; + + let projection = translation.dot(&plane_normalized); + translation = Vector3::new( + translation.x - plane_normalized.x * projection, + translation.y - plane_normalized.y * projection, + translation.z - plane_normalized.z * projection, + ); + } + + if constraints.preserve_coplanarity { + diagnostics.push( + BrepDiagnostic::info( + "preserve_coplanarity_requested", + "preserveCoplanarity hint recorded; direct vertex/edge edits may still alter adjacent coplanarity", + ) + .with_domain(domain, Some(topology_id)), + ); + } + + if !is_finite_vector(translation) { + return Err(BrepDiagnostic::error( + "invalid_translation", + "Constraint resolution produced non-finite translation", + ) + .with_domain(domain, Some(topology_id))); + } + + Ok((translation, diagnostics)) + } + + fn collect_changed_domains_for_vertices( + &self, + vertex_ids: &HashSet, + ) -> (Vec, Vec, Vec) { + let mut changed_faces = HashSet::new(); + let mut changed_edges = HashSet::new(); + + for halfedge in &self.local_brep.halfedges { + if vertex_ids.contains(&halfedge.from) || vertex_ids.contains(&halfedge.to) { + changed_edges.insert(halfedge.edge); + if let Some(face_id) = halfedge.face { + changed_faces.insert(face_id); + } + } + } + + let mut face_ids = changed_faces.into_iter().collect::>(); + face_ids.sort_unstable(); + + let mut edge_ids = changed_edges.into_iter().collect::>(); + edge_ids.sort_unstable(); + + let mut vertices = vertex_ids.iter().copied().collect::>(); + vertices.sort_unstable(); + + (face_ids, edge_ids, vertices) + } + + fn edge_lookup_by_endpoints(&self) -> HashMap<(u32, u32), u32> { + let mut lookup = HashMap::new(); + for edge in &self.local_brep.edges { + if let Some((start, end)) = self.local_brep.get_edge_endpoints(edge.id) { + lookup.insert(undirected_edge_key(start, end), edge.id); + } + } + lookup + } + + fn rebuild_faces_with_inserted_edge_vertex( + &self, + brep: &Brep, + edge_id: u32, + from_id: u32, + to_id: u32, + inserted_vertex_id: u32, + affected_faces: &mut Vec, + ) -> Result, Vec>)>, BrepDiagnostic> { + let mut updated_faces = Vec::with_capacity(brep.faces.len()); + + for face in &brep.faces { + let outer_loop = brep.get_loop_vertex_indices(face.outer_loop); + let (outer, outer_changed) = + insert_vertex_into_loop(&outer_loop, from_id, to_id, inserted_vertex_id); + + let mut holes = Vec::with_capacity(face.inner_loops.len()); + let mut hole_changed = false; + for loop_id in &face.inner_loops { + let hole_loop = brep.get_loop_vertex_indices(*loop_id); + let (hole, changed) = + insert_vertex_into_loop(&hole_loop, from_id, to_id, inserted_vertex_id); + holes.push(hole); + hole_changed |= changed; + } + + if outer_changed || hole_changed { + affected_faces.push(face.id); + } + + updated_faces.push((outer, holes)); + } + + if affected_faces.is_empty() { + return Err(BrepDiagnostic::error( + "edge_not_on_face", + format!("Edge {} is not part of any editable face loop", edge_id), + ) + .with_domain("edge", Some(edge_id))); + } + + Ok(updated_faces) + } + + fn single_face_loop_edges(&self, brep: &crate::brep::Brep) -> Result, BrepDiagnostic> { + let face = brep.faces.first().ok_or_else(|| { + BrepDiagnostic::error("missing_face", "Single-face topology has no face") + .with_domain("face", Some(0)) + })?; + + let halfedges = brep.get_loop_halfedges(face.outer_loop).map_err(|error| { + BrepDiagnostic::error( + "invalid_face_topology", + format!("Failed to inspect face loop: {}", error), + ) + .with_domain("face", Some(face.id)) + })?; + + let mut edges = Vec::with_capacity(halfedges.len()); + for halfedge_id in halfedges { + let Some(halfedge) = brep.halfedges.get(halfedge_id as usize) else { + return Err(BrepDiagnostic::error( + "invalid_halfedge_reference", + format!("Missing halfedge {} in face loop", halfedge_id), + ) + .with_domain("face", Some(face.id))); + }; + edges.push(halfedge.edge); + } + + Ok(edges) + } + + fn incident_face_count(&self, edge_id: u32) -> usize { + let Some(edge) = self.local_brep.edges.get(edge_id as usize) else { + return 0; + }; + + let mut count = 0usize; + let halfedges = [Some(edge.halfedge), edge.twin_halfedge]; + for halfedge_id in halfedges.into_iter().flatten() { + if let Some(halfedge) = self.local_brep.halfedges.get(halfedge_id as usize) { + if halfedge.face.is_some() { + count += 1; + } + } + } + + count + } +} + +fn undirected_edge_key(a: u32, b: u32) -> (u32, u32) { + if a <= b { + (a, b) + } else { + (b, a) + } +} + +fn interpolate_loop_edge_position( + brep: &Brep, + from_id: u32, + to_id: u32, + t: f64, + edge_id: u32, +) -> Result { + let from = brep + .vertices + .get(from_id as usize) + .map(|vertex| vertex.position) + .ok_or_else(|| { + BrepDiagnostic::error( + "missing_vertex_reference", + format!( + "Vertex {} referenced by edge {} is missing", + from_id, edge_id + ), + ) + .with_domain("vertex", Some(from_id)) + })?; + let to = brep + .vertices + .get(to_id as usize) + .map(|vertex| vertex.position) + .ok_or_else(|| { + BrepDiagnostic::error( + "missing_vertex_reference", + format!("Vertex {} referenced by edge {} is missing", to_id, edge_id), + ) + .with_domain("vertex", Some(to_id)) + })?; + + Ok(Vector3::new( + from.x + (to.x - from.x) * t, + from.y + (to.y - from.y) * t, + from.z + (to.z - from.z) * t, + )) +} + +fn insert_vertex_into_loop( + loop_vertices: &[u32], + from_id: u32, + to_id: u32, + inserted_vertex_id: u32, +) -> (Vec, bool) { + let mut updated = loop_vertices.to_vec(); + + for index in 0..loop_vertices.len() { + let current = loop_vertices[index]; + let next = loop_vertices[(index + 1) % loop_vertices.len()]; + + if (current == from_id && next == to_id) || (current == to_id && next == from_id) { + updated.insert(index + 1, inserted_vertex_id); + return (updated, true); + } + } + + (updated, false) +} + +pub(super) fn collect_closed_quad_edge_ring( + brep: &Brep, + start_edge_id: u32, +) -> Result { + let incident_faces = super::inspection::incident_faces_for_edge(brep, start_edge_id); + if incident_faces.len() != 2 { + return Err(BrepDiagnostic::error( + "unsupported_topology", + "loopCut currently requires an edge with two incident faces in a closed quad ring", + ) + .with_domain("edge", Some(start_edge_id))); + } + + let mut last_error = None; + for start_face_id in incident_faces { + match traverse_closed_quad_edge_ring(brep, start_edge_id, start_face_id) { + Ok(ring) => return Ok(ring), + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| { + BrepDiagnostic::error( + "unsupported_topology", + "loopCut could not resolve a valid closed quad edge ring", + ) + .with_domain("edge", Some(start_edge_id)) + })) +} + +fn traverse_closed_quad_edge_ring( + brep: &Brep, + start_edge_id: u32, + start_face_id: u32, +) -> Result { + let mut edge_ids = vec![start_edge_id]; + let mut face_steps = Vec::new(); + let mut visited_faces = HashSet::new(); + let mut current_edge_id = start_edge_id; + let mut current_face_id = start_face_id; + + loop { + if !visited_faces.insert(current_face_id) { + return Err(BrepDiagnostic::error( + "unsupported_topology", + "loopCut edge ring revisited a face before closing cleanly", + ) + .with_domain("face", Some(current_face_id))); + } + + let face = brep + .faces + .iter() + .find(|candidate| candidate.id == current_face_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "missing_face", + format!("Face {} does not exist", current_face_id), + ) + .with_domain("face", Some(current_face_id)) + })?; + + if !face.inner_loops.is_empty() { + return Err(BrepDiagnostic::error( + "unsupported_topology", + "loopCut currently supports quad faces without holes", + ) + .with_domain("face", Some(current_face_id))); + } + + let loop_edges = face_outer_loop_edges(brep, face.id)?; + if loop_edges.len() != 4 { + return Err(BrepDiagnostic::error( + "unsupported_topology", + "loopCut currently supports quad edge rings only", + ) + .with_domain("face", Some(current_face_id))); + } + + let current_index = loop_edges + .iter() + .position(|candidate| *candidate == current_edge_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "invalid_face_topology", + format!( + "Edge {} is not part of face {} while traversing loopCut ring", + current_edge_id, current_face_id + ), + ) + .with_domain("edge", Some(current_edge_id)) + })?; + let opposite_edge_id = loop_edges[(current_index + 2) % loop_edges.len()]; + + face_steps.push(LoopCutFaceStep { + face_id: current_face_id, + input_edge_id: current_edge_id, + opposite_edge_id, + }); + + if opposite_edge_id == start_edge_id { + return Ok(LoopCutRing { + edge_ids, + face_steps, + }); + } + + if edge_ids.contains(&opposite_edge_id) { + return Err(BrepDiagnostic::error( + "unsupported_topology", + "loopCut encountered a repeated opposite edge before closing the ring", + ) + .with_domain("edge", Some(opposite_edge_id))); + } + + edge_ids.push(opposite_edge_id); + let incident_faces = super::inspection::incident_faces_for_edge(brep, opposite_edge_id); + if incident_faces.len() != 2 { + return Err(BrepDiagnostic::error( + "unsupported_topology", + "loopCut currently requires a closed quad ring without boundary edges", + ) + .with_domain("edge", Some(opposite_edge_id))); + } + + current_face_id = incident_faces + .into_iter() + .find(|face_id| *face_id != current_face_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "unsupported_topology", + format!( + "Edge {} did not expose a traversable neighboring face for loopCut", + opposite_edge_id + ), + ) + .with_domain("edge", Some(opposite_edge_id)) + })?; + current_edge_id = opposite_edge_id; + } +} + +fn face_outer_loop_edges(brep: &Brep, face_id: u32) -> Result, BrepDiagnostic> { + let face = brep + .faces + .iter() + .find(|candidate| candidate.id == face_id) + .ok_or_else(|| { + BrepDiagnostic::error("missing_face", format!("Face {} does not exist", face_id)) + .with_domain("face", Some(face_id)) + })?; + + let loop_halfedges = brep.get_loop_halfedges(face.outer_loop).map_err(|error| { + BrepDiagnostic::error( + "invalid_face_topology", + format!("Failed to inspect face {} loop: {}", face_id, error), + ) + .with_domain("face", Some(face_id)) + })?; + + let mut edges = Vec::with_capacity(loop_halfedges.len()); + for halfedge_id in loop_halfedges { + let halfedge = brep.halfedges.get(halfedge_id as usize).ok_or_else(|| { + BrepDiagnostic::error( + "invalid_halfedge_reference", + format!( + "Face {} references missing halfedge {}", + face_id, halfedge_id + ), + ) + .with_domain("face", Some(face_id)) + })?; + edges.push(halfedge.edge); + } + + Ok(edges) +} + +fn split_loop_between_vertices( + loop_vertices: &[u32], + first_inserted_vertex_id: u32, + second_inserted_vertex_id: u32, + face_id: u32, +) -> Result<(Vec, Vec), BrepDiagnostic> { + let first_index = loop_vertices + .iter() + .position(|vertex_id| *vertex_id == first_inserted_vertex_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "loop_cut_failed", + format!( + "Inserted loop-cut vertex {} is missing from face {} boundary", + first_inserted_vertex_id, face_id + ), + ) + .with_domain("face", Some(face_id)) + })?; + let second_index = loop_vertices + .iter() + .position(|vertex_id| *vertex_id == second_inserted_vertex_id) + .ok_or_else(|| { + BrepDiagnostic::error( + "loop_cut_failed", + format!( + "Inserted loop-cut vertex {} is missing from face {} boundary", + second_inserted_vertex_id, face_id + ), + ) + .with_domain("face", Some(face_id)) + })?; + + if first_index == second_index { + return Err(BrepDiagnostic::error( + "loop_cut_failed", + format!( + "Face {} loopCut vertices collapsed to the same boundary slot", + face_id + ), + ) + .with_domain("face", Some(face_id))); + } + + let primary = slice_cyclic_loop(loop_vertices, first_index, second_index); + let secondary = slice_cyclic_loop(loop_vertices, second_index, first_index); + + if primary.len() < 3 || secondary.len() < 3 { + return Err(BrepDiagnostic::error( + "loop_cut_failed", + format!("Face {} loopCut produced an invalid face boundary", face_id), + ) + .with_domain("face", Some(face_id))); + } + + Ok((primary, secondary)) +} + +fn slice_cyclic_loop(loop_vertices: &[u32], start_index: usize, end_index: usize) -> Vec { + let mut output = vec![loop_vertices[start_index]]; + let mut current_index = start_index; + + while current_index != end_index { + current_index = (current_index + 1) % loop_vertices.len(); + output.push(loop_vertices[current_index]); + } + + output +} diff --git a/main/opengeometry/src/freeform/inspection.rs b/main/opengeometry/src/freeform/inspection.rs new file mode 100644 index 0000000..06d2aa7 --- /dev/null +++ b/main/opengeometry/src/freeform/inspection.rs @@ -0,0 +1,229 @@ +use std::collections::HashSet; + +use openmaths::Vector3; +use wasm_bindgen::prelude::JsValue; + +use crate::brep::{Brep, Face}; + +use super::{EdgeInfo, FaceInfo, OGFreeformGeometry, VertexInfo}; + +impl OGFreeformGeometry { + pub(super) fn build_face_info(&self, face_id: u32) -> Result { + let brep = self.world_brep(); + let face = brep + .faces + .iter() + .find(|face| face.id == face_id) + .ok_or_else(|| JsValue::from_str(&format!("Face {} does not exist", face_id)))?; + + let loop_ids = face_loop_ids(face); + let edge_ids = collect_face_edge_ids(&brep, face)?; + let vertex_ids = collect_face_vertex_ids(&brep, face)?; + let adjacent_face_ids = collect_adjacent_faces(&brep, &edge_ids, face.id); + + Ok(FaceInfo { + face_id, + centroid: compute_face_centroid(&brep, face).unwrap_or(Vector3::new(0.0, 0.0, 0.0)), + normal: face.normal, + surface_type: "planar".to_string(), + loop_ids, + edge_ids, + vertex_ids, + adjacent_face_ids, + }) + } + + pub(super) fn build_edge_info(&self, edge_id: u32) -> Result { + let brep = self.world_brep(); + let (start_id, end_id) = brep + .get_edge_endpoints(edge_id) + .ok_or_else(|| JsValue::from_str(&format!("Edge {} does not exist", edge_id)))?; + + let start = brep + .vertices + .get(start_id as usize) + .map(|vertex| vertex.position) + .ok_or_else(|| { + JsValue::from_str(&format!("Edge {} start vertex is missing", edge_id)) + })?; + + let end = brep + .vertices + .get(end_id as usize) + .map(|vertex| vertex.position) + .ok_or_else(|| JsValue::from_str(&format!("Edge {} end vertex is missing", edge_id)))?; + + Ok(EdgeInfo { + edge_id, + curve_type: "line".to_string(), + start_vertex_id: start_id, + end_vertex_id: end_id, + start, + end, + incident_face_ids: incident_faces_for_edge(&brep, edge_id), + }) + } + + pub(super) fn build_vertex_info(&self, vertex_id: u32) -> Result { + let brep = self.world_brep(); + let vertex = brep + .vertices + .get(vertex_id as usize) + .ok_or_else(|| JsValue::from_str(&format!("Vertex {} does not exist", vertex_id)))?; + + let (edge_ids, face_ids) = connected_edges_and_faces(&brep, vertex_id); + + Ok(VertexInfo { + vertex_id, + position: vertex.position, + edge_ids, + face_ids, + }) + } +} + +pub(super) fn face_loop_ids(face: &Face) -> Vec { + let mut loop_ids = vec![face.outer_loop]; + loop_ids.extend(face.inner_loops.iter().copied()); + loop_ids +} + +pub(super) fn collect_face_vertex_ids(brep: &Brep, face: &Face) -> Result, String> { + let mut vertex_ids = Vec::new(); + let mut seen = HashSet::new(); + + for loop_id in face_loop_ids(face) { + let loop_vertices = brep + .get_loop_vertex_indices(loop_id) + .into_iter() + .collect::>(); + + if loop_vertices.is_empty() { + return Err(format!( + "Loop {} for face {} does not contain vertices", + loop_id, face.id + )); + } + + for vertex_id in loop_vertices { + if seen.insert(vertex_id) { + vertex_ids.push(vertex_id); + } + } + } + + Ok(vertex_ids) +} + +fn collect_face_edge_ids(brep: &Brep, face: &Face) -> Result, JsValue> { + let mut edge_ids = Vec::new(); + let mut seen = HashSet::new(); + + for loop_id in face_loop_ids(face) { + let loop_halfedges = brep.get_loop_halfedges(loop_id).map_err(|error| { + JsValue::from_str(&format!("Failed to read loop {}: {}", loop_id, error)) + })?; + + for halfedge_id in loop_halfedges { + let Some(halfedge) = brep.halfedges.get(halfedge_id as usize) else { + continue; + }; + if seen.insert(halfedge.edge) { + edge_ids.push(halfedge.edge); + } + } + } + + Ok(edge_ids) +} + +fn collect_adjacent_faces(brep: &Brep, edge_ids: &[u32], current_face_id: u32) -> Vec { + let mut adjacent = Vec::new(); + let mut seen = HashSet::new(); + + for edge_id in edge_ids { + for face_id in incident_faces_for_edge(brep, *edge_id) { + if face_id == current_face_id { + continue; + } + if seen.insert(face_id) { + adjacent.push(face_id); + } + } + } + + adjacent +} + +pub(super) fn incident_faces_for_edge(brep: &Brep, edge_id: u32) -> Vec { + let mut faces = Vec::new(); + let mut seen = HashSet::new(); + + let Some(edge) = brep.edges.get(edge_id as usize) else { + return faces; + }; + + let mut halfedges = vec![edge.halfedge]; + if let Some(twin_halfedge) = edge.twin_halfedge { + halfedges.push(twin_halfedge); + } + + for halfedge_id in halfedges { + let Some(halfedge) = brep.halfedges.get(halfedge_id as usize) else { + continue; + }; + + if let Some(face_id) = halfedge.face { + if seen.insert(face_id) { + faces.push(face_id); + } + } + } + + faces +} + +fn connected_edges_and_faces(brep: &Brep, vertex_id: u32) -> (Vec, Vec) { + let mut edge_ids = Vec::new(); + let mut face_ids = Vec::new(); + let mut seen_edges = HashSet::new(); + let mut seen_faces = HashSet::new(); + + for halfedge in &brep.halfedges { + if halfedge.from != vertex_id && halfedge.to != vertex_id { + continue; + } + + if seen_edges.insert(halfedge.edge) { + edge_ids.push(halfedge.edge); + } + + if let Some(face_id) = halfedge.face { + if seen_faces.insert(face_id) { + face_ids.push(face_id); + } + } + } + + (edge_ids, face_ids) +} + +fn compute_face_centroid(brep: &Brep, face: &Face) -> Option { + let vertices = brep.get_vertices_by_face_id(face.id); + if vertices.is_empty() { + return None; + } + + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_z = 0.0; + + for vertex in &vertices { + sum_x += vertex.x; + sum_y += vertex.y; + sum_z += vertex.z; + } + + let count = vertices.len() as f64; + Some(Vector3::new(sum_x / count, sum_y / count, sum_z / count)) +} diff --git a/main/opengeometry/src/freeform/mod.rs b/main/opengeometry/src/freeform/mod.rs new file mode 100644 index 0000000..a69c8e1 --- /dev/null +++ b/main/opengeometry/src/freeform/mod.rs @@ -0,0 +1,621 @@ +//! Freeform geometry editing built on top of OpenGeometry BReps. +//! +//! This module owns the public `OGFreeformGeometry` wasm surface. The direct +//! editing kernels live here, while higher-level GUI packages are expected to +//! decide when a parametric object should be converted into freeform mode. + +mod capabilities; +mod edits; +mod inspection; +mod remap; +#[cfg(test)] +mod tests; +mod topology_display; +mod types; +mod validation; + +use openmaths::Vector3; +use serde::Deserialize; +use wasm_bindgen::prelude::*; + +use crate::brep::Brep; +use crate::spatial::placement::Placement3D; + +use remap::{build_topology_remap, topology_changed, TopologySnapshot}; +use validation::validate_geometry; + +pub use types::*; + +#[derive(Clone, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct EditResultOptions { + include_brep_serialized: bool, + include_local_brep_serialized: bool, + include_geometry_serialized: bool, + include_outline_geometry_serialized: bool, + include_topology_remap: bool, + include_deltas: bool, +} + +impl Default for EditResultOptions { + fn default() -> Self { + Self { + include_brep_serialized: false, + include_local_brep_serialized: false, + include_geometry_serialized: false, + include_outline_geometry_serialized: false, + include_topology_remap: true, + include_deltas: true, + } + } +} + +#[derive(Clone, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct EditOperationOptions { + #[serde(flatten)] + result: EditResultOptions, + constraint_axis: Option, + constraint_plane_normal: Option, + preserve_coplanarity: bool, + constraint_frame: String, + open_surface_mode: bool, +} + +impl Default for EditOperationOptions { + fn default() -> Self { + Self { + result: EditResultOptions::default(), + constraint_axis: None, + constraint_plane_normal: None, + preserve_coplanarity: false, + constraint_frame: "local".to_string(), + open_surface_mode: false, + } + } +} + +#[derive(Clone, Default)] +pub(super) struct ConstraintSettings { + axis: Option, + plane_normal: Option, + preserve_coplanarity: bool, + constraint_frame: String, +} + +impl From<&EditOperationOptions> for ConstraintSettings { + fn from(options: &EditOperationOptions) -> Self { + Self { + axis: options.constraint_axis, + plane_normal: options.constraint_plane_normal, + preserve_coplanarity: options.preserve_coplanarity, + constraint_frame: options.constraint_frame.clone(), + } + } +} + +#[wasm_bindgen] +pub struct OGFreeformGeometry { + id: String, + local_brep: Brep, + placement: Placement3D, +} + +impl ObjectTransformation { + fn from_placement(placement: &Placement3D) -> Self { + Self { + anchor: placement.anchor, + translation: placement.translation(), + rotation: placement.rotation(), + scale: placement.scale(), + } + } + + fn apply_to_placement(&self, placement: &mut Placement3D) -> Result<(), String> { + placement.set_anchor(self.anchor); + placement.set_transform(self.translation, self.rotation, self.scale) + } +} + +#[wasm_bindgen] +impl OGFreeformGeometry { + #[wasm_bindgen(constructor)] + pub fn new(id: String, local_brep_serialized: String) -> Result { + let local_brep: Brep = serde_json::from_str(&local_brep_serialized).map_err(|error| { + JsValue::from_str(&format!( + "Failed to deserialize freeform BRep JSON payload: {}", + error + )) + })?; + + local_brep.validate_topology().map_err(|error| { + JsValue::from_str(&format!("Invalid freeform BRep topology: {}", error)) + })?; + + Ok(Self { + id, + local_brep, + placement: Placement3D::new(), + }) + } + + #[wasm_bindgen(getter)] + pub fn id(&self) -> String { + self.id.clone() + } + + #[wasm_bindgen(js_name = getBrepSerialized)] + pub fn get_brep_serialized(&self) -> String { + serde_json::to_string(&self.world_brep()).unwrap_or_else(|_| "{}".to_string()) + } + + #[wasm_bindgen(js_name = getLocalBrepSerialized)] + pub fn get_local_brep_serialized(&self) -> String { + serde_json::to_string(&self.local_brep).unwrap_or_else(|_| "{}".to_string()) + } + + #[wasm_bindgen(js_name = getGeometrySerialized)] + pub fn get_geometry_serialized(&self) -> String { + let world = self.world_brep(); + serde_json::to_string(&world.get_triangle_vertex_buffer()) + .unwrap_or_else(|_| "[]".to_string()) + } + + #[wasm_bindgen(js_name = getOutlineGeometrySerialized)] + pub fn get_outline_geometry_serialized(&self) -> String { + let world = self.world_brep(); + serde_json::to_string(&world.get_outline_vertex_buffer()) + .unwrap_or_else(|_| "[]".to_string()) + } + + #[wasm_bindgen(js_name = getPlacementSerialized)] + pub fn get_placement_serialized(&self) -> String { + serde_json::to_string(&ObjectTransformation::from_placement(&self.placement)) + .unwrap_or_else(|_| "{}".to_string()) + } + + #[wasm_bindgen(js_name = setPlacementSerialized)] + pub fn set_placement_serialized( + &mut self, + placement_serialized: String, + ) -> Result<(), JsValue> { + let transform: ObjectTransformation = serde_json::from_str(&placement_serialized) + .map_err(|error| JsValue::from_str(&format!("Invalid placement payload: {}", error)))?; + + transform + .apply_to_placement(&mut self.placement) + .map_err(|error| JsValue::from_str(&error)) + } + + #[wasm_bindgen(js_name = setTransform)] + pub fn set_transform( + &mut self, + translation: Vector3, + rotation: Vector3, + scale: Vector3, + ) -> Result<(), JsValue> { + self.placement + .set_transform(translation, rotation, scale) + .map_err(|error| JsValue::from_str(&error)) + } + + #[wasm_bindgen(js_name = setTranslation)] + pub fn set_translation(&mut self, translation: Vector3) { + self.placement.set_translation(translation); + } + + #[wasm_bindgen(js_name = setRotation)] + pub fn set_rotation(&mut self, rotation: Vector3) { + self.placement.set_rotation(rotation); + } + + #[wasm_bindgen(js_name = setScale)] + pub fn set_scale(&mut self, scale: Vector3) -> Result<(), JsValue> { + self.placement + .set_scale(scale) + .map_err(|error| JsValue::from_str(&error)) + } + + #[wasm_bindgen(js_name = setAnchor)] + pub fn set_anchor(&mut self, anchor: Vector3) { + self.placement.set_anchor(anchor); + } + + #[wasm_bindgen(js_name = getTopologyRenderData)] + pub fn get_topology_render_data(&self) -> Result { + let payload = self.build_topology_display_data(); + serde_json::to_string(&payload).map_err(|error| { + JsValue::from_str(&format!( + "Failed to serialize topology display data: {}", + error + )) + }) + } + + #[wasm_bindgen(js_name = getFaceInfo)] + pub fn get_face_info(&self, face_id: u32) -> Result { + let info = self.build_face_info(face_id)?; + serde_json::to_string(&info).map_err(|error| { + JsValue::from_str(&format!("Failed to serialize face info: {}", error)) + }) + } + + #[wasm_bindgen(js_name = getEdgeInfo)] + pub fn get_edge_info(&self, edge_id: u32) -> Result { + let info = self.build_edge_info(edge_id)?; + serde_json::to_string(&info).map_err(|error| { + JsValue::from_str(&format!("Failed to serialize edge info: {}", error)) + }) + } + + #[wasm_bindgen(js_name = getVertexInfo)] + pub fn get_vertex_info(&self, vertex_id: u32) -> Result { + let info = self.build_vertex_info(vertex_id)?; + serde_json::to_string(&info).map_err(|error| { + JsValue::from_str(&format!("Failed to serialize vertex info: {}", error)) + }) + } + + #[wasm_bindgen(js_name = getEditCapabilities)] + pub fn get_edit_capabilities(&self) -> Result { + let capabilities = self.build_entity_edit_capabilities(); + serde_json::to_string(&capabilities).map_err(|error| { + JsValue::from_str(&format!("Failed to serialize edit capabilities: {}", error)) + }) + } + + #[wasm_bindgen(js_name = getFaceEditCapabilities)] + pub fn get_face_edit_capabilities(&self, face_id: u32) -> Result { + let capabilities = self.build_face_edit_capabilities(face_id)?; + serde_json::to_string(&capabilities).map_err(|error| { + JsValue::from_str(&format!( + "Failed to serialize face edit capabilities: {}", + error + )) + }) + } + + #[wasm_bindgen(js_name = getEdgeEditCapabilities)] + pub fn get_edge_edit_capabilities(&self, edge_id: u32) -> Result { + let capabilities = self.build_edge_edit_capabilities(edge_id)?; + serde_json::to_string(&capabilities).map_err(|error| { + JsValue::from_str(&format!( + "Failed to serialize edge edit capabilities: {}", + error + )) + }) + } + + #[wasm_bindgen(js_name = getVertexEditCapabilities)] + pub fn get_vertex_edit_capabilities(&self, vertex_id: u32) -> Result { + let capabilities = self.build_vertex_edit_capabilities(vertex_id)?; + serde_json::to_string(&capabilities).map_err(|error| { + JsValue::from_str(&format!( + "Failed to serialize vertex edit capabilities: {}", + error + )) + }) + } + + #[wasm_bindgen(js_name = pushPullFace)] + pub fn push_pull_face( + &mut self, + face_id: u32, + distance: f64, + options_json: Option, + ) -> Result { + let options = Self::parse_edit_operation_options(options_json)?; + let constraints = ConstraintSettings::from(&options); + self.apply_edit(&options, |entity| { + entity.push_pull_face_internal(face_id, distance, &constraints) + }) + } + + #[wasm_bindgen(js_name = moveFace)] + pub fn move_face( + &mut self, + face_id: u32, + translation: Vector3, + options_json: Option, + ) -> Result { + let options = Self::parse_edit_operation_options(options_json)?; + let constraints = ConstraintSettings::from(&options); + self.apply_edit(&options, |entity| { + entity.translate_face_by_vector_internal(face_id, translation, &constraints) + }) + } + + #[wasm_bindgen(js_name = moveEdge)] + pub fn move_edge( + &mut self, + edge_id: u32, + translation: Vector3, + options_json: Option, + ) -> Result { + let options = Self::parse_edit_operation_options(options_json)?; + let constraints = ConstraintSettings::from(&options); + self.apply_edit(&options, |entity| { + entity.move_edge_internal(edge_id, translation, &constraints) + }) + } + + #[wasm_bindgen(js_name = moveVertex)] + pub fn move_vertex( + &mut self, + vertex_id: u32, + translation: Vector3, + options_json: Option, + ) -> Result { + let options = Self::parse_edit_operation_options(options_json)?; + let constraints = ConstraintSettings::from(&options); + self.apply_edit(&options, |entity| { + entity.move_vertex_internal(vertex_id, translation, &constraints) + }) + } + + #[wasm_bindgen(js_name = extrudeFace)] + pub fn extrude_face( + &mut self, + face_id: u32, + distance: f64, + options_json: Option, + ) -> Result { + let options = Self::parse_edit_operation_options(options_json)?; + self.apply_edit(&options, |entity| { + entity.extrude_face_internal(face_id, distance, options.open_surface_mode) + }) + } + + #[wasm_bindgen(js_name = cutFace)] + pub fn cut_face( + &mut self, + face_id: u32, + start_edge_id: u32, + start_t: f64, + end_edge_id: u32, + end_t: f64, + options_json: Option, + ) -> Result { + let options = Self::parse_edit_operation_options(options_json)?; + self.apply_edit(&options, |entity| { + entity.cut_face_internal(face_id, start_edge_id, start_t, end_edge_id, end_t) + }) + } + + #[wasm_bindgen(js_name = insertVertexOnEdge)] + pub fn insert_vertex_on_edge( + &mut self, + edge_id: u32, + t: f64, + options_json: Option, + ) -> Result { + let options = Self::parse_edit_operation_options(options_json)?; + self.apply_edit(&options, |entity| { + entity.insert_vertex_on_edge_internal(edge_id, t) + }) + } + + #[wasm_bindgen(js_name = splitEdge)] + pub fn split_edge( + &mut self, + edge_id: u32, + t: f64, + options_json: Option, + ) -> Result { + let options = Self::parse_edit_operation_options(options_json)?; + self.apply_edit(&options, |entity| entity.split_edge_internal(edge_id, t)) + } + + #[wasm_bindgen(js_name = loopCut)] + pub fn loop_cut( + &mut self, + edge_id: u32, + t: f64, + options_json: Option, + ) -> Result { + let options = Self::parse_edit_operation_options(options_json)?; + self.apply_edit(&options, |entity| { + entity.loop_cut_edge_ring_internal(edge_id, t) + }) + } + + #[wasm_bindgen(js_name = removeVertex)] + pub fn remove_vertex( + &mut self, + vertex_id: u32, + options_json: Option, + ) -> Result { + let options = Self::parse_edit_operation_options(options_json)?; + self.apply_edit(&options, |entity| entity.remove_vertex_internal(vertex_id)) + } +} + +impl OGFreeformGeometry { + fn parse_edit_operation_options( + options_json: Option, + ) -> Result { + match options_json { + Some(raw) => serde_json::from_str(&raw).map_err(|error| { + JsValue::from_str(&format!("Invalid edit options payload: {}", error)) + }), + None => Ok(EditOperationOptions::default()), + } + } + + fn world_brep(&self) -> Brep { + self.local_brep.transformed(&self.placement) + } + + fn apply_edit( + &mut self, + options: &EditOperationOptions, + mut edit: F, + ) -> Result + where + F: FnMut(&mut Self) -> Result, + { + let backup_brep = self.local_brep.clone(); + let topology_before = TopologySnapshot::from_brep(&self.local_brep); + + let mut diagnostics = Vec::new(); + let mut effect = EditEffect::default(); + + match edit(self) { + Ok(outcome) => { + diagnostics.extend(outcome.diagnostics.clone()); + effect = outcome; + } + Err(error) => { + diagnostics.push(error); + } + } + + if !has_error_diagnostics(&diagnostics) { + if let Err(error) = self.local_brep.validate_topology() { + diagnostics.push(BrepDiagnostic::error( + "invalid_topology", + format!("Edited BRep has invalid topology: {}", error), + )); + } + + diagnostics.extend(validate_geometry(&self.local_brep)); + } + + let failed = has_error_diagnostics(&diagnostics); + if failed { + self.local_brep = backup_brep; + } + + let validity = BrepValidity { + ok: !failed, + healed: Some(false), + diagnostics, + }; + + self.serialize_edit_result( + validity, + &topology_before, + if failed { None } else { Some(&effect) }, + &options.result, + ) + } + + fn serialize_edit_result( + &self, + validity: BrepValidity, + topology_before: &TopologySnapshot, + effect: Option<&EditEffect>, + options: &EditResultOptions, + ) -> Result { + let world = self.world_brep(); + let topology_after = TopologySnapshot::from_brep(&self.local_brep); + let topology_changed_flag = topology_changed(topology_before, &topology_after); + + let fallback_changed_faces = if topology_changed_flag { + Some(topology_after.face_ids().to_vec()) + } else { + None + }; + let fallback_changed_edges = if topology_changed_flag { + Some(topology_after.edge_ids().to_vec()) + } else { + None + }; + let fallback_changed_vertices = if topology_changed_flag { + Some(topology_after.vertex_ids().to_vec()) + } else { + None + }; + + let result = FreeformEditResult { + entity_id: self.id.clone(), + brep_serialized: if options.include_brep_serialized { + Some(serde_json::to_string(&world).map_err(|error| { + JsValue::from_str(&format!("Failed to serialize world BRep: {}", error)) + })?) + } else { + None + }, + local_brep_serialized: if options.include_local_brep_serialized { + Some(serde_json::to_string(&self.local_brep).map_err(|error| { + JsValue::from_str(&format!("Failed to serialize local BRep: {}", error)) + })?) + } else { + None + }, + geometry_serialized: if options.include_geometry_serialized { + Some( + serde_json::to_string(&world.get_triangle_vertex_buffer()).map_err( + |error| { + JsValue::from_str(&format!( + "Failed to serialize geometry buffer: {}", + error + )) + }, + )?, + ) + } else { + None + }, + outline_geometry_serialized: if options.include_outline_geometry_serialized { + Some( + serde_json::to_string(&world.get_outline_vertex_buffer()).map_err(|error| { + JsValue::from_str(&format!("Failed to serialize outline buffer: {}", error)) + })?, + ) + } else { + None + }, + topology_changed: topology_changed_flag, + topology_remap: if options.include_topology_remap { + Some(build_topology_remap( + topology_before, + &topology_after, + effect.and_then(|entry| entry.topology_journal.as_ref()), + )) + } else { + None + }, + changed_faces: if options.include_deltas { + effect + .map(|entry| normalize_ids(&entry.changed_faces)) + .or(fallback_changed_faces) + } else { + None + }, + changed_edges: if options.include_deltas { + effect + .map(|entry| normalize_ids(&entry.changed_edges)) + .or(fallback_changed_edges) + } else { + None + }, + changed_vertices: if options.include_deltas { + effect + .map(|entry| normalize_ids(&entry.changed_vertices)) + .or(fallback_changed_vertices) + } else { + None + }, + validity, + placement: ObjectTransformation::from_placement(&self.placement), + }; + + serde_json::to_string(&result).map_err(|error| { + JsValue::from_str(&format!("Failed to serialize edit result: {}", error)) + }) + } +} + +fn has_error_diagnostics(diagnostics: &[BrepDiagnostic]) -> bool { + diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) +} + +fn normalize_ids(ids: &[u32]) -> Vec { + let mut normalized = ids.to_vec(); + normalized.sort_unstable(); + normalized.dedup(); + normalized +} diff --git a/main/opengeometry/src/freeform/remap.rs b/main/opengeometry/src/freeform/remap.rs new file mode 100644 index 0000000..c6dec31 --- /dev/null +++ b/main/opengeometry/src/freeform/remap.rs @@ -0,0 +1,216 @@ +use std::collections::{HashMap, HashSet}; + +use crate::brep::Brep; + +use super::{ + TopologyChangeJournal, TopologyCreatedIds, TopologyDomainJournal, TopologyRemap, + TopologyRemapEntry, TopologyRemapStatus, +}; + +#[derive(Clone)] +pub(super) struct TopologySnapshot { + shell_ids: Vec, + face_ids: Vec, + loop_ids: Vec, + edge_ids: Vec, + vertex_ids: Vec, +} + +impl TopologySnapshot { + pub(super) fn from_brep(brep: &Brep) -> Self { + Self { + shell_ids: sorted_ids(brep.shells.iter().map(|shell| shell.id).collect()), + face_ids: sorted_ids(brep.faces.iter().map(|face| face.id).collect()), + loop_ids: sorted_ids(brep.loops.iter().map(|loop_ref| loop_ref.id).collect()), + edge_ids: sorted_ids(brep.edges.iter().map(|edge| edge.id).collect()), + vertex_ids: sorted_ids(brep.vertices.iter().map(|vertex| vertex.id).collect()), + } + } + + pub(super) fn face_ids(&self) -> &[u32] { + &self.face_ids + } + + pub(super) fn edge_ids(&self) -> &[u32] { + &self.edge_ids + } + + pub(super) fn vertex_ids(&self) -> &[u32] { + &self.vertex_ids + } +} + +pub(super) fn topology_changed(before: &TopologySnapshot, after: &TopologySnapshot) -> bool { + before.shell_ids != after.shell_ids + || before.face_ids != after.face_ids + || before.loop_ids != after.loop_ids + || before.edge_ids != after.edge_ids + || before.vertex_ids != after.vertex_ids +} + +pub(super) fn build_topology_remap( + before: &TopologySnapshot, + after: &TopologySnapshot, + journal: Option<&TopologyChangeJournal>, +) -> TopologyRemap { + let after_face_ids: HashSet = after.face_ids.iter().copied().collect(); + let after_edge_ids: HashSet = after.edge_ids.iter().copied().collect(); + let after_vertex_ids: HashSet = after.vertex_ids.iter().copied().collect(); + + let face_mapping = build_domain_mapping( + &before.face_ids, + &after_face_ids, + journal.map(|entry| &entry.faces.mapping), + ); + let edge_mapping = build_domain_mapping( + &before.edge_ids, + &after_edge_ids, + journal.map(|entry| &entry.edges.mapping), + ); + let vertex_mapping = build_domain_mapping( + &before.vertex_ids, + &after_vertex_ids, + journal.map(|entry| &entry.vertices.mapping), + ); + + let created_ids = TopologyCreatedIds { + faces: resolve_created_ids( + &before.face_ids, + &after.face_ids, + journal.map(|entry| &entry.faces), + ), + edges: resolve_created_ids( + &before.edge_ids, + &after.edge_ids, + journal.map(|entry| &entry.edges), + ), + vertices: resolve_created_ids( + &before.vertex_ids, + &after.vertex_ids, + journal.map(|entry| &entry.vertices), + ), + }; + + TopologyRemap { + faces: build_domain_entries_from_mapping(&before.face_ids, &face_mapping), + edges: build_domain_entries_from_mapping(&before.edge_ids, &edge_mapping), + vertices: build_domain_entries_from_mapping(&before.vertex_ids, &vertex_mapping), + created_ids, + } +} + +pub(super) fn build_domain_entries_from_mapping( + old_ids: &[u32], + mapping: &HashMap>, +) -> Vec { + let mut normalized_mapping: HashMap> = HashMap::new(); + + for old_id in old_ids { + let mut new_ids = mapping.get(old_id).cloned().unwrap_or_default(); + new_ids.sort_unstable(); + new_ids.dedup(); + normalized_mapping.insert(*old_id, new_ids); + } + + let mut new_id_usage_count: HashMap = HashMap::new(); + for new_ids in normalized_mapping.values() { + for new_id in new_ids { + *new_id_usage_count.entry(*new_id).or_insert(0) += 1; + } + } + + let mut entries = Vec::with_capacity(old_ids.len()); + for old_id in old_ids { + let new_ids = normalized_mapping.get(old_id).cloned().unwrap_or_default(); + let primary_id = new_ids.first().copied(); + let status = classify_status(&new_ids, &new_id_usage_count); + + entries.push(TopologyRemapEntry { + old_id: *old_id, + new_ids, + primary_id, + status, + }); + } + + entries.sort_by_key(|entry| entry.old_id); + entries +} + +fn classify_status( + new_ids: &[u32], + new_id_usage_count: &HashMap, +) -> TopologyRemapStatus { + if new_ids.is_empty() { + return TopologyRemapStatus::Deleted; + } + + if new_ids.len() > 1 { + return TopologyRemapStatus::Split; + } + + let new_id = new_ids[0]; + if new_id_usage_count.get(&new_id).copied().unwrap_or(0) > 1 { + return TopologyRemapStatus::Merged; + } + + TopologyRemapStatus::Unchanged +} + +fn build_domain_mapping( + old_ids: &[u32], + after_ids: &HashSet, + journal_mapping: Option<&HashMap>>, +) -> HashMap> { + let mut mapping = HashMap::new(); + + for old_id in old_ids { + if let Some(mapped) = journal_mapping.and_then(|entry| entry.get(old_id)) { + let mut normalized = mapped.clone(); + normalized.sort_unstable(); + normalized.dedup(); + normalized.retain(|id| after_ids.contains(id)); + mapping.insert(*old_id, normalized); + continue; + } + + if after_ids.contains(old_id) { + mapping.insert(*old_id, vec![*old_id]); + } else { + mapping.insert(*old_id, Vec::new()); + } + } + + mapping +} + +fn resolve_created_ids( + before_ids: &[u32], + after_ids: &[u32], + domain_journal: Option<&TopologyDomainJournal>, +) -> Vec { + let before_set: HashSet = before_ids.iter().copied().collect(); + let after_set: HashSet = after_ids.iter().copied().collect(); + + if let Some(journal) = domain_journal { + let mut created = journal.created_ids.clone(); + created.sort_unstable(); + created.dedup(); + created.retain(|id| after_set.contains(id)); + return created; + } + + let mut inferred = after_ids + .iter() + .copied() + .filter(|id| !before_set.contains(id)) + .collect::>(); + inferred.sort_unstable(); + inferred.dedup(); + inferred +} + +fn sorted_ids(mut ids: Vec) -> Vec { + ids.sort_unstable(); + ids +} diff --git a/main/opengeometry/src/freeform/tests.rs b/main/opengeometry/src/freeform/tests.rs new file mode 100644 index 0000000..5c6195e --- /dev/null +++ b/main/opengeometry/src/freeform/tests.rs @@ -0,0 +1,692 @@ +use openmaths::Vector3; + +use super::*; +use crate::primitives::arc::OGArc; +use crate::primitives::cuboid::OGCuboid; +use crate::primitives::curve::OGCurve; +use crate::primitives::cylinder::OGCylinder; +use crate::primitives::line::OGLine; +use crate::primitives::polygon::OGPolygon; +use crate::primitives::polyline::OGPolyline; +use crate::primitives::rectangle::OGRectangle; +use crate::primitives::sphere::OGSphere; +use crate::primitives::sweep::OGSweep; +use crate::primitives::wedge::OGWedge; + +fn parse_edit_result(payload: &str) -> FreeformEditResult { + serde_json::from_str(payload).expect("edit result payload") +} + +fn parse_face_info(payload: &str) -> FaceInfo { + serde_json::from_str(payload).expect("face info payload") +} + +fn parse_vertex_info(payload: &str) -> VertexInfo { + serde_json::from_str(payload).expect("vertex info payload") +} + +fn top_face_id(entity: &OGFreeformGeometry) -> u32 { + let mut best_face = 0; + let mut best_y = f64::NEG_INFINITY; + + for face in &entity.local_brep.faces { + let info = parse_face_info(&entity.get_face_info(face.id).expect("face info")); + if info.centroid.y > best_y { + best_y = info.centroid.y; + best_face = face.id; + } + } + + best_face +} + +fn vertical_edge_id(entity: &OGFreeformGeometry) -> u32 { + entity + .local_brep + .edges + .iter() + .find_map(|edge| { + let (start_id, end_id) = entity.local_brep.get_edge_endpoints(edge.id)?; + let start = entity.local_brep.vertices.get(start_id as usize)?.position; + let end = entity.local_brep.vertices.get(end_id as usize)?.position; + + let dx = (end.x - start.x).abs(); + let dy = (end.y - start.y).abs(); + let dz = (end.z - start.z).abs(); + + if dy > 0.2 && dx <= 1.0e-6 && dz <= 1.0e-6 { + Some(edge.id) + } else { + None + } + }) + .expect("vertical edge") +} + +fn right_side_face_id(entity: &OGFreeformGeometry) -> u32 { + entity + .local_brep + .faces + .iter() + .find_map(|face| { + let info = parse_face_info(&entity.get_face_info(face.id).ok()?); + if info.normal.x > 0.5 && info.normal.y.abs() < 1.0e-6 { + Some(face.id) + } else { + None + } + }) + .expect("right side face") +} + +fn opposite_vertical_face_edges(entity: &OGFreeformGeometry, face_id: u32) -> (u32, u32) { + let info = parse_face_info(&entity.get_face_info(face_id).expect("face info")); + let mut vertical_edges = info + .edge_ids + .iter() + .filter_map(|edge_id| { + let edge = entity.local_brep.edges.get(*edge_id as usize)?; + let (start_id, end_id) = entity.local_brep.get_edge_endpoints(edge.id)?; + let start = entity.local_brep.vertices.get(start_id as usize)?.position; + let end = entity.local_brep.vertices.get(end_id as usize)?.position; + + let dx = (end.x - start.x).abs(); + let dy = (end.y - start.y).abs(); + let dz = (end.z - start.z).abs(); + + if dy > 0.2 && dx <= 1.0e-6 && dz <= 1.0e-6 { + let avg_x = (start.x + end.x) * 0.5; + Some((avg_x, edge.id)) + } else { + None + } + }) + .collect::>(); + + vertical_edges.sort_by(|left, right| left.0.total_cmp(&right.0)); + + let left_edge = vertical_edges.first().expect("left vertical edge").1; + let right_edge = vertical_edges.last().expect("right vertical edge").1; + + (left_edge, right_edge) +} + +fn remap_entry_for(entries: &[TopologyRemapEntry], old_id: u32) -> &TopologyRemapEntry { + entries + .iter() + .find(|entry| entry.old_id == old_id) + .expect("remap entry") +} + +fn create_freeform_polygon_entity(id: &str) -> OGFreeformGeometry { + let mut polygon = OGPolygon::new(format!("{}-source", id)); + polygon + .set_config(vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(2.0, 0.0, 0.0), + Vector3::new(2.0, 0.0, 2.0), + Vector3::new(0.0, 0.0, 2.0), + ]) + .expect("polygon config"); + + OGFreeformGeometry::new(id.to_string(), polygon.get_local_brep_serialized()) + .expect("freeform polygon") +} + +#[test] +fn extrude_face_on_open_surface_creates_watertight_shell_and_created_ids() { + let mut entity = create_freeform_polygon_entity("entity-polygon-extrude"); + + let result = parse_edit_result( + &entity + .extrude_face(0, 1.0, None) + .expect("extrude face should return payload"), + ); + + assert!(result.validity.ok); + assert!(result.topology_changed); + + let remap = result.topology_remap.expect("topology remap"); + let face_entry = remap_entry_for(&remap.faces, 0); + assert_eq!(face_entry.new_ids, vec![0]); + assert_eq!(face_entry.primary_id, Some(0)); + assert_eq!(face_entry.status, TopologyRemapStatus::Unchanged); + + assert_eq!(remap.created_ids.vertices.len(), 4); + assert_eq!(remap.created_ids.faces.len(), 5); + assert!(!remap.created_ids.edges.is_empty()); + + assert_eq!(entity.local_brep.faces.len(), 6); + assert_eq!(entity.local_brep.shells.len(), 1); + assert!(entity.local_brep.shells[0].is_closed); +} + +#[test] +fn extrude_face_on_closed_solid_falls_back_to_push_pull_without_topology_change() { + let mut cuboid = OGCuboid::new("cuboid-extrude-solid".to_string()); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 2.0, 2.0) + .expect("cuboid config"); + + let mut entity = OGFreeformGeometry::new( + "entity-cuboid-extrude-solid".to_string(), + cuboid.get_local_brep_serialized(), + ) + .expect("freeform entity"); + + let face_id = top_face_id(&entity); + + let result = parse_edit_result( + &entity + .extrude_face(face_id, 0.5, None) + .expect("extrude face payload"), + ); + + assert!(result.validity.ok); + assert!(!result.topology_changed); + assert!(result + .validity + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "solid_face_extrude")); +} + +#[test] +fn split_edge_and_remove_vertex_emit_semantic_remap_statuses() { + let mut entity = create_freeform_polygon_entity("entity-topology-edits"); + + let split = parse_edit_result(&entity.split_edge(0, 0.5, None).expect("split edge payload")); + + assert!(split.validity.ok); + assert!(split.topology_changed); + + let split_remap = split.topology_remap.expect("split remap"); + let split_entry = remap_entry_for(&split_remap.edges, 0); + assert_eq!(split_entry.status, TopologyRemapStatus::Split); + assert_eq!(split_entry.new_ids.len(), 2); + + let inserted_vertex_id = *split_remap + .created_ids + .vertices + .first() + .expect("inserted vertex id"); + + let removed = parse_edit_result( + &entity + .remove_vertex(inserted_vertex_id, None) + .expect("remove vertex payload"), + ); + + assert!(removed.validity.ok); + assert!(removed.topology_changed); + + let removed_remap = removed.topology_remap.expect("remove remap"); + let removed_vertex_entry = remap_entry_for(&removed_remap.vertices, inserted_vertex_id); + assert_eq!(removed_vertex_entry.status, TopologyRemapStatus::Deleted); + assert!(removed_vertex_entry.new_ids.is_empty()); + assert!(removed_remap + .edges + .iter() + .any(|entry| entry.status == TopologyRemapStatus::Merged)); +} + +#[test] +fn repeated_identical_sequences_are_deterministic() { + let mut first = create_freeform_polygon_entity("entity-determinism-a"); + let mut second = create_freeform_polygon_entity("entity-determinism-b"); + + let first_split = + parse_edit_result(&first.split_edge(1, 0.3, None).expect("first split payload")); + let second_split = parse_edit_result( + &second + .split_edge(1, 0.3, None) + .expect("second split payload"), + ); + + let first_inserted = first_split + .topology_remap + .as_ref() + .expect("first remap") + .created_ids + .vertices[0]; + let second_inserted = second_split + .topology_remap + .as_ref() + .expect("second remap") + .created_ids + .vertices[0]; + + let first_remove = parse_edit_result( + &first + .remove_vertex(first_inserted, None) + .expect("first remove payload"), + ); + let second_remove = parse_edit_result( + &second + .remove_vertex(second_inserted, None) + .expect("second remove payload"), + ); + + let first_json = serde_json::to_string(&first_remove.topology_remap).expect("first remap json"); + let second_json = + serde_json::to_string(&second_remove.topology_remap).expect("second remap json"); + + assert_eq!(first_json, second_json); + + let mut first_brep: serde_json::Value = + serde_json::from_str(&first.get_local_brep_serialized()).expect("first brep json"); + let mut second_brep: serde_json::Value = + serde_json::from_str(&second.get_local_brep_serialized()).expect("second brep json"); + + first_brep["id"] = serde_json::Value::Null; + second_brep["id"] = serde_json::Value::Null; + + assert_eq!(first_brep, second_brep); +} + +#[test] +fn unsupported_topology_edit_returns_structured_error_diagnostic() { + let mut cuboid = OGCuboid::new("cuboid-unsupported".to_string()); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 2.0, 2.0) + .expect("cuboid config"); + + let mut entity = OGFreeformGeometry::new( + "entity-cuboid-unsupported".to_string(), + cuboid.get_local_brep_serialized(), + ) + .expect("freeform entity"); + + let result = parse_edit_result( + &entity + .remove_vertex(0, None) + .expect("remove vertex payload"), + ); + + assert!(!result.validity.ok); + assert!(result + .validity + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "unsupported_topology")); +} + +#[test] +fn constraints_project_move_vertex_translation_to_axis() { + let mut cuboid = OGCuboid::new("cuboid-constraints".to_string()); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 2.0, 2.0) + .expect("cuboid config"); + + let mut entity = OGFreeformGeometry::new( + "entity-cuboid-constraints".to_string(), + cuboid.get_local_brep_serialized(), + ) + .expect("freeform entity"); + + let before = parse_vertex_info(&entity.get_vertex_info(0).expect("vertex info before")); + + let options = r#"{ + "constraintAxis": { "x": 0.0, "y": 1.0, "z": 0.0 }, + "preserveCoplanarity": true + }"#; + + let result = parse_edit_result( + &entity + .move_vertex(0, Vector3::new(1.0, 0.2, 3.0), Some(options.to_string())) + .expect("move vertex payload"), + ); + + assert!(result.validity.ok); + + let after = parse_vertex_info(&entity.get_vertex_info(0).expect("vertex info after")); + + assert!((after.position.x - before.position.x).abs() < 1.0e-9); + assert!((after.position.y - before.position.y - 0.2).abs() < 1.0e-9); + assert!((after.position.z - before.position.z).abs() < 1.0e-9); + + assert!(result + .validity + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "preserve_coplanarity_requested")); +} + +#[test] +fn lean_defaults_omit_heavy_payloads_and_include_deltas() { + let mut entity = create_freeform_polygon_entity("entity-lean-defaults"); + + let result = parse_edit_result( + &entity + .move_vertex(0, Vector3::new(0.25, 0.0, 0.0), None) + .expect("move vertex payload"), + ); + + assert!(result.validity.ok); + assert!(result.brep_serialized.is_none()); + assert!(result.local_brep_serialized.is_none()); + assert!(result.geometry_serialized.is_none()); + assert!(result.outline_geometry_serialized.is_none()); + + assert!(result.topology_remap.is_some()); + assert!(result.changed_vertices.is_some()); + assert!(!result + .changed_vertices + .expect("changed vertices") + .is_empty()); +} + +#[test] +fn capability_endpoints_report_entity_and_feature_flags() { + let entity = create_freeform_polygon_entity("entity-capabilities"); + + let entity_caps: EditCapabilities = serde_json::from_str( + &entity + .get_edit_capabilities() + .expect("entity capabilities payload"), + ) + .expect("entity capabilities json"); + + assert!(entity_caps.can_insert_vertex_on_edge); + assert!(entity_caps.can_split_edge); + assert!(entity_caps.can_remove_vertex); + assert!(entity_caps.can_cut_face); + + let face_caps: FeatureEditCapabilities = serde_json::from_str( + &entity + .get_face_edit_capabilities(0) + .expect("face capabilities payload"), + ) + .expect("face capabilities json"); + + assert!(face_caps.can_extrude_face); + assert!(face_caps.can_move_face); + assert!(face_caps.can_cut_face); +} + +#[test] +fn converted_cuboid_supports_edge_split_and_returns_created_vertex() { + let mut cuboid = OGCuboid::new("cuboid-edge-split".to_string()); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 2.0, 2.0) + .expect("cuboid config"); + + let mut entity = OGFreeformGeometry::new( + "entity-cuboid-edge-split".to_string(), + cuboid.get_local_brep_serialized(), + ) + .expect("freeform entity"); + + let edge_caps: FeatureEditCapabilities = serde_json::from_str( + &entity + .get_edge_edit_capabilities(0) + .expect("edge capabilities payload"), + ) + .expect("edge capabilities json"); + assert!(edge_caps.can_split_edge); + + let result = parse_edit_result(&entity.split_edge(0, 0.5, None).expect("split edge payload")); + + assert!(result.validity.ok); + assert!(result.topology_changed); + assert_eq!( + result + .topology_remap + .as_ref() + .expect("topology remap") + .created_ids + .vertices + .len(), + 1 + ); +} + +#[test] +fn converted_cuboid_loop_cut_splits_side_ring_into_additional_faces() { + let mut cuboid = OGCuboid::new("cuboid-loop-cut".to_string()); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 2.0, 2.0) + .expect("cuboid config"); + + let mut entity = OGFreeformGeometry::new( + "entity-cuboid-loop-cut".to_string(), + cuboid.get_local_brep_serialized(), + ) + .expect("freeform entity"); + + let edge_id = vertical_edge_id(&entity); + let edge_caps: FeatureEditCapabilities = serde_json::from_str( + &entity + .get_edge_edit_capabilities(edge_id) + .expect("edge capabilities payload"), + ) + .expect("edge capabilities json"); + assert!(edge_caps.can_loop_cut); + + let result = parse_edit_result( + &entity + .loop_cut(edge_id, 0.5, None) + .expect("loop cut payload"), + ); + + assert!(result.validity.ok); + assert!(result.topology_changed); + assert_eq!(entity.local_brep.faces.len(), 10); + assert_eq!(entity.local_brep.vertices.len(), 12); + assert_eq!(entity.local_brep.shells.len(), 1); + assert!(entity.local_brep.shells[0].is_closed); + + let remap = result.topology_remap.expect("topology remap"); + assert_eq!(remap.created_ids.vertices.len(), 4); + assert_eq!(remap.created_ids.faces.len(), 4); + assert!(remap.created_ids.edges.len() >= 4); + assert_eq!( + remap + .faces + .iter() + .filter(|entry| entry.status == TopologyRemapStatus::Split) + .count(), + 4 + ); +} + +#[test] +fn converted_cuboid_cut_face_splits_only_the_selected_side_face() { + let mut cuboid = OGCuboid::new("cuboid-cut-face".to_string()); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 2.0, 2.0) + .expect("cuboid config"); + + let mut entity = OGFreeformGeometry::new( + "entity-cuboid-cut-face".to_string(), + cuboid.get_local_brep_serialized(), + ) + .expect("freeform entity"); + + let face_id = right_side_face_id(&entity); + let face_caps: FeatureEditCapabilities = serde_json::from_str( + &entity + .get_face_edit_capabilities(face_id) + .expect("face capabilities payload"), + ) + .expect("face capabilities json"); + assert!(face_caps.can_cut_face); + + let (start_edge_id, end_edge_id) = opposite_vertical_face_edges(&entity, face_id); + let result = parse_edit_result( + &entity + .cut_face(face_id, start_edge_id, 0.5, end_edge_id, 0.5, None) + .expect("cut face payload"), + ); + + assert!(result.validity.ok); + assert!(result.topology_changed); + assert_eq!(entity.local_brep.faces.len(), 7); + assert_eq!(entity.local_brep.vertices.len(), 10); + assert_eq!(entity.local_brep.shells.len(), 1); + + let remap = result.topology_remap.expect("topology remap"); + let face_entry = remap_entry_for(&remap.faces, face_id); + assert_eq!(face_entry.status, TopologyRemapStatus::Split); + assert_eq!(face_entry.new_ids.len(), 2); + assert_eq!(remap.created_ids.faces.len(), 1); + assert_eq!(remap.created_ids.vertices.len(), 2); + assert_eq!(remap.created_ids.edges.len(), 1); + + let created_face_id = remap.created_ids.faces[0]; + let original_face_info = parse_face_info(&entity.get_face_info(face_id).expect("face info")); + let created_face_info = parse_face_info( + &entity + .get_face_info(created_face_id) + .expect("created face info"), + ); + assert_eq!(original_face_info.edge_ids.len(), 4); + assert_eq!(created_face_info.edge_ids.len(), 4); +} + +#[test] +fn topology_remap_builder_supports_split_merge_and_deleted_statuses() { + let old_ids = vec![10, 11, 12, 13]; + let mut mapping = std::collections::HashMap::>::new(); + mapping.insert(10, vec![100, 101]); + mapping.insert(11, vec![200]); + mapping.insert(12, vec![200]); + mapping.insert(13, Vec::new()); + + let entries = super::remap::build_domain_entries_from_mapping(&old_ids, &mapping); + + let split_entry = remap_entry_for(&entries, 10); + assert_eq!(split_entry.status, TopologyRemapStatus::Split); + assert_eq!(split_entry.primary_id, Some(100)); + assert_eq!(split_entry.new_ids, vec![100, 101]); + + let merged_left = remap_entry_for(&entries, 11); + assert_eq!(merged_left.status, TopologyRemapStatus::Merged); + assert_eq!(merged_left.primary_id, Some(200)); + assert_eq!(merged_left.new_ids, vec![200]); + + let merged_right = remap_entry_for(&entries, 12); + assert_eq!(merged_right.status, TopologyRemapStatus::Merged); + assert_eq!(merged_right.primary_id, Some(200)); + assert_eq!(merged_right.new_ids, vec![200]); + + let deleted_entry = remap_entry_for(&entries, 13); + assert_eq!(deleted_entry.status, TopologyRemapStatus::Deleted); + assert_eq!(deleted_entry.primary_id, None); + assert!(deleted_entry.new_ids.is_empty()); +} + +#[test] +fn freeform_entity_can_be_created_from_all_native_local_breps() { + let mut line = OGLine::new("line-source".to_string()); + line.set_config(Vector3::new(-1.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)) + .expect("line config"); + + let mut polyline = OGPolyline::new("polyline-source".to_string()); + polyline + .set_config(vec![ + Vector3::new(-1.0, 0.0, -1.0), + Vector3::new(0.0, 0.0, 0.5), + Vector3::new(1.0, 0.0, 0.0), + ]) + .expect("polyline config"); + + let mut arc = OGArc::new("arc-source".to_string()); + arc.set_config( + Vector3::new(0.0, 0.0, 0.0), + 2.0, + 0.0, + std::f64::consts::PI, + 24, + ) + .expect("arc config"); + + let mut curve = OGCurve::new("curve-source".to_string()); + curve + .set_config(vec![ + Vector3::new(-1.0, 0.0, 0.0), + Vector3::new(-0.2, 0.6, 0.2), + Vector3::new(0.5, 0.3, 0.8), + Vector3::new(1.0, 0.0, 0.0), + ]) + .expect("curve config"); + + let mut rectangle = OGRectangle::new("rectangle-source".to_string()); + rectangle + .set_config(Vector3::new(0.0, 0.0, 0.0), 3.0, 2.0) + .expect("rectangle config"); + + let mut polygon = OGPolygon::new("polygon-source".to_string()); + polygon + .set_config(vec![ + Vector3::new(-1.0, 0.0, -1.0), + Vector3::new(1.0, 0.0, -1.0), + Vector3::new(1.5, 0.0, 0.5), + Vector3::new(-1.0, 0.0, 1.0), + ]) + .expect("polygon config"); + + let mut cuboid = OGCuboid::new("cuboid-source".to_string()); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 3.0, 4.0) + .expect("cuboid config"); + + let mut cylinder = OGCylinder::new("cylinder-source".to_string()); + cylinder + .set_config( + Vector3::new(0.0, 0.0, 0.0), + 1.0, + 2.0, + std::f64::consts::TAU, + 24, + ) + .expect("cylinder config"); + + let mut sphere = OGSphere::new("sphere-source".to_string()); + sphere + .set_config(Vector3::new(0.0, 0.0, 0.0), 1.25, 16, 12) + .expect("sphere config"); + + let mut wedge = OGWedge::new("wedge-source".to_string()); + wedge + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 1.5, 1.0) + .expect("wedge config"); + + let mut sweep = OGSweep::new("sweep-source".to_string()); + sweep + .set_config( + vec![ + Vector3::new(-1.0, 0.0, 0.0), + Vector3::new(0.0, 0.8, 0.3), + Vector3::new(1.0, 1.2, 0.0), + ], + vec![ + Vector3::new(-0.2, 0.0, -0.2), + Vector3::new(0.2, 0.0, -0.2), + Vector3::new(0.2, 0.0, 0.2), + Vector3::new(-0.2, 0.0, 0.2), + ], + ) + .expect("sweep config"); + + let sources = vec![ + ("line", line.get_local_brep_serialized()), + ("polyline", polyline.get_local_brep_serialized()), + ("arc", arc.get_local_brep_serialized()), + ("curve", curve.get_local_brep_serialized()), + ("rectangle", rectangle.get_local_brep_serialized()), + ("polygon", polygon.get_local_brep_serialized()), + ("cuboid", cuboid.get_local_brep_serialized()), + ("cylinder", cylinder.get_local_brep_serialized()), + ("sphere", sphere.get_local_brep_serialized()), + ("wedge", wedge.get_local_brep_serialized()), + ("sweep", sweep.get_local_brep_serialized()), + ]; + + for (label, local_brep) in sources { + let entity = OGFreeformGeometry::new(format!("freeform-{}", label), local_brep) + .unwrap_or_else(|_| panic!("{} should convert to freeform", label)); + + assert!(!entity.get_local_brep_serialized().is_empty()); + } +} diff --git a/main/opengeometry/src/freeform/topology_display.rs b/main/opengeometry/src/freeform/topology_display.rs new file mode 100644 index 0000000..b4a49f3 --- /dev/null +++ b/main/opengeometry/src/freeform/topology_display.rs @@ -0,0 +1,90 @@ +use openmaths::Vector3; + +use crate::operations::triangulate::triangulate_polygon_with_holes; + +use super::{ + OGFreeformGeometry, TopologyEdgeRenderData, TopologyFaceRenderData, TopologyRenderData, + TopologyVertexRenderData, +}; + +impl OGFreeformGeometry { + pub(super) fn build_topology_display_data(&self) -> TopologyRenderData { + let brep = self.world_brep(); + let mut faces_payload = Vec::with_capacity(brep.faces.len()); + + for face in &brep.faces { + let (face_vertices, hole_vertices) = brep.get_vertices_and_holes_by_face_id(face.id); + if face_vertices.len() < 3 { + continue; + } + + let triangles = triangulate_polygon_with_holes(&face_vertices, &hole_vertices); + let all_vertices: Vec = face_vertices + .iter() + .copied() + .chain(hole_vertices.iter().flatten().copied()) + .collect(); + + let mut positions = Vec::with_capacity(all_vertices.len() * 3); + for vertex in &all_vertices { + positions.push(vertex.x); + positions.push(vertex.y); + positions.push(vertex.z); + } + + let mut indices = Vec::with_capacity(triangles.len() * 3); + for triangle in triangles { + indices.push(triangle[0] as u32); + indices.push(triangle[1] as u32); + indices.push(triangle[2] as u32); + } + + faces_payload.push(TopologyFaceRenderData { + face_id: face.id, + positions, + indices, + }); + } + + let mut edges_payload = Vec::with_capacity(brep.edges.len()); + for edge in &brep.edges { + let Some((start, end)) = brep.get_edge_endpoints(edge.id) else { + continue; + }; + + let Some(start_vertex) = brep.vertices.get(start as usize) else { + continue; + }; + let Some(end_vertex) = brep.vertices.get(end as usize) else { + continue; + }; + + edges_payload.push(TopologyEdgeRenderData { + edge_id: edge.id, + positions: vec![ + start_vertex.position.x, + start_vertex.position.y, + start_vertex.position.z, + end_vertex.position.x, + end_vertex.position.y, + end_vertex.position.z, + ], + }); + } + + let vertices_payload = brep + .vertices + .iter() + .map(|vertex| TopologyVertexRenderData { + vertex_id: vertex.id, + position: vertex.position, + }) + .collect(); + + TopologyRenderData { + faces: faces_payload, + edges: edges_payload, + vertices: vertices_payload, + } + } +} diff --git a/main/opengeometry/src/freeform/types.rs b/main/opengeometry/src/freeform/types.rs new file mode 100644 index 0000000..c0ba8b5 --- /dev/null +++ b/main/opengeometry/src/freeform/types.rs @@ -0,0 +1,265 @@ +use std::collections::HashMap; + +use openmaths::Vector3; +use serde::{Deserialize, Serialize}; + +pub const GEOMETRY_EPSILON: f64 = 1.0e-9; +pub const FACE_AREA_EPSILON: f64 = 1.0e-12; + +#[derive(Clone, Serialize, Deserialize)] +pub struct ObjectTransformation { + pub anchor: Vector3, + pub translation: Vector3, + pub rotation: Vector3, + pub scale: Vector3, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TopologyRemapStatus { + Unchanged, + Split, + Merged, + Deleted, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct TopologyRemapEntry { + pub old_id: u32, + pub new_ids: Vec, + pub primary_id: Option, + pub status: TopologyRemapStatus, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct TopologyCreatedIds { + #[serde(default)] + pub faces: Vec, + #[serde(default)] + pub edges: Vec, + #[serde(default)] + pub vertices: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct TopologyRemap { + pub faces: Vec, + pub edges: Vec, + pub vertices: Vec, + pub created_ids: TopologyCreatedIds, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticSeverity { + Info, + Warning, + Error, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct BrepDiagnostic { + pub code: String, + pub severity: DiagnosticSeverity, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub topology_id: Option, +} + +impl BrepDiagnostic { + pub fn info(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + severity: DiagnosticSeverity::Info, + message: message.into(), + domain: None, + topology_id: None, + } + } + + pub fn warning(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + severity: DiagnosticSeverity::Warning, + message: message.into(), + domain: None, + topology_id: None, + } + } + + pub fn error(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + severity: DiagnosticSeverity::Error, + message: message.into(), + domain: None, + topology_id: None, + } + } + + pub fn with_domain(mut self, domain: impl Into, topology_id: Option) -> Self { + self.domain = Some(domain.into()); + self.topology_id = topology_id; + self + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct BrepValidity { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub healed: Option, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct FaceInfo { + pub face_id: u32, + pub centroid: Vector3, + pub normal: Vector3, + pub surface_type: String, + pub loop_ids: Vec, + pub edge_ids: Vec, + pub vertex_ids: Vec, + pub adjacent_face_ids: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct EdgeInfo { + pub edge_id: u32, + pub curve_type: String, + pub start_vertex_id: u32, + pub end_vertex_id: u32, + pub start: Vector3, + pub end: Vector3, + pub incident_face_ids: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct VertexInfo { + pub vertex_id: u32, + pub position: Vector3, + pub edge_ids: Vec, + pub face_ids: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct TopologyFaceRenderData { + pub face_id: u32, + pub positions: Vec, + pub indices: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct TopologyEdgeRenderData { + pub edge_id: u32, + pub positions: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct TopologyVertexRenderData { + pub vertex_id: u32, + pub position: Vector3, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct TopologyRenderData { + pub faces: Vec, + pub edges: Vec, + pub vertices: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct FreeformEditResult { + pub entity_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub brep_serialized: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub local_brep_serialized: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub geometry_serialized: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub outline_geometry_serialized: Option, + pub topology_changed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub topology_remap: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub changed_faces: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub changed_edges: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub changed_vertices: Option>, + pub validity: BrepValidity, + pub placement: ObjectTransformation, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct EditCapabilities { + pub can_push_pull_face: bool, + pub can_move_face: bool, + pub can_extrude_face: bool, + pub can_cut_face: bool, + pub can_move_edge: bool, + pub can_move_vertex: bool, + pub can_insert_vertex_on_edge: bool, + pub can_remove_vertex: bool, + pub can_split_edge: bool, + pub can_loop_cut: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reasons: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct FeatureEditCapabilities { + pub domain: String, + pub topology_id: u32, + pub can_push_pull_face: bool, + pub can_move_face: bool, + pub can_extrude_face: bool, + pub can_cut_face: bool, + pub can_move_edge: bool, + pub can_move_vertex: bool, + pub can_insert_vertex_on_edge: bool, + pub can_remove_vertex: bool, + pub can_split_edge: bool, + pub can_loop_cut: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reasons: Vec, +} + +#[derive(Clone, Default)] +pub(super) struct TopologyDomainJournal { + pub mapping: HashMap>, + pub created_ids: Vec, +} + +impl TopologyDomainJournal { + pub fn map(&mut self, old_id: u32, mut new_ids: Vec) { + new_ids.sort_unstable(); + new_ids.dedup(); + self.mapping.insert(old_id, new_ids); + } + + pub fn add_created(&mut self, id: u32) { + self.created_ids.push(id); + } +} + +#[derive(Clone, Default)] +pub(super) struct TopologyChangeJournal { + pub faces: TopologyDomainJournal, + pub edges: TopologyDomainJournal, + pub vertices: TopologyDomainJournal, +} + +#[derive(Default)] +pub(super) struct EditEffect { + pub diagnostics: Vec, + pub topology_journal: Option, + pub changed_faces: Vec, + pub changed_edges: Vec, + pub changed_vertices: Vec, +} diff --git a/main/opengeometry/src/freeform/validation.rs b/main/opengeometry/src/freeform/validation.rs new file mode 100644 index 0000000..e756898 --- /dev/null +++ b/main/opengeometry/src/freeform/validation.rs @@ -0,0 +1,147 @@ +use openmaths::Vector3; + +use crate::brep::Brep; +use crate::operations::triangulate::triangulate_polygon_with_holes; + +use super::{BrepDiagnostic, FACE_AREA_EPSILON, GEOMETRY_EPSILON}; + +pub(super) fn normalized(vector: Vector3) -> Option { + let length_sq = vector.x * vector.x + vector.y * vector.y + vector.z * vector.z; + if !length_sq.is_finite() || length_sq <= GEOMETRY_EPSILON * GEOMETRY_EPSILON { + return None; + } + + let inverse_length = length_sq.sqrt().recip(); + Some(Vector3::new( + vector.x * inverse_length, + vector.y * inverse_length, + vector.z * inverse_length, + )) +} + +pub(super) fn is_finite_vector(vector: Vector3) -> bool { + vector.x.is_finite() && vector.y.is_finite() && vector.z.is_finite() +} + +fn face_area(brep: &Brep, face_id: u32) -> f64 { + let (face_vertices, hole_vertices) = brep.get_vertices_and_holes_by_face_id(face_id); + if face_vertices.len() < 3 { + return 0.0; + } + + let triangles = triangulate_polygon_with_holes(&face_vertices, &hole_vertices); + let all_vertices: Vec = face_vertices + .iter() + .copied() + .chain(hole_vertices.iter().flatten().copied()) + .collect(); + + let mut area = 0.0; + for triangle in triangles { + let Some(a) = all_vertices.get(triangle[0]) else { + continue; + }; + let Some(b) = all_vertices.get(triangle[1]) else { + continue; + }; + let Some(c) = all_vertices.get(triangle[2]) else { + continue; + }; + + let ab = Vector3::new(b.x - a.x, b.y - a.y, b.z - a.z); + let ac = Vector3::new(c.x - a.x, c.y - a.y, c.z - a.z); + let cross = ab.cross(&ac); + let triangle_area = + (cross.x * cross.x + cross.y * cross.y + cross.z * cross.z).sqrt() * 0.5; + area += triangle_area; + } + + area +} + +pub(super) fn validate_geometry(brep: &Brep) -> Vec { + let mut diagnostics = Vec::new(); + + for vertex in &brep.vertices { + if !is_finite_vector(vertex.position) { + diagnostics.push( + BrepDiagnostic::error( + "non_finite_vertex", + format!("Vertex {} has non-finite coordinates", vertex.id), + ) + .with_domain("vertex", Some(vertex.id)), + ); + } + } + + for edge in &brep.edges { + let Some((start_id, end_id)) = brep.get_edge_endpoints(edge.id) else { + diagnostics.push( + BrepDiagnostic::error( + "invalid_edge_endpoints", + format!("Edge {} has invalid endpoints", edge.id), + ) + .with_domain("edge", Some(edge.id)), + ); + continue; + }; + + let Some(start) = brep + .vertices + .get(start_id as usize) + .map(|vertex| vertex.position) + else { + diagnostics.push( + BrepDiagnostic::error( + "missing_edge_vertex", + format!("Edge {} start vertex {} is missing", edge.id, start_id), + ) + .with_domain("edge", Some(edge.id)), + ); + continue; + }; + let Some(end) = brep + .vertices + .get(end_id as usize) + .map(|vertex| vertex.position) + else { + diagnostics.push( + BrepDiagnostic::error( + "missing_edge_vertex", + format!("Edge {} end vertex {} is missing", edge.id, end_id), + ) + .with_domain("edge", Some(edge.id)), + ); + continue; + }; + + let dx = start.x - end.x; + let dy = start.y - end.y; + let dz = start.z - end.z; + let length_sq = dx * dx + dy * dy + dz * dz; + if !length_sq.is_finite() || length_sq <= GEOMETRY_EPSILON * GEOMETRY_EPSILON { + diagnostics.push( + BrepDiagnostic::error( + "collapsed_edge", + format!("Edge {} collapsed to near-zero length", edge.id), + ) + .with_domain("edge", Some(edge.id)), + ); + } + } + + for face in &brep.faces { + let area = face_area(brep, face.id); + if !area.is_finite() || area <= FACE_AREA_EPSILON { + diagnostics.push( + BrepDiagnostic::error( + "degenerate_face", + format!("Face {} has near-zero area", face.id), + ) + .with_domain("face", Some(face.id)), + ); + } + } + + diagnostics +} diff --git a/main/opengeometry/src/lib.rs b/main/opengeometry/src/lib.rs index d85d701..959caa5 100644 --- a/main/opengeometry/src/lib.rs +++ b/main/opengeometry/src/lib.rs @@ -35,5 +35,7 @@ pub mod spatial { pub mod booleans; pub mod brep; +pub mod editor; pub mod export; +pub mod freeform; pub mod scenegraph;