diff --git a/docs/constraint-engine.md b/docs/constraint-engine.md index a3bdeb67..9583c3fe 100644 --- a/docs/constraint-engine.md +++ b/docs/constraint-engine.md @@ -4,7 +4,7 @@ The constraint system has two layers: user-facing constraints (persisted, visibl ## User-Facing Constraints -Currently there are two user-facing types: `LinearConstraint` (target distance between two endpoints) and `PerpendicularConstraint` (right angle between three points — center, A, and B, where center→A ⟂ center→B). +Currently there are two user-facing types: `LinearConstraint` (target distance between two endpoints) and `PerpendicularConstraint` (right angle between three points -- center, A, and B, where center->A is perpendicular to center->B). **Endpoints** (`ConstraintEndpoint`) can be: @@ -52,7 +52,7 @@ Triggered by `ReconstrainAction` (key R) or programmatically: - Fixed point pins for vertices that should not move 2. `gradientDescent()` runs the solver 3. `DCELShapeIndex.computeShapesForVertexId()` maps solved vertex positions back to shape-level updates (polygon point indices, rectangle corners, ellipse key points) -4. Each shape is updated via `updatePolygon()`/`updateRectangle()`/`updateEllipse()` +4. Each shape is updated via `GeometryComponent.update` with the new geometry data 5. DCEL is immediatly resynced for all touched shapes 6. Entire solve is wrapped in a single history transaction diff --git a/docs/geometry-store.md b/docs/geometry-store.md index df010a20..175857bb 100644 --- a/docs/geometry-store.md +++ b/docs/geometry-store.md @@ -4,12 +4,30 @@ ## What It Owns -- **All geometries**: stored in `geometryById: Map`. Use `listWithComponent`/`listWithComponents` to query by component type. +- **All geometries**: stored in `geometryById: Map`. Use `listWithComponent`/`listWithComponents` to query by component. - **Working (transient) shapes**: `workingPolygon`, `workingRectangle`, `workingEllipse`, `workingConstraints` -- these represent shapes being actively drawn, NOT persisted - **DCEL index**: `dcelIndex: DCELShapeIndex` for planar subdivision queries All shapes use ID prefixes: `ply_` (polygon), `rct_` (rectangle), `elp_` (ellipse), `cns_` (constraint). IDs are generated by `HistoryManager.generateStableId()` which uses UUIDs plus a monotonic counter to prevent collisions across load/save cycles. +## Unified Component System + +All geometry data is stored via a single `GeometryComponent` (`src/lib/entity/components/GeometryComponent.ts`). Each geometry entity has exactly one geometry component whose data is a discriminated union: + +```ts +type GeometryData = PolygonData | RectangleData | EllipseData; +``` + +Each `*Data` type has a `type` discriminator: + +- `PolygonData` — `{ type: 'polygon', points, closed, openAtIndex, lastFillColor? }` +- `RectangleData` — `{ type: 'rectangle', upperLeft, lowerRight }` +- `EllipseData` — `{ type: 'ellipse', center, radiusX, radiusY }` + +Read geometry data with `GeometryComponent.get(entity)`, which returns a `GeometryData`. Use `switch (data.type)` to discriminate. Update with `GeometryComponent.update(entity, partial)`. + +The per-shape component types (`PolygonComponent`, `RectangleComponent`, `EllipseComponent`) are deprecated and used only internally by the old component definition files. + ## CRUD Methods All operations are unified by component detection rather than shape-specific methods: @@ -18,7 +36,7 @@ All operations are unified by component detection rather than shape-specific met | --------------------------- | ------------------------- | -------------------------------------------------- | | Insert | `add(idPrefix, template)` | `addDirect(geometry)` | | Update | `updateById(id, fn)` | `updateByIdDirect(id, fn)` | -| Update (narrowed component) | — | `updateByIdWithComponentDirect(id, component, fn)` | +| Update (narrowed component) | -- | `updateByIdWithComponentDirect(id, component, fn)` | | Delete | `delete(id)` | `deleteDirect(id)` | The history-recording methods call Direct internally, then record the operation to HistoryManager. HistoryManager uses only Direct methods during undo/redo replay to avoid infinite recording loops. @@ -27,13 +45,15 @@ Direct methods emit `geometryAdded`/`geometryUpdated`/`geometryDeleted` events s ## Query Methods -| Method | Returns | -| ----------------------------------------- | ----------------------------------------------------------------------------- | -| `listWithComponent(component)` | `Array>` — all geometries with that component | -| `listWithComponents(a, b, c?, d?)` | `Array>` — all geometries with multiple components | -| `getById(id)` | `Geometry \| Polygon \| null` | -| `getByIdWithComponent(id, component)` | `Geometry \| null` — narrowed by component | -| `getByIdWithComponents(id, a, b, c?, d?)` | `Geometry \| null` — narrowed by multiple components | +| Method | Returns | +| ----------------------------------------- | --------------------------------------------------------------------------- | +| `listWithComponent(component)` | `Array>` — all geometries with that component | +| `listWithComponents(a, b, c?, d?)` | `Array>` — all geometries with multiple components | +| `getById(id)` | `Entity \| null` | +| `getByIdWithComponent(id, component)` | `Entity \| null` — narrowed by component | +| `getByIdWithComponents(id, a, b, c?, d?)` | `Entity \| null` — narrowed by multiple components | + +To get all geometry entities: `store.listWithComponent(GeometryComponent)` returns `Array`. ## DCEL Sync @@ -47,18 +67,18 @@ Emitted on `EventEmitter`: | Event | Payload | | ------------------- | --------------------- | -| `geometryAdded` | `geometry: Geometry` | -| `geometryUpdated` | `geometry: Geometry` | +| `geometryAdded` | `geometry: Entity` | +| `geometryUpdated` | `geometry: Entity` | | `geometryDeleted` | `geometryId: Id` | | `workingXxxChanged` | working shape or null | -Legacy shape-specific events (`polygonsChanged`, `rectanglesChanged`, `ellipsesChanged`, etc.) have been removed. Subscribe to `geometryAdded`/`geometryUpdated`/`geometryDeleted` instead and re-query with `listWithComponent`. +Subscribe to `geometryAdded`/`geometryUpdated`/`geometryDeleted` and re-query with `listWithComponent` as needed. ## Constraint Endpoint Resolution `resolveConstraintEndpoint(endpoint)` handles four endpoint types: - `"point"` -- returns the literal `SheetPosition` -- `"locked-rectangle"` -- looks up rectangle, computes corner via `rectCorners()` -- `"locked-ellipse"` -- looks up ellipse, computes key point via `ellipsePoints()` -- `"locked-polygon"` -- looks up polygon, returns the indexed segment's point +- `"locked-rectangle"` -- looks up geometry, computes corner via `GeometryComponent.keyPoints` +- `"locked-ellipse"` -- looks up geometry, computes key point via `GeometryComponent.keyPoints` +- `"locked-polygon"` -- looks up geometry, returns the indexed segment's point diff --git a/docs/history-management.md b/docs/history-management.md index d9d0eea7..7978eba4 100644 --- a/docs/history-management.md +++ b/docs/history-management.md @@ -4,13 +4,13 @@ ## Undo/Redo Stacks -Two LIFO stacks: `undoStack` and `redoStack` of `UndoEntry` discriminated union types. There are ~25 entry types covering every undoable operation (generic geometry insert/delete/fillColor/renderOrder/linkDimensions, polygon-specific move/vertex ops, rectangle/ellipse move, constraint CRUD, etc.). +Two LIFO stacks: `undoStack` and `redoStack` of `UndoEntry` discriminated union types. There are ~25 entry types covering every undoable operation (generic geometry insert/delete/fillColor/renderOrder/linkDimensions, polygon-specific move/vertex ops, shape move, constraint CRUD, etc.). - `undo()`: pop from undoStack, apply reverse, push to redoStack - `redo()`: pop from redoStack, apply forward, push to undoStack - Both emit `stacksChange` event (used by UndoAction/RedoAction to enable/disable buttons) -## Unified Entry Types +## Entry Types Historically there were separate entry types per shape (`PolygonInsertEntry`, `RectangleInsertEntry`, `EllipseInsertEntry`). These have been unified into a single set of entry types that operate on `Geometry` directly: @@ -22,9 +22,9 @@ Historically there were separate entry types per shape (`PolygonInsertEntry`, `R | RenderOrder | `polygon-render-order` / `rectangle-render-order` / `ellipse-render-order` | `render-order` | | LinkDimensions | `rectangle-link-dimensions` / `ellipse-link-dimensions` | `link-dimensions` | -Move entries (`polygon-move`, `rectangle-move`, `ellipse-move`) remain separate since they store different data shapes (segment arrays vs `{upperLeft, lowerRight}` vs `{center, radiusX, radiusY}`). +Move entries (`polygon-move`, `rectangle-move`, `ellipse-move`) remain separate since they store different data shapes (segment arrays vs `RectangleData` vs `EllipseData`). -Move entries store **raw component data** rather than geometry wrappers — e.g., `rectangle-move` stores `before: RectangleComponent[keyof RectangleComponent]` (which is `{ upperLeft, lowerRight }`), not a full `Geometry`. +Move entries store **raw geometry state data** rather than full entities -- e.g., `rectangle-move` stores `before: RectangleData` and `after: RectangleData`, not full `Entity>`. Factory functions: `UndoEntry.insert(geometry)`, `UndoEntry.deleteGeometry(geometry)`, `UndoEntry.fillColor(id, before, after)`, etc. @@ -54,6 +54,8 @@ The `applyForward`/`applyReverse` switch statements use only **Direct** methods This prevents infinite loops (HistoryManager -> GeometryStore -> HistoryManager -> ...). +When updating polygon geometry in handlers, always use `GeometryComponent.isPolygon(old)` guards inside the `updateByIdWithComponentDirect` callback rather than casting with `as Entity>`. + ## State Serialization Full undo/redo stacks are serialized into the SVG file's magic state comment (``). The `stableIdCounter` is also preserved to prevent ID collisions after load. diff --git a/docs/layers.md b/docs/layers.md index fcbe249f..1a81b8ea 100644 --- a/docs/layers.md +++ b/docs/layers.md @@ -26,14 +26,9 @@ Within each layer, shapes are sorted ascending by `renderOrder: number`. New sha | Raise to Top | `renderOrder = 0` | | Lower to Bottom | `renderOrder = getMaxRenderOrder()` | -## Per-Shape-Type Rendering +## Rendering -Each renderer file exports `ListLayers` or `SingleLayers` maps from `RendererLayers` to rendering components. For example, `PolygonLayers[Solids]` renders ``, `PolygonLayers[Overlays]` renders ``. - -The `ViewportRenderer2D` composes all layers by iterating the layer order and for each layer, rendering: - -1. `ListLayersRenderer` -- iterates completed shapes (polygons, rectangles, ellipses) sorted by renderOrder -2. `SingleLayerRenderer`s -- global overlays (constraints, working shapes, DCEL debug) +All geometries are rendered by `GeometryRenderer.tsx` which iterates entities from the store, reads their `GeometryComponent` data, and dispatches to per-type draw routines via `switch (data.type)`. ## State Not Serialized diff --git a/src/__tests__/ApplyFilterToGeometryAction.test.ts b/src/__tests__/ApplyFilterToGeometryAction.test.ts new file mode 100644 index 00000000..13ec0163 --- /dev/null +++ b/src/__tests__/ApplyFilterToGeometryAction.test.ts @@ -0,0 +1,576 @@ +import { ActionsManager } from '@/lib/actions/ActionsManager'; +import { + ConstraintComponent, + type CubicBezierSegment, + GeometryComponent, + PointSegment, + Polygon, + Rectangle, +} from '@/lib/entity'; +import { GeometryStore, ID_PREFIXES } from '@/lib/entity/GeometryStore'; +import { DEFAULT_COLOR } from '@/lib/entity/colors'; +import { FilterComponent } from '@/lib/entity/components/FilterComponent'; +import { FilletFilter } from '@/lib/entity/filters/fillet'; +import { HistoryManager } from '@/lib/history/HistoryManager'; +import { Sheet } from '@/lib/sheet/Sheet'; +import { SelectionManager } from '@/lib/tools/SelectionManager'; +import { Length } from '@/lib/units/length'; +import { SheetPosition } from '@/lib/viewport/types'; + +function makePoint(x: number, y: number): PointSegment { + return { type: 'point', point: new SheetPosition(x, y) }; +} + +describe('ApplyFilterToGeometryAction', () => { + let historyManager: HistoryManager; + let geometryStore: GeometryStore; + let selectionManager: SelectionManager; + let actionsManager: ActionsManager; + + beforeEach(() => { + const sheet = Sheet.a4(); + historyManager = new HistoryManager(); + geometryStore = new GeometryStore(historyManager); + historyManager.setGeometryStore(geometryStore); + selectionManager = new SelectionManager(); + actionsManager = new ActionsManager(sheet, geometryStore, selectionManager, historyManager); + historyManager.setGeometryStore(geometryStore); + }); + + describe('Rectangle', () => { + let rect: Rectangle; + + beforeEach(() => { + rect = geometryStore.addOrdered( + ID_PREFIXES.rectangle, + Rectangle.create(new SheetPosition(0, 0), new SheetPosition(100, 100), { + fillColor: DEFAULT_COLOR, + linkDimensions: false, + }), + ) as Rectangle; + }); + + it('applies upperRight fillet to rectangle', async () => { + const filterId = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rect.id, + 'lowerRight', + 'upperRight', + 'upperLeft', + Length.centimeters(20), + ), + ).id; + selectionManager.select(filterId); + await actionsManager.execute('apply-filter-to-geometry'); + + const polygons = geometryStore.listWithComponent(GeometryComponent); + expect(polygons).toHaveLength(1); + + const points = GeometryComponent.get(polygons[0]).points; + expect(points.length).toBe(6); + + // Point segments at indices 0,1,3,4,5 + expect(points[0].type).toBe('point'); + expect(points[1].type).toBe('point'); + expect(points[2].type).toBe('arc-cubic'); + expect(points[3].type).toBe('point'); + expect(points[4].type).toBe('point'); + expect(points[5].type).toBe('point'); + + // Point positions: UL -> split(80,0) -> arc -> LR -> LL -> UL + expect(points[0].point.x).toBeCloseTo(0); + expect(points[0].point.y).toBeCloseTo(0); + expect(points[1].point.x).toBeCloseTo(80); + expect(points[1].point.y).toBeCloseTo(0); + expect(points[3].point.x).toBeCloseTo(100); + expect(points[3].point.y).toBeCloseTo(100); + expect(points[4].point.x).toBeCloseTo(0); + expect(points[4].point.y).toBeCloseTo(100); + expect(points[5].point.x).toBeCloseTo(0); + expect(points[5].point.y).toBeCloseTo(0); + expect(GeometryComponent.get(polygons[0]).closed).toBe(true); + + // Arc destination = splitB on the vertical edge (100, 20) + const arc = points[2] as CubicBezierSegment; + expect(arc.point.x).toBeCloseTo(100); + expect(arc.point.y).toBeCloseTo(20); + + // Control points should be non-trivial (tangent to both edges) + expect(arc.controlPointA.x).toBeCloseTo(91.05, 2); + expect(arc.controlPointA.y).toBeCloseTo(0, 2); + expect(arc.controlPointB.x).toBeCloseTo(100); + expect(arc.controlPointB.y).toBeCloseTo(8.95, 2); + + // Make sure rectangle horizontal/vertical constraints are attached to the right indexes + const constraintGeoms = geometryStore.findConstraintsByGeometryId(polygons[0].id); + expect( + constraintGeoms + .filter((g) => ConstraintComponent.get(g).type === 'horizontal') + .map((g) => { + const c = ConstraintComponent.get(g); + if (c.pointA.type !== 'locked-polygon') { + throw new Error(`Constraint ${JSON.stringify(c)} point a not locked-polygon!`); + } + if (c.pointB.type !== 'locked-polygon') { + throw new Error(`Constraint ${JSON.stringify(c)} point b not locked-polygon!`); + } + return `${c.pointA.pointIndex},${c.pointB.pointIndex}`; + }) + .sort(), + ).toEqual(['0,1', '3,4']); + expect( + constraintGeoms + .filter((g) => ConstraintComponent.get(g).type === 'vertical') + .map((g) => { + const c = ConstraintComponent.get(g); + if (c.pointA.type !== 'locked-polygon') { + throw new Error(`Constraint ${JSON.stringify(c)} point a not locked-polygon!`); + } + if (c.pointB.type !== 'locked-polygon') { + throw new Error(`Constraint ${JSON.stringify(c)} point b not locked-polygon!`); + } + return `${c.pointA.pointIndex},${c.pointB.pointIndex}`; + }) + .sort(), + ).toEqual(['2,3', '4,0']); + }); + + it('applies lowerRight fillet to rectangle', async () => { + const filterId = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rect.id, + 'lowerLeft', + 'lowerRight', + 'upperRight', + Length.centimeters(20), + ), + ).id; + selectionManager.select(filterId); + await actionsManager.execute('apply-filter-to-geometry'); + + const polygons = geometryStore.listWithComponent(GeometryComponent); + expect(polygons).toHaveLength(1); + + const points = GeometryComponent.get(polygons[0]).points; + expect(points.length).toBe(6); + + expect(points[0].type).toBe('point'); + expect(points[1].type).toBe('point'); + expect(points[2].type).toBe('point'); + expect(points[3].type).toBe('arc-cubic'); + expect(points[4].type).toBe('point'); + expect(points[5].type).toBe('point'); + + // Polygon: UL -> UR -> split(100,80) -> arc -> LL -> UL + expect(points[0].point.x).toBeCloseTo(0); + expect(points[0].point.y).toBeCloseTo(0); + expect(points[1].point.x).toBeCloseTo(100); + expect(points[1].point.y).toBeCloseTo(0); + expect(points[2].point.x).toBeCloseTo(100); + expect(points[2].point.y).toBeCloseTo(80); + expect(points[4].point.x).toBeCloseTo(0); + expect(points[4].point.y).toBeCloseTo(100); + expect(points[5].point.x).toBeCloseTo(0); + expect(points[5].point.y).toBeCloseTo(0); + expect(GeometryComponent.get(polygons[0]).closed).toBe(true); + + const arc = points[3] as CubicBezierSegment; + expect(arc.point.x).toBeCloseTo(80); + expect(arc.point.y).toBeCloseTo(100); + + expect(arc.controlPointA.x).toBeCloseTo(100, 2); + expect(arc.controlPointA.y).toBeCloseTo(91.05, 2); + expect(arc.controlPointB.x).toBeCloseTo(91.05, 2); + expect(arc.controlPointB.y).toBeCloseTo(100); + + // Make sure rectangle horizontal/vertical constraints are attached to the right indexes + const constraintGeoms = geometryStore.findConstraintsByGeometryId(polygons[0].id); + expect( + constraintGeoms + .flatMap((g) => { + const c = ConstraintComponent.get(g); + if (c.type !== 'horizontal') { + return []; + } + if (c.pointA.type !== 'locked-polygon' || c.pointB.type !== 'locked-polygon') { + return []; + } + return [`${c.pointA.pointIndex},${c.pointB.pointIndex}`]; + }) + .sort(), + ).toEqual(['0,1', '3,4']); + expect( + constraintGeoms + .flatMap((g) => { + const c = ConstraintComponent.get(g); + if (c.type !== 'vertical') { + return []; + } + if (c.pointA.type !== 'locked-polygon' || c.pointB.type !== 'locked-polygon') { + return []; + } + return [`${c.pointA.pointIndex},${c.pointB.pointIndex}`]; + }) + .sort(), + ).toEqual(['1,2', '4,0']); + }); + + it('applies lowerLeft fillet to rectangle', async () => { + const filterId = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rect.id, + 'lowerRight', + 'lowerLeft', + 'upperLeft', + Length.centimeters(20), + ), + ).id; + selectionManager.select(filterId); + await actionsManager.execute('apply-filter-to-geometry'); + + const polygons = geometryStore.listWithComponent(GeometryComponent); + expect(polygons).toHaveLength(1); + + const points = GeometryComponent.get(polygons[0]).points; + expect(points.length).toBe(6); + + expect(points[0].type).toBe('point'); + expect(points[1].type).toBe('point'); + expect(points[2].type).toBe('point'); + expect(points[3].type).toBe('point'); + expect(points[4].type).toBe('arc-cubic'); + expect(points[5].type).toBe('point'); + + // Polygon: UL -> UR -> LR -> split(20,100) -> arc -> UL + expect(points[0].point.x).toBeCloseTo(0); + expect(points[0].point.y).toBeCloseTo(0); + expect(points[1].point.x).toBeCloseTo(100); + expect(points[1].point.y).toBeCloseTo(0); + expect(points[2].point.x).toBeCloseTo(100); + expect(points[2].point.y).toBeCloseTo(100); + expect(points[3].point.x).toBeCloseTo(20); + expect(points[3].point.y).toBeCloseTo(100); + expect(points[5].point.x).toBeCloseTo(0); + expect(points[5].point.y).toBeCloseTo(0); + expect(GeometryComponent.get(polygons[0]).closed).toBe(true); + + const arc = points[4] as CubicBezierSegment; + expect(arc.point.x).toBeCloseTo(0); + expect(arc.point.y).toBeCloseTo(80); + + expect(arc.controlPointA.x).toBeCloseTo(8.95, 2); + expect(arc.controlPointA.y).toBeCloseTo(100, 2); + expect(arc.controlPointB.x).toBeCloseTo(0, 2); + expect(arc.controlPointB.y).toBeCloseTo(91.05, 2); + + // Make sure rectangle horizontal/vertical constraints are attached to the right indexes + const constraintGeoms = geometryStore.findConstraintsByGeometryId(polygons[0].id); + expect( + constraintGeoms + .flatMap((g) => { + const c = ConstraintComponent.get(g); + if (c.type !== 'horizontal') { + return []; + } + if (c.pointA.type !== 'locked-polygon' || c.pointB.type !== 'locked-polygon') { + return []; + } + return [`${c.pointA.pointIndex},${c.pointB.pointIndex}`]; + }) + .sort(), + ).toEqual(['0,1', '2,3']); + expect( + constraintGeoms + .flatMap((g) => { + const c = ConstraintComponent.get(g); + if (c.type !== 'vertical') { + return []; + } + if (c.pointA.type !== 'locked-polygon' || c.pointB.type !== 'locked-polygon') { + return []; + } + return [`${c.pointA.pointIndex},${c.pointB.pointIndex}`]; + }) + .sort(), + ).toEqual(['1,2', '4,0']); + }); + + it('applies upperLeft fillet to rectangle', async () => { + const filterId = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rect.id, + 'lowerLeft', + 'upperLeft', + 'upperRight', + Length.centimeters(20), + ), + ).id; + selectionManager.select(filterId); + await actionsManager.execute('apply-filter-to-geometry'); + + const polygons = geometryStore.listWithComponent(GeometryComponent); + expect(polygons).toHaveLength(1); + + const points = GeometryComponent.get(polygons[0]).points; + expect(points.length).toBe(6); + + expect(points[0].type).toBe('point'); + expect(points[1].type).toBe('point'); + expect(points[2].type).toBe('point'); + expect(points[3].type).toBe('point'); + expect(points[4].type).toBe('point'); + expect(points[5].type).toBe('arc-cubic'); + + // Polygon: split(20,0) -> UR -> LR -> LL -> split(0,20) -> arc -> split(20,0) closed + // Does NOT start at UL(0,0) — wrapping case shifts the polygon start + expect(points[0].point.x).toBeCloseTo(20); + expect(points[0].point.y).toBeCloseTo(0); + expect(points[1].point.x).toBeCloseTo(100); + expect(points[1].point.y).toBeCloseTo(0); + expect(points[2].point.x).toBeCloseTo(100); + expect(points[2].point.y).toBeCloseTo(100); + expect(points[3].point.x).toBeCloseTo(0); + expect(points[3].point.y).toBeCloseTo(100); + expect(points[4].point.x).toBeCloseTo(0); + expect(points[4].point.y).toBeCloseTo(20); + expect(GeometryComponent.get(polygons[0]).closed).toBe(true); + + const arc = points[5] as CubicBezierSegment; + expect(arc.point.x).toBeCloseTo(20); + expect(arc.point.y).toBeCloseTo(0); + + expect(arc.controlPointA.x).toBeCloseTo(0, 2); + expect(arc.controlPointA.y).toBeCloseTo(8.95, 2); + expect(arc.controlPointB.x).toBeCloseTo(8.95, 2); + expect(arc.controlPointB.y).toBeCloseTo(0, 2); + + // Make sure rectangle horizontal/vertical constraints are attached to the right indexes + const constraintGeoms = geometryStore.findConstraintsByGeometryId(polygons[0].id); + expect( + constraintGeoms + .flatMap((g) => { + const c = ConstraintComponent.get(g); + if (c.type !== 'horizontal') { + return []; + } + if (c.pointA.type !== 'locked-polygon' || c.pointB.type !== 'locked-polygon') { + return []; + } + return [`${c.pointA.pointIndex},${c.pointB.pointIndex}`]; + }) + .sort(), + ).toEqual(['0,1', '2,3']); + expect( + constraintGeoms + .flatMap((g) => { + const c = ConstraintComponent.get(g); + if (c.type !== 'vertical') { + return []; + } + if (c.pointA.type !== 'locked-polygon' || c.pointB.type !== 'locked-polygon') { + return []; + } + return [`${c.pointA.pointIndex},${c.pointB.pointIndex}`]; + }) + .sort(), + ).toEqual(['1,2', '3,4']); + }); + + it('applies two fillets sequentially to rectangle', async () => { + // Create both filters on the same rectangle + const filter1Id = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rect.id, + 'lowerRight', + 'upperRight', + 'upperLeft', + Length.centimeters(20), + ), + ).id; + const filter2Id = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rect.id, + 'lowerLeft', + 'lowerRight', + 'upperRight', + Length.centimeters(20), + ), + ).id; + + // Apply first filter (upperRight) + selectionManager.select(filter1Id); + await actionsManager.execute('apply-filter-to-geometry'); + + let polygons = geometryStore.listWithComponent(GeometryComponent); + expect(polygons).toHaveLength(1); + + let points = GeometryComponent.get(polygons[0]).points; + expect(points.length).toBe(6); + + // Point segments at indices 0,1,3,4,5 + expect(points[0].type).toBe('point'); + expect(points[1].type).toBe('point'); + expect(points[2].type).toBe('arc-cubic'); + expect(points[3].type).toBe('point'); + expect(points[4].type).toBe('point'); + expect(points[5].type).toBe('point'); + + // Arc destination = splitB on the vertical edge (100, 20) + const arcA = points[2] as CubicBezierSegment; + expect(arcA.point.x).toBeCloseTo(100); + expect(arcA.point.y).toBeCloseTo(20); + + expect(arcA.controlPointA.x).toBeCloseTo(91.05, 2); + expect(arcA.controlPointA.y).toBeCloseTo(0, 2); + expect(arcA.controlPointB.x).toBeCloseTo(100); + expect(arcA.controlPointB.y).toBeCloseTo(8.95, 2); + + // Make sure filter2 was updated to point to newly created polygon + const filter2 = geometryStore.getByIdWithComponent(filter2Id, FilterComponent)!; + expect(FilterComponent.get(filter2).geometryId).toStrictEqual(polygons[0].id); + expect((FilterComponent.get(filter2) as any).geometryType).toStrictEqual('polygon'); + + // Apply second filter (lowerRight) + selectionManager.deselect(filter1Id); + selectionManager.select(filter2Id); + await actionsManager.execute('apply-filter-to-geometry'); + + polygons = geometryStore.listWithComponent(GeometryComponent); + expect(polygons).toHaveLength(1); + + // After clicking the lower right corner, there should be another new arc added + points = GeometryComponent.get(polygons[0]).points; + expect(points.length).toBe(7); + + // Point segments at indices 0,1,3,4,5 + expect(points[0].type).toBe('point'); + expect(points[1].type).toBe('point'); + expect(points[2].type).toBe('arc-cubic'); + expect(points[3].type).toBe('point'); + expect(points[4].type).toBe('arc-cubic'); + expect(points[5].type).toBe('point'); + expect(points[6].type).toBe('point'); + + const arcB = points[4] as CubicBezierSegment; + expect(arcB.controlPointA.x).toBeCloseTo(100, 2); + expect(arcB.controlPointA.y).toBeCloseTo(91.05, 2); + expect(arcB.controlPointB.x).toBeCloseTo(91.05, 2); + expect(arcB.controlPointB.y).toBeCloseTo(100); + }); + }); + + describe('Polygon', () => { + it('applies fillet to polygon middle point', async () => { + geometryStore.addOrdered( + ID_PREFIXES.polygon, + Polygon.create([makePoint(0, 0), makePoint(100, 0), makePoint(100, 100), makePoint(0, 0)], { + closed: true, + }), + ); + const polygonId = geometryStore.listWithComponent(GeometryComponent)[0].id; + + const filterId = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnPolygon(polygonId, 0, 1, 2, Length.centimeters(20)), + ).id; + selectionManager.select(filterId); + await actionsManager.execute('apply-filter-to-geometry'); + + const polygons = geometryStore.listWithComponent(GeometryComponent); + expect(polygons).toHaveLength(1); + + const points = GeometryComponent.get(polygons[0]).points; + expect(points.length).toBe(5); + + // Point segments at indices 0,1,3,4,5 + expect(points[0].type).toBe('point'); + expect(points[1].type).toBe('point'); + expect(points[2].type).toBe('arc-cubic'); + expect(points[3].type).toBe('point'); + expect(points[4].type).toBe('point'); + + // Point positions: (0,0) -> split(80,0) -> arc -> (100,100) -> (0,0) + expect(points[0].point.x).toBeCloseTo(0); + expect(points[0].point.y).toBeCloseTo(0); + expect(points[1].point.x).toBeCloseTo(80); + expect(points[1].point.y).toBeCloseTo(0); + expect(points[3].point.x).toBeCloseTo(100); + expect(points[3].point.y).toBeCloseTo(100); + expect(points[4].point.x).toBeCloseTo(0); + expect(points[4].point.y).toBeCloseTo(0); + expect(GeometryComponent.get(polygons[0]).closed).toBe(true); + + // Arc destination = splitB on the vertical edge (100, 20) + const arc = points[2] as CubicBezierSegment; + expect(arc.point.x).toBeCloseTo(100); + expect(arc.point.y).toBeCloseTo(20); + + // Control points should be non-trivial (tangent to both edges) + expect(arc.controlPointA.x).toBeCloseTo(91.05, 2); + expect(arc.controlPointA.y).toBeCloseTo(0, 2); + expect(arc.controlPointB.x).toBeCloseTo(100); + expect(arc.controlPointB.y).toBeCloseTo(8.95, 2); + }); + + it('applies fillet to polygon starting point (wrap)', async () => { + geometryStore.addOrdered( + ID_PREFIXES.polygon, + Polygon.create( + [makePoint(100, 0), makePoint(100, 100), makePoint(0, 0), makePoint(100, 0)], + { closed: true }, + ), + ); + const polygonId = geometryStore.listWithComponent(GeometryComponent)[0].id; + + const filterId = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnPolygon(polygonId, 2, 0, 1, Length.centimeters(20)), + ).id; + selectionManager.select(filterId); + await actionsManager.execute('apply-filter-to-geometry'); + + const polygons = geometryStore.listWithComponent(GeometryComponent); + expect(polygons).toHaveLength(1); + + const points = GeometryComponent.get(polygons[0]).points; + expect(points.length).toBe(5); + + // Point segments at indices 0,1,2,3 + expect(points[0].type).toBe('point'); + expect(points[1].type).toBe('point'); + expect(points[2].type).toBe('point'); + expect(points[3].type).toBe('point'); + expect(points[4].type).toBe('arc-cubic'); + + // Point positions: UL -> split(80,0) -> arc -> LR -> LL -> UL + expect(points[0].point.x).toBeCloseTo(100); + expect(points[0].point.y).toBeCloseTo(20); + expect(points[1].point.x).toBeCloseTo(100); + expect(points[1].point.y).toBeCloseTo(100); + expect(points[2].point.x).toBeCloseTo(0); + expect(points[2].point.y).toBeCloseTo(0); + expect(points[3].point.x).toBeCloseTo(80); + expect(points[3].point.y).toBeCloseTo(0); + expect(points[4].point.x).toBeCloseTo(100); + expect(points[4].point.y).toBeCloseTo(20); + expect(GeometryComponent.get(polygons[0]).closed).toBe(true); + + // Arc destination = splitB on the vertical edge (100, 20) + const arc = points[4] as CubicBezierSegment; + expect(arc.point.x).toBeCloseTo(100); + expect(arc.point.y).toBeCloseTo(20); + + // Control points should be non-trivial (tangent to both edges) + expect(arc.controlPointA.x).toBeCloseTo(91.05, 2); + expect(arc.controlPointA.y).toBeCloseTo(0, 2); + expect(arc.controlPointB.x).toBeCloseTo(100); + expect(arc.controlPointB.y).toBeCloseTo(8.95, 2); + }); + }); +}); diff --git a/src/__tests__/FilletTool.test.ts b/src/__tests__/FilletTool.test.ts index e98c0c2b..b8948bad 100644 --- a/src/__tests__/FilletTool.test.ts +++ b/src/__tests__/FilletTool.test.ts @@ -1,19 +1,12 @@ import { ActionsManager } from '@/lib/actions/ActionsManager'; -import { - ConstraintComponent, - ConstraintEndpoint, - type CubicBezierSegment, - GeometryComponent, - PointSegment, - Polygon, - type PolygonSegment, - Rectangle, -} from '@/lib/entity'; +import { ConstraintEndpoint, PointSegment, Polygon, Rectangle } from '@/lib/entity'; import { GeometryStore, ID_PREFIXES } from '@/lib/entity/GeometryStore'; import { DEFAULT_COLOR } from '@/lib/entity/colors'; +import { FilterComponent } from '@/lib/entity/components/FilterComponent'; +import { FilletFilter, FilletFilterData } from '@/lib/entity/filters/fillet'; import { HistoryManager } from '@/lib/history/HistoryManager'; import { SerializationManager } from '@/lib/serialization/SerializationManager'; -import { SHEET_UNITS_TO_PIXELS, Sheet } from '@/lib/sheet/Sheet'; +import { Sheet } from '@/lib/sheet/Sheet'; import { subscribeToEvents } from '@/lib/subscribe-to-events'; import { CornerState } from '@/lib/tools/BaseCornerGeometryReplacerTool'; import { FilletTool } from '@/lib/tools/FilletTool'; @@ -21,40 +14,18 @@ import { SelectionManager } from '@/lib/tools/SelectionManager'; import { ToolManager } from '@/lib/tools/ToolManager'; import { CentimetersType, Length } from '@/lib/units/length'; import { ViewportControls } from '@/lib/viewport/ViewportControls'; -import { - ScreenPosition, - SheetPosition, - type ViewportState, - WorldPosition, -} from '@/lib/viewport/types'; +import { ScreenPosition, SheetPosition, type ViewportState } from '@/lib/viewport/types'; function makePoint(x: number, y: number): PointSegment { return { type: 'point', point: new SheetPosition(x, y) }; } function sheetToScreen(x: number, y: number, viewport: ViewportState): ScreenPosition { - return new WorldPosition(x * SHEET_UNITS_TO_PIXELS, y * SHEET_UNITS_TO_PIXELS).toScreen(viewport); -} - -function clickRectangleCorner( - toolManager: ToolManager, - geometryStore: GeometryStore, - rect: Rectangle, - cornerLabel: 'upperLeft' | 'upperRight' | 'lowerRight' | 'lowerLeft', - viewport: ViewportState, -) { - const endpoint = ConstraintEndpoint.lockedToRectangle(rect.id, cornerLabel); - const pos = geometryStore.resolveConstraintEndpoint(endpoint); - if (!pos) { - throw new Error(`Could not resolve corner ${cornerLabel}`); - } - toolManager.handleMouseMove(sheetToScreen(pos.x, pos.y, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(pos.x, pos.y, viewport), viewport); - // Wait for the event to be dispatched synchronously - return pos; + return new SheetPosition(x, y).toWorld().toScreen(viewport); } describe('FilletTool', () => { + let sheet: Sheet; let historyManager: HistoryManager; let geometryStore: GeometryStore; let selectionManager: SelectionManager; @@ -65,7 +36,7 @@ describe('FilletTool', () => { let viewportControls: ViewportControls; beforeEach(() => { - const sheet = Sheet.a4(); + sheet = Sheet.a4(); historyManager = new HistoryManager(); geometryStore = new GeometryStore(historyManager); historyManager.setGeometryStore(geometryStore); @@ -91,7 +62,7 @@ describe('FilletTool', () => { filletTool = toolManager.getTool('edit').activeSubTool as FilletTool; }); - describe('Rectangle', () => { + describe('Rectangle corner clicks create filters', () => { let rect: Rectangle; beforeEach(() => { rect = geometryStore.addOrdered( @@ -100,601 +71,155 @@ describe('FilletTool', () => { fillColor: DEFAULT_COLOR, linkDimensions: false, }), - ) as Rectangle; + ); }); - it('upperRight corner: arc at index 2, polygon starts at UL', () => { - clickRectangleCorner(toolManager, geometryStore, rect, 'upperRight', viewport); - - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); + it.each(['upperRight', 'lowerRight', 'lowerLeft', 'upperLeft'] as const)( + '%s corner', + (cornerLabel) => { + const endpoint = ConstraintEndpoint.lockedToRectangle(rect.id, cornerLabel); + const pos = geometryStore.resolveConstraintEndpoint(endpoint); + if (!pos) { + throw new Error(`Could not resolve corner ${cornerLabel}`); + } - const polygons = geometryStore.listWithComponent(GeometryComponent); - expect(polygons).toHaveLength(1); - - const points = GeometryComponent.get(polygons[0]).points; - expect(points.length).toBe(6); - - // Point segments at indices 0,1,3,4,5 - expect(points[0].type).toBe('point'); - expect(points[1].type).toBe('point'); - expect(points[2].type).toBe('arc-cubic'); - expect(points[3].type).toBe('point'); - expect(points[4].type).toBe('point'); - expect(points[5].type).toBe('point'); - - // Point positions: UL -> split(80,0) -> arc -> LR -> LL -> UL - expect(points[0].point.x).toBeCloseTo(0); - expect(points[0].point.y).toBeCloseTo(0); - expect(points[1].point.x).toBeCloseTo(80); - expect(points[1].point.y).toBeCloseTo(0); - expect(points[3].point.x).toBeCloseTo(100); - expect(points[3].point.y).toBeCloseTo(100); - expect(points[4].point.x).toBeCloseTo(0); - expect(points[4].point.y).toBeCloseTo(100); - expect(points[5].point.x).toBeCloseTo(0); - expect(points[5].point.y).toBeCloseTo(0); - expect(GeometryComponent.get(polygons[0]).closed).toBe(true); - - // Arc destination = splitB on the vertical edge (100, 20) - const arc = points[2] as CubicBezierSegment; - expect(arc.point.x).toBeCloseTo(100); - expect(arc.point.y).toBeCloseTo(20); - - // Control points should be non-trivial (tangent to both edges) - expect(arc.controlPointA.x).toBeCloseTo(91.05, 2); - expect(arc.controlPointA.y).toBeCloseTo(0, 2); - expect(arc.controlPointB.x).toBeCloseTo(100); - expect(arc.controlPointB.y).toBeCloseTo(8.95, 2); - - // Make sure rectangle horizontal/vertical constraints are attached to the right indexes - const constraintGeoms = geometryStore.findConstraintsByGeometryId(polygons[0].id); - expect( - constraintGeoms - .filter((g) => ConstraintComponent.get(g).type === 'horizontal') - .map((g) => { - const c = ConstraintComponent.get(g); - if (c.pointA.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point a not locked-polygon!`); - } - if (c.pointB.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point b not locked-polygon!`); - } - return `${c.pointA.pointIndex},${c.pointB.pointIndex}`; - }) - .sort(), - ).toEqual(['0,1', '3,4']); - expect( - constraintGeoms - .filter((g) => ConstraintComponent.get(g).type === 'vertical') - .map((g) => { - const c = ConstraintComponent.get(g); - if (c.pointA.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point a not locked-polygon!`); - } - if (c.pointB.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point b not locked-polygon!`); - } - return `${c.pointA.pointIndex},${c.pointB.pointIndex}`; - }) - .sort(), - ).toEqual(['2,3', '4,0']); - }); + // Click the given corner + toolManager.handleMouseMove(sheetToScreen(pos.x, pos.y, viewport), viewport); + toolManager.handleMouseDown(sheetToScreen(pos.x, pos.y, viewport), viewport); - it('lowerRight corner: arc at index 3', () => { - clickRectangleCorner(toolManager, geometryStore, rect, 'lowerRight', viewport); + // Enter an offset distance, and commit + filletTool.onChangeCurrentOffset(Length.centimeters(20)); + filletTool.commit(); + + // Make sure filter was added + const filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + + // Make sure filter is centered at `centerEndpoint` + const filter = FilterComponent.get(filters[0]); + expect(filter.type).toStrictEqual('fillet'); + expect((filter as FilletFilterData).geometryType).toStrictEqual('rectangle'); + expect((filter as any).pointCenterKeyPoint).toStrictEqual(cornerLabel); + expect( + (filter as FilletFilterData).offset.toSheetUnits(sheet.defaultUnit).magnitude, + ).toBeCloseTo(20); + }, + ); + it('two corners in sequence', () => { + // First corner: upperRight + toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); + toolManager.handleMouseDown(sheetToScreen(100, 0, viewport), viewport); filletTool.onChangeCurrentOffset(Length.centimeters(20)); filletTool.commit(); - const polygons = geometryStore.listWithComponent(GeometryComponent); - expect(polygons).toHaveLength(1); - - const points = GeometryComponent.get(polygons[0]).points; - expect(points.length).toBe(6); - - expect(points[0].type).toBe('point'); - expect(points[1].type).toBe('point'); - expect(points[2].type).toBe('point'); - expect(points[3].type).toBe('arc-cubic'); - expect(points[4].type).toBe('point'); - expect(points[5].type).toBe('point'); - - // Polygon: UL -> UR -> split(100,80) -> arc -> LL -> UL - expect(points[0].point.x).toBeCloseTo(0); - expect(points[0].point.y).toBeCloseTo(0); - expect(points[1].point.x).toBeCloseTo(100); - expect(points[1].point.y).toBeCloseTo(0); - expect(points[2].point.x).toBeCloseTo(100); - expect(points[2].point.y).toBeCloseTo(80); - expect(points[4].point.x).toBeCloseTo(0); - expect(points[4].point.y).toBeCloseTo(100); - expect(points[5].point.x).toBeCloseTo(0); - expect(points[5].point.y).toBeCloseTo(0); - expect(GeometryComponent.get(polygons[0]).closed).toBe(true); - - const arc = points[3] as CubicBezierSegment; - expect(arc.point.x).toBeCloseTo(80); - expect(arc.point.y).toBeCloseTo(100); - - expect(arc.controlPointA.x).toBeCloseTo(100, 2); - expect(arc.controlPointA.y).toBeCloseTo(91.05, 2); - expect(arc.controlPointB.x).toBeCloseTo(91.05, 2); - expect(arc.controlPointB.y).toBeCloseTo(100); - - // Make sure rectangle horizontal/vertical constraints are attached to the right indexes - const constraintGeoms = geometryStore.findConstraintsByGeometryId(polygons[0].id); - expect( - constraintGeoms - .filter((g) => ConstraintComponent.get(g).type === 'horizontal') - .map((g) => { - const c = ConstraintComponent.get(g); - if (c.pointA.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point a not locked-polygon!`); - } - if (c.pointB.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point b not locked-polygon!`); - } - return `${c.pointA.pointIndex},${c.pointB.pointIndex}`; - }) - .sort(), - ).toEqual(['0,1', '3,4']); - expect( - constraintGeoms - .filter((g) => ConstraintComponent.get(g).type === 'vertical') - .map((g) => { - const c = ConstraintComponent.get(g); - if (c.pointA.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point a not locked-polygon!`); - } - if (c.pointB.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point b not locked-polygon!`); - } - return `${c.pointA.pointIndex},${c.pointB.pointIndex}`; - }) - .sort(), - ).toEqual(['1,2', '4,0']); - }); - - it('lowerLeft corner: arc at index 4', () => { - clickRectangleCorner(toolManager, geometryStore, rect, 'lowerLeft', viewport); + let filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + expect((FilterComponent.get(filters[0]) as any).pointCenterKeyPoint).toBe('upperRight'); + // Second corner: lowerRight + toolManager.handleMouseMove(sheetToScreen(100, 100, viewport), viewport); + toolManager.handleMouseDown(sheetToScreen(100, 100, viewport), viewport); filletTool.onChangeCurrentOffset(Length.centimeters(20)); filletTool.commit(); - const polygons = geometryStore.listWithComponent(GeometryComponent); - expect(polygons).toHaveLength(1); - - const points = GeometryComponent.get(polygons[0]).points; - expect(points.length).toBe(6); - - expect(points[0].type).toBe('point'); - expect(points[1].type).toBe('point'); - expect(points[2].type).toBe('point'); - expect(points[3].type).toBe('point'); - expect(points[4].type).toBe('arc-cubic'); - expect(points[5].type).toBe('point'); - - // Polygon: UL -> UR -> LR -> split(20,100) -> arc -> UL - expect(points[0].point.x).toBeCloseTo(0); - expect(points[0].point.y).toBeCloseTo(0); - expect(points[1].point.x).toBeCloseTo(100); - expect(points[1].point.y).toBeCloseTo(0); - expect(points[2].point.x).toBeCloseTo(100); - expect(points[2].point.y).toBeCloseTo(100); - expect(points[3].point.x).toBeCloseTo(20); - expect(points[3].point.y).toBeCloseTo(100); - expect(points[5].point.x).toBeCloseTo(0); - expect(points[5].point.y).toBeCloseTo(0); - expect(GeometryComponent.get(polygons[0]).closed).toBe(true); - - const arc = points[4] as CubicBezierSegment; - expect(arc.point.x).toBeCloseTo(0); - expect(arc.point.y).toBeCloseTo(80); - - expect(arc.controlPointA.x).toBeCloseTo(8.95, 2); - expect(arc.controlPointA.y).toBeCloseTo(100, 2); - expect(arc.controlPointB.x).toBeCloseTo(0, 2); - expect(arc.controlPointB.y).toBeCloseTo(91.05, 2); - - // Make sure rectangle horizontal/vertical constraints are attached to the right indexes - const constraintGeoms = geometryStore.findConstraintsByGeometryId(polygons[0].id); - expect( - constraintGeoms - .filter((g) => ConstraintComponent.get(g).type === 'horizontal') - .map((g) => { - const c = ConstraintComponent.get(g); - if (c.pointA.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point a not locked-polygon!`); - } - if (c.pointB.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point b not locked-polygon!`); - } - return `${c.pointA.pointIndex},${c.pointB.pointIndex}`; - }) - .sort(), - ).toEqual(['0,1', '2,3']); - expect( - constraintGeoms - .filter((g) => ConstraintComponent.get(g).type === 'vertical') - .map((g) => { - const c = ConstraintComponent.get(g); - if (c.pointA.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point a not locked-polygon!`); - } - if (c.pointB.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point b not locked-polygon!`); - } - return `${c.pointA.pointIndex},${c.pointB.pointIndex}`; - }) - .sort(), - ).toEqual(['1,2', '4,0']); + filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(2); }); + }); - it('upperLeft corner: arc at the end (index 5), polygon no longer starts at UL', () => { - clickRectangleCorner(toolManager, geometryStore, rect, 'upperLeft', viewport); - - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); - - const polygons = geometryStore.listWithComponent(GeometryComponent); - expect(polygons).toHaveLength(1); - - const points = GeometryComponent.get(polygons[0]).points; - expect(points.length).toBe(6); - - expect(points[0].type).toBe('point'); - expect(points[1].type).toBe('point'); - expect(points[2].type).toBe('point'); - expect(points[3].type).toBe('point'); - expect(points[4].type).toBe('point'); - expect(points[5].type).toBe('arc-cubic'); - - // Polygon: split(20,0) -> UR -> LR -> LL -> split(0,20) -> arc -> split(20,0) closed - // Does NOT start at UL(0,0) — wrapping case shifts the polygon start - expect(points[0].point.x).toBeCloseTo(20); - expect(points[0].point.y).toBeCloseTo(0); - expect(points[1].point.x).toBeCloseTo(100); - expect(points[1].point.y).toBeCloseTo(0); - expect(points[2].point.x).toBeCloseTo(100); - expect(points[2].point.y).toBeCloseTo(100); - expect(points[3].point.x).toBeCloseTo(0); - expect(points[3].point.y).toBeCloseTo(100); - expect(points[4].point.x).toBeCloseTo(0); - expect(points[4].point.y).toBeCloseTo(20); - expect(GeometryComponent.get(polygons[0]).closed).toBe(true); - - const arc = points[5] as CubicBezierSegment; - expect(arc.point.x).toBeCloseTo(20); - expect(arc.point.y).toBeCloseTo(0); - - expect(arc.controlPointA.x).toBeCloseTo(0, 2); - expect(arc.controlPointA.y).toBeCloseTo(8.95, 2); - expect(arc.controlPointB.x).toBeCloseTo(8.95, 2); - expect(arc.controlPointB.y).toBeCloseTo(0, 2); - - // Make sure rectangle horizontal/vertical constraints are attached to the right indexes - const constraintGeoms = geometryStore.findConstraintsByGeometryId(polygons[0].id); - expect( - constraintGeoms - .filter((g) => ConstraintComponent.get(g).type === 'horizontal') - .map((g) => { - const c = ConstraintComponent.get(g); - if (c.pointA.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point a not locked-polygon!`); - } - if (c.pointB.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point b not locked-polygon!`); - } - return `${c.pointA.pointIndex},${c.pointB.pointIndex}`; - }) - .sort(), - ).toEqual(['0,1', '2,3']); - expect( - constraintGeoms - .filter((g) => ConstraintComponent.get(g).type === 'vertical') - .map((g) => { - const c = ConstraintComponent.get(g); - if (c.pointA.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point a not locked-polygon!`); - } - if (c.pointB.type !== 'locked-polygon') { - throw new Error(`Constraint ${JSON.stringify(c)} point b not locked-polygon!`); - } - return `${c.pointA.pointIndex},${c.pointB.pointIndex}`; - }) - .sort(), - ).toEqual(['1,2', '3,4']); + describe('Commit and click interaction', () => { + let rect: Rectangle; + beforeEach(() => { + rect = geometryStore.addOrdered( + ID_PREFIXES.rectangle, + Rectangle.create(new SheetPosition(0, 0), new SheetPosition(100, 100), { + fillColor: DEFAULT_COLOR, + linkDimensions: false, + }), + ); }); - it('upperRight then lowerRight corner should create two arcs', () => { - toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(100, 0, viewport), viewport); - - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); + it('clicking before current corner commits filter and activates new corner', async () => { + const events = subscribeToEvents(filletTool, ['pendingCornerChange', 'activeCornerChange']); - let polygons = geometryStore.listWithComponent(GeometryComponent); - expect(polygons).toHaveLength(1); - - let points = GeometryComponent.get(polygons[0]).points; - expect(points.length).toBe(6); - - // Point segments at indices 0,1,3,4,5 - expect(points[0].type).toBe('point'); - expect(points[1].type).toBe('point'); - expect(points[2].type).toBe('arc-cubic'); - expect(points[3].type).toBe('point'); - expect(points[4].type).toBe('point'); - expect(points[5].type).toBe('point'); - - // Point positions: UL -> split(80,0) -> arc -> LR -> LL -> UL - expect(points[0].point.x).toBeCloseTo(0); - expect(points[0].point.y).toBeCloseTo(0); - expect(points[1].point.x).toBeCloseTo(80); - expect(points[1].point.y).toBeCloseTo(0); - expect(points[3].point.x).toBeCloseTo(100); - expect(points[3].point.y).toBeCloseTo(100); - expect(points[4].point.x).toBeCloseTo(0); - expect(points[4].point.y).toBeCloseTo(100); - expect(points[5].point.x).toBeCloseTo(0); - expect(points[5].point.y).toBeCloseTo(0); - expect(GeometryComponent.get(polygons[0]).closed).toBe(true); - - // Arc destination = splitB on the vertical edge (100, 20) - const arc = points[2] as CubicBezierSegment; - expect(arc.point.x).toBeCloseTo(100); - expect(arc.point.y).toBeCloseTo(20); - - // Now, click lower right corner + // Click to add a fillet on the lower right point toolManager.handleMouseMove(sheetToScreen(100, 100, viewport), viewport); toolManager.handleMouseDown(sheetToScreen(100, 100, viewport), viewport); + + // Set a length, but do NOT commit it filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); - polygons = geometryStore.listWithComponent(GeometryComponent); - expect(polygons).toHaveLength(1); - - // After clicking the lower right corner, there should be another new arc added - points = GeometryComponent.get(polygons[0]).points; - expect(points.length).toBe(7); - - // Point segments at indices 0,1,3,4,5 - expect(points[0].type).toBe('point'); - expect(points[1].type).toBe('point'); - expect(points[2].type).toBe('arc-cubic'); - expect(points[3].type).toBe('point'); - expect(points[4].type).toBe('arc-cubic'); - expect(points[5].type).toBe('point'); - expect(points[6].type).toBe('point'); - - // Point positions: UL -> split(80,0) -> arc -> split(80, 0) -> LR -> LL -> UL - expect(points[0].point.x).toBeCloseTo(0); - expect(points[0].point.y).toBeCloseTo(0); - expect(points[1].point.x).toBeCloseTo(80); - expect(points[1].point.y).toBeCloseTo(0); - expect(points[2].point.x).toBeCloseTo(100); // Arc A - expect(points[2].point.y).toBeCloseTo(20); - expect(points[3].point.x).toBeCloseTo(100); - expect(points[3].point.y).toBeCloseTo(80); - expect(points[4].point.x).toBeCloseTo(80); // Arc B - expect(points[4].point.y).toBeCloseTo(100); - expect(points[5].point.x).toBeCloseTo(0); - expect(points[5].point.y).toBeCloseTo(100); - expect(points[6].point.x).toBeCloseTo(0); - expect(points[6].point.y).toBeCloseTo(0); - expect(GeometryComponent.get(polygons[0]).closed).toBe(true); - - // Arc destination = splitB on the vertical edge (100, 20) - const arcA = points[2] as CubicBezierSegment; - expect(arcA.controlPointA.x).toBeCloseTo(91.05, 2); - expect(arcA.controlPointA.y).toBeCloseTo(0, 2); - expect(arcA.controlPointB.x).toBeCloseTo(100); - expect(arcA.controlPointB.y).toBeCloseTo(8.95, 2); - - const arcB = points[4] as CubicBezierSegment; - expect(arcB.controlPointA.x).toBeCloseTo(100, 2); - expect(arcB.controlPointA.y).toBeCloseTo(91.05, 2); - expect(arcB.controlPointB.x).toBeCloseTo(91.05, 2); - expect(arcB.controlPointB.y).toBeCloseTo(100); + events.clearBufferedEvents(); + + // Now hover / click on the upper left corner (before lower right) + toolManager.handleMouseMove(sheetToScreen(0, 0, viewport), viewport); + toolManager.handleMouseDown(sheetToScreen(0, 0, viewport), viewport); + + // The first corner's filter should have been committed + const filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + expect((FilterComponent.get(filters[0]) as any).pointCenterKeyPoint).toBe('lowerRight'); + + // The second fillet gets made active + // First a no-op event from the abort in commit: + let event = await events.waitFor('activeCornerChange'); + expect(event).toBeNull(); + // Then the actual event: + event = await events.waitFor('activeCornerChange'); + expect(event?.mode).toStrictEqual('rectangle'); + if (event?.mode !== 'rectangle') { + throw new Error('not rectangle'); + } + expect(event?.centerEndpoint).toStrictEqual('upperLeft'); + + // And the current offset length should have persisted + expect(filletTool.currentOffset?.type).toStrictEqual(CentimetersType); + expect(filletTool.currentOffset?.magnitude).toStrictEqual(20); }); - describe('pending hover state', () => { - it('should allow hovering over all rectangle corner points', async () => { - const events = subscribeToEvents(filletTool, ['pendingCornerChange']); + it('clicking after current corner commits filter and activates new corner', async () => { + const events = subscribeToEvents(filletTool, ['pendingCornerChange', 'activeCornerChange']); - // Hover over upper left - toolManager.handleMouseMove(sheetToScreen(0, 0, viewport), viewport); - let event = await events.waitFor('pendingCornerChange'); - - expect(event?.mode).toStrictEqual('rectangle'); - if (event?.mode !== 'rectangle') { - throw new Error('not rectangle'); - } - expect(event?.pointAEndpoint).toStrictEqual('lowerLeft'); - expect(event?.pointAPos.x).toStrictEqual(0); - expect(event?.pointAPos.y).toStrictEqual(100); - expect(event?.centerEndpoint).toStrictEqual('upperLeft'); - expect(event?.centerPos.x).toStrictEqual(0); - expect(event?.centerPos.y).toStrictEqual(0); - expect(event?.pointBEndpoint).toStrictEqual('upperRight'); - expect(event?.pointBPos.x).toStrictEqual(100); - expect(event?.pointBPos.y).toStrictEqual(0); - - // Hover over upper right - toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); - event = await events.waitFor('pendingCornerChange'); - - expect(event?.mode).toStrictEqual('rectangle'); - if (event?.mode !== 'rectangle') { - throw new Error('not rectangle'); - } - expect(event?.pointAEndpoint).toStrictEqual('lowerRight'); - expect(event?.pointAPos.x).toStrictEqual(100); - expect(event?.pointAPos.y).toStrictEqual(100); - expect(event?.centerEndpoint).toStrictEqual('upperRight'); - expect(event?.centerPos.x).toStrictEqual(100); - expect(event?.centerPos.y).toStrictEqual(0); - expect(event?.pointBEndpoint).toStrictEqual('upperLeft'); - expect(event?.pointBPos.x).toStrictEqual(0); - expect(event?.pointBPos.y).toStrictEqual(0); - - // Hover over lower right - toolManager.handleMouseMove(sheetToScreen(100, 100, viewport), viewport); - event = await events.waitFor('pendingCornerChange'); - - expect(event?.mode).toStrictEqual('rectangle'); - if (event?.mode !== 'rectangle') { - throw new Error('not rectangle'); - } - expect(event?.pointAEndpoint).toStrictEqual('lowerLeft'); - expect(event?.pointAPos.x).toStrictEqual(0); - expect(event?.pointAPos.y).toStrictEqual(100); - expect(event?.centerEndpoint).toStrictEqual('lowerRight'); - expect(event?.centerPos.x).toStrictEqual(100); - expect(event?.centerPos.y).toStrictEqual(100); - expect(event?.pointBEndpoint).toStrictEqual('upperRight'); - expect(event?.pointBPos.x).toStrictEqual(100); - expect(event?.pointBPos.y).toStrictEqual(0); - - // Hover over lower left - toolManager.handleMouseMove(sheetToScreen(0, 100, viewport), viewport); - event = await events.waitFor('pendingCornerChange'); - - expect(event?.mode).toStrictEqual('rectangle'); - if (event?.mode !== 'rectangle') { - throw new Error('not rectangle'); - } - expect(event?.pointAEndpoint).toStrictEqual('lowerRight'); - expect(event?.pointAPos.x).toStrictEqual(100); - expect(event?.pointAPos.y).toStrictEqual(100); - expect(event?.centerEndpoint).toStrictEqual('lowerLeft'); - expect(event?.centerPos.x).toStrictEqual(0); - expect(event?.centerPos.y).toStrictEqual(100); - expect(event?.pointBEndpoint).toStrictEqual('upperLeft'); - expect(event?.pointBPos.x).toStrictEqual(0); - expect(event?.pointBPos.y).toStrictEqual(0); - }); - it('should disallow hovering over active rectangle corner points', async () => { - const events = subscribeToEvents(filletTool, ['pendingCornerChange', 'activeCornerChange']); - - // Hovering over upper left works - toolManager.handleMouseMove(sheetToScreen(0, 0, viewport), viewport); - let event = await events.waitFor('pendingCornerChange'); - - expect(event?.mode).toStrictEqual('rectangle'); - if (event?.mode !== 'rectangle') { - throw new Error('not rectangle'); - } - expect(event?.centerEndpoint).toStrictEqual('upperLeft'); - - // Click to make upper left the active point - toolManager.handleMouseDown(sheetToScreen(0, 0, viewport), viewport); - - // And make sure an activeCornerChange event is emitted - event = await events.waitFor('activeCornerChange'); - expect(event?.mode).toStrictEqual('rectangle'); - if (event?.mode !== 'rectangle') { - throw new Error('not rectangle'); - } - expect(event?.centerEndpoint).toStrictEqual('upperLeft'); - - // Now, hovering over the upper right point should still work - toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); - event = await events.waitFor('pendingCornerChange'); - - expect(event?.mode).toStrictEqual('rectangle'); - if (event?.mode !== 'rectangle') { - throw new Error('not rectangle'); - } - expect(event?.centerEndpoint).toStrictEqual('upperRight'); - - // But not the upper left, since it is active - toolManager.handleMouseMove(sheetToScreen(0, 0, viewport), viewport); - expect(events.areThereBufferedEvents('activeCornerChange')).toBeFalsy(); - }); - it('should be able to click corner points before the current one to add fillets to each', async () => { - const events = subscribeToEvents(filletTool, ['pendingCornerChange', 'activeCornerChange']); - - // Click to add a fillet on the lower right point - toolManager.handleMouseMove(sheetToScreen(100, 100, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(100, 100, viewport), viewport); - - // Set a length, but do NOT commit it - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - - events.clearBufferedEvents(); - - // Now hover / click on the upper left corner (before lower right) - toolManager.handleMouseMove(sheetToScreen(0, 0, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(0, 0, viewport), viewport); - - // This should cause the first fillet to get committed - let polygonGeometry = geometryStore.listWithComponent(GeometryComponent)[0]; - let points = GeometryComponent.get(polygonGeometry).points; - expect(points.filter((p: PolygonSegment) => p.type === 'arc-cubic')).toHaveLength(1); - - // And the second fillet to get made active. - // First a no-op event: - let event = await events.waitFor('activeCornerChange'); - expect(event).toBeNull(); - // Then the actual event: - event = await events.waitFor('activeCornerChange'); - expect(event?.mode).toStrictEqual('polygon'); - if (event?.mode !== 'polygon') { - throw new Error('not polygon'); - } - expect(event?.centerIndex).toStrictEqual(0); - - // And the current offset length should have persisted - expect(filletTool.currentOffset?.type).toStrictEqual(CentimetersType); - expect(filletTool.currentOffset?.magnitude).toStrictEqual(20); - }); - it('should be able to click corner points after the current one to add fillets to each', async () => { - const events = subscribeToEvents(filletTool, ['pendingCornerChange', 'activeCornerChange']); - - // Click to add a fillet on the lower right point - toolManager.handleMouseMove(sheetToScreen(100, 100, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(100, 100, viewport), viewport); + // Click to add a fillet on the lower right point + toolManager.handleMouseMove(sheetToScreen(100, 100, viewport), viewport); + toolManager.handleMouseDown(sheetToScreen(100, 100, viewport), viewport); - // Set a length, but do NOT commit it - filletTool.onChangeCurrentOffset(Length.centimeters(20)); + // Set a length, but do NOT commit it + filletTool.onChangeCurrentOffset(Length.centimeters(20)); - events.clearBufferedEvents(); - - // Now hover / click on the lower left corner (after lower right) - toolManager.handleMouseMove(sheetToScreen(0, 100, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(0, 100, viewport), viewport); - - // This should cause the first fillet to get committed - let polygonGeometry = geometryStore.listWithComponent(GeometryComponent)[0]; - let points = GeometryComponent.get(polygonGeometry).points; - expect(points.filter((p: PolygonSegment) => p.type === 'arc-cubic')).toHaveLength(1); - - // And the second fillet to get made active. - // First a no-op event: - let event = await events.waitFor('activeCornerChange'); - expect(event).toBeNull(); - // Then the actual event: - event = await events.waitFor('activeCornerChange'); - expect(event?.mode).toStrictEqual('polygon'); - if (event?.mode !== 'polygon') { - throw new Error('not polygon'); - } - expect(event?.centerIndex).toStrictEqual( - 3 /* expected index */ + 1 /* extra point from fillet */, - ); - - // And the current offset length should have persisted - expect(filletTool.currentOffset?.type).toStrictEqual(CentimetersType); - expect(filletTool.currentOffset?.magnitude).toStrictEqual(20); - }); + events.clearBufferedEvents(); + + // Now hover / click on the lower left corner (after lower right) + toolManager.handleMouseMove(sheetToScreen(0, 100, viewport), viewport); + toolManager.handleMouseDown(sheetToScreen(0, 100, viewport), viewport); + + // The first corner's filter should have been committed + const filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + expect((FilterComponent.get(filters[0]) as any).pointCenterKeyPoint).toBe('lowerRight'); + + // The second fillet gets made active + // First a no-op event from the abort in commit: + let event = await events.waitFor('activeCornerChange'); + expect(event).toBeNull(); + // Then the actual event: + event = await events.waitFor('activeCornerChange'); + expect(event?.mode).toStrictEqual('rectangle'); + if (event?.mode !== 'rectangle') { + throw new Error('not rectangle'); + } + expect(event?.centerEndpoint).toStrictEqual('lowerLeft'); + + // And the current offset length should have persisted + expect(filletTool.currentOffset?.type).toStrictEqual(CentimetersType); + expect(filletTool.currentOffset?.magnitude).toStrictEqual(20); }); }); - describe('Polygon', () => { + describe('Polygon corner clicks create filters', () => { it('middle point of a closed triangular polygon', () => { - geometryStore.addOrdered( + const { id: polygonId } = geometryStore.addOrdered( ID_PREFIXES.polygon, Polygon.create([makePoint(0, 0), makePoint(100, 0), makePoint(100, 100), makePoint(0, 0)], { closed: true, @@ -707,43 +232,17 @@ describe('FilletTool', () => { filletTool.onChangeCurrentOffset(Length.centimeters(20)); filletTool.commit(); - const polygons = geometryStore.listWithComponent(GeometryComponent); - expect(polygons).toHaveLength(1); - - const points = GeometryComponent.get(polygons[0]).points; - expect(points.length).toBe(5); - - // Point segments at indices 0,1,3,4,5 - expect(points[0].type).toBe('point'); - expect(points[1].type).toBe('point'); - expect(points[2].type).toBe('arc-cubic'); - expect(points[3].type).toBe('point'); - expect(points[4].type).toBe('point'); - - // Point positions: UL -> split(80,0) -> arc -> LR -> LL -> UL - expect(points[0].point.x).toBeCloseTo(0); - expect(points[0].point.y).toBeCloseTo(0); - expect(points[1].point.x).toBeCloseTo(80); - expect(points[1].point.y).toBeCloseTo(0); - expect(points[3].point.x).toBeCloseTo(100); - expect(points[3].point.y).toBeCloseTo(100); - expect(points[4].point.x).toBeCloseTo(0); - expect(points[4].point.y).toBeCloseTo(0); - expect(GeometryComponent.get(polygons[0]).closed).toBe(true); - - // Arc destination = splitB on the vertical edge (100, 20) - const arc = points[2] as CubicBezierSegment; - expect(arc.point.x).toBeCloseTo(100); - expect(arc.point.y).toBeCloseTo(20); - - // Control points should be non-trivial (tangent to both edges) - expect(arc.controlPointA.x).toBeCloseTo(91.05, 2); - expect(arc.controlPointA.y).toBeCloseTo(0, 2); - expect(arc.controlPointB.x).toBeCloseTo(100); - expect(arc.controlPointB.y).toBeCloseTo(8.95, 2); + const filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + const filter = FilterComponent.get(filters[0]); + expect(filter.type).toStrictEqual('fillet'); + expect((filter as FilletFilterData).geometryType).toStrictEqual('polygon'); + expect((filter as FilletFilterData).geometryId).toStrictEqual(polygonId); + expect((filter as any).pointCenterIndex).toStrictEqual(1); }); + it('starting point of a closed triangular polygon', () => { - geometryStore.addOrdered( + const { id: polygonId } = geometryStore.addOrdered( ID_PREFIXES.polygon, Polygon.create( [makePoint(100, 0), makePoint(100, 100), makePoint(0, 0), makePoint(100, 0)], @@ -757,65 +256,216 @@ describe('FilletTool', () => { filletTool.onChangeCurrentOffset(Length.centimeters(20)); filletTool.commit(); - const polygons = geometryStore.listWithComponent(GeometryComponent); - expect(polygons).toHaveLength(1); - - const points = GeometryComponent.get(polygons[0]).points; - expect(points.length).toBe(5); - - // Point segments at indices 0,1,2,3 - expect(points[0].type).toBe('point'); - expect(points[1].type).toBe('point'); - expect(points[2].type).toBe('point'); - expect(points[3].type).toBe('point'); - expect(points[4].type).toBe('arc-cubic'); - - // Point positions: UL -> split(80,0) -> arc -> LR -> LL -> UL - expect(points[0].point.x).toBeCloseTo(100); - expect(points[0].point.y).toBeCloseTo(20); - expect(points[1].point.x).toBeCloseTo(100); - expect(points[1].point.y).toBeCloseTo(100); - expect(points[2].point.x).toBeCloseTo(0); - expect(points[2].point.y).toBeCloseTo(0); - expect(points[3].point.x).toBeCloseTo(80); - expect(points[3].point.y).toBeCloseTo(0); - expect(points[4].point.x).toBeCloseTo(100); - expect(points[4].point.y).toBeCloseTo(20); - expect(GeometryComponent.get(polygons[0]).closed).toBe(true); - - // Arc destination = splitB on the vertical edge (100, 20) - const arc = points[4] as CubicBezierSegment; - expect(arc.point.x).toBeCloseTo(100); - expect(arc.point.y).toBeCloseTo(20); - - // Control points should be non-trivial (tangent to both edges) - expect(arc.controlPointA.x).toBeCloseTo(91.05, 2); - expect(arc.controlPointA.y).toBeCloseTo(0, 2); - expect(arc.controlPointB.x).toBeCloseTo(100); - expect(arc.controlPointB.y).toBeCloseTo(8.95, 2); + const filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + const filter = FilterComponent.get(filters[0]); + expect(filter.type).toBe('fillet'); + expect((filter as FilletFilterData).geometryType).toBe('polygon'); + expect((filter as FilletFilterData).geometryId).toStrictEqual(polygonId); + expect((filter as any).pointCenterIndex).toBe(0); }); - it('should not be able to place a fillet on a point where one side is arc-cubic / arc-quadratic', async () => { - const events = subscribeToEvents(filletTool, ['pendingCornerChange']); + }); - geometryStore.addOrdered( - ID_PREFIXES.polygon, - Polygon.create( - [ - makePoint(0, 0), - { - type: 'arc-quadratic', - point: new SheetPosition(100, 100), - controlPoint: new SheetPosition(50, 50), - }, - makePoint(0, 100), - ], - { closed: true }, - ), + describe('hover behavior', () => { + let rect: Rectangle; + beforeEach(() => { + rect = geometryStore.addOrdered( + ID_PREFIXES.rectangle, + Rectangle.create(new SheetPosition(0, 0), new SheetPosition(100, 100), { + fillColor: DEFAULT_COLOR, + linkDimensions: false, + }), ); + }); + + it('should allow hovering over all rectangle corner points', async () => { + const events = subscribeToEvents(filletTool, ['pendingCornerChange']); + // Hover over upper left + toolManager.handleMouseMove(sheetToScreen(0, 0, viewport), viewport); + let event = await events.waitFor('pendingCornerChange'); + + expect(event?.mode).toStrictEqual('rectangle'); + if (event?.mode !== 'rectangle') { + throw new Error('not rectangle'); + } + expect(event?.pointAEndpoint).toStrictEqual('lowerLeft'); + expect(event?.pointAPos.x).toStrictEqual(0); + expect(event?.pointAPos.y).toStrictEqual(100); + expect(event?.centerEndpoint).toStrictEqual('upperLeft'); + expect(event?.centerPos.x).toStrictEqual(0); + expect(event?.centerPos.y).toStrictEqual(0); + expect(event?.pointBEndpoint).toStrictEqual('upperRight'); + expect(event?.pointBPos.x).toStrictEqual(100); + expect(event?.pointBPos.y).toStrictEqual(0); + + // Hover over upper right + toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); + event = await events.waitFor('pendingCornerChange'); + + expect(event?.mode).toStrictEqual('rectangle'); + if (event?.mode !== 'rectangle') { + throw new Error('not rectangle'); + } + expect(event?.pointAEndpoint).toStrictEqual('lowerRight'); + expect(event?.pointAPos.x).toStrictEqual(100); + expect(event?.pointAPos.y).toStrictEqual(100); + expect(event?.centerEndpoint).toStrictEqual('upperRight'); + expect(event?.centerPos.x).toStrictEqual(100); + expect(event?.centerPos.y).toStrictEqual(0); + expect(event?.pointBEndpoint).toStrictEqual('upperLeft'); + expect(event?.pointBPos.x).toStrictEqual(0); + expect(event?.pointBPos.y).toStrictEqual(0); + + // Hover over lower right toolManager.handleMouseMove(sheetToScreen(100, 100, viewport), viewport); + event = await events.waitFor('pendingCornerChange'); + + expect(event?.mode).toStrictEqual('rectangle'); + if (event?.mode !== 'rectangle') { + throw new Error('not rectangle'); + } + expect(event?.pointAEndpoint).toStrictEqual('lowerLeft'); + expect(event?.pointAPos.x).toStrictEqual(0); + expect(event?.pointAPos.y).toStrictEqual(100); + expect(event?.centerEndpoint).toStrictEqual('lowerRight'); + expect(event?.centerPos.x).toStrictEqual(100); + expect(event?.centerPos.y).toStrictEqual(100); + expect(event?.pointBEndpoint).toStrictEqual('upperRight'); + expect(event?.pointBPos.x).toStrictEqual(100); + expect(event?.pointBPos.y).toStrictEqual(0); + + // Hover over lower left + toolManager.handleMouseMove(sheetToScreen(0, 100, viewport), viewport); + event = await events.waitFor('pendingCornerChange'); + + expect(event?.mode).toStrictEqual('rectangle'); + if (event?.mode !== 'rectangle') { + throw new Error('not rectangle'); + } + expect(event?.pointAEndpoint).toStrictEqual('lowerRight'); + expect(event?.pointAPos.x).toStrictEqual(100); + expect(event?.pointAPos.y).toStrictEqual(100); + expect(event?.centerEndpoint).toStrictEqual('lowerLeft'); + expect(event?.centerPos.x).toStrictEqual(0); + expect(event?.centerPos.y).toStrictEqual(100); + expect(event?.pointBEndpoint).toStrictEqual('upperLeft'); + expect(event?.pointBPos.x).toStrictEqual(0); + expect(event?.pointBPos.y).toStrictEqual(0); + }); - expect(await events.waitFor('pendingCornerChange')).toBeNull(); + it('should disallow hovering over active rectangle corner points', async () => { + const events = subscribeToEvents(filletTool, ['pendingCornerChange', 'activeCornerChange']); + + // Hovering over upper left works + toolManager.handleMouseMove(sheetToScreen(0, 0, viewport), viewport); + let event = await events.waitFor('pendingCornerChange'); + + expect(event?.mode).toStrictEqual('rectangle'); + if (event?.mode !== 'rectangle') { + throw new Error('not rectangle'); + } + expect(event?.centerEndpoint).toStrictEqual('upperLeft'); + + // Click to make upper left the active point + toolManager.handleMouseDown(sheetToScreen(0, 0, viewport), viewport); + + // And make sure an activeCornerChange event is emitted + event = await events.waitFor('activeCornerChange'); + expect(event?.mode).toStrictEqual('rectangle'); + if (event?.mode !== 'rectangle') { + throw new Error('not rectangle'); + } + expect(event?.centerEndpoint).toStrictEqual('upperLeft'); + + // Now, hovering over the upper right point should still work + toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); + event = await events.waitFor('pendingCornerChange'); + + expect(event?.mode).toStrictEqual('rectangle'); + if (event?.mode !== 'rectangle') { + throw new Error('not rectangle'); + } + expect(event?.centerEndpoint).toStrictEqual('upperRight'); + + // But not the upper left, since it is active + toolManager.handleMouseMove(sheetToScreen(0, 0, viewport), viewport); + expect(events.areThereBufferedEvents('activeCornerChange')).toBeFalsy(); }); }); + + it('should not be able to place a fillet on a point where one side is arc-cubic / arc-quadratic', async () => { + const events = subscribeToEvents(filletTool, ['pendingCornerChange']); + + geometryStore.addOrdered( + ID_PREFIXES.polygon, + Polygon.create( + [ + makePoint(0, 0), + { + type: 'arc-quadratic', + point: new SheetPosition(100, 100), + controlPoint: new SheetPosition(50, 50), + }, + makePoint(0, 100), + ], + { closed: true }, + ), + ); + + toolManager.handleMouseMove(sheetToScreen(100, 100, viewport), viewport); + + expect(await events.waitFor('pendingCornerChange')).toBeNull(); + }); + + it('should disallow hovering over rectangle corner points with previously attached fillets', async () => { + const events = subscribeToEvents(filletTool, ['pendingCornerChange']); + + // Create a new rectangle + const rectangle = geometryStore.addOrdered( + ID_PREFIXES.rectangle, + Rectangle.create(new SheetPosition(0, 0), new SheetPosition(100, 100), { + fillColor: DEFAULT_COLOR, + linkDimensions: false, + }), + ); + + // Add a fillet centered on the upperLeft point of the rectangle + geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rectangle.id, + 'lowerLeft', + 'upperLeft', + 'upperRight', + Length.centimeters(20), + ), + ); + + // Hovering over upper left does nothing + toolManager.handleMouseMove(sheetToScreen(0, 0, viewport), viewport); + expect(await events.waitFor('pendingCornerChange')).toBeNull(); + }); + + it('should disallow hovering over polygon vertex points with previously attached fillets', async () => { + const events = subscribeToEvents(filletTool, ['pendingCornerChange']); + + // Create a new rectangle + const rectangle = geometryStore.addOrdered( + ID_PREFIXES.polygon, + Polygon.create([makePoint(0, 0), makePoint(100, 0), makePoint(100, 100), makePoint(0, 0)], { + closed: true, + }), + ); + + // Add a fillet centered on the upperLeft point of the rectangle + geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnPolygon(rectangle.id, 0, 1, 2, Length.centimeters(20)), + ); + + // Hovering over point at index=1 does nothing + toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); + expect(await events.waitFor('pendingCornerChange')).toBeNull(); + }); }); diff --git a/src/__tests__/GeometryComponent.test.ts b/src/__tests__/GeometryComponent.test.ts new file mode 100644 index 00000000..85d7e3d5 --- /dev/null +++ b/src/__tests__/GeometryComponent.test.ts @@ -0,0 +1,417 @@ +import { ActionsManager } from '@/lib/actions/ActionsManager'; +import { + CubicBezierSegment, + Ellipse, + GeometryComponent, + PointSegment, + Polygon, + Rectangle, + RenderShapeEllipse, + RenderShapePolygon, + RenderShapeRectangle, +} from '@/lib/entity'; +import { GeometryStore, ID_PREFIXES } from '@/lib/entity/GeometryStore'; +import { FilletFilter } from '@/lib/entity/filters/fillet'; +import { MirrorFilter } from '@/lib/entity/filters/mirror'; +import { HistoryManager } from '@/lib/history/HistoryManager'; +import { SerializationManager } from '@/lib/serialization/SerializationManager'; +import { Sheet } from '@/lib/sheet/Sheet'; +import { FilletTool } from '@/lib/tools/FilletTool'; +import { SelectionManager } from '@/lib/tools/SelectionManager'; +import { ToolManager } from '@/lib/tools/ToolManager'; +import { Length } from '@/lib/units/length'; +import { ViewportControls } from '@/lib/viewport/ViewportControls'; +import { SheetPosition, type ViewportState } from '@/lib/viewport/types'; + +function makePoint(x: number, y: number): PointSegment { + return { type: 'point', point: new SheetPosition(x, y) }; +} + +describe('GeometryComponent', () => { + let sheet: Sheet; + let historyManager: HistoryManager; + let geometryStore: GeometryStore; + let selectionManager: SelectionManager; + let actionsManager: ActionsManager; + let toolManager: ToolManager; + let viewport: ViewportState; + let filletTool: FilletTool; + let viewportControls: ViewportControls; + + beforeEach(() => { + sheet = Sheet.a4(); + historyManager = new HistoryManager(); + geometryStore = new GeometryStore(historyManager); + historyManager.setGeometryStore(geometryStore); + selectionManager = new SelectionManager(); + toolManager = new ToolManager(geometryStore, selectionManager, historyManager); + actionsManager = new ActionsManager(sheet, geometryStore, selectionManager, historyManager); + historyManager.setGeometryStore(geometryStore); + toolManager = new ToolManager(geometryStore, selectionManager, historyManager); + toolManager.setSerializationManager( + new SerializationManager(actionsManager, toolManager, sheet), + ); + + viewportControls = new ViewportControls({ + canvasWidth: 800, + canvasHeight: 600, + sheet, + }); + toolManager.setViewportControls(viewportControls); + viewport = viewportControls.getState().viewport; + + toolManager.setActiveTool('edit'); + toolManager.changeToolSubTool('edit', 'fillet'); + filletTool = toolManager.getTool('edit').activeSubTool as FilletTool; + }); + + describe('getRenderShapes', () => { + describe('no filters does nothing', () => { + it('should return rectangles unprocessed', async () => { + // Create a new rectangle + const rectangle = geometryStore.addOrdered( + ID_PREFIXES.rectangle, + Rectangle.create(new SheetPosition(0, 0), new SheetPosition(100, 100)), + ); + + // Generate the shape, make sure that the fillet corner is rendered + const shapes = GeometryComponent.getRenderShapes(rectangle, sheet.defaultUnit); + + expect(shapes).toHaveLength(1); + expect(shapes[0].shape).toStrictEqual('rectangle'); + const polygonShape = shapes[0] as RenderShapeRectangle; + expect(polygonShape.upperLeft.x).toStrictEqual(0); + expect(polygonShape.upperLeft.y).toStrictEqual(0); + expect(polygonShape.lowerRight.x).toStrictEqual(100); + expect(polygonShape.lowerRight.y).toStrictEqual(100); + }); + + it('should return ellipses unprocessed', async () => { + // Create a new rectangle + const ellipse = geometryStore.addOrdered( + ID_PREFIXES.ellipse, + Ellipse.create(new SheetPosition(0, 0), { radiusX: 10, radiusY: 20 }), + ); + + // Generate the shape, make sure that the ellipse is rendered + const shapes = GeometryComponent.getRenderShapes(ellipse, sheet.defaultUnit); + + expect(shapes).toHaveLength(1); + expect(shapes[0].shape).toStrictEqual('ellipse'); + const ellipseShape = shapes[0] as RenderShapeEllipse; + expect(ellipseShape.center.x).toStrictEqual(0); + expect(ellipseShape.center.y).toStrictEqual(0); + expect(ellipseShape.radiusX).toStrictEqual(10); + expect(ellipseShape.radiusY).toStrictEqual(20); + }); + + it('should return polygons unprocessed', async () => { + // Create a new polygon (triangle) + const polygon = geometryStore.addOrdered( + ID_PREFIXES.polygon, + Polygon.create( + [makePoint(0, 0), makePoint(100, 0), makePoint(100, 100), makePoint(0, 0)], + { + closed: true, + }, + ), + ); + + // Generate the shape with no filters — should pass through unchanged + const shapes = GeometryComponent.getRenderShapes(polygon, sheet.defaultUnit); + + expect(shapes).toHaveLength(1); + expect(shapes[0].shape).toStrictEqual('polygon'); + const polygonShape = shapes[0] as RenderShapePolygon; + expect(polygonShape.primary).toStrictEqual(true); + expect(polygonShape.closed).toStrictEqual(true); + expect(polygonShape.points).toHaveLength(4); + + expect(polygonShape.points[0].point.x).toStrictEqual(0); + expect(polygonShape.points[0].point.y).toStrictEqual(0); + expect(polygonShape.points[1].point.x).toStrictEqual(100); + expect(polygonShape.points[1].point.y).toStrictEqual(0); + expect(polygonShape.points[2].point.x).toStrictEqual(100); + expect(polygonShape.points[2].point.y).toStrictEqual(100); + expect(polygonShape.points[3].point.x).toStrictEqual(0); + expect(polygonShape.points[3].point.y).toStrictEqual(0); + }); + }); + + describe('filler / chamfer', () => { + it('should render rectangles with fillets properly', async () => { + // Create a new rectangle + const rectangle = geometryStore.addOrdered( + ID_PREFIXES.rectangle, + Rectangle.create(new SheetPosition(0, 0), new SheetPosition(100, 100)), + ); + + // Add a fillet centered on the upperRight point of the rectangle + const filter = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rectangle.id, + 'upperLeft', + 'upperRight', + 'lowerRight', + Length.centimeters(20), + ), + ); + + // Generate the shape, make sure that the fillet corner is rendered + const shapes = GeometryComponent.getRenderShapes(rectangle, sheet.defaultUnit, [filter]); + + expect(shapes).toHaveLength(1); + expect(shapes[0].shape).toStrictEqual('polygon'); + const polygonShape = shapes[0] as RenderShapePolygon; + expect(polygonShape.points).toHaveLength(6); + + // Make sure all points are positioned where they should be + expect(polygonShape.points[0].type).toStrictEqual('point'); + expect(polygonShape.points[0].point.x).toStrictEqual(0); + expect(polygonShape.points[0].point.y).toStrictEqual(0); + + expect(polygonShape.points[1].type).toStrictEqual('point'); + expect(polygonShape.points[1].point.x).toStrictEqual(80); + expect(polygonShape.points[1].point.y).toStrictEqual(0); + + expect(polygonShape.points[2].type).toStrictEqual('arc-cubic'); + expect(polygonShape.points[2].point.x).toStrictEqual(100); + expect(polygonShape.points[2].point.y).toStrictEqual(20); + expect((polygonShape.points[2] as CubicBezierSegment).controlPointA.x).toBeCloseTo(91.045); + expect((polygonShape.points[2] as CubicBezierSegment).controlPointA.y).toStrictEqual(0); + expect((polygonShape.points[2] as CubicBezierSegment).controlPointB.x).toStrictEqual(100); + expect((polygonShape.points[2] as CubicBezierSegment).controlPointB.y).toBeCloseTo(8.954); + + expect(polygonShape.points[3].type).toStrictEqual('point'); + expect(polygonShape.points[3].point.x).toStrictEqual(100); + expect(polygonShape.points[3].point.y).toStrictEqual(100); + + expect(polygonShape.points[4].type).toStrictEqual('point'); + expect(polygonShape.points[4].point.x).toStrictEqual(0); + expect(polygonShape.points[4].point.y).toStrictEqual(100); + + expect(polygonShape.points[5].type).toStrictEqual('point'); + expect(polygonShape.points[5].point.x).toStrictEqual(0); + expect(polygonShape.points[5].point.y).toStrictEqual(0); + }); + + it('should render rectangles with multiple fillets properly (upper right then lower right)', async () => { + // Create a new rectangle + const rectangle = geometryStore.addOrdered( + ID_PREFIXES.rectangle, + Rectangle.create(new SheetPosition(0, 0), new SheetPosition(100, 100)), + ); + + // Add a fillet centered on the upperRight point of the rectangle + const filterUpperRight = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rectangle.id, + 'upperLeft', + 'upperRight', + 'lowerRight', + Length.centimeters(20), + ), + ); + + // Add a fillet centered on the upperRight point of the rectangle + const filterLowerRight = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rectangle.id, + 'upperRight', + 'lowerRight', + 'lowerLeft', + Length.centimeters(20), + ), + ); + + // Generate the shape, make sure that the fillet corner is rendered + const shapes = GeometryComponent.getRenderShapes(rectangle, sheet.defaultUnit, [ + filterUpperRight, + filterLowerRight, + ]); + + expect(shapes).toHaveLength(1); + expect(shapes[0].shape).toStrictEqual('polygon'); + const polygonShape = shapes[0] as RenderShapePolygon; + expect(polygonShape.points).toHaveLength(7); + + // Make sure all points are positioned where they should be + expect(polygonShape.points[0].type).toStrictEqual('point'); + expect(polygonShape.points[0].point.x).toStrictEqual(0); + expect(polygonShape.points[0].point.y).toStrictEqual(0); + + expect(polygonShape.points[1].type).toStrictEqual('point'); + expect(polygonShape.points[1].point.x).toStrictEqual(80); + expect(polygonShape.points[1].point.y).toStrictEqual(0); + + expect(polygonShape.points[2].type).toStrictEqual('arc-cubic'); + expect(polygonShape.points[2].point.x).toStrictEqual(100); + expect(polygonShape.points[2].point.y).toStrictEqual(20); + expect((polygonShape.points[2] as CubicBezierSegment).controlPointA.x).toBeCloseTo(91.045); + expect((polygonShape.points[2] as CubicBezierSegment).controlPointA.y).toStrictEqual(0); + expect((polygonShape.points[2] as CubicBezierSegment).controlPointB.x).toStrictEqual(100); + expect((polygonShape.points[2] as CubicBezierSegment).controlPointB.y).toBeCloseTo(8.954); + + expect(polygonShape.points[3].type).toStrictEqual('point'); + expect(polygonShape.points[3].point.x).toStrictEqual(100); + expect(polygonShape.points[3].point.y).toStrictEqual(80); + + expect(polygonShape.points[4].type).toStrictEqual('arc-cubic'); + expect(polygonShape.points[4].point.x).toStrictEqual(80); + expect(polygonShape.points[4].point.y).toStrictEqual(100); + expect((polygonShape.points[4] as CubicBezierSegment).controlPointA.x).toStrictEqual(100); + expect((polygonShape.points[4] as CubicBezierSegment).controlPointA.y).toBeCloseTo(91.045); + expect((polygonShape.points[4] as CubicBezierSegment).controlPointB.x).toBeCloseTo(91.045); + expect((polygonShape.points[4] as CubicBezierSegment).controlPointB.y).toStrictEqual(100); + + expect(polygonShape.points[5].type).toStrictEqual('point'); + expect(polygonShape.points[5].point.x).toStrictEqual(0); + expect(polygonShape.points[5].point.y).toStrictEqual(100); + + expect(polygonShape.points[6].type).toStrictEqual('point'); + expect(polygonShape.points[6].point.x).toStrictEqual(0); + expect(polygonShape.points[6].point.y).toStrictEqual(0); + }); + }); + + it('should mirror rectangles across a line', async () => { + // Create a new rectangle + const rectangle = geometryStore.addOrdered( + ID_PREFIXES.rectangle, + Rectangle.create(new SheetPosition(0, 0), new SheetPosition(100, 100)), + ); + + const filter = geometryStore.add( + ID_PREFIXES.filter, + MirrorFilter.create(rectangle.id, new SheetPosition(200, 0), new SheetPosition(200, 100)), + ); + + // Generate the shape, make sure that the fillet corner is rendered + const shapes = GeometryComponent.getRenderShapes(rectangle, sheet.defaultUnit, [filter]); + + expect(shapes).toHaveLength(2); + + expect(shapes[0].shape).toStrictEqual('rectangle'); + const rectangleOriginal = shapes[0] as RenderShapeRectangle; + expect(rectangleOriginal.upperLeft.x).toStrictEqual(0); + expect(rectangleOriginal.upperLeft.y).toStrictEqual(0); + expect(rectangleOriginal.lowerRight.x).toStrictEqual(100); + expect(rectangleOriginal.lowerRight.y).toStrictEqual(100); + + expect(shapes[1].shape).toStrictEqual('rectangle'); + const rectangleMirrored = shapes[1] as RenderShapeRectangle; + expect(rectangleMirrored.upperLeft.x).toStrictEqual(300); + expect(rectangleMirrored.upperLeft.y).toStrictEqual(0); + expect(rectangleMirrored.lowerRight.x).toStrictEqual(400); + expect(rectangleMirrored.lowerRight.y).toStrictEqual(100); + }); + + it('should mirror rectangle with fillet across a line', async () => { + // Create a new rectangle + const rectangle = geometryStore.addOrdered( + ID_PREFIXES.rectangle, + Rectangle.create(new SheetPosition(0, 0), new SheetPosition(100, 100)), + ); + + // Add a fillet centered on the upperRight point of the rectangle + const filletFilter = geometryStore.add( + ID_PREFIXES.filter, + FilletFilter.createOnRectangle( + rectangle.id, + 'upperLeft', + 'upperRight', + 'lowerRight', + Length.centimeters(20), + ), + ); + + const mirrorFilter = geometryStore.add( + ID_PREFIXES.filter, + MirrorFilter.create(rectangle.id, new SheetPosition(200, 0), new SheetPosition(200, 100)), + ); + + // Generate the shape, make sure that the fillet corner is rendered + const shapes = GeometryComponent.getRenderShapes(rectangle, sheet.defaultUnit, [ + // NOTE: the order here is backwards from the order they should be applied in, + // that's also part of the test. Fillet should be applied first, then the rectangle with + // fillet shape should be mirrored. + mirrorFilter, + filletFilter, + ]); + + expect(shapes).toHaveLength(2); + expect(shapes[0].shape).toStrictEqual('polygon'); + + // Render the original polygon (rendered on left) + const originalShape = shapes[0] as RenderShapePolygon; + expect(originalShape.points).toHaveLength(6); + + // Make sure all points are positioned where they should be + expect(originalShape.points[0].type).toStrictEqual('point'); + expect(originalShape.points[0].point.x).toStrictEqual(0); + expect(originalShape.points[0].point.y).toStrictEqual(0); + + expect(originalShape.points[1].type).toStrictEqual('point'); + expect(originalShape.points[1].point.x).toStrictEqual(80); + expect(originalShape.points[1].point.y).toStrictEqual(0); + + expect(originalShape.points[2].type).toStrictEqual('arc-cubic'); + expect(originalShape.points[2].point.x).toStrictEqual(100); + expect(originalShape.points[2].point.y).toStrictEqual(20); + expect((originalShape.points[2] as CubicBezierSegment).controlPointA.x).toBeCloseTo(91.045); + expect((originalShape.points[2] as CubicBezierSegment).controlPointA.y).toStrictEqual(0); + expect((originalShape.points[2] as CubicBezierSegment).controlPointB.x).toStrictEqual(100); + expect((originalShape.points[2] as CubicBezierSegment).controlPointB.y).toBeCloseTo(8.954); + + expect(originalShape.points[3].type).toStrictEqual('point'); + expect(originalShape.points[3].point.x).toStrictEqual(100); + expect(originalShape.points[3].point.y).toStrictEqual(100); + + expect(originalShape.points[4].type).toStrictEqual('point'); + expect(originalShape.points[4].point.x).toStrictEqual(0); + expect(originalShape.points[4].point.y).toStrictEqual(100); + + expect(originalShape.points[5].type).toStrictEqual('point'); + expect(originalShape.points[5].point.x).toStrictEqual(0); + expect(originalShape.points[5].point.y).toStrictEqual(0); + + expect(shapes[1].shape).toStrictEqual('polygon'); + + // Render the mirrored polygon (flipped over x=200 vertical line) + const mirroredShape = shapes[1] as RenderShapePolygon; + expect(mirroredShape.points).toHaveLength(6); + + // Make sure all points are positioned where they should be + expect(mirroredShape.points[0].type).toStrictEqual('point'); + expect(mirroredShape.points[0].point.x).toStrictEqual(400); + expect(mirroredShape.points[0].point.y).toStrictEqual(0); + + expect(mirroredShape.points[1].type).toStrictEqual('point'); + expect(mirroredShape.points[1].point.x).toStrictEqual(320); + expect(mirroredShape.points[1].point.y).toStrictEqual(0); + + expect(mirroredShape.points[2].type).toStrictEqual('arc-cubic'); + expect(mirroredShape.points[2].point.x).toStrictEqual(300); + expect(mirroredShape.points[2].point.y).toStrictEqual(20); + expect((mirroredShape.points[2] as CubicBezierSegment).controlPointA.x).toBeCloseTo(308.955); + expect((mirroredShape.points[2] as CubicBezierSegment).controlPointA.y).toStrictEqual(0); + expect((mirroredShape.points[2] as CubicBezierSegment).controlPointB.x).toStrictEqual(300); + expect((mirroredShape.points[2] as CubicBezierSegment).controlPointB.y).toBeCloseTo(8.954); + + expect(mirroredShape.points[3].type).toStrictEqual('point'); + expect(mirroredShape.points[3].point.x).toStrictEqual(300); + expect(mirroredShape.points[3].point.y).toStrictEqual(100); + + expect(mirroredShape.points[4].type).toStrictEqual('point'); + expect(mirroredShape.points[4].point.x).toStrictEqual(400); + expect(mirroredShape.points[4].point.y).toStrictEqual(100); + + expect(mirroredShape.points[5].type).toStrictEqual('point'); + expect(mirroredShape.points[5].point.x).toStrictEqual(400); + expect(mirroredShape.points[5].point.y).toStrictEqual(0); + }); + }); +}); diff --git a/src/app/components/FilletFilterIndicator.tsx b/src/app/components/FilletFilterIndicator.tsx index c8f04a5c..e96af0f2 100644 --- a/src/app/components/FilletFilterIndicator.tsx +++ b/src/app/components/FilletFilterIndicator.tsx @@ -3,7 +3,7 @@ import { extend } from '@pixi/react'; import { FederatedPointerEvent, Graphics, Sprite } from 'pixi.js'; import { useCallback, useMemo } from 'react'; -import { Vector2 } from '@/lib/math'; +import { Vector2, computeFilletArcControlPoints } from '@/lib/math'; import { SHEET_UNITS_TO_PIXELS, Sheet } from '@/lib/sheet/Sheet'; import { FilletFilterIconTexture, SPRITE_SCALE_FACTOR } from '@/lib/textures'; import { Length } from '@/lib/units/length'; @@ -89,17 +89,27 @@ export default function FilletFilterIndicator({ const curveStart = Vector2.add(vCenter, Vector2.scale(dirA, offsetWorld)); const curveEnd = Vector2.add(vCenter, Vector2.scale(dirB, offsetWorld)); - // Compute the angle between the two edges for the arc approximation - const cosTheta = Math.max(-1, Math.min(1, Vector2.dot(dirA, dirB))); - const theta = Math.acos(cosTheta); - const kVal = (4 / 3) * Math.tan(theta / 4); - const kR = kVal * offsetWorld; - - const cpA = Vector2.add(vCenter, Vector2.scale(dirA, kR)); - const cpB = Vector2.add(vCenter, Vector2.scale(dirB, kR)); + // Use the shared cubic bezier arc approximation (same as CornerReplacement) + const { controlPointA, controlPointB } = computeFilletArcControlPoints( + curveStart, + curveEnd, + Vector2.scale(dirA, -1), // tStart: toward corner + dirB, // tEnd: away from corner + offsetWorld, + vCenter, + vA, + vB, + ); graphics.moveTo(curveStart.x, curveStart.y); - graphics.bezierCurveTo(cpA.x, cpA.y, cpB.x, cpB.y, curveEnd.x, curveEnd.y); + graphics.bezierCurveTo( + controlPointA.x, + controlPointA.y, + controlPointB.x, + controlPointB.y, + curveEnd.x, + curveEnd.y, + ); graphics.stroke(); }, [vA, vCenter, vB, color, lineWidth, offset], diff --git a/src/app/components/ViewportRenderer2D.tsx b/src/app/components/ViewportRenderer2D.tsx index 260dab0d..c6f6ad23 100644 --- a/src/app/components/ViewportRenderer2D.tsx +++ b/src/app/components/ViewportRenderer2D.tsx @@ -27,6 +27,7 @@ import { type Id, RenderOrderComponent, } from '@/lib/entity'; +import { type Filter } from '@/lib/entity/filters'; import { type Geometry } from '@/lib/entity/geometry'; import { KeyCombo } from '@/lib/index-mapper'; import { @@ -261,6 +262,9 @@ function ListLayersRenderer>(props: { const items = props.layersItemsPairs .flatMap(([layers, items], index) => { const layer = layers[props.layerName]; + if (typeof layer === 'undefined') { + return []; + } if (typeof layer !== 'function') { return [{ key: `${index}`, renderOrder: 0, jsx: layer }]; } @@ -296,6 +300,9 @@ export default function ViewportRenderer2D({ height: number; } | null>(null); const [geometries, setGeometries] = useState>([]); + const [filtersByGeometryId, setFiltersByGeometryId] = useState< + Map> + >(new Map()); const [workingPolygon, setWorkingPolygon] = useState(null); const [workingRectangle, setWorkingRectangle] = useState(null); const [datums, setDatums] = useState>([]); @@ -363,7 +370,18 @@ export default function ViewportRenderer2D({ geometryStore.on('workingConstraintsChanged', setWorkingConstraints); const refreshAll = () => { - setGeometries(geometryStore.listWithComponents(GeometryComponent, RenderOrderComponent)); + const geometries = geometryStore.listWithComponents(GeometryComponent, RenderOrderComponent); + setGeometries(geometries); + + setFiltersByGeometryId( + new Map( + geometries.map((geom) => { + const filters = geometryStore.findFiltersByGeometryId(geom.id); + return [geom.id, filters] as const; + }), + ), + ); + setDatums(geometryStore.listWithComponent(DatumComponent)); }; geometryStore.on('geometryAdded', refreshAll); @@ -725,6 +743,7 @@ export default function ViewportRenderer2D({ mouseScreenPos, // FIXME: break this out into another context, it will change often snapHintsVisibility, highlightedGeometryId, + filtersByGeometryId, }) satisfies ViewportContextData, [ sheet, @@ -733,6 +752,7 @@ export default function ViewportRenderer2D({ activeTool, selectionManager, mouseScreenPos, + filtersByGeometryId, ], ); @@ -1186,7 +1206,7 @@ export default function ViewportRenderer2D({ ) : null} - {keyPointSnapInfo && viewportControlsState ? ( + {keyPointSnapInfo && viewportControlsState && activeTool.type !== 'edit' ? ( = ({ geometry }) => { - const { activeTool, viewportControls, viewportScale, mouseScreenPos, highlightedGeometryId } = - useViewportContext(); - const geometryData = GeometryComponent.get(geometry); + const { + activeTool, + viewportControls, + viewportScale, + mouseScreenPos, + highlightedGeometryId, + filtersByGeometryId, + sheet, + } = useViewportContext(); let fillColor = FillColorComponent.getOptional(geometry); let stroke = 0x000000; @@ -485,148 +491,175 @@ const GeometrySolid: React.FunctionComponent<{ geometry: Geometry }> = ({ geomet ); }, [activeTool.type, viewportControls, viewportScale, mouseScreenPos]); - switch (geometryData.type) { - case 'polygon': - return ( - <> - 1} - eventMode="static" - onFillPointerDown={onFillPointerDown} - onFillPointerOver={onFillPointerOver} - onFillPointerOut={onFillPointerOut} - /> + const [sheetDefaultUnit, setSheetDefaultUnit] = useState(sheet.defaultUnit); + useEffect(() => { + sheet.on('defaultUnitChange', setSheetDefaultUnit); + return () => { + sheet.off('defaultUnitChange', setSheetDefaultUnit); + }; + }, [sheet]); - {/* Edge detectors for non-closed, non-selected polygons based on mouse proximity. - * Only renders detectors for segments that might intersect the proximity AABB. - * This makes it easier to select edges on open polygons. */} - {!closed && !isSelected && mousePositionProximityAABB ? ( - <> - {geometryData.points.slice(1).map((seg, i) => { - const prevSeg = geometryData.points[i]; - if (!prevSeg) { - return null; - } + const filters = useMemo( + () => filtersByGeometryId.get(geometry.id), + [filtersByGeometryId, geometry.id], + ); + const renderShapes = useMemo(() => { + return GeometryComponent.getRenderShapes(geometry, sheetDefaultUnit, filters); + }, [geometry, sheetDefaultUnit, filters]); - // Use Cohen-Sutherland to quickly cull segments that don't intersect the proximity box - if (seg.type === 'point') { - const segment = { start: prevSeg.point, end: seg.point }; - if ( - !CohenSutherland.lineSegmentMightIntersectBoundingBox( - segment, - mousePositionProximityAABB, - ) - ) { - return null; - } - return ( - - ); - } else if (seg.type === 'arc-quadratic') { - const curve: QuadraticCurve = { - start: prevSeg.point, - end: seg.point, - controlPoint: seg.controlPoint, - }; - if ( - !CohenSutherland.quadraticCurveMightIntersectBoundingBox( - curve, - mousePositionProximityAABB, - ) - ) { - return null; - } - return ( - - ); - } else if (seg.type === 'arc-cubic') { - const curve: CubicCurve = { - start: prevSeg.point, - end: seg.point, - controlPointA: seg.controlPointA, - controlPointB: seg.controlPointB, - }; - if ( - !CohenSutherland.cubicCurveMightIntersectBoundingBox( - curve, - mousePositionProximityAABB, - ) - ) { - return null; - } - return ( - - ); - } - return null; - })} - - ) : null} - - ); - case 'rectangle': - return ( - 1} - eventMode="static" - onFillPointerDown={onFillPointerDown} - onFillPointerOver={onFillPointerOver} - onFillPointerOut={onFillPointerOut} - /> - ); - case 'ellipse': - return ( - 1} - eventMode="static" - onFillPointerDown={onFillPointerDown} - onFillPointerOver={onFillPointerOver} - onFillPointerOut={onFillPointerOut} - /> - ); - default: - geometryData satisfies never; - break; - } + return ( + <> + {renderShapes.map((renderShape) => { + switch (renderShape.shape) { + case 'polygon': + return ( + + 1} + eventMode="static" + onFillPointerDown={onFillPointerDown} + onFillPointerOver={onFillPointerOver} + onFillPointerOut={onFillPointerOut} + /> + + {/* Edge detectors for non-closed, non-selected polygons based on mouse proximity. + * Only renders detectors for segments that might intersect the proximity AABB. + * This makes it easier to select edges on open polygons. */} + {renderShape.primary && !closed && !isSelected && mousePositionProximityAABB ? ( + <> + {renderShape.points.slice(1).map((seg, i) => { + const prevSeg = renderShape.points[i]; + if (!prevSeg) { + return null; + } + + // Use Cohen-Sutherland to quickly cull segments that don't intersect the proximity box + switch (seg.type) { + case 'point': + const segment = { start: prevSeg.point, end: seg.point }; + if ( + !CohenSutherland.lineSegmentMightIntersectBoundingBox( + segment, + mousePositionProximityAABB, + ) + ) { + return null; + } + return ( + + ); + case 'arc-quadratic': + const quadraticCurve: QuadraticCurve = { + start: prevSeg.point, + end: seg.point, + controlPoint: seg.controlPoint, + }; + if ( + !CohenSutherland.quadraticCurveMightIntersectBoundingBox( + quadraticCurve, + mousePositionProximityAABB, + ) + ) { + return null; + } + return ( + + ); + case 'arc-cubic': + const cubicCurve: CubicCurve = { + start: prevSeg.point, + end: seg.point, + controlPointA: seg.controlPointA, + controlPointB: seg.controlPointB, + }; + if ( + !CohenSutherland.cubicCurveMightIntersectBoundingBox( + cubicCurve, + mousePositionProximityAABB, + ) + ) { + return null; + } + return ( + + ); + default: + seg satisfies never; + break; + } + })} + + ) : null} + + ); + case 'rectangle': + return ( + 1} + eventMode="static" + onFillPointerDown={onFillPointerDown} + onFillPointerOver={onFillPointerOver} + onFillPointerOut={onFillPointerOut} + /> + ); + case 'ellipse': + return ( + 1} + eventMode="static" + onFillPointerDown={onFillPointerDown} + onFillPointerOver={onFillPointerOver} + onFillPointerOut={onFillPointerOut} + /> + ); + default: + renderShape satisfies never; + break; + } + })} + + ); }; type PolygonDecorationsRendererProps = { diff --git a/src/contexts/viewport-context.tsx b/src/contexts/viewport-context.tsx index 05301583..7b690fd9 100644 --- a/src/contexts/viewport-context.tsx +++ b/src/contexts/viewport-context.tsx @@ -1,6 +1,8 @@ import { createContext, useContext } from 'react'; import { type Entity } from '@/lib/entity'; import { GeometryStore } from '@/lib/entity/GeometryStore'; +import { type Filter } from '@/lib/entity/filters'; +import { type Geometry } from '@/lib/entity/geometry'; import { type Sheet } from '@/lib/sheet/Sheet'; import { type SnapHintsVisibility } from '@/lib/tools/BaseTool'; import { type SelectionManager } from '@/lib/tools/SelectionManager'; @@ -20,6 +22,7 @@ export type ViewportContextData = { mouseScreenPos: ScreenPosition | null; snapHintsVisibility: SnapHintsVisibility | null; highlightedGeometryId: Entity['id'] | null; + filtersByGeometryId: Map>; }; const ViewportContext = createContext(null); diff --git a/src/lib/actions/ActionsManager.ts b/src/lib/actions/ActionsManager.ts index 5b0b8b95..6cead7c7 100644 --- a/src/lib/actions/ActionsManager.ts +++ b/src/lib/actions/ActionsManager.ts @@ -6,6 +6,7 @@ import { type SerializationManager } from '@/lib/serialization/SerializationMana import { type Sheet } from '@/lib/sheet/Sheet'; import { SelectionManager } from '@/lib/tools/SelectionManager'; import { ToolManager } from '@/lib/tools/ToolManager'; +import { ApplyFilterToGeometryAction } from './ApplyFilterToGeometryAction'; import { ConvertToPolygonAction } from './ConvertToPolygonAction'; import { CopyAction } from './CopyAction'; import { DeleteSelectedAction } from './DeleteSelectedAction'; @@ -52,6 +53,7 @@ const ACTIONS = [ OpenClosePolygonAction, ToggleLinkDimensionsAction, ConvertToPolygonAction, + ApplyFilterToGeometryAction, ]; const ACTIONS_BY_TYPE = { undo: UndoAction, @@ -76,6 +78,7 @@ const ACTIONS_BY_TYPE = { 'open-close-polygon': OpenClosePolygonAction, 'toggle-link-dimensions': ToggleLinkDimensionsAction, 'convert-to-polygon': ConvertToPolygonAction, + 'apply-filter-to-geometry': ApplyFilterToGeometryAction, }; export type ActionType = keyof typeof ACTIONS_BY_TYPE; export type Action = InstanceType<(typeof ACTIONS_BY_TYPE)[ActionType]>; diff --git a/src/lib/actions/ApplyFilterToGeometryAction.tsx b/src/lib/actions/ApplyFilterToGeometryAction.tsx new file mode 100644 index 00000000..cbeb7246 --- /dev/null +++ b/src/lib/actions/ApplyFilterToGeometryAction.tsx @@ -0,0 +1,887 @@ +import { Zap as ZapIcon } from 'lucide-react'; +import React from 'react'; +import { + ColinearConstraint, + Constraint, + ConstraintComponent, + ConstraintEndpoint, + Datum, + Entity, + GeometryComponent, + HorizontalConstraint, + type Id, + VerticalConstraint, +} from '@/lib/entity'; +import { ID_PREFIXES } from '@/lib/entity/GeometryStore'; +import { FilterComponent } from '@/lib/entity/components/FilterComponent'; +import { type ChamferFilterData } from '@/lib/entity/filters/chamfer'; +import { type FilletFilterData } from '@/lib/entity/filters/fillet'; +import { PolygonData } from '@/lib/entity/geometry/polygon'; +import { PolygonSegment } from '@/lib/entity/polygon'; +import { type RectangleEndpoint } from '@/lib/entity/rectangle'; +import { CornerReplacement, type CornerSegmentFactory, Vector2, mod } from '@/lib/math'; +import { CubicCurve, LineSegment, QuadraticCurve, SheetPosition } from '@/lib/viewport/types'; +import { ActionsManager } from './ActionsManager'; +import { BaseAction } from './BaseAction'; + +type CornerState = + | { + mode: 'rectangle'; + geometryId: Id; + centerEndpoint: RectangleEndpoint; + pointAEndpoint: RectangleEndpoint; + pointBEndpoint: RectangleEndpoint; + centerPos: SheetPosition; + pointAPos: SheetPosition; + pointBPos: SheetPosition; + } + | { + mode: 'polygon'; + geometryId: Id; + centerIndex: number; + pointAIndex: number; + pointBIndex: number; + centerPos: SheetPosition; + pointAPos: SheetPosition; + pointBPos: SheetPosition; + }; + +type ResolveGeometryAndIndicesResults = { + geometryId: Id; + polygon: Entity>; + polygonData: PolygonData; + centerIndex: number; + pointAIndex: number; + pointBIndex: number; + centerDatumId: Datum['id'] | null; + pointAIsAfterCenter: boolean; + pointBIsAfterCenter: boolean; +}; + +type ValidateOffsetResults = { + centerPos: SheetPosition; + pointAPos: SheetPosition; + pointBPos: SheetPosition; + lenA: number; + lenB: number; + tA: number; + tB: number; + offset: number; +}; + +type SplitEdgesAtOffsetResults = { + geometry: Entity>; + splitAPos: SheetPosition; + splitBPos: SheetPosition; + splitAIdx: number; + splitBIdx: number; + centerIdxFirst: number; +}; + +type BuildCornerSegmentResults = { + geometry: Entity>; + addedSegmentIndex: number; +}; + +export class ApplyFilterToGeometryAction extends BaseAction { + type = 'apply-filter-to-geometry' as const; + label = 'Apply Filter'; + stability = 'beta' as const; + + get icon(): React.ReactNode { + return ; + } + + constructor(actionManager: ActionsManager) { + super(actionManager); + + this.getSelectionManager().on('selectionChange', this.updateDisabled); + this.disabled = !this.hasApplicableFilters(); + } + + private updateDisabled = () => { + this.disabled = !this.hasApplicableFilters(); + }; + + private hasApplicableFilters(): boolean { + return [...this.getSelectionManager().getSelectedIds()].some((id) => { + const geom = this.getGeometryStore().getByIdWithComponent(id, FilterComponent); + if (!geom) { + return false; + } + const filter = FilterComponent.get(geom); + return filter.type === 'fillet' || filter.type === 'chamfer'; + }); + } + + async execute() { + this.getHistoryManager().applyTransaction( + this.type, + () => { + const selectedIds = [...this.getSelectionManager().getSelectedIds()]; + // Tracks rectangle → polygon ID remapping when convertRectangleToPolygon + // generates a new ID. Filters that reference the old rectangle ID need + // to resolve against the new polygon. + const geometryIdRemap = new Map(); + const remappedPolygons = new Map>>(); + + for (const id of selectedIds) { + const filterGeom = this.getGeometryStore().getByIdWithComponent(id, FilterComponent); + if (!filterGeom) { + continue; + } + const filter = FilterComponent.get(filterGeom); + + switch (filter.type) { + case 'fillet': { + const offset = filter.offset.toSheetUnits(this.getSheet().defaultUnit).magnitude; + const factory: CornerSegmentFactory = CornerReplacement.filletArc; + + const pending = this.resolvePending(filter, geometryIdRemap, remappedPolygons); + if (!pending) { + continue; + } + + const step1 = this.resolveGeometryAndIndices(pending); + + const step2 = this.validateOffset(step1, offset); + if (!step2) { + continue; + } + const step3 = this.splitEdgesAtOffset(step1, step2); + const step4 = this.buildCornerSegment(step1, step2, factory); + this.addColinearConstraints(step1, step2, step3); + this.addRectilinearConstraints(step1, step4); + + this.recordRemap(pending, step4.geometry, geometryIdRemap, remappedPolygons); + + this.getGeometryStore().deleteById(id); + this.getSelectionManager().deselect(id); + break; + } + case 'chamfer': { + const offset = filter.offset.toSheetUnits(this.getSheet().defaultUnit).magnitude; + const factory: CornerSegmentFactory = CornerReplacement.chamferLine; + + const pending = this.resolvePending(filter, geometryIdRemap, remappedPolygons); + if (!pending) { + continue; + } + + const step1 = this.resolveGeometryAndIndices(pending); + + const step2 = this.validateOffset(step1, offset); + if (!step2) { + continue; + } + const step3 = this.splitEdgesAtOffset(step1, step2); + const step4 = this.buildCornerSegment(step1, step2, factory); + this.addColinearConstraints(step1, step2, step3); + this.addRectilinearConstraints(step1, step4); + + this.recordRemap(pending, step4.geometry, geometryIdRemap, remappedPolygons); + + this.getGeometryStore().deleteById(id); + this.getSelectionManager().deselect(id); + break; + } + case 'mirror': + break; + default: + filter satisfies never; + break; + } + } + }, + { collapseIfSingle: true }, + ); + } + + /** + * If a rectangle was converted to a polygon during this step, record the + * remapping so subsequent filters (both in this batch and in future action + * invocations) can resolve against the new polygon. + */ + private recordRemap( + pending: CornerState, + finalPolygon: Entity>, + geometryIdRemap: Map, + remappedPolygons: Map>>, + ): void { + if (pending.mode === 'rectangle' && finalPolygon.id !== pending.geometryId) { + geometryIdRemap.set(pending.geometryId, finalPolygon.id); + remappedPolygons.set(pending.geometryId, finalPolygon); + + // Convert any other filter that still references the old rectangle into + // a polygon-mode filter so it acts just like a native polygon filter. + // This handles both same-execution multi-filter batches and separate + // sequential action invocations. + const allFilters = this.getGeometryStore().listWithComponent(FilterComponent); + for (const f of allFilters) { + const otherFilter = FilterComponent.get(f); + if ( + (otherFilter.type !== 'fillet' && otherFilter.type !== 'chamfer') || + otherFilter.geometryType !== 'rectangle' || + otherFilter.geometryId !== pending.geometryId + ) { + continue; + } + + const cornerState = this.buildCornerStateFromPolygon(finalPolygon, otherFilter); + if (!cornerState || cornerState.mode !== 'polygon') { + continue; + } + + this.getGeometryStore().updateByIdWithComponent(f.id, FilterComponent, (g) => + FilterComponent.update(g, { + type: otherFilter.type, + offset: otherFilter.offset, + geometryType: 'polygon' as const, + geometryId: finalPolygon.id, + pointAIndex: cornerState.pointAIndex, + pointCenterIndex: cornerState.centerIndex, + pointBIndex: cornerState.pointBIndex, + } as any), + ); + } + } + } + + /** + * Resolves a filter into a {@link CornerState}, first trying the filter's + * own geometry reference, then falling back to a previously remapped polygon + * if a prior filter already converted the rectangle into a polygon. + */ + private resolvePending( + filter: FilletFilterData | ChamferFilterData, + geometryIdRemap: Map, + remappedPolygons: Map>>, + ): CornerState | null { + const pending = this.buildCornerState(filter); + if (pending) { + return pending; + } + + // If the filter references a rectangle that was already converted to a + // polygon by a previous filter in this batch, resolve against the polygon. + if (filter.geometryType === 'rectangle' && geometryIdRemap.has(filter.geometryId)) { + const poly = remappedPolygons.get(filter.geometryId)!; + return this.buildCornerStateFromPolygon(poly, filter); + } + + return null; + } + + private buildCornerState(filter: FilletFilterData | ChamferFilterData): CornerState | null { + const store = this.getGeometryStore(); + + if (filter.geometryType === 'polygon') { + const centerPos = store.resolveConstraintEndpoint( + ConstraintEndpoint.lockedToPolygon(filter.geometryId, filter.pointCenterIndex), + ); + const posA = store.resolveConstraintEndpoint( + ConstraintEndpoint.lockedToPolygon(filter.geometryId, filter.pointAIndex), + ); + const posB = store.resolveConstraintEndpoint( + ConstraintEndpoint.lockedToPolygon(filter.geometryId, filter.pointBIndex), + ); + if (!centerPos || !posA || !posB) { + return null; + } + return { + mode: 'polygon', + geometryId: filter.geometryId, + centerIndex: filter.pointCenterIndex, + pointAIndex: filter.pointAIndex, + pointBIndex: filter.pointBIndex, + centerPos, + pointAPos: posA, + pointBPos: posB, + }; + } + + if (filter.geometryType === 'rectangle') { + const targetGeom = store.getByIdWithComponent(filter.geometryId, GeometryComponent); + if (!targetGeom) { + return null; + } + + // Geometry is still a rectangle — resolve via lockedToRectangle + if (GeometryComponent.isRectangle(targetGeom)) { + const centerPos = store.resolveConstraintEndpoint( + ConstraintEndpoint.lockedToRectangle(filter.geometryId, filter.pointCenterKeyPoint), + ); + const posA = store.resolveConstraintEndpoint( + ConstraintEndpoint.lockedToRectangle(filter.geometryId, filter.pointAKeyPoint), + ); + const posB = store.resolveConstraintEndpoint( + ConstraintEndpoint.lockedToRectangle(filter.geometryId, filter.pointBKeyPoint), + ); + if (!centerPos || !posA || !posB) { + return null; + } + return { + mode: 'rectangle', + geometryId: filter.geometryId, + centerEndpoint: filter.pointCenterKeyPoint, + pointAEndpoint: filter.pointAKeyPoint, + pointBEndpoint: filter.pointBKeyPoint, + centerPos, + pointAPos: posA, + pointBPos: posB, + }; + } + + // Rectangle was already converted to a polygon by a previous filter application. + // Infer corner positions from the polygon's bounding box, find the center vertex + // in the polygon, and traverse to find the actual adjacent vertices. + if (GeometryComponent.isPolygon(targetGeom)) { + return this.buildCornerStateFromPolygon( + targetGeom as Entity>, + filter, + ); + } + + return null; + } + + return null; + } + + /** + * Fallback for a rectangle-mode filter whose target geometry has already been + * converted to a polygon by a previous filter application. Computes the corner + * positions from the polygon's bounding box, finds the center vertex in the + * polygon, and traverses to determine the actual adjacent non-collocated vertices. + */ + private buildCornerStateFromPolygon( + polygon: Entity>, + filter: FilletFilterData | ChamferFilterData, + ): CornerState | null { + const polygonData = GeometryComponent.get(polygon); + + // Compute bounding box to infer the original rectangle's corner positions + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const pt of polygonData.points) { + if (pt.point.x < minX) { + minX = pt.point.x; + } + if (pt.point.y < minY) { + minY = pt.point.y; + } + if (pt.point.x > maxX) { + maxX = pt.point.x; + } + if (pt.point.y > maxY) { + maxY = pt.point.y; + } + } + + const cornerPositions: Record = { + upperLeft: new SheetPosition(minX, minY), + upperRight: new SheetPosition(maxX, minY), + lowerRight: new SheetPosition(maxX, maxY), + lowerLeft: new SheetPosition(minX, maxY), + }; + + // The filter's rectangle-mode fields are named pointAKeyPoint, etc. + const filterRect = filter as unknown as { + pointCenterKeyPoint: string; + pointAKeyPoint: string; + pointBKeyPoint: string; + }; + const centerPos = cornerPositions[filterRect.pointCenterKeyPoint]; + if (!centerPos) { + return null; + } + + // Find the center position in the current polygon + const centerIndex = this.findPointIndexByPos(polygonData.points, centerPos); + if (centerIndex < 0) { + // Corner was already filleted away + return null; + } + + const centerPoint = polygonData.points[centerIndex].point; + + // Traverse to find non-collocated adjacent vertices (same as tool's handleMouseDown) + let pointAIndex = centerIndex - 1; + while (pointAIndex < 0) { + pointAIndex += polygonData.points.length; + } + while ( + polygonData.points[pointAIndex].point.x === centerPoint.x && + polygonData.points[pointAIndex].point.y === centerPoint.y + ) { + pointAIndex -= 1; + while (pointAIndex < 0) { + pointAIndex += polygonData.points.length; + } + } + + let pointBIndex = centerIndex + 1; + while (pointBIndex >= polygonData.points.length) { + pointBIndex -= polygonData.points.length; + } + while ( + polygonData.points[pointBIndex].point.x === centerPoint.x && + polygonData.points[pointBIndex].point.y === centerPoint.y + ) { + pointBIndex += 1; + while (pointBIndex >= polygonData.points.length) { + pointBIndex -= polygonData.points.length; + } + } + + return { + mode: 'polygon', + geometryId: polygon.id, + centerIndex, + pointAIndex, + pointBIndex, + centerPos, + pointAPos: polygonData.points[pointAIndex].point, + pointBPos: polygonData.points[pointBIndex].point, + }; + } + + private resolveGeometryAndIndices(pending: CornerState): ResolveGeometryAndIndicesResults { + const geometryStore = this.getGeometryStore(); + + let geometryId = pending.geometryId; + let geometry: Entity>; + let polygonData: PolygonData; + let centerDatumId: Datum['id'] | null = null; + let centerIndex: number = -1; + let pointAIndex: number = -1; + let pointBIndex: number = -1; + let pointAIsAfterCenter: boolean; + let pointBIsAfterCenter: boolean; + switch (pending.mode) { + case 'polygon': { + const result = geometryStore.getByIdWithComponent(geometryId, GeometryComponent); + if (!result || !GeometryComponent.isPolygon(result)) { + throw new Error( + 'ApplyFilterToGeometryAction.resolveGeometryAndIndices: polygon not found', + ); + } + geometry = result; + polygonData = GeometryComponent.get(geometry); + + centerIndex = pending.centerIndex; + pointAIndex = pending.pointAIndex; + pointBIndex = pending.pointBIndex; + + const n = polygonData.points.length; + pointAIsAfterCenter = mod(pointAIndex - centerIndex, n) === 1; + pointBIsAfterCenter = mod(pointBIndex - centerIndex, n) === 1; + + // Migrate constraints attached to centerIndex to a datum + const constraints = geometryStore.findConstraintsByGeometryId(geometryId); + for (const c of constraints) { + const keys = Constraint.getPositionKeys(c); + for (const key of keys) { + const ep = Constraint.getEndpoint(c, key); + if ( + ep && + ep.type === 'locked-polygon' && + ep.id === geometryId && + ep.pointIndex === pending.centerIndex + ) { + if (!centerDatumId) { + const datum = geometryStore.addOrdered( + ID_PREFIXES.datum, + Datum.create(polygonData.points[pending.centerIndex].point), + ); + centerDatumId = datum.id; + } + geometryStore.updateByIdWithComponent(c.id, ConstraintComponent, (g) => + ConstraintComponent.update(g, { + [key]: ConstraintEndpoint.lockedToDatum(centerDatumId!), + }), + ); + } + } + } + break; + } + case 'rectangle': { + const resolvedCenter = geometryStore.resolveConstraintEndpoint( + ConstraintEndpoint.lockedToRectangle(geometryId, pending.centerEndpoint), + ); + const resolvedA = geometryStore.resolveConstraintEndpoint( + ConstraintEndpoint.lockedToRectangle(geometryId, pending.pointAEndpoint), + ); + const resolvedB = geometryStore.resolveConstraintEndpoint( + ConstraintEndpoint.lockedToRectangle(geometryId, pending.pointBEndpoint), + ); + if (!resolvedCenter || !resolvedA || !resolvedB) { + throw new Error( + 'ApplyFilterToGeometryAction.resolveGeometryAndIndices: rectangle endpoints not resolved', + ); + } + + // Migrate constraints attached to center endpoint to a datum + const constraints = geometryStore.findConstraintsByGeometryId(geometryId); + for (const c of constraints) { + const keys = Constraint.getPositionKeys(c); + for (const key of keys) { + const ep = Constraint.getEndpoint(c, key); + if ( + ep && + ep.type === 'locked-rectangle' && + ep.id === geometryId && + ep.point === pending.centerEndpoint + ) { + if (!centerDatumId) { + const datum = geometryStore.add(ID_PREFIXES.datum, Datum.create(resolvedCenter)); + centerDatumId = datum.id; + } + geometryStore.updateByIdWithComponent(c.id, ConstraintComponent, (g) => + ConstraintComponent.update(g, { + [key]: ConstraintEndpoint.lockedToDatum(centerDatumId!), + }), + ); + } + } + } + + // Convert rectangle to polygon + geometry = geometryStore.convertRectangleToPolygon(geometryId, { + insertConstraints: false, + }); + geometryId = geometry.id; + polygonData = GeometryComponent.get(geometry); + + // Find all three point indices by position in new polygon + for (let i = 0; i < polygonData.points.length - 1; i += 1) { + const p = polygonData.points[i].point; + if (p.x === resolvedCenter.x && p.y === resolvedCenter.y) { + centerIndex = i; + } + if (p.x === resolvedA.x && p.y === resolvedA.y) { + pointAIndex = i; + } + if (p.x === resolvedB.x && p.y === resolvedB.y) { + pointBIndex = i; + } + } + + if ( + typeof centerIndex !== 'number' || + typeof pointAIndex !== 'number' || + typeof pointBIndex !== 'number' + ) { + throw new Error( + 'ApplyFilterToGeometryAction.resolveGeometryAndIndices: could not find all point indices', + ); + } + + if (polygonData.closed) { + const pointsLengthWithoutClosed = polygonData.points.length - 1; + if (pointAIndex === 0 || pointAIndex === pointsLengthWithoutClosed - 1) { + pointBIsAfterCenter = pointBIndex > centerIndex; + pointAIsAfterCenter = !pointBIsAfterCenter; + } else if (pointBIndex === 0 || pointBIndex === pointsLengthWithoutClosed - 1) { + pointAIsAfterCenter = pointAIndex > centerIndex; + pointBIsAfterCenter = !pointAIsAfterCenter; + } else { + pointAIsAfterCenter = pointAIndex > centerIndex; + pointBIsAfterCenter = pointBIndex > centerIndex; + } + } else { + pointAIsAfterCenter = pointAIndex > centerIndex; + pointBIsAfterCenter = pointBIndex > centerIndex; + } + + while (centerIndex >= polygonData.points.length - 1) { + centerIndex -= polygonData.points.length - 1; + } + while (pointAIndex >= polygonData.points.length - 1) { + pointAIndex -= polygonData.points.length - 1; + } + while (pointBIndex >= polygonData.points.length - 1) { + pointBIndex -= polygonData.points.length - 1; + } + + break; + } + default: + pending satisfies never; + throw new Error( + `ApplyFilterToGeometryAction.resolveGeometryAndIndices: Unknown pending.mode value ${(pending as any).mode}`, + ); + } + + return { + geometryId, + polygon: geometry, + polygonData, + centerIndex, + pointAIndex, + pointBIndex, + centerDatumId, + pointAIsAfterCenter, + pointBIsAfterCenter, + }; + } + + private validateOffset( + step1: ResolveGeometryAndIndicesResults, + offset: number, + ): ValidateOffsetResults | null { + const polygonData = step1.polygonData; + const centerIndex = step1.centerIndex; + const pointAIndex = step1.pointAIndex; + const pointBIndex = step1.pointBIndex; + + const centerPos = polygonData.points[centerIndex].point; + const pointAPos = polygonData.points[pointAIndex].point; + const pointBPos = polygonData.points[pointBIndex].point; + + const lenA = Vector2.dist(centerPos, pointAPos); + const lenB = Vector2.dist(centerPos, pointBPos); + + if (offset >= lenA || offset >= lenB) { + return null; + } + + const tA = step1.pointAIsAfterCenter ? offset / lenA : 1 - offset / lenA; + const tB = step1.pointBIsAfterCenter ? offset / lenB : 1 - offset / lenB; + + return { + centerPos, + pointAPos, + pointBPos, + lenA, + lenB, + tA, + tB, + offset, + }; + } + + private splitEdgesAtOffset( + step1: ResolveGeometryAndIndicesResults, + step2: ValidateOffsetResults, + ): SplitEdgesAtOffsetResults { + const geometryStore = this.getGeometryStore(); + const historyManager = this.getHistoryManager(); + + let geometry = step1.polygon; + const geometryId = step1.geometryId; + const polygonData = step1.polygonData; + + // Split both edges (higher index first to avoid index shifts) + let sortedSplits = [ + { index: step1.pointAIsAfterCenter ? step1.pointAIndex - 1 : step1.pointAIndex, t: step2.tA }, + { index: step1.pointBIsAfterCenter ? step1.pointBIndex - 1 : step1.pointBIndex, t: step2.tB }, + ] + .map((sp) => { + while (sp.index < 0) { + sp.index += polygonData.points.length - 1; + } + return sp; + }) + .sort((a, b) => b.index - a.index); + + for (const { index, t } of sortedSplits) { + const currentConstraints = geometryStore.findConstraintsByGeometryId(geometryId); + const result = GeometryComponent.addPointOnEdge(geometry, currentConstraints, index, { + type: 't', + t, + }); + if (!result) { + continue; + } + geometry = result.geometry as typeof geometry; + + for (const event of result.updatedConstraintHistoryEvents) { + historyManager.apply(event); + } + } + + geometryStore.updateById(geometryId, geometry); + + const splitAPos = Vector2.lerp(step2.centerPos, step2.pointAPos, step2.offset / step2.lenA); + const splitBPos = Vector2.lerp(step2.centerPos, step2.pointBPos, step2.offset / step2.lenB); + + const currentPoints = GeometryComponent.get(geometry).points; + const splitAIdx = this.findPointIndexByPos(currentPoints, splitAPos); + const splitBIdx = this.findPointIndexByPos(currentPoints, splitBPos); + const centerIdxFirst = this.findPointIndexByPos(currentPoints, step2.centerPos); + + if (splitAIdx < 0 || splitBIdx < 0 || centerIdxFirst < 0) { + throw new Error( + 'ApplyFilterToGeometryAction.splitEdgesAtOffset: could not find split or center indices', + ); + } + + return { + geometry, + splitAPos, + splitBPos, + splitAIdx, + splitBIdx, + centerIdxFirst, + }; + } + + private buildCornerSegment( + step1: ResolveGeometryAndIndicesResults, + step2: ValidateOffsetResults, + factory: CornerSegmentFactory, + ): BuildCornerSegmentResults { + const geometryStore = this.getGeometryStore(); + const geometryId = step1.geometryId; + + const preSplitPoints = step1.polygonData.points; + + const viewportSegs: Array< + LineSegment | QuadraticCurve | CubicCurve + > = []; + for (let i = 0; i < preSplitPoints.length - 1; i += 1) { + viewportSegs.push( + PolygonSegment.toLineSegmentOrCurve(preSplitPoints[i].point, preSplitPoints[i + 1]), + ); + } + + const viewportLength = viewportSegs.length; + const cornerSegIndex = (step1.centerIndex - 1 + viewportLength) % viewportLength; + + const result = CornerReplacement.applyToPolygon( + viewportSegs, + cornerSegIndex, + step2.offset, + factory, + ); + + const newPoints: Array = []; + const [firstPoint] = PolygonSegment.fromLineSegmentOrCurve(result.segments[0]); + newPoints.push({ type: 'point', point: firstPoint }); + for (const seg of result.segments) { + const [, polySeg] = PolygonSegment.fromLineSegmentOrCurve(seg); + newPoints.push(polySeg); + } + + const addedSegmentIndex = result.insertedSegmentIndex; + + geometryStore.updateById(geometryId, (old) => { + if (!Entity.hasComponent(old, GeometryComponent)) { + return old; + } + return GeometryComponent.update(old as Entity>, { + points: newPoints, + }); + }); + + return { + geometry: geometryStore.getByIdWithComponent(geometryId, GeometryComponent) as Entity< + GeometryComponent + >, + addedSegmentIndex, + }; + } + + private addColinearConstraints( + step1: ResolveGeometryAndIndicesResults, + step2: ValidateOffsetResults, + step3: SplitEdgesAtOffsetResults, + ): void { + const geometryStore = this.getGeometryStore(); + const geometryId = step1.geometryId; + const centerDatumId = step1.centerDatumId; + + if (!centerDatumId) { + return; + } + + const finalPoly = geometryStore.getByIdWithComponent(geometryId, GeometryComponent) as Entity< + GeometryComponent + >; + if (!finalPoly) { + return; + } + const finalPoints = GeometryComponent.get( + finalPoly as Entity>, + ).points; + + const farAIdx = this.findPointIndexByPos(finalPoints, step2.pointAPos); + const splitAFinalIdx = this.findPointIndexByPos(finalPoints, step3.splitAPos); + const farBIdx = this.findPointIndexByPos(finalPoints, step2.pointBPos); + const splitBFinalIdx = this.findPointIndexByPos(finalPoints, step3.splitBPos); + + if (farAIdx >= 0 && splitAFinalIdx >= 0) { + geometryStore.add( + ID_PREFIXES.constraint, + ColinearConstraint.create( + ConstraintEndpoint.lockedToDatum(centerDatumId), + ConstraintEndpoint.lockedToPolygon(geometryId, farAIdx), + ConstraintEndpoint.lockedToPolygon(geometryId, splitAFinalIdx), + ), + ); + } + if (farBIdx >= 0 && splitBFinalIdx >= 0) { + geometryStore.add( + ID_PREFIXES.constraint, + ColinearConstraint.create( + ConstraintEndpoint.lockedToDatum(centerDatumId), + ConstraintEndpoint.lockedToPolygon(geometryId, farBIdx), + ConstraintEndpoint.lockedToPolygon(geometryId, splitBFinalIdx), + ), + ); + } + } + + private addRectilinearConstraints( + step1: ResolveGeometryAndIndicesResults, + step4: BuildCornerSegmentResults, + ): void { + const geometryStore = this.getGeometryStore(); + const geometryId = step1.geometryId; + const addedSegmentIndex = step4.addedSegmentIndex; + + const polygonData = step1.polygonData; + if (polygonData.points.length !== 5 || !polygonData.closed) { + return; + } + + let counter = 0; + for (const side of ['top', 'right', 'bottom', 'left'] as const) { + const pointA = ConstraintEndpoint.lockedToPolygon(geometryId, counter); + let pointBIndex = counter + 1; + if (pointBIndex > 4) { + pointBIndex = 0; + } + const pointB = ConstraintEndpoint.lockedToPolygon(geometryId, pointBIndex); + + switch (side) { + case 'top': + case 'bottom': + geometryStore.add(ID_PREFIXES.constraint, HorizontalConstraint.create(pointA, pointB)); + break; + case 'left': + case 'right': + geometryStore.add(ID_PREFIXES.constraint, VerticalConstraint.create(pointA, pointB)); + break; + } + + counter += 1; + if (counter === addedSegmentIndex) { + counter += 1; + } + } + } + + private findPointIndexByPos(points: Array<{ point: SheetPosition }>, pos: SheetPosition): number { + for (let i = 0; i < points.length; i++) { + if (points[i].point.x === pos.x && points[i].point.y === pos.y) { + return i; + } + } + return -1; + } +} diff --git a/src/lib/entity/GeometryStore.ts b/src/lib/entity/GeometryStore.ts index 5c199232..f9f28c9a 100644 --- a/src/lib/entity/GeometryStore.ts +++ b/src/lib/entity/GeometryStore.ts @@ -797,6 +797,14 @@ export class GeometryStore extends EventEmitter { return matches; } + /** Returns all filters who are attached to the given geometry id. */ + findFiltersByGeometryId(geometryId: Geometry['id']): Array> { + return this.listWithComponent(FilterComponent).filter((f) => { + const data = FilterComponent.get(f); + return data.geometryId === geometryId; + }); + } + /** Returns all constraints whose endpoints reference the given geometry ID * (via locked-rectangle, locked-ellipse, or locked-polygon). */ findConstraintsByGeometryId(geometryId: Id): Array> { diff --git a/src/lib/entity/components/FilterComponent.ts b/src/lib/entity/components/FilterComponent.ts index a7637b56..34a80bc9 100644 --- a/src/lib/entity/components/FilterComponent.ts +++ b/src/lib/entity/components/FilterComponent.ts @@ -1,4 +1,5 @@ import { Filter, type FilterData } from '../filters'; +import { RectangleEndpoint } from '../rectangle'; import { type Entity, type EntityComponent } from '../types'; /** @@ -49,4 +50,48 @@ export namespace FilterComponent { }, }; } + + export function isLockedToRectangle( + geometry: Entity, + rectangleId: Entity['id'], + rectanglePoint: RectangleEndpoint, + ) { + const filter = FilterComponent.get(geometry); + switch (filter.type) { + case 'fillet': + case 'chamfer': + return ( + filter.geometryType === 'rectangle' && + filter.geometryId === rectangleId && + filter.pointCenterKeyPoint === rectanglePoint + ); + case 'mirror': + return false; + default: + filter satisfies never; + return false; + } + } + + export function isLockedToPolygon( + geometry: Entity, + polygonId: Entity['id'], + pointIndex: number, + ) { + const filter = FilterComponent.get(geometry); + switch (filter.type) { + case 'fillet': + case 'chamfer': + return ( + filter.geometryType === 'polygon' && + filter.geometryId === polygonId && + filter.pointCenterIndex === pointIndex + ); + case 'mirror': + return false; + default: + filter satisfies never; + return false; + } + } } diff --git a/src/lib/entity/components/GeometryComponent.ts b/src/lib/entity/components/GeometryComponent.ts index 041c8572..3561da7b 100644 --- a/src/lib/entity/components/GeometryComponent.ts +++ b/src/lib/entity/components/GeometryComponent.ts @@ -1,10 +1,69 @@ -import { KeyPoints, Rect, SheetPosition } from '@/lib/viewport/types'; +import { BoundingBox, CornerReplacement } from '@/lib/math'; +import { type UnitType } from '@/lib/units/length'; +import { + CubicCurve, + KeyPoints, + LineSegment, + QuadraticCurve, + Rect, + SheetPosition, +} from '@/lib/viewport/types'; +import { Filter, FilterData } from '../filters'; import { type Geometry, type GeometryData } from '../geometry'; import { EllipseData } from '../geometry/ellipse'; import { PolygonData, PolygonSegment } from '../geometry/polygon'; import { RectangleData } from '../geometry/rectangle'; import { type Entity, type EntityComponent, ResizeParams } from '../types'; import { ConstraintComponent } from './ConstraintComponent'; +import { FilterComponent } from './FilterComponent'; + +export type RenderShapePolygon = { + shape: 'polygon'; + key: string; + primary: boolean; + points: Array; + closed: boolean; +}; +export type RenderShapeRectangle = { + shape: 'rectangle'; + key: string; + upperLeft: SheetPosition; + lowerRight: SheetPosition; +}; +export type RenderShapeEllipse = { + shape: 'ellipse'; + key: string; + center: SheetPosition; + radiusX: number; + radiusY: number; +}; + +export type RenderShape = RenderShapePolygon | RenderShapeRectangle | RenderShapeEllipse; + +namespace RenderShape { + export function polygon( + key: string, + points: Array, + closed: boolean, + options?: { primary?: boolean }, + ): RenderShape { + return { shape: 'polygon' as const, key, primary: options?.primary ?? false, points, closed }; + } + export function rectangle( + key: string, + upperLeft: SheetPosition, + lowerRight: SheetPosition, + ): RenderShape { + return { shape: 'rectangle' as const, key, upperLeft, lowerRight }; + } + export function ellipse( + key: string, + center: SheetPosition, + args: { radiusX: number; radiusY: number }, + ): RenderShape { + return { shape: 'ellipse' as const, key, center, radiusX: args.radiusX, radiusY: args.radiusY }; + } +} /** * Entity component for a geometry - a rectangle, ellipse, or polygon. @@ -113,7 +172,7 @@ export namespace GeometryComponent { default: state satisfies never; throw new Error( - `GeometryComponent.keyPoints: Unknown polygon data type ${(state as any).type}`, + `GeometryComponent.keyPoints: Unknown geometry data type ${(state as any).type}`, ); } } @@ -130,7 +189,7 @@ export namespace GeometryComponent { default: state satisfies never; throw new Error( - `GeometryComponent.boundingBox: Unknown polygon data type ${(state as any).type}`, + `GeometryComponent.boundingBox: Unknown geometry data type ${(state as any).type}`, ); } } @@ -142,11 +201,20 @@ export namespace GeometryComponent { const state = GeometryComponent.get(geometry); switch (state.type) { case 'polygon': - return PolygonData.translate(geometry as Entity>, transform) as E; + return PolygonData.translate( + geometry as Entity>, + transform, + ) as E; case 'rectangle': - return RectangleData.translate(geometry as Entity>, transform) as E; + return RectangleData.translate( + geometry as Entity>, + transform, + ) as E; case 'ellipse': - return EllipseData.translate(geometry as Entity>, transform) as E; + return EllipseData.translate( + geometry as Entity>, + transform, + ) as E; default: state satisfies never; throw new Error( @@ -184,7 +252,7 @@ export namespace GeometryComponent { default: state satisfies never; throw new Error( - `GeometryComponent.equals: Unknown polygon data type ${(state as any).type}`, + `GeometryComponent.equals: Unknown geometry data type ${(state as any).type}`, ); } } @@ -217,7 +285,7 @@ export namespace GeometryComponent { default: state satisfies never; throw new Error( - `GeometryComponent.resize: Unknown polygon data type ${(state as any).type}`, + `GeometryComponent.resize: Unknown geometry data type ${(state as any).type}`, ); } } @@ -478,4 +546,321 @@ export namespace GeometryComponent { ) { return PolygonData.addPointOnEdge(geometry, constraints, segmentIndex, newPointPosition); } + + /** Mirrors a point over an infinite line defined by two points. */ + function mirrorPointOverLine( + point: SheetPosition, + lineA: SheetPosition, + lineB: SheetPosition, + ): SheetPosition { + const dx = lineB.x - lineA.x; + const dy = lineB.y - lineA.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) { + return point; + } + const t = ((point.x - lineA.x) * dx + (point.y - lineA.y) * dy) / lenSq; + const projX = lineA.x + t * dx; + const projY = lineA.y + t * dy; + return new SheetPosition(2 * projX - point.x, 2 * projY - point.y); + } + + /** A map used to look up each filter and determine the order in which they should be applied. + * Lower number = applied earlier, larger number = applied later. */ + const FILTER_DATA_TYPE_SORT_ORDER: { [key in FilterData['type']]: number } = { + fillet: 1, + chamfer: 1, + mirror: 2, + }; + + export function getRenderShapes( + geometry: Entity>, + sheetDefaultUnit: UnitType, + filters: Array = [], + ): Array { + let shapes; + + const state = GeometryComponent.get(geometry); + switch (state.type) { + case 'polygon': + shapes = [RenderShape.polygon(geometry.id, state.points, state.closed, { primary: true })]; + break; + case 'rectangle': + shapes = [RenderShape.rectangle(geometry.id, state.upperLeft, state.lowerRight)]; + break; + case 'ellipse': + shapes = [ + RenderShape.ellipse(geometry.id, state.center, { + radiusX: state.radiusX, + radiusY: state.radiusY, + }), + ]; + break; + default: + state satisfies never; + throw new Error( + `GeometryComponent.getRenderShapes: Unknown geometry data type ${(state as any).type}`, + ); + } + + let filterApplicationCounter = 0; + for (const filter of filters.sort((a, b) => { + const aScore = FILTER_DATA_TYPE_SORT_ORDER[FilterComponent.get(a).type]; + const bScore = FILTER_DATA_TYPE_SORT_ORDER[FilterComponent.get(b).type]; + return aScore - bScore; + })) { + const filterData = FilterComponent.get(filter); + switch (filterData.type) { + case 'mirror': { + const mirrorResults = shapes.flatMap((renderShape) => { + filterApplicationCounter += 1; + const key = `${filter.id}_${filterApplicationCounter}`; + + switch (renderShape.shape) { + case 'rectangle': { + // IMPORTANT: the below algorithm does not properly handle flipping over non 90 or 45 + // degree lines, since there isn't a way to represent a rotated rectangle currently. + // + // FIXME: Address this, it's a bug that is fairly noticable. + const corners = BoundingBox.cornersToArray( + BoundingBox.corners( + BoundingBox.fromPoints([renderShape.upperLeft, renderShape.lowerRight]), + ), + ); + const flippedCorners = corners.map((point) => + mirrorPointOverLine(point, filterData.pointA, filterData.pointB), + ); + const ul = new SheetPosition( + Math.min(...flippedCorners.map((p) => p.x)), + Math.min(...flippedCorners.map((p) => p.y)), + ); + const lr = new SheetPosition( + Math.max(...flippedCorners.map((p) => p.x)), + Math.max(...flippedCorners.map((p) => p.y)), + ); + return [RenderShape.rectangle(key, ul, lr)]; + } + case 'ellipse': { + const mirroredCenter = mirrorPointOverLine( + renderShape.center, + filterData.pointA, + filterData.pointB, + ); + return [ + RenderShape.ellipse(key, mirroredCenter, { + radiusX: renderShape.radiusX, + radiusY: renderShape.radiusY, + }), + ]; + } + case 'polygon': { + const mirroredPoints = renderShape.points.map((segment) => { + const mirroredPoint = mirrorPointOverLine( + segment.point, + filterData.pointA, + filterData.pointB, + ); + switch (segment.type) { + case 'point': + return { type: 'point' as const, point: mirroredPoint }; + case 'arc-quadratic': + return { + type: 'arc-quadratic' as const, + point: mirroredPoint, + controlPoint: mirrorPointOverLine( + segment.controlPoint, + filterData.pointA, + filterData.pointB, + ), + }; + case 'arc-cubic': + return { + type: 'arc-cubic' as const, + point: mirroredPoint, + controlPointA: mirrorPointOverLine( + segment.controlPointA, + filterData.pointA, + filterData.pointB, + ), + controlPointB: mirrorPointOverLine( + segment.controlPointB, + filterData.pointA, + filterData.pointB, + ), + }; + default: + segment satisfies never; + throw new Error( + `getRenderShapes: Unknown polygon segment type ${(segment as any).type}`, + ); + } + }); + return [ + RenderShape.polygon(key, mirroredPoints, renderShape.closed, { + primary: false, + }), + ]; + } + default: + renderShape satisfies never; + throw new Error( + `getRenderShapes: Unknown render shape type ${(renderShape as any).shape}`, + ); + } + }); + shapes.push(...mirrorResults); + break; + } + case 'fillet': + case 'chamfer': { + const factory = + filterData.type === 'fillet' + ? CornerReplacement.filletArc + : CornerReplacement.chamferLine; + const offsetNum = filterData.offset.toSheetUnits(sheetDefaultUnit).magnitude; + + shapes = shapes.flatMap((renderShape) => { + filterApplicationCounter += 1; + const key = `${filter.id}_${filterApplicationCounter}`; + + let resultSegs: Array< + LineSegment | QuadraticCurve | CubicCurve + > | null = null; + + switch (renderShape.shape) { + case 'rectangle': { + switch (filterData.geometryType) { + case 'rectangle': + resultSegs = CornerReplacement.applyToRectangle( + renderShape.upperLeft, + renderShape.lowerRight, + filterData.pointCenterKeyPoint, + offsetNum, + factory, + ).segments; + break; + case 'polygon': + console.warn( + 'GeometryComponent.getRenderShapes: applying fillet/chamfer - geoemtryType of polygon cannot apply to renderShape of rectangle, skipping...', + ); + break; + default: + filterData satisfies never; + break; + } + break; + } + case 'polygon': { + // Convert polygon points to viewport segments + const pointsLength = renderShape.points.length; + const viewportSegs: Array< + | LineSegment + | QuadraticCurve + | CubicCurve + > = []; + for (let i = 0; i < pointsLength - 1; i += 1) { + viewportSegs.push( + PolygonSegment.toLineSegmentOrCurve( + renderShape.points[i].point, + renderShape.points[i + 1], + ), + ); + } + + switch (filterData.geometryType) { + case 'rectangle': { + // Skip non corner points + if ( + filterData.pointCenterKeyPoint !== 'upperLeft' && + filterData.pointCenterKeyPoint !== 'upperRight' && + filterData.pointCenterKeyPoint !== 'lowerLeft' && + filterData.pointCenterKeyPoint !== 'lowerRight' + ) { + break; + } + + // Find the viewport segment index whose end is the center vertex + const cornerPositions = BoundingBox.corners( + BoundingBox.fromPoints(renderShape.points.map((p) => p.point)), + ); + const centerPos = cornerPositions[filterData.pointCenterKeyPoint]; + // Find the center vertex index in the polygon + let centerPtIndex: number | null = null; + for (let i = 0; i < pointsLength - 1; i += 1) { + if ( + renderShape.points[i].point.x === centerPos.x && + renderShape.points[i].point.y === centerPos.y + ) { + centerPtIndex = i; + break; + } + } + if (centerPtIndex === null) { + return []; + } + const cornerIndex = + (centerPtIndex - 1 + viewportSegs.length) % viewportSegs.length; + + resultSegs = CornerReplacement.applyToPolygon( + viewportSegs, + cornerIndex, + offsetNum, + factory, + ).segments; + break; + } + case 'polygon': { + // Find the viewport segment index whose end is the center vertex + const cornerIndex = + (filterData.pointCenterIndex - 1 + viewportSegs.length) % viewportSegs.length; + + resultSegs = CornerReplacement.applyToPolygon( + viewportSegs, + cornerIndex, + offsetNum, + factory, + ).segments; + break; + } + default: + filterData satisfies never; + } + break; + } + case 'ellipse': + // Ellipses can't have fillets / chamfers + // So just pass through unchanged + return [renderShape]; + default: + renderShape satisfies never; + throw new Error( + `getRenderShapes: Unknown render shape type ${(renderShape as any).shape}`, + ); + } + + if (!resultSegs) { + return []; + } + + // Convert viewport segments back to PolygonSegment[] + const newPoints: Array = []; + const [firstPoint] = PolygonSegment.fromLineSegmentOrCurve(resultSegs[0]); + newPoints.push({ type: 'point', point: firstPoint }); + for (const seg of resultSegs) { + const [, polySeg] = PolygonSegment.fromLineSegmentOrCurve(seg); + newPoints.push(polySeg); + } + + return [RenderShape.polygon(key, newPoints, true)]; + }); + break; + } + default: + filterData satisfies never; + break; + } + } + + return shapes; + } } diff --git a/src/lib/entity/rectangle.ts b/src/lib/entity/rectangle.ts index f5ee3054..2a1963d3 100644 --- a/src/lib/entity/rectangle.ts +++ b/src/lib/entity/rectangle.ts @@ -18,6 +18,8 @@ export type Rectangle = Entity< /** A rectangle without params that will be added by the {@link GeometryStore#addRectangle} method */ export type RectangleTemplate = Omit, 'id'>; +export type RectangleCorner = 'upperLeft' | 'upperRight' | 'lowerRight' | 'lowerLeft'; + /** A point on a rectangle that a constraint endpoint can lock to. * Derived from {@link GeometryComponent.keyPoints} -> {@link RectangleData.keyPoints}. */ export type RectangleEndpoint = KeyPointKeys>; diff --git a/src/lib/entity/types.ts b/src/lib/entity/types.ts index ef02b93e..5a167663 100644 --- a/src/lib/entity/types.ts +++ b/src/lib/entity/types.ts @@ -64,7 +64,7 @@ export namespace Entity { } /** Given a {@link Entity} with a given component, generate an entity with exclusively that - * component. Very similar in concept to Pick<{ a: 1, b: 2}, 'a'> */ + * component. Very similar in concept to Pick<{ a: 1, b: 2}, 'a'> */ export function pickComponent( entity: Entity, component: { readonly key: keyof C }, @@ -78,8 +78,8 @@ export namespace Entity { } /** Given a {@link Entity} and a given component that another {@link Entity} contains, take the - * value of the component from the first entity and copy it into the second. Conceptually similar - * to Object.assign(entity, source). */ + * value of the component from the first entity and copy it into the second. Conceptually similar + * to Object.assign(entity, source). */ export function assignComponent( entity: Entity, component: { readonly key: keyof C }, diff --git a/src/lib/math/bounding-box.ts b/src/lib/math/bounding-box.ts index aee2551a..1b1a6141 100644 --- a/src/lib/math/bounding-box.ts +++ b/src/lib/math/bounding-box.ts @@ -1,4 +1,4 @@ -import { Entity, type PolygonSegment } from '@/lib/entity'; +import { type PolygonSegment } from '@/lib/entity'; import { type Position, type Rect, type RectCorners, SheetPosition } from '@/lib/viewport/types'; /** Namespace for axis-aligned bounding box (AABB) operations. */ diff --git a/src/lib/math/corner-replacement.ts b/src/lib/math/corner-replacement.ts new file mode 100644 index 00000000..a045c726 --- /dev/null +++ b/src/lib/math/corner-replacement.ts @@ -0,0 +1,368 @@ +import { type RectangleEndpoint } from '@/lib/entity/rectangle'; +import { CubicCurve, LineSegment, type Position, QuadraticCurve } from '@/lib/viewport/types'; +import { Vector2 } from './vector'; + +/** Factory callback that creates the corner replacement segment (arc or line). */ +export type CornerSegmentFactory

= ( + p0: P, + p3: P, + tStart: P, + tEnd: P, + offset: number, + farAPos: P, + cornerPos: P, + farBPos: P, +) => CubicCurve

| LineSegment

| QuadraticCurve

; + +/** Return type for applyToPolygon. */ +export type FilletChamferPolygonResult

= { + segments: Array | QuadraticCurve

| CubicCurve

>; + /** Maps old vertex index (0..n-1, where vertex i = segments[i].start) to its index in the new segment list. */ + oldPointToNewPoint: Map; + /** Index of the inserted corner segment in the viewport `segments` array. */ + insertedSegmentIndex: number; +}; + +/** Return type for applyToRectangle. */ +export type FilletChamferRectangleResult

= { + segments: Array | QuadraticCurve

| CubicCurve

>; + /** Maps each rectangle corner label to its vertex index in the new segment list. The corner that was filleted/chamfered is excluded. */ + oldPointToNewPoint: Map; +}; + +/** + * Computes the position of a rectangle corner label given the upper-left and + * lower-right bounds of an axis-aligned rectangle. + */ +function getRectangleCornerPosition

( + upperLeft: P, + lowerRight: P, + corner: RectangleEndpoint, +): P { + const Cn = (upperLeft as any).constructor as new (x: number, y: number) => P; + switch (corner) { + case 'upperLeft': + return new Cn(upperLeft.x, upperLeft.y); + case 'upperRight': + return new Cn(lowerRight.x, upperLeft.y); + case 'lowerRight': + return new Cn(lowerRight.x, lowerRight.y); + case 'lowerLeft': + return new Cn(upperLeft.x, lowerRight.y); + case 'center': + return new Cn( + upperLeft.x + (lowerRight.x - upperLeft.x) / 2, + upperLeft.y + (lowerRight.y - upperLeft.y) / 2, + ); + default: + corner satisfies never; + throw new Error(`getRectangleCornerPosition: No corner ${corner} found.`); + } +} + +/** + * Given a corner position and the positions of the two adjacent corners, + * computes the split points along each edge at the given offset distance + * and the unit tangents at those split points (pointing away from the + * corner along each edge). + */ +function computeCornerSplitInfo

( + cornerPos: P, + adjacentAPos: P, + adjacentBPos: P, + offset: number, +): { + splitA: P; + splitB: P; + dirA: P; + dirB: P; +} { + const dirA = Vector2.norm(Vector2.sub(adjacentAPos, cornerPos)); + const dirB = Vector2.norm(Vector2.sub(adjacentBPos, cornerPos)); + const splitA = Vector2.add(cornerPos, Vector2.scale(dirA, offset)); + const splitB = Vector2.add(cornerPos, Vector2.scale(dirB, offset)); + return { splitA, splitB, dirA, dirB }; +} + +/** + * Computes the cubic bezier control points for an arc that replaces a + * polygon corner, using the standard cubic bezier arc approximation. + * + * The arc runs from start (splitA) to end (splitB). The tangents tell + * how the incoming and outgoing edges meet at the corner. + * + * @param start - Split position on the first edge (p0). + * @param end - Split position on the second edge (p3). + * @param tStart - Unit tangent at start, pointing toward the corner. + * @param tEnd - Unit tangent at end, pointing away from the corner. + * @param offset - The fillet offset distance (also the arc "radius"). + * @param centerPos - Position of the original corner vertex. + * @param farAPos - Position of the far end of the first edge. + * @param farBPos - Position of the far end of the second edge. + */ +export function computeFilletArcControlPoints

( + start: P, + end: P, + tStart: P, + tEnd: P, + offset: number, + centerPos: P, + farAPos: P, + farBPos: P, +): { controlPointA: P; controlPointB: P } { + // Compute the angle between the two edges meeting at the corner + const cosTheta = Math.max( + -1, + Math.min( + 1, + Vector2.dot( + Vector2.norm(Vector2.sub(farAPos, centerPos)), + Vector2.norm(Vector2.sub(farBPos, centerPos)), + ), + ), + ); + const theta = Math.acos(cosTheta); + // Magic constant for cubic bezier quarter-arc approximation: 4/3 * tan(angle/4) + const kVal = (4 / 3) * Math.tan(theta / 4); + const kR = kVal * offset; + + const controlPointA = Vector2.add(start, Vector2.scale(tStart, kR)); + const controlPointB = Vector2.sub(end, Vector2.scale(tEnd, kR)); + + return { controlPointA, controlPointB }; +} + +/** The four rectangle corners in CCW perimeter order. */ +const RECTANGLE_PERIMETER_ORDER: Array = [ + 'upperLeft', + 'upperRight', + 'lowerRight', + 'lowerLeft', +]; + +/** + * Converts an axis-aligned rectangle into an array of four line segments + * representing its perimeter edges in CCW order. + * + * Segment 0: upperLeft -> upperRight + * Segment 1: upperRight -> lowerRight + * Segment 2: lowerRight -> lowerLeft + * Segment 3: lowerLeft -> upperLeft + */ +function rectangleToSegments

( + upperLeft: P, + lowerRight: P, +): Array> { + const ul = getRectangleCornerPosition(upperLeft, lowerRight, 'upperLeft'); + const ur = getRectangleCornerPosition(upperLeft, lowerRight, 'upperRight'); + const lr = getRectangleCornerPosition(upperLeft, lowerRight, 'lowerRight'); + const ll = getRectangleCornerPosition(upperLeft, lowerRight, 'lowerLeft'); + + return [ + { start: ul, end: ur }, + { start: ur, end: lr }, + { start: lr, end: ll }, + { start: ll, end: ul }, + ]; +} + +/** + * Returns the segment index whose `end` is the given rectangle corner. + * The fillet/chamfer is applied at the junction between this segment + * (index) and the next (index + 1 mod 4). + */ +function getRectangleCornerSegmentIndex(corner: RectangleEndpoint): number { + const idx = RECTANGLE_PERIMETER_ORDER.indexOf(corner); + // The corner sits at the end of segment (idx - 1), wrapping for idx=0. + return (idx + 3) % 4; +} + +/** + * Computes the old-to-new vertex index mapping for a corner replacement + * operation on a polygon. + * + * Vertex i corresponds to segments[i].start (= segments[(i-1+n)%n].end). + * The corner vertex (at nextIdx) is removed; all vertices after it shift + * by +1 in the non-wrap case and are unchanged in the wrap case. + */ +function buildOldToNewMap(n: number, cornerIndex: number): Map { + const map = new Map(); + const nextIdx = (cornerIndex + 1) % n; + for (let i = 0; i < n; i += 1) { + if (i === nextIdx) { + // The corner vertex is removed - do not include in the map + continue; + } + if (nextIdx > cornerIndex) { + // Non-wrap: vertices beyond the removed one shift by +1 + if (i < nextIdx) { + map.set(i, i); + } else { + map.set(i, i + 1); + } + } else { + // Wrap: vertices 1..n-1 stay at the same index + map.set(i, i); + } + } + return map; +} + +// --------------------------------------------------------------------------- +// CornerReplacement namespace +// --------------------------------------------------------------------------- + +export namespace CornerReplacement { + /** Pre-built factory: cubic bezier arc for a fillet. */ + export function filletArc

( + p0: P, + p3: P, + tStart: P, + tEnd: P, + offset: number, + farAPos: P, + cornerPos: P, + farBPos: P, + ): CubicCurve

{ + const { controlPointA, controlPointB } = computeFilletArcControlPoints( + p0, + p3, + tStart, + tEnd, + offset, + cornerPos, + farAPos, + farBPos, + ); + return { start: p0, end: p3, controlPointA, controlPointB }; + } + + /** Pre-built factory: straight line for a chamfer. */ + export function chamferLine

( + p0: P, + p3: P, + _tStart: P, + _tEnd: P, + _offset: number, + _farAPos: P, + _cornerPos: P, + _farBPos: P, + ): LineSegment

{ + return { start: p0, end: p3 }; + } + + /** + * Applies a corner replacement to a rectangle corner, returning the full + * polygon segment list that results from converting the rectangle to a + * polygon and replacing the specified corner with a segment created by + * the provided factory. + */ + export function applyToRectangle

( + upperLeft: P, + lowerRight: P, + corner: RectangleEndpoint, + offset: number, + segmentFactory: CornerSegmentFactory

, + ): FilletChamferRectangleResult

{ + const segments = rectangleToSegments(upperLeft, lowerRight); + const cornerIndex = getRectangleCornerSegmentIndex(corner); + const result = applyToPolygon(segments, cornerIndex, offset, segmentFactory); + + // Convert vertex-index map to RectangleEndpoint map + const labelMap = new Map(); + for (const [oldIdx, newIdx] of result.oldPointToNewPoint) { + labelMap.set(RECTANGLE_PERIMETER_ORDER[oldIdx], newIdx); + } + + return { segments: result.segments, oldPointToNewPoint: labelMap }; + } + + /** + * Applies a corner replacement (fillet or chamfer) to a polygon corner + * by replacing the two edge segments that meet at the given index with + * truncated versions and a new segment created by the factory callback. + * + * The corner is at the junction between segments[cornerIndex] and the next + * segment (wrapping around). Both edge segments must be plain line + * segments (no control points); otherwise returns the input unchanged. + */ + export function applyToPolygon

( + segments: Array | QuadraticCurve

| CubicCurve

>, + cornerIndex: number, + offset: number, + segmentFactory: CornerSegmentFactory

, + ): FilletChamferPolygonResult

{ + const n = segments.length; + if (n < 2) { + return { segments, oldPointToNewPoint: new Map(), insertedSegmentIndex: -1 }; + } + const nextIdx = (cornerIndex + 1) % n; + + const segA = segments[cornerIndex]; + const segB = segments[nextIdx]; + + // Both segments must be plain line segments for the replacement to be applied + if ( + CubicCurve.isCubicCurve(segA) || + QuadraticCurve.isQuadraticCurve(segA) || + CubicCurve.isCubicCurve(segB) || + QuadraticCurve.isQuadraticCurve(segB) + ) { + return { segments, oldPointToNewPoint: new Map(), insertedSegmentIndex: -1 }; + } + + const cornerPos = segA.end; + const farAPos = segA.start; + const farBPos = segB.end; + + const { splitA, splitB, dirA, dirB } = computeCornerSplitInfo( + cornerPos, + farAPos, + farBPos, + offset, + ); + + // Tangent at splitA points toward the corner (opposite of dirA) + const tStart = Vector2.scale(dirA, -1); + // Tangent at splitB points toward farBPos (same as dirB) + const tEnd = dirB; + + const cornerSegment = segmentFactory( + splitA, + splitB, + tStart, + tEnd, + offset, + farAPos, + cornerPos, + farBPos, + ); + + const truncatedA: LineSegment

= { start: farAPos, end: splitA }; + const truncatedB: LineSegment

= { start: splitB, end: farBPos }; + + const isWrapping = nextIdx < cornerIndex; + let result: Array | QuadraticCurve

| CubicCurve

>; + let insertedSegmentIndex: number; + + if (isWrapping) { + result = [truncatedB, ...segments.slice(nextIdx + 1, cornerIndex), truncatedA, cornerSegment]; + insertedSegmentIndex = result.length - 1; + } else { + result = [ + ...segments.slice(0, cornerIndex), + truncatedA, + cornerSegment, + truncatedB, + ...segments.slice(nextIdx + 1), + ]; + insertedSegmentIndex = cornerIndex + 1; + } + + return { + segments: result, + oldPointToNewPoint: buildOldToNewMap(n, cornerIndex), + insertedSegmentIndex, + }; + } +} diff --git a/src/lib/math/index.ts b/src/lib/math/index.ts index a561be14..c98a5860 100644 --- a/src/lib/math/index.ts +++ b/src/lib/math/index.ts @@ -1,4 +1,9 @@ -import { type PointSegment, type PolygonSegment } from '@/lib/entity'; +import { + type PointSegment, + type PolygonSegment, + RectangleCorner, + RectangleEndpoint, +} from '@/lib/entity'; import { CubicCurve, Position, QuadraticCurve, SheetPosition } from '@/lib/viewport/types'; import { DeCasteljau } from './bezier'; import { Vector2 } from './vector'; @@ -10,6 +15,13 @@ export { Vector2 } from './vector'; export { BoundingBox } from './bounding-box'; export { type CohenSutherlandOutcode, CohenSutherland } from './cohen-sutherland'; export { DeCasteljau, cubicBezierAt } from './bezier'; +export { + CornerReplacement, + computeFilletArcControlPoints, + type CornerSegmentFactory, + type FilletChamferPolygonResult, + type FilletChamferRectangleResult, +} from './corner-replacement'; /* Round a number to an arbitrary number of decimal places. */ export function round(n: number, places: number = 0): number { @@ -17,6 +29,11 @@ export function round(n: number, places: number = 0): number { return Math.round(n * power) / power; } +/** Modular arithmetic helper that returns a non-negative remainder. */ +export function mod(n: number, m: number): number { + return ((n % m) + m) % m; +} + /** Points on an ellipse used for constraint syncing. */ export type EllipsePoints

= { center: P; diff --git a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx index bff30bc2..eddef264 100644 --- a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx +++ b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx @@ -1,27 +1,20 @@ import { - ColinearConstraint, - Constraint, ConstraintComponent, ConstraintEndpoint, Datum, DatumComponent, Entity, GeometryComponent, - HorizontalConstraint, type Id, - Polygon, - VerticalConstraint, } from '@/lib/entity'; import { ID_PREFIXES } from '@/lib/entity/GeometryStore'; -import { FilletFilter } from '@/lib/entity/filters'; import { PolygonData } from '@/lib/entity/geometry/polygon'; -import { RectangleData } from '@/lib/entity/geometry/rectangle'; -import { PolygonSegment } from '@/lib/entity/polygon'; import { type RectangleEndpoint } from '@/lib/entity/rectangle'; -import { Vector2 } from '@/lib/math'; import { applyKeyPointSnapping } from '@/lib/snapping'; import { Length } from '@/lib/units/length'; import { ScreenPosition, SheetPosition, type ViewportState } from '@/lib/viewport/types'; +import { FilterComponent } from '../entity/components/FilterComponent'; +import { FilterTemplate } from '../entity/filters'; import { BaseTool } from './BaseTool'; export type CornerState = @@ -102,38 +95,6 @@ export type ValidateOffsetResults = { offset: number; }; -/** - * Results from splitEdgesAtOffset: inserts two new vertices on the polygon edges - * at the offset distance from the center. Constraint history events are replayed - * to maintain constraint integrity. - */ -type SplitEdgesAtOffsetResults = { - /** The updated polygon geometry after both edge splits. */ - geometry: Entity>; - /** Sheet position where the first edge was split (center->pointA side). */ - splitAPos: SheetPosition; - /** Sheet position where the second edge was split (center->pointB side). */ - splitBPos: SheetPosition; - /** Index in the post-split polygon.points array of splitA. */ - splitAIdx: number; - /** Index in the post-split polygon.points array of splitB. */ - splitBIdx: number; - /** Index of the center vertex in the post-split polygon. */ - centerIdxFirst: number; -}; - -/** - * Results from buildCornerSegment: the updated polygon geometry with the corner replacement - * segment inserted and the index where it was added. - */ -type BuildCornerSegmentResults = { - /** The updated polygon geometry with the corner segment inserted. */ - geometry: Entity>; - /** The index in polygon.points where the segment was inserted. Used by subsequent - * steps to skip over the segment when iterating perimeter points. */ - addedSegmentIndex: number; -}; - /** For a rectangle, each corner's two adjacent corners are always the same two, so clicking * any corner identifies all three points without further clicks. Only the 4 perimeter corners * are included; extras like 'center' are omitted since corner replacement only makes sense at corners. */ @@ -192,6 +153,8 @@ export abstract class BaseCornerGeometryReplacerTool extend protected defaultCursor = 'pointer'; + protected abstract createFilter(pending: CornerState, offset: Length): FilterTemplate; + handleToolBlur(): void { this.state = { type: 'idle' }; this.lastCommittedOffset = null; @@ -256,34 +219,7 @@ export abstract class BaseCornerGeometryReplacerTool extend // Commit any in flight corner decoration before continuing if (this.state.type === 'awaiting-distance') { - const lastEndpoint = this.state.active; - - // Look up the rectangle BEFORE commit destroys it via conversion to polygon - // in processCornerReplacement. - const rawEndpointRectangleGeometry = - rawEndpoint.type === 'locked-rectangle' - ? (this.getGeometryStore().getByIdWithComponent( - rawEndpoint.id, - GeometryComponent, - ) as Entity>) - : null; - - const result = this.commit(); - - // Map from rectangle key points to new polygon key points - if ( - result && - lastEndpoint.mode === 'rectangle' && - rawEndpoint.type === 'locked-rectangle' && - rawEndpointRectangleGeometry - ) { - rawEndpoint = this.convertRectangleCornerToPolygonIndex( - rawEndpointRectangleGeometry, - rawEndpoint.point, - result.outputPolygonId, - lastEndpoint.centerEndpoint, - ); - } + this.commit(); } switch (rawEndpoint.type) { @@ -424,6 +360,18 @@ export abstract class BaseCornerGeometryReplacerTool extend return; } + // Make sure there isn't an existing filter on this corner point + // If so, then don't allow another filter to be placed on it. + const existingFilter = this.getGeometryStore() + .findFiltersByGeometryId(keyPointEndpoint.id) + .find((f) => + FilterComponent.isLockedToRectangle(f, keyPointEndpoint.id, keyPointEndpoint.point), + ); + if (existingFilter) { + this.emit('pendingCornerChange', null); + return; + } + const centerPos = geometryStore.resolveConstraintEndpoint(keyPointEndpoint); const adjacencies = RECTANGLE_ADJACENCY[keyPointEndpoint.point as RectangleEndpoint]; if (!centerPos || typeof adjacencies === 'undefined') { @@ -466,6 +414,18 @@ export abstract class BaseCornerGeometryReplacerTool extend return; } + // Make sure there isn't an existing filter on this corner point + // If so, then don't allow another filter to be placed on it. + const existingFilter = this.getGeometryStore() + .findFiltersByGeometryId(keyPointEndpoint.id) + .find((f) => + FilterComponent.isLockedToPolygon(f, keyPointEndpoint.id, keyPointEndpoint.pointIndex), + ); + if (existingFilter) { + this.emit('pendingCornerChange', null); + return; + } + const geometry = geometryStore.getByIdWithComponent( keyPointEndpoint.id, GeometryComponent, @@ -474,7 +434,7 @@ export abstract class BaseCornerGeometryReplacerTool extend this.emit('pendingCornerChange', null); return; } - const polygon = GeometryComponent.get(geometry as Entity>); + const polygon = GeometryComponent.get(geometry); const centerPoint = polygon.points[keyPointEndpoint.pointIndex].point; let previousIndex = keyPointEndpoint.pointIndex - 1; @@ -559,40 +519,18 @@ export abstract class BaseCornerGeometryReplacerTool extend return; } const pending = this.state.active; - const historyManager = this.getHistoryManager(); const sheet = this.getSheet(); if (!sheet) { return; } - const offset = this.state.currentOffset.toSheetUnits(sheet.defaultUnit).magnitude; - const currentOffset = this.state.currentOffset; - - const filter = - pending.mode === 'rectangle' - ? FilletFilter.createOnRectangle( - pending.geometryId, - pending.pointAEndpoint, - pending.centerEndpoint, - pending.pointBEndpoint, - currentOffset, - ) - : FilletFilter.createOnPolygon( - pending.geometryId, - pending.pointAIndex, - pending.centerIndex, - pending.pointBIndex, - currentOffset, - ); - this.getGeometryStore().add(ID_PREFIXES.filter, filter); - - const result = historyManager.applyTransaction(this.type, () => { - return this.processCornerReplacement(pending, offset); - }); + this.getGeometryStore().add( + ID_PREFIXES.filter, + this.createFilter(pending, this.state.currentOffset), + ); this.lastCommittedOffset = this.state.currentOffset; this.abort(); - return result; } private abort(): void { @@ -601,659 +539,4 @@ export abstract class BaseCornerGeometryReplacerTool extend this.emit('activeCornerChange', null); this.emit('keyPointSnapChange', null); } - - /** - * Converts a rectangle corner label to a locked-polygon endpoint using the - * {@link RectangleComponent.keyPoints().perimeterLabels} ordering to determine the - * base polygon point index. Accounts for a previously decorated corner that was - * at an earlier position in the perimeter, which shifts the current corner's index - * by +1. - */ - private convertRectangleCornerToPolygonIndex( - rectangle: Entity>, - cornerLabel: RectangleEndpoint, - outputPolygonId: Id, - previousDecoratedCorner: RectangleEndpoint, - ): Extract { - const perimeterLabels = GeometryComponent.keyPoints( - rectangle as Entity>, - ).perimeterLabels; - - const baseIndex = perimeterLabels.indexOf(cornerLabel as (typeof perimeterLabels)[0]); - const previousIndex = perimeterLabels.indexOf( - previousDecoratedCorner as (typeof perimeterLabels)[0], - ); - - let offset = 0; - if (previousIndex >= 0 && previousIndex < baseIndex) { - offset += 1; - } - - return { - type: 'locked-polygon', - id: outputPolygonId, - pointIndex: baseIndex + offset, - }; - } - - /** Executes the corner replacement operation. Must be called inside a history transaction. */ - private processCornerReplacement( - pending: CornerState, - offset: number, - ): { outputPolygonId: Polygon['id'] } | null { - const step1 = this.resolveGeometryAndIndices(pending); - const step2 = this.validateOffset(step1, offset); - if (!step2) { - return null; - } - const step3 = this.splitEdgesAtOffset(step1, step2); - const step4 = this.buildCornerSegment(step1, step2, step3); - this.addColinearConstraints(step1, step2, step3); - this.addRectilinearConstraints(step1, step4); - return { outputPolygonId: step1.geometryId }; - } - - /** - * Resolves the polygon geometry and computes the center/pointA/pointB indices, handling - * both direct polygon selection and rectangle shortcut modes. Also migrates any existing - * constraints attached to the center point to a new datum. - */ - private resolveGeometryAndIndices(pending: CornerState): ResolveGeometryAndIndicesResults { - const geometryStore = this.getGeometryStore(); - - let geometryId = pending.geometryId; - let geometry: Entity>; - let polygonData: PolygonData; - let centerDatumId: Datum['id'] | null = null; - let centerIndex: number = -1; - let pointAIndex: number = -1; - let pointBIndex: number = -1; - let pointAIsAfterCenter: boolean; - let pointBIsAfterCenter: boolean; - switch (pending.mode) { - case 'polygon': { - const result = geometryStore.getByIdWithComponent(geometryId, GeometryComponent); - if (!result || !GeometryComponent.isPolygon(result)) { - throw new Error( - 'BaseCornerGeometryReplacerTool.resolveGeometryAndIndices: polygon not found', - ); - } - geometry = result; - polygonData = GeometryComponent.get(geometry); - - centerIndex = pending.centerIndex; - pointAIndex = pending.pointAIndex; - pointBIndex = pending.pointBIndex; - - // Use modular arithmetic to correctly handle wrapping in closed polygons. - // A point is "after" the center if its cyclic distance is +1 (mod n). - const n = polygonData.points.length; - pointAIsAfterCenter = mod(pointAIndex - centerIndex, n) === 1; - pointBIsAfterCenter = mod(pointBIndex - centerIndex, n) === 1; - - // Get any constraints attached to the centerIndex, and move these to a datum - const constraints = geometryStore.findConstraintsByGeometryId(geometryId); - for (const c of constraints) { - const constraint = ConstraintComponent.get(c); - const keys = Constraint.getPositionKeys(c); - for (const key of keys) { - const ep = Constraint.getEndpoint(c, key); - if ( - ep && - ep.type === 'locked-polygon' && - ep.id === geometryId && - ep.pointIndex === pending.centerIndex - ) { - // Found a constraint attached to the "center" point! - // So make a datum if needed and migrate it over to be locked to the datum. - if (!centerDatumId) { - const datum = geometryStore.addOrdered( - ID_PREFIXES.datum, - Datum.create(polygonData.points[pending.centerIndex].point), - ); - centerDatumId = datum.id; - } - geometryStore.updateByIdWithComponent(c.id, ConstraintComponent, (g) => - ConstraintComponent.update(g, { - [key]: ConstraintEndpoint.lockedToDatum(centerDatumId!), - }), - ); - } - } - } - break; - } - case 'rectangle': { - const resolvedCenter = geometryStore.resolveConstraintEndpoint( - ConstraintEndpoint.lockedToRectangle(geometryId, pending.centerEndpoint), - ); - const resolvedA = geometryStore.resolveConstraintEndpoint( - ConstraintEndpoint.lockedToRectangle(geometryId, pending.pointAEndpoint), - ); - const resolvedB = geometryStore.resolveConstraintEndpoint( - ConstraintEndpoint.lockedToRectangle(geometryId, pending.pointBEndpoint), - ); - if (!resolvedCenter || !resolvedA || !resolvedB) { - throw new Error( - 'BaseCornerGeometryReplacerTool.resolveGeometryAndIndices: rectangle endpoints not resolved', - ); - } - - // Get any constraints attached to the "center" point, and move these to a datum - const constraints = geometryStore.findConstraintsByGeometryId(geometryId); - for (const c of constraints) { - const keys = Constraint.getPositionKeys(c); - for (const key of keys) { - const ep = Constraint.getEndpoint(c, key); - if ( - ep && - ep.type === 'locked-rectangle' && - ep.id === geometryId && - ep.point === pending.centerEndpoint - ) { - // Found a constraint attached to the "center" point! - // So make a datum if needed and migrate it over to be locked to the datum. - if (!centerDatumId) { - const datum = geometryStore.add(ID_PREFIXES.datum, Datum.create(resolvedCenter)); - centerDatumId = datum.id; - } - geometryStore.updateByIdWithComponent(c.id, ConstraintComponent, (g) => - ConstraintComponent.update(g, { - [key]: ConstraintEndpoint.lockedToDatum(centerDatumId!), - }), - ); - } - } - } - - // Convert from rectangle => polygon - geometry = geometryStore.convertRectangleToPolygon(geometryId, { - insertConstraints: false, - }); - geometryId = geometry.id; - polygonData = GeometryComponent.get(geometry); - - // Find all three point indices by position in the new polygon - for ( - let i = 0; - i < polygonData.points.length - 1 /* subtract final closed point */; - i += 1 - ) { - const p = polygonData.points[i].point; - if (p.x === resolvedCenter.x && p.y === resolvedCenter.y) { - centerIndex = i; - } - if (p.x === resolvedA.x && p.y === resolvedA.y) { - pointAIndex = i; - } - if (p.x === resolvedB.x && p.y === resolvedB.y) { - pointBIndex = i; - } - } - - if ( - typeof centerIndex !== 'number' || - typeof pointAIndex !== 'number' || - typeof pointBIndex !== 'number' - ) { - throw new Error( - 'BaseCornerGeometryReplacerTool.resolveGeometryAndIndices: could not find all point indices', - ); - } - - if (polygonData.closed) { - // pointAIndex or pointBIndex being at 0 or points.length-1 means sort of the same thing for - // closed polygons (which a converted rectangle always will be). - // - // It is sort of domain specific which one you want... so if one point is at an extreme, - // then compute the other point and use the negation of it as the original point value - // (since they should always be on opposite sides of each other). - const pointsLengthWithoutClosed = polygonData.points.length - 1; - if (pointAIndex === 0 || pointAIndex === pointsLengthWithoutClosed - 1) { - pointBIsAfterCenter = pointBIndex > centerIndex; - pointAIsAfterCenter = !pointBIsAfterCenter; - } else if (pointBIndex === 0 || pointBIndex === pointsLengthWithoutClosed - 1) { - pointAIsAfterCenter = pointAIndex > centerIndex; - pointBIsAfterCenter = !pointAIsAfterCenter; - } else { - pointAIsAfterCenter = pointAIndex > centerIndex; - pointBIsAfterCenter = pointBIndex > centerIndex; - } - } else { - // Open polygons need no special logic. - pointAIsAfterCenter = pointAIndex > centerIndex; - pointBIsAfterCenter = pointBIndex > centerIndex; - } - - while (centerIndex >= polygonData.points.length - 1) { - centerIndex -= polygonData.points.length - 1; - } - while (pointAIndex >= polygonData.points.length - 1) { - pointAIndex -= polygonData.points.length - 1; - } - while (pointBIndex >= polygonData.points.length - 1) { - pointBIndex -= polygonData.points.length - 1; - } - - break; - } - default: - pending satisfies never; - throw new Error( - `BaseCornerGeometryReplacerTool.resolveGeometryAndIndices: Unknown pending.mode value ${(pending as any).mode}`, - ); - } - - return { - geometryId, - polygon: geometry, - polygonData, - centerIndex, - pointAIndex, - pointBIndex, - centerDatumId, - pointAIsAfterCenter, - pointBIsAfterCenter, - }; - } - - /** - * Validates that the offset is smaller than both edge lengths from center to pointA - * and center to pointB. Returns positions and pre-computed t values used by subsequent - * steps to locate split points and determine arc geometry. Returns null if the offset - * is too large (silent early exit). - */ - private validateOffset( - step1: ResolveGeometryAndIndicesResults, - offset: number, - ): ValidateOffsetResults | null { - const polygonData = step1.polygonData; - const centerIndex = step1.centerIndex; - const pointAIndex = step1.pointAIndex; - const pointBIndex = step1.pointBIndex; - - const centerPos = polygonData.points[centerIndex].point; - const pointAPos = polygonData.points[pointAIndex].point; - const pointBPos = polygonData.points[pointBIndex].point; - - const lenA = Vector2.dist(centerPos, pointAPos); - const lenB = Vector2.dist(centerPos, pointBPos); - - if (offset >= lenA || offset >= lenB) { - return null; - } - // Compute split t values using the CENTER position from the polygon - - // For the edge from center->point: segment starts at centerIndex, t = offset/len - // For the edge from point->center: segment starts at pointIndex, t = 1 - offset/len - const tA = step1.pointAIsAfterCenter ? offset / lenA : 1 - offset / lenA; - const tB = step1.pointBIsAfterCenter ? offset / lenB : 1 - offset / lenB; - - return { - centerPos, - pointAPos, - pointBPos, - lenA, - lenB, - tA, - tB, - offset, - }; - } - - /** - * Inserts two new vertices on the polygon edges at the offset distance from the - * center. Uses PolygonComponent.addPointOnEdge to insert midpoint vertices at the - * calculated t values. Split indices are computed higher-first to avoid index-shift - * issues during insertion. Constraint history events are replayed to maintain constraint - * integrity. - */ - private splitEdgesAtOffset( - step1: ResolveGeometryAndIndicesResults, - step2: ValidateOffsetResults, - ): SplitEdgesAtOffsetResults { - const geometryStore = this.getGeometryStore(); - const historyManager = this.getHistoryManager(); - - let geometry = step1.polygon; - const geometryId = step1.geometryId; - const polygonData = step1.polygonData; - - // Split both edges (higher index first to avoid index shifts) - let sortedSplits = [ - { index: step1.pointAIsAfterCenter ? step1.pointAIndex - 1 : step1.pointAIndex, t: step2.tA }, - { index: step1.pointBIsAfterCenter ? step1.pointBIndex - 1 : step1.pointBIndex, t: step2.tB }, - ] - .map((sp) => { - while (sp.index < 0) { - sp.index += polygonData.points.length - 1; - } - return sp; - }) - .sort((a, b) => b.index - a.index); - - for (const { index, t } of sortedSplits) { - const currentConstraints = geometryStore.findConstraintsByGeometryId(geometryId); - const result = GeometryComponent.addPointOnEdge(geometry, currentConstraints, index, { - type: 't', - t, - }); - if (!result) { - continue; - } - geometry = result.geometry as typeof geometry; - - for (const event of result.updatedConstraintHistoryEvents) { - historyManager.apply(event); - } - } - - geometryStore.updateById(geometryId, geometry); - - // Find split positions and rebuild the polygon with the corner segment. - // Position matching replaces fragile index arithmetic — the array-seam - // wrapping case (center at index 0 of a closed polygon) is inherently - // handled by comparing positions rather than computing shifted indices. - const splitAPos = Vector2.lerp(step2.centerPos, step2.pointAPos, step2.offset / step2.lenA); - const splitBPos = Vector2.lerp(step2.centerPos, step2.pointBPos, step2.offset / step2.lenB); - - const currentPoints = GeometryComponent.get(geometry).points; - const splitAIdx = this.findPointIndexByPos(currentPoints, splitAPos); - const splitBIdx = this.findPointIndexByPos(currentPoints, splitBPos); - const centerIdxFirst = this.findPointIndexByPos(currentPoints, step2.centerPos); - - if (splitAIdx < 0 || splitBIdx < 0 || centerIdxFirst < 0) { - throw new Error( - 'BaseCornerGeometryReplacerTool.splitEdgesAtOffset: could not find split or center indices', - ); - } - - return { - geometry, - splitAPos, - splitBPos, - splitAIdx, - splitBIdx, - centerIdxFirst, - }; - } - - /** - * Creates the polygon segment that replaces the corner vertex between two split points. - * The returned segment's `point` field should be set to the given `point` parameter - * (the destination position for this segment in the polygon). - * - * For FilletTool: returns a CubicBezierSegment with control points computed from - * the tangents, offset, and corner angle. - * For ChamferTool: returns a simple PointSegment. - * - * @param point - Destination position for the segment in the polygon. - * @param p0 - Start position of the corner replacement curve. - * @param p3 - End position of the corner replacement curve. - * @param tStart - Unit tangent direction at the start point. - * @param tEnd - Unit tangent direction at the end point. - * @param offset - The fillet/chamfer offset distance. - * @param step2 - Results from validateOffset. - */ - protected abstract createCornerSegment( - point: SheetPosition, - p0: SheetPosition, - p3: SheetPosition, - tStart: SheetPosition, - tEnd: SheetPosition, - offset: number, - step2: ValidateOffsetResults, - ): PolygonSegment; - - /** - * Determines whether the arc wraps around the polygon seam or replaces the - * center in-place, computes tangent directions, and rebuilds the polygon with - * the corner replacement segment inserted at the correct position. Uses - * createCornerSegment to produce the actual segment geometry. - */ - private buildCornerSegment( - step1: ResolveGeometryAndIndicesResults, - step2: ValidateOffsetResults, - step3: SplitEdgesAtOffsetResults, - ): BuildCornerSegmentResults { - const geometryStore = this.getGeometryStore(); - const geometryId = step1.geometryId; - const centerPos = step2.centerPos; - const pointAPos = step2.pointAPos; - const pointBPos = step2.pointBPos; - const splitAPos = step3.splitAPos; - const splitBPos = step3.splitBPos; - const splitAIdx = step3.splitAIdx; - const splitBIdx = step3.splitBIdx; - const centerIdxFirst = step3.centerIdxFirst; - const offset = step2.offset; - - const minSplitIdx = Math.min(splitAIdx, splitBIdx); - const maxSplitIdx = Math.max(splitAIdx, splitBIdx); - const isWrapping = !(minSplitIdx < centerIdxFirst && centerIdxFirst < maxSplitIdx); - - // Compute tangents from polygon edge directions at the split points. - // The tangent at P0 matches the direction from the previous vertex toward P0; - // the tangent at P3 matches the direction from P3 toward the next vertex. - const pts = GeometryComponent.get(step3.geometry).points; - let p0: SheetPosition; - let p3: SheetPosition; - let tStart: SheetPosition; - let tEnd: SheetPosition; - - if (isWrapping) { - if (maxSplitIdx === splitAIdx) { - p0 = splitAPos; - p3 = splitBPos; - } else { - p0 = splitBPos; - p3 = splitAPos; - } - tStart = Vector2.norm( - Vector2.sub(p0, pts[(maxSplitIdx - 1 + pts.length) % pts.length].point), - ); - } else if (maxSplitIdx === splitAIdx) { - p0 = splitBPos; - p3 = splitAPos; - tStart = Vector2.norm(Vector2.sub(p0, pts[splitBIdx - 1].point)); - } else { - p0 = splitAPos; - p3 = splitBPos; - tStart = Vector2.norm(Vector2.sub(p0, pts[splitAIdx - 1].point)); - } - - if (isWrapping) { - tEnd = Vector2.norm(Vector2.sub(pts[(minSplitIdx + 1) % pts.length].point, p3)); - } else if (maxSplitIdx === splitAIdx) { - tEnd = Vector2.norm(Vector2.sub(pts[splitAIdx + 1].point, p3)); - } else { - tEnd = Vector2.norm(Vector2.sub(pts[splitBIdx + 1].point, p3)); - } - - let addedSegmentIndex = -1; - geometryStore.updateById(geometryId, (old) => { - if (!Entity.hasComponent(old, GeometryComponent)) { - return old; - } - const oldPoints = GeometryComponent.get(old as Entity>).points; - let newPoints: Array; - if (isWrapping) { - const segment = this.createCornerSegment( - oldPoints[minSplitIdx].point, - p0, - p3, - tStart, - tEnd, - offset, - step2, - ); - newPoints = [...oldPoints.slice(minSplitIdx, maxSplitIdx + 1), segment]; - addedSegmentIndex = newPoints.length - 1; - } else if (maxSplitIdx === splitAIdx) { - const segment = this.createCornerSegment( - oldPoints[splitAIdx].point, - p0, - p3, - tStart, - tEnd, - offset, - step2, - ); - newPoints = [ - ...oldPoints.slice(0, splitAIdx - 1), - segment, - ...oldPoints.slice(splitBIdx + 3), - ]; - addedSegmentIndex = splitAIdx - 2; - } else { - const segment = this.createCornerSegment( - oldPoints[splitBIdx].point, - p0, - p3, - tStart, - tEnd, - offset, - step2, - ); - newPoints = [ - ...oldPoints.slice(0, splitBIdx - 1), - segment, - ...oldPoints.slice(splitAIdx + 3), - ]; - addedSegmentIndex = splitBIdx - 2; - } - return GeometryComponent.update(old as Entity>, { - points: newPoints, - }); - }); - - return { - geometry: geometryStore.getByIdWithComponent(geometryId, GeometryComponent) as Entity< - GeometryComponent - >, - addedSegmentIndex, - }; - } - - /** - * Adds colinear constraints linking the center datum to the far vertices (pointA, pointB) - * and their corresponding split vertices. These constraints keep the corner replacement - * centered on the original corner. Must happen AFTER segment insertion so indices resolve - * against the final polygon. - */ - private addColinearConstraints( - step1: ResolveGeometryAndIndicesResults, - step2: ValidateOffsetResults, - step3: SplitEdgesAtOffsetResults, - ): void { - const geometryStore = this.getGeometryStore(); - const geometryId = step1.geometryId; - const centerDatumId = step1.centerDatumId; - - if (!centerDatumId) { - return; - } - - const finalPoly = geometryStore.getByIdWithComponent(geometryId, GeometryComponent) as Entity< - GeometryComponent - >; - if (!finalPoly) { - return; - } - const finalPoints = GeometryComponent.get( - finalPoly as Entity>, - ).points; - - const farAIdx = this.findPointIndexByPos(finalPoints, step2.pointAPos); - const splitAFinalIdx = this.findPointIndexByPos(finalPoints, step3.splitAPos); - const farBIdx = this.findPointIndexByPos(finalPoints, step2.pointBPos); - const splitBFinalIdx = this.findPointIndexByPos(finalPoints, step3.splitBPos); - - if (farAIdx >= 0 && splitAFinalIdx >= 0) { - geometryStore.add( - ID_PREFIXES.constraint, - ColinearConstraint.create( - ConstraintEndpoint.lockedToDatum(centerDatumId), - ConstraintEndpoint.lockedToPolygon(geometryId, farAIdx), - ConstraintEndpoint.lockedToPolygon(geometryId, splitAFinalIdx), - ), - ); - } - if (farBIdx >= 0 && splitBFinalIdx >= 0) { - geometryStore.add( - ID_PREFIXES.constraint, - ColinearConstraint.create( - ConstraintEndpoint.lockedToDatum(centerDatumId), - ConstraintEndpoint.lockedToPolygon(geometryId, farBIdx), - ConstraintEndpoint.lockedToPolygon(geometryId, splitBFinalIdx), - ), - ); - } - } - - /** - * For polygons created by converting a rectangle, adds horizontal constraints on - * top/bottom edges and vertical constraints on left/right edges. Iterates perimeter - * points in order, skipping over the inserted segment index. The rectangle is always - * converted to a 5-point polygon (4 sides + 1 corner segment), so this iterates the - * 4 sides in ['top', 'right', 'bottom', 'left'] order. - */ - private addRectilinearConstraints( - step1: ResolveGeometryAndIndicesResults, - step4: BuildCornerSegmentResults, - ): void { - const geometryStore = this.getGeometryStore(); - const geometryId = step1.geometryId; - const addedSegmentIndex = step4.addedSegmentIndex; - - // Only applies when a rectangle was converted to a polygon. - // This happens when resolveGeometryAndIndices processed a 'rectangle' mode pending state, - // which we detect by checking if the polygon has 5 points and was closed. - const polygonData = step1.polygonData; - if (polygonData.points.length !== 5 || !polygonData.closed) { - return; - } - - let counter = 0; - for (const side of ['top', 'right', 'bottom', 'left'] as const) { - const pointA = ConstraintEndpoint.lockedToPolygon(geometryId, counter); - let pointBIndex = counter + 1; - if (pointBIndex > 4 /* 4 sides */) { - pointBIndex = 0; - } - const pointB = ConstraintEndpoint.lockedToPolygon(geometryId, pointBIndex); - - switch (side) { - case 'top': - case 'bottom': - geometryStore.add(ID_PREFIXES.constraint, HorizontalConstraint.create(pointA, pointB)); - break; - case 'left': - case 'right': - geometryStore.add(ID_PREFIXES.constraint, VerticalConstraint.create(pointA, pointB)); - break; - } - - counter += 1; - if (counter === addedSegmentIndex) { - // Skip over the corner segment - counter += 1; - } - } - } - - /** Finds the index of a point in polygon points by position match. */ - private findPointIndexByPos(points: Array<{ point: SheetPosition }>, pos: SheetPosition): number { - for (let i = 0; i < points.length; i++) { - if (points[i].point.x === pos.x && points[i].point.y === pos.y) { - return i; - } - } - return -1; - } -} - -/** Modular arithmetic helper that returns a non-negative remainder. */ -function mod(n: number, m: number): number { - return ((n % m) + m) % m; } diff --git a/src/lib/tools/ChamferTool.tsx b/src/lib/tools/ChamferTool.tsx index 14e8b19b..3ba55389 100644 --- a/src/lib/tools/ChamferTool.tsx +++ b/src/lib/tools/ChamferTool.tsx @@ -1,7 +1,9 @@ import React from 'react'; -import { PolygonSegment } from '@/lib/entity/polygon'; +import { CornerReplacement, type CornerSegmentFactory } from '@/lib/math'; import { SheetPosition } from '@/lib/viewport/types'; -import { BaseCornerGeometryReplacerTool } from './BaseCornerGeometryReplacerTool'; +import { ChamferFilter } from '../entity/filters/chamfer'; +import { Length } from '../units/length'; +import { BaseCornerGeometryReplacerTool, CornerState } from './BaseCornerGeometryReplacerTool'; /** * Custom chamfer icon: a square with a 45-degree beveled corner (matches the @@ -49,7 +51,23 @@ export class ChamferTool extends BaseCornerGeometryReplacerTool<'chamfer'> { return ; } - protected createCornerSegment(point: SheetPosition): PolygonSegment { - return { type: 'point', point }; + protected createFilter(pending: CornerState, offset: Length) { + if (pending.mode === 'rectangle') { + return ChamferFilter.createOnRectangle( + pending.geometryId, + pending.pointAEndpoint, + pending.centerEndpoint, + pending.pointBEndpoint, + offset, + ); + } else { + return ChamferFilter.createOnPolygon( + pending.geometryId, + pending.pointAIndex, + pending.centerIndex, + pending.pointBIndex, + offset, + ); + } } } diff --git a/src/lib/tools/FilletTool.tsx b/src/lib/tools/FilletTool.tsx index cdabc46f..f12496e3 100644 --- a/src/lib/tools/FilletTool.tsx +++ b/src/lib/tools/FilletTool.tsx @@ -1,11 +1,7 @@ import { SquareRoundCornerIcon } from 'lucide-react'; -import { PolygonSegment } from '@/lib/entity/polygon'; -import { Vector2 } from '@/lib/math'; -import { SheetPosition } from '@/lib/viewport/types'; -import { - BaseCornerGeometryReplacerTool, - type ValidateOffsetResults, -} from './BaseCornerGeometryReplacerTool'; +import { FilletFilter } from '../entity/filters'; +import { Length } from '../units/length'; +import { BaseCornerGeometryReplacerTool, CornerState } from './BaseCornerGeometryReplacerTool'; /** * A tool for creating fillets (rounded corners) on polygon shapes. @@ -28,42 +24,23 @@ export class FilletTool extends BaseCornerGeometryReplacerTool<'fillet'> { return ; } - protected createCornerSegment( - point: SheetPosition, - p0: SheetPosition, - p3: SheetPosition, - tStart: SheetPosition, - tEnd: SheetPosition, - offset: number, - step2: ValidateOffsetResults, - ): PolygonSegment { - // Compute the angle between the two edges for the arc approximation - const pointAPos = step2.pointAPos; - const pointBPos = step2.pointBPos; - const centerPos = step2.centerPos; - const r = offset; - const cosTheta = Math.max( - -1, - Math.min( - 1, - Vector2.dot( - Vector2.norm(Vector2.sub(pointAPos, centerPos)), - Vector2.norm(Vector2.sub(pointBPos, centerPos)), - ), - ), - ); - const theta = Math.acos(cosTheta); - const kVal = (4 / 3) * Math.tan(theta / 4); - const kR = kVal * r; - - const cpA = Vector2.add(p0, Vector2.scale(tStart, kR)); - const cpB = Vector2.sub(p3, Vector2.scale(tEnd, kR)); - - return { - type: 'arc-cubic' as const, - point, - controlPointA: cpA, - controlPointB: cpB, - }; + protected createFilter(pending: CornerState, offset: Length) { + if (pending.mode === 'rectangle') { + return FilletFilter.createOnRectangle( + pending.geometryId, + pending.pointAEndpoint, + pending.centerEndpoint, + pending.pointBEndpoint, + offset, + ); + } else { + return FilletFilter.createOnPolygon( + pending.geometryId, + pending.pointAIndex, + pending.centerIndex, + pending.pointBIndex, + offset, + ); + } } } diff --git a/src/lib/tools/SelectTool.tsx b/src/lib/tools/SelectTool.tsx index 5798839d..ddff5606 100644 --- a/src/lib/tools/SelectTool.tsx +++ b/src/lib/tools/SelectTool.tsx @@ -171,8 +171,8 @@ function isEndpointOnEdge( /** A representation of all entities which can be moved. The type is a union, and the associated * namespace has utility functions for computing metrics about the given entity in a generic way. */ type DragState = - | { state: 'geometry', geometry: Entity } - | { state: 'datum', entity: Entity }; + | { state: 'geometry'; geometry: Entity } + | { state: 'datum'; entity: Entity }; namespace DragState { /** Extracts the {@link DragState} from the passed {@link Entity}, or null if nothing could be computed. */ @@ -276,14 +276,15 @@ namespace DragState { ): DragState | null { switch (state.state) { case 'geometry': - return { ...state, geometry: GeometryComponent.resize(state.geometry, params, originalBBox) }; + return { + ...state, + geometry: GeometryComponent.resize(state.geometry, params, originalBBox), + }; case 'datum': return null; default: state satisfies never; - console.warn( - `DragState.resize: Unknown state.for ${(state as any)?.for}. Doing nothing.`, - ); + console.warn(`DragState.resize: Unknown state.for ${(state as any)?.for}. Doing nothing.`); return state; } } @@ -1530,7 +1531,7 @@ export class SelectTool extends BaseTool { return this.handleFillPointerDown(screenPos, viewportControls, geometryId); } - private handleFillPointerDown ( + private handleFillPointerDown( screenPos: ScreenPosition, viewportControls: ViewportControls, entityId: Entity['id'], @@ -1736,15 +1737,24 @@ export class SelectTool extends BaseTool { // FIXME: add log return; } - if (GeometryComponent.isPolygon(state.geometry) && GeometryComponent.isPolygon(entity)) { + if ( + GeometryComponent.isPolygon(state.geometry) && + GeometryComponent.isPolygon(entity) + ) { const before = GeometryComponent.get(state.geometry); const after = GeometryComponent.get(entity); forwardsActions.push(UndoEntry.polygonMove(id, before.points, after.points)); - } else if (GeometryComponent.isEllipse(state.geometry) && GeometryComponent.isEllipse(entity)) { + } else if ( + GeometryComponent.isEllipse(state.geometry) && + GeometryComponent.isEllipse(entity) + ) { const before = GeometryComponent.get(state.geometry); const after = GeometryComponent.get(entity); forwardsActions.push(UndoEntry.ellipseMove(id, before, after)); - } else if (GeometryComponent.isRectangle(state.geometry) && GeometryComponent.isRectangle(entity)) { + } else if ( + GeometryComponent.isRectangle(state.geometry) && + GeometryComponent.isRectangle(entity) + ) { const before = GeometryComponent.get(state.geometry); const after = GeometryComponent.get(entity); forwardsActions.push(UndoEntry.rectangleMove(id, before, after)); @@ -1758,7 +1768,9 @@ export class SelectTool extends BaseTool { const before = DatumComponent.get(state.entity); const after = DatumComponent.get(entity); // FIXME: make this history entry take just before / after, not wrapped in the object - forwardsActions.push(UndoEntry.datumMove(id, { position: before }, { position: after })); + forwardsActions.push( + UndoEntry.datumMove(id, { position: before }, { position: after }), + ); break; default: state satisfies never; @@ -1785,9 +1797,7 @@ export class SelectTool extends BaseTool { if (!geometry) { continue; } - this.getGeometryStore().updateByIdDirect(id, (old) => - DragState.apply(state, old), - ); + this.getGeometryStore().updateByIdDirect(id, (old) => DragState.apply(state, old)); } } @@ -1978,9 +1988,7 @@ export class SelectTool extends BaseTool { for (const [id, originalState] of groupStates) { const newState = DragState.resize(originalState, params, unionBBox); if (newState) { - this.getGeometryStore().updateByIdDirect(id, (old) => - DragState.apply(newState, old), - ); + this.getGeometryStore().updateByIdDirect(id, (old) => DragState.apply(newState, old)); } } }, @@ -2004,28 +2012,35 @@ export class SelectTool extends BaseTool { // FIXME: add log return; } - if (GeometryComponent.isPolygon(originalState.geometry) && GeometryComponent.isPolygon(afterGeometry)) { + if ( + GeometryComponent.isPolygon(originalState.geometry) && + GeometryComponent.isPolygon(afterGeometry) + ) { const before = GeometryComponent.get(originalState.geometry); const after = GeometryComponent.get(afterGeometry); this.getHistoryManager().push( - UndoEntry.polygonMove(id, before.points, after.points) + UndoEntry.polygonMove(id, before.points, after.points), ); - } else if (GeometryComponent.isEllipse(originalState.geometry) && GeometryComponent.isEllipse(afterGeometry)) { + } else if ( + GeometryComponent.isEllipse(originalState.geometry) && + GeometryComponent.isEllipse(afterGeometry) + ) { const before = GeometryComponent.get(originalState.geometry); const after = GeometryComponent.get(afterGeometry); - this.getHistoryManager().push( - UndoEntry.ellipseMove(id, before, after), - ); - } else if (GeometryComponent.isRectangle(originalState.geometry) && GeometryComponent.isRectangle(afterGeometry)) { + this.getHistoryManager().push(UndoEntry.ellipseMove(id, before, after)); + } else if ( + GeometryComponent.isRectangle(originalState.geometry) && + GeometryComponent.isRectangle(afterGeometry) + ) { const before = GeometryComponent.get(originalState.geometry); const after = GeometryComponent.get(afterGeometry); - this.getHistoryManager().push( - UndoEntry.rectangleMove(id, before, after), - ); + this.getHistoryManager().push(UndoEntry.rectangleMove(id, before, after)); } break; case 'datum': - console.warn('SelectTool.onCommit: Datum was resized, but this should not be possible. Doing nothing.'); + console.warn( + 'SelectTool.onCommit: Datum was resized, but this should not be possible. Doing nothing.', + ); break; default: originalState satisfies never;