From caf4bcecbd279da36a6f3ab6fde998bdb1c8b687 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sat, 18 Jul 2026 19:01:20 -0400 Subject: [PATCH 01/22] feat: add GeometryRenderer.getRenderShapes for centralized GeometryComponent rendering --- src/app/components/ViewportRenderer2D.tsx | 3 + src/components/GeometryRenderer.tsx | 293 +++++++++--------- .../entity/components/GeometryComponent.ts | 48 ++- 3 files changed, 200 insertions(+), 144 deletions(-) diff --git a/src/app/components/ViewportRenderer2D.tsx b/src/app/components/ViewportRenderer2D.tsx index 260dab0d..6313152c 100644 --- a/src/app/components/ViewportRenderer2D.tsx +++ b/src/app/components/ViewportRenderer2D.tsx @@ -261,6 +261,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 }]; } diff --git a/src/components/GeometryRenderer.tsx b/src/components/GeometryRenderer.tsx index 025d0bfa..e12d88d1 100644 --- a/src/components/GeometryRenderer.tsx +++ b/src/components/GeometryRenderer.tsx @@ -485,148 +485,161 @@ 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 renderShapes = useMemo(() => GeometryComponent.getRenderShapes(geometry), [geometry]); - {/* 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; - } + return ( + <> + {renderShapes.map((renderShape) => { + switch (renderShape.shape) { + case 'polygon': + return ( + + 1} + eventMode="static" + onFillPointerDown={onFillPointerDown} + onFillPointerOver={onFillPointerOver} + onFillPointerOut={onFillPointerOut} + /> - // 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; - } + {/* 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/lib/entity/components/GeometryComponent.ts b/src/lib/entity/components/GeometryComponent.ts index 041c8572..6cb52514 100644 --- a/src/lib/entity/components/GeometryComponent.ts +++ b/src/lib/entity/components/GeometryComponent.ts @@ -6,6 +6,23 @@ import { RectangleData } from '../geometry/rectangle'; import { type Entity, type EntityComponent, ResizeParams } from '../types'; import { ConstraintComponent } from './ConstraintComponent'; +export type RenderShape = + | { shape: 'polygon'; key: string; primary: boolean; points: Array, closed: boolean } + | { shape: 'rectangle'; key: string; upperLeft: SheetPosition; lowerRight: SheetPosition } + | { shape: 'ellipse'; key: string; center: SheetPosition; radiusX: number; radiusY: number }; + +namespace RenderShape { + export function polygon(key: string, points: Array, closed: boolean, options?: { primary?: boolean }) { + return { shape: 'polygon' as const, key, primary: options?.primary ?? false, points, closed }; + } + export function rectangle(key: string, upperLeft: SheetPosition, lowerRight: SheetPosition) { + return { shape: 'rectangle' as const, key, upperLeft, lowerRight }; + } + export function ellipse(key: string, center: SheetPosition, args: { radiusX: number, radiusY: number }) { + 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 +130,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 +147,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}`, ); } } @@ -184,7 +201,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 +234,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 +495,27 @@ export namespace GeometryComponent { ) { return PolygonData.addPointOnEdge(geometry, constraints, segmentIndex, newPointPosition); } + + export function getRenderShapes( + geometry: Entity>, + ): Array { + const state = GeometryComponent.get(geometry); + switch (state.type) { + case 'polygon': + return [ + RenderShape.polygon(geometry.id, state.points, state.closed, { primary: true }), + ]; + case 'rectangle': + return [ + RenderShape.rectangle(geometry.id, state.upperLeft, state.lowerRight), + ]; + case 'ellipse': + return [RenderShape.ellipse(geometry.id, state.center, { radiusX: state.radiusX, radiusY: state.radiusY })]; + default: + state satisfies never; + throw new Error( + `GeometryComponent.getRenderShapes: Unknown geometry data type ${(state as any).type}`, + ); + } + } } From f51487922cf7f4d7b6152a109df00c596fb858cc Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sat, 18 Jul 2026 19:19:09 -0400 Subject: [PATCH 02/22] feat: add filters parameter to GeometryComponent.getRenderShapes --- src/app/components/ViewportRenderer2D.tsx | 13 ++++++++++++- src/components/GeometryRenderer.tsx | 12 ++++++++---- src/contexts/viewport-context.tsx | 3 +++ src/lib/entity/GeometryStore.ts | 8 ++++++++ src/lib/entity/components/GeometryComponent.ts | 3 +++ 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/app/components/ViewportRenderer2D.tsx b/src/app/components/ViewportRenderer2D.tsx index 6313152c..fff5a775 100644 --- a/src/app/components/ViewportRenderer2D.tsx +++ b/src/app/components/ViewportRenderer2D.tsx @@ -72,6 +72,7 @@ import CornerOverlay from './CornerOverlay'; import FitToScreenButton from './FitToScreenButton'; import { HoverTooltip } from './HoverTooltip'; import { KeyboardShortcut } from './KeyboardShortcut'; +import { type Filter } from '@/lib/entity/filters'; extend({ Container, @@ -299,6 +300,7 @@ export default function ViewportRenderer2D({ height: number; } | null>(null); const [geometries, setGeometries] = useState>([]); + const [filtersByGeometryId, setFiltersByGeometryId] = useState>>(new Map()); const [workingPolygon, setWorkingPolygon] = useState(null); const [workingRectangle, setWorkingRectangle] = useState(null); const [datums, setDatums] = useState>([]); @@ -366,7 +368,14 @@ 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); @@ -728,6 +737,7 @@ export default function ViewportRenderer2D({ mouseScreenPos, // FIXME: break this out into another context, it will change often snapHintsVisibility, highlightedGeometryId, + filtersByGeometryId, }) satisfies ViewportContextData, [ sheet, @@ -736,6 +746,7 @@ export default function ViewportRenderer2D({ activeTool, selectionManager, mouseScreenPos, + filtersByGeometryId, ], ); diff --git a/src/components/GeometryRenderer.tsx b/src/components/GeometryRenderer.tsx index e12d88d1..433170b3 100644 --- a/src/components/GeometryRenderer.tsx +++ b/src/components/GeometryRenderer.tsx @@ -1,5 +1,5 @@ import { EventMode, FederatedPointerEvent, Graphics } from 'pixi.js'; -import { Fragment, useCallback, useMemo } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { useViewportContext } from '@/contexts/viewport-context'; import { useClosestPointToSegment } from '@/hooks/useClosestPointToSegment'; import { useDraggingShapeState } from '@/hooks/useDraggingShapeState'; @@ -28,6 +28,8 @@ import { CurveControlPointHandlesSprites } from './CurveControlPointHandlesSprit import { CurveEdgeHitDetector } from './CurveEdgeHitDetector'; import { HandleSprites } from './HandleSprites'; import { LineSegmentEdgeHitDetector } from './LineSegmentEdgeHitDetector'; +import { FilterComponent } from '@/lib/entity/components/FilterComponent'; +import { type Filter } from '@/lib/entity/filters'; /** Size of the center corsshairs rendered on ellipses. */ const CIRCLE_CENTER_MARKER_SIZE_PX = 8; @@ -418,9 +420,8 @@ const MIN_POLYGON_HIGH_FIDELITY_SIZE_PX = 48; const PROXIMITY_EDGE_DETECTOR_RADIUS_PX = 64; const GeometrySolid: React.FunctionComponent<{ geometry: Geometry }> = ({ geometry }) => { - const { activeTool, viewportControls, viewportScale, mouseScreenPos, highlightedGeometryId } = + const { activeTool, viewportControls, viewportScale, mouseScreenPos, highlightedGeometryId, filtersByGeometryId } = useViewportContext(); - const geometryData = GeometryComponent.get(geometry); let fillColor = FillColorComponent.getOptional(geometry); let stroke = 0x000000; @@ -485,7 +486,10 @@ const GeometrySolid: React.FunctionComponent<{ geometry: Geometry }> = ({ geomet ); }, [activeTool.type, viewportControls, viewportScale, mouseScreenPos]); - const renderShapes = useMemo(() => GeometryComponent.getRenderShapes(geometry), [geometry]); + const filters = useMemo(() => filtersByGeometryId.get(geometry.id), [filtersByGeometryId, geometry.id]); + const renderShapes = useMemo(() => { + return GeometryComponent.getRenderShapes(geometry, filters); + }, [geometry, filters]); return ( <> diff --git a/src/contexts/viewport-context.tsx b/src/contexts/viewport-context.tsx index 05301583..e7e30ab4 100644 --- a/src/contexts/viewport-context.tsx +++ b/src/contexts/viewport-context.tsx @@ -1,5 +1,7 @@ import { createContext, useContext } from 'react'; import { type Entity } from '@/lib/entity'; +import { type Geometry } from '@/lib/entity/geometry'; +import { type Filter } from '@/lib/entity/filters'; import { GeometryStore } from '@/lib/entity/GeometryStore'; import { type Sheet } from '@/lib/sheet/Sheet'; import { type SnapHintsVisibility } from '@/lib/tools/BaseTool'; @@ -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/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/GeometryComponent.ts b/src/lib/entity/components/GeometryComponent.ts index 6cb52514..f0f1fca0 100644 --- a/src/lib/entity/components/GeometryComponent.ts +++ b/src/lib/entity/components/GeometryComponent.ts @@ -5,6 +5,8 @@ import { PolygonData, PolygonSegment } from '../geometry/polygon'; import { RectangleData } from '../geometry/rectangle'; import { type Entity, type EntityComponent, ResizeParams } from '../types'; import { ConstraintComponent } from './ConstraintComponent'; +import { Filter } from '../filters'; +import { FilterComponent } from './FilterComponent'; export type RenderShape = | { shape: 'polygon'; key: string; primary: boolean; points: Array, closed: boolean } @@ -498,6 +500,7 @@ export namespace GeometryComponent { export function getRenderShapes( geometry: Entity>, + filters: Array = [], ): Array { const state = GeometryComponent.get(geometry); switch (state.type) { From db09a55cacccef466b4ced52a864fe32a11a2953 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sat, 18 Jul 2026 19:22:49 -0400 Subject: [PATCH 03/22] docs: update docs to take into account new GeometryComponent --- docs/constraint-engine.md | 4 +-- docs/geometry-store.md | 50 ++++++++++++++++++++++++++------------ docs/history-management.md | 10 +++++--- docs/layers.md | 9 ++----- 4 files changed, 45 insertions(+), 28 deletions(-) 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 From b0985eaf753f75490b0d42bffa93c09bae508af0 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sat, 18 Jul 2026 20:09:03 -0400 Subject: [PATCH 04/22] feat: add initial getRenderShapes Mirror filter implementation --- .../entity/components/GeometryComponent.ts | 195 ++++++++++++++++-- src/lib/math/bounding-box.ts | 2 +- 2 files changed, 181 insertions(+), 16 deletions(-) diff --git a/src/lib/entity/components/GeometryComponent.ts b/src/lib/entity/components/GeometryComponent.ts index f0f1fca0..23cc2960 100644 --- a/src/lib/entity/components/GeometryComponent.ts +++ b/src/lib/entity/components/GeometryComponent.ts @@ -1,26 +1,46 @@ +import { BoundingBox, rectangleToPolygon } from '@/lib/math'; import { KeyPoints, Rect, SheetPosition } from '@/lib/viewport/types'; +import { Filter } 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 { Filter } from '../filters'; import { FilterComponent } from './FilterComponent'; export type RenderShape = - | { shape: 'polygon'; key: string; primary: boolean; points: Array, closed: boolean } + | { + shape: 'polygon'; + key: string; + primary: boolean; + points: Array; + closed: boolean; + } | { shape: 'rectangle'; key: string; upperLeft: SheetPosition; lowerRight: SheetPosition } | { shape: 'ellipse'; key: string; center: SheetPosition; radiusX: number; radiusY: number }; namespace RenderShape { - export function polygon(key: string, points: Array, closed: boolean, options?: { primary?: boolean }) { + 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) { + 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 }) { + 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 }; } } @@ -161,11 +181,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( @@ -498,27 +527,163 @@ 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); + } + export function getRenderShapes( geometry: Entity>, filters: Array = [], ): Array { + let shapes; + const state = GeometryComponent.get(geometry); switch (state.type) { case 'polygon': - return [ - RenderShape.polygon(geometry.id, state.points, state.closed, { primary: true }), - ]; + shapes = [RenderShape.polygon(geometry.id, state.points, state.closed, { primary: true })]; + break; case 'rectangle': - return [ - RenderShape.rectangle(geometry.id, state.upperLeft, state.lowerRight), - ]; + shapes = [RenderShape.rectangle(geometry.id, state.upperLeft, state.lowerRight)]; + break; case 'ellipse': - return [RenderShape.ellipse(geometry.id, state.center, { radiusX: state.radiusX, radiusY: state.radiusY })]; + 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) { + const data = FilterComponent.get(filter); + switch (data.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, data.pointA, data.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, + data.pointA, + data.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, + data.pointA, + data.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, + data.pointA, + data.pointB, + ), + }; + case 'arc-cubic': + return { + type: 'arc-cubic' as const, + point: mirroredPoint, + controlPointA: mirrorPointOverLine( + segment.controlPointA, + data.pointA, + data.pointB, + ), + controlPointB: mirrorPointOverLine( + segment.controlPointB, + data.pointA, + data.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': + // TODO + break; + default: + data satisfies never; + break; + } + } + + return shapes; } } 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. */ From 24af43b2b25fcf0d583232661322a3fdf00a652d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sat, 18 Jul 2026 20:18:33 -0400 Subject: [PATCH 05/22] fix: run npm run format --- src/app/components/ViewportRenderer2D.tsx | 18 +++-- src/components/GeometryRenderer.tsx | 21 ++++-- src/contexts/viewport-context.tsx | 4 +- .../entity/components/GeometryComponent.ts | 18 +++-- src/lib/entity/types.ts | 6 +- src/lib/tools/SelectTool.tsx | 71 +++++++++++-------- 6 files changed, 86 insertions(+), 52 deletions(-) diff --git a/src/app/components/ViewportRenderer2D.tsx b/src/app/components/ViewportRenderer2D.tsx index fff5a775..59fbfd56 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 { @@ -72,7 +73,6 @@ import CornerOverlay from './CornerOverlay'; import FitToScreenButton from './FitToScreenButton'; import { HoverTooltip } from './HoverTooltip'; import { KeyboardShortcut } from './KeyboardShortcut'; -import { type Filter } from '@/lib/entity/filters'; extend({ Container, @@ -300,7 +300,9 @@ export default function ViewportRenderer2D({ height: number; } | null>(null); const [geometries, setGeometries] = useState>([]); - const [filtersByGeometryId, setFiltersByGeometryId] = useState>>(new Map()); + const [filtersByGeometryId, setFiltersByGeometryId] = useState< + Map> + >(new Map()); const [workingPolygon, setWorkingPolygon] = useState(null); const [workingRectangle, setWorkingRectangle] = useState(null); const [datums, setDatums] = useState>([]); @@ -371,10 +373,14 @@ export default function ViewportRenderer2D({ 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; - }))); + setFiltersByGeometryId( + new Map( + geometries.map((geom) => { + const filters = geometryStore.findFiltersByGeometryId(geom.id); + return [geom.id, filters] as const; + }), + ), + ); setDatums(geometryStore.listWithComponent(DatumComponent)); }; diff --git a/src/components/GeometryRenderer.tsx b/src/components/GeometryRenderer.tsx index 433170b3..4e9eec09 100644 --- a/src/components/GeometryRenderer.tsx +++ b/src/components/GeometryRenderer.tsx @@ -6,6 +6,8 @@ import { useDraggingShapeState } from '@/hooks/useDraggingShapeState'; import { useGeometries } from '@/hooks/useGeoemtries'; import { useSelectionManagerSelectedIds } from '@/hooks/useSelectionManagerSelectedIds'; import { FillColorComponent, GeometryComponent, PolygonSegment } from '@/lib/entity'; +import { FilterComponent } from '@/lib/entity/components/FilterComponent'; +import { type Filter } from '@/lib/entity/filters'; import { type Geometry } from '@/lib/entity/geometry'; import { BoundingBox, CohenSutherland } from '@/lib/math'; import { ListLayers, RendererLayers } from '@/lib/renderer'; @@ -28,8 +30,6 @@ import { CurveControlPointHandlesSprites } from './CurveControlPointHandlesSprit import { CurveEdgeHitDetector } from './CurveEdgeHitDetector'; import { HandleSprites } from './HandleSprites'; import { LineSegmentEdgeHitDetector } from './LineSegmentEdgeHitDetector'; -import { FilterComponent } from '@/lib/entity/components/FilterComponent'; -import { type Filter } from '@/lib/entity/filters'; /** Size of the center corsshairs rendered on ellipses. */ const CIRCLE_CENTER_MARKER_SIZE_PX = 8; @@ -420,8 +420,14 @@ const MIN_POLYGON_HIGH_FIDELITY_SIZE_PX = 48; const PROXIMITY_EDGE_DETECTOR_RADIUS_PX = 64; const GeometrySolid: React.FunctionComponent<{ geometry: Geometry }> = ({ geometry }) => { - const { activeTool, viewportControls, viewportScale, mouseScreenPos, highlightedGeometryId, filtersByGeometryId } = - useViewportContext(); + const { + activeTool, + viewportControls, + viewportScale, + mouseScreenPos, + highlightedGeometryId, + filtersByGeometryId, + } = useViewportContext(); let fillColor = FillColorComponent.getOptional(geometry); let stroke = 0x000000; @@ -486,7 +492,10 @@ const GeometrySolid: React.FunctionComponent<{ geometry: Geometry }> = ({ geomet ); }, [activeTool.type, viewportControls, viewportScale, mouseScreenPos]); - const filters = useMemo(() => filtersByGeometryId.get(geometry.id), [filtersByGeometryId, geometry.id]); + const filters = useMemo( + () => filtersByGeometryId.get(geometry.id), + [filtersByGeometryId, geometry.id], + ); const renderShapes = useMemo(() => { return GeometryComponent.getRenderShapes(geometry, filters); }, [geometry, filters]); @@ -545,7 +554,7 @@ const GeometrySolid: React.FunctionComponent<{ geometry: Geometry }> = ({ geomet onPointerDown={onFillPointerDown} /> ); - case 'arc-quadratic': + case 'arc-quadratic': const quadraticCurve: QuadraticCurve = { start: prevSeg.point, end: seg.point, diff --git a/src/contexts/viewport-context.tsx b/src/contexts/viewport-context.tsx index e7e30ab4..7b690fd9 100644 --- a/src/contexts/viewport-context.tsx +++ b/src/contexts/viewport-context.tsx @@ -1,8 +1,8 @@ import { createContext, useContext } from 'react'; import { type Entity } from '@/lib/entity'; -import { type Geometry } from '@/lib/entity/geometry'; -import { type Filter } from '@/lib/entity/filters'; 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'; diff --git a/src/lib/entity/components/GeometryComponent.ts b/src/lib/entity/components/GeometryComponent.ts index 23cc2960..9571b887 100644 --- a/src/lib/entity/components/GeometryComponent.ts +++ b/src/lib/entity/components/GeometryComponent.ts @@ -613,10 +613,12 @@ export namespace GeometryComponent { data.pointA, data.pointB, ); - return [RenderShape.ellipse(key, mirroredCenter, { - radiusX: renderShape.radiusX, - radiusY: renderShape.radiusY, - })] + return [ + RenderShape.ellipse(key, mirroredCenter, { + radiusX: renderShape.radiusX, + radiusY: renderShape.radiusY, + }), + ]; } case 'polygon': { const mirroredPoints = renderShape.points.map((segment) => { @@ -660,9 +662,11 @@ export namespace GeometryComponent { ); } }); - return [RenderShape.polygon(key, mirroredPoints, renderShape.closed, { - primary: false, - })]; + return [ + RenderShape.polygon(key, mirroredPoints, renderShape.closed, { + primary: false, + }), + ]; } default: renderShape satisfies never; 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/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; From 4a95ec2ca30d50021319ffd5023dc59e04f9f9a9 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sun, 19 Jul 2026 09:18:51 -0400 Subject: [PATCH 06/22] feat: add fillet / chamfer utilities --- src/lib/math/fillet-chamfer.ts | 399 +++++++++++++++++++++++++++++++++ src/lib/math/index.ts | 1 + src/lib/tools/FilletTool.tsx | 35 +-- 3 files changed, 412 insertions(+), 23 deletions(-) create mode 100644 src/lib/math/fillet-chamfer.ts diff --git a/src/lib/math/fillet-chamfer.ts b/src/lib/math/fillet-chamfer.ts new file mode 100644 index 00000000..6e2fecf6 --- /dev/null +++ b/src/lib/math/fillet-chamfer.ts @@ -0,0 +1,399 @@ +import { type RectangleEndpoint } from '@/lib/entity/rectangle'; +import { CubicCurve, LineSegment, type Position, QuadraticCurve } from '@/lib/viewport/types'; +import { Vector2 } from './vector'; + +/** Return type for applyToPolygon: the modified segment list and a map from old vertex indices to new vertex indices. */ +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; +}; + +/** Return type for applyToRectangle: the full polygon segment list and a map from rectangle corner labels to new vertex indices. */ +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 corners = RECTANGLE_PERIMETER_ORDER.map((corner) => { + return getRectangleCornerPosition(upperLeft, lowerRight, corner); + }); + + return [ + { start: corners[0], end: corners[1] }, + { start: corners[1], end: corners[2] }, + { start: corners[2], end: corners[3] }, + { start: corners[3], end: corners[4] }, + ]; +} + +/** + * 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; +} + +// --------------------------------------------------------------------------- +// Fillet namespace +// --------------------------------------------------------------------------- + +export namespace Fillet { + /** + * Applies a fillet to a rectangle corner, returning the full polygon segment + * list that results from converting the rectangle to a polygon, then + * replacing the specified corner with a cubic bezier arc. + */ + export function applyToRectangle

( + upperLeft: P, + lowerRight: P, + corner: RectangleEndpoint, + offset: number, + ): FilletChamferRectangleResult

{ + const segments = rectangleToSegments(upperLeft, lowerRight); + const cornerIndex = getRectangleCornerSegmentIndex(corner); + const result = applyToPolygon(segments, cornerIndex, offset); + + // 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 fillet to a polygon corner by replacing the two edge segments + * that meet at the given index with truncated versions of those edges and + * a cubic bezier arc between them. + * + * The corner is at the junction between segments[cornerIndex] and the next + * segment (wrapping around for closed loops). Both segments must be plain + * line segments (no control points); if either is a bezier curve, the + * function returns the input and an identity map. + */ + export function applyToPolygon

( + segments: Array | QuadraticCurve

| CubicCurve

>, + cornerIndex: number, + offset: number, + ): FilletChamferPolygonResult

{ + const n = segments.length; + if (n < 2) { + return { segments, oldPointToNewPoint: new Map() }; + } + const nextIdx = (cornerIndex + 1) % n; + + const segA = segments[cornerIndex]; + const segB = segments[nextIdx]; + + // Both segments must be plain line segments for the fillet to be applied + if ( + 'controlPoint' in segA || + 'controlPointA' in segA || + 'controlPoint' in segB || + 'controlPointA' in segB + ) { + return { segments, oldPointToNewPoint: new Map() }; + } + + 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 { controlPointA, controlPointB } = computeFilletArcControlPoints( + splitA, + splitB, + tStart, + tEnd, + offset, + cornerPos, + farAPos, + farBPos, + ); + + // Build the new segments list: truncate edge A at splitA, insert the arc, + // truncate edge B at splitB + const truncatedA: LineSegment

= { start: farAPos, end: splitA }; + const arc: CubicCurve

= { start: splitA, end: splitB, controlPointA, controlPointB }; + const truncatedB: LineSegment

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

| CubicCurve

>; + if (isWrapping) { + result = [truncatedB, ...segments.slice(nextIdx + 1, cornerIndex), truncatedA, arc]; + } else { + result = [ + ...segments.slice(0, cornerIndex), + truncatedA, + arc, + truncatedB, + ...segments.slice(nextIdx + 1), + ]; + } + + return { segments: result, oldPointToNewPoint: buildOldToNewMap(n, cornerIndex) }; + } +} + +// --------------------------------------------------------------------------- +// Chamfer namespace +// --------------------------------------------------------------------------- + +export namespace Chamfer { + /** + * Applies a chamfer to a rectangle corner, returning the full polygon segment + * list that results from converting the rectangle to a polygon, then + * replacing the specified corner with a straight line bevel. + */ + export function applyToRectangle

( + upperLeft: P, + lowerRight: P, + corner: RectangleEndpoint, + offset: number, + ): FilletChamferRectangleResult

{ + const segments = rectangleToSegments(upperLeft, lowerRight); + const cornerIndex = getRectangleCornerSegmentIndex(corner); + const result = applyToPolygon(segments, cornerIndex, offset); + + // 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 chamfer to a polygon corner by replacing the two edge segments + * that meet at the given index with truncated versions of those edges and a + * straight line segment between them. + * + * The corner is at the junction between segments[cornerIndex] and the next + * segment (wrapping around for closed loops). Both segments must be plain + * line segments (no control points); if either is a bezier curve, the + * function returns the input and an identity map. + */ + export function applyToPolygon

( + segments: Array | QuadraticCurve

| CubicCurve

>, + cornerIndex: number, + offset: number, + ): FilletChamferPolygonResult

{ + const n = segments.length; + if (n < 2) { + return { segments, oldPointToNewPoint: new Map() }; + } + const nextIdx = (cornerIndex + 1) % n; + + const segA = segments[cornerIndex]; + const segB = segments[nextIdx]; + + // Both segments must be plain line segments for the chamfer to be applied + if ( + 'controlPoint' in segA || + 'controlPointA' in segA || + 'controlPoint' in segB || + 'controlPointA' in segB + ) { + return { segments, oldPointToNewPoint: new Map() }; + } + + const cornerPos = segA.end; + const farAPos = segA.start; + const farBPos = segB.end; + + const { splitA, splitB } = computeCornerSplitInfo(cornerPos, farAPos, farBPos, offset); + + const truncatedA: LineSegment

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

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

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

| CubicCurve

>; + if (isWrapping) { + result = [truncatedB, ...segments.slice(nextIdx + 1, cornerIndex), truncatedA, chamferLine]; + } else { + result = [ + ...segments.slice(0, cornerIndex), + truncatedA, + chamferLine, + truncatedB, + ...segments.slice(nextIdx + 1), + ]; + } + + return { segments: result, oldPointToNewPoint: buildOldToNewMap(n, cornerIndex) }; + } +} diff --git a/src/lib/math/index.ts b/src/lib/math/index.ts index a561be14..c1089110 100644 --- a/src/lib/math/index.ts +++ b/src/lib/math/index.ts @@ -10,6 +10,7 @@ export { Vector2 } from './vector'; export { BoundingBox } from './bounding-box'; export { type CohenSutherlandOutcode, CohenSutherland } from './cohen-sutherland'; export { DeCasteljau, cubicBezierAt } from './bezier'; +export { Fillet, Chamfer, computeFilletArcControlPoints } from './fillet-chamfer'; /* Round a number to an arbitrary number of decimal places. */ export function round(n: number, places: number = 0): number { diff --git a/src/lib/tools/FilletTool.tsx b/src/lib/tools/FilletTool.tsx index cdabc46f..cfd2e603 100644 --- a/src/lib/tools/FilletTool.tsx +++ b/src/lib/tools/FilletTool.tsx @@ -1,6 +1,6 @@ import { SquareRoundCornerIcon } from 'lucide-react'; import { PolygonSegment } from '@/lib/entity/polygon'; -import { Vector2 } from '@/lib/math'; +import { Vector2, computeFilletArcControlPoints } from '@/lib/math'; import { SheetPosition } from '@/lib/viewport/types'; import { BaseCornerGeometryReplacerTool, @@ -37,33 +37,22 @@ export class FilletTool extends BaseCornerGeometryReplacerTool<'fillet'> { 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 { controlPointA, controlPointB } = computeFilletArcControlPoints( + p0, + p3, + tStart, + tEnd, + offset, + step2.centerPos, + step2.pointAPos, + step2.pointBPos, ); - 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, + controlPointA, + controlPointB, }; } } From 909c518ffbfce06d40f896941011c44531b02805 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sun, 19 Jul 2026 09:49:56 -0400 Subject: [PATCH 07/22] feat: port BaseCornerGeometryReplacerTool to use new CornerReplacement utilities --- ...illet-chamfer.ts => corner-replacement.ts} | 221 ++++++++---------- src/lib/math/index.ts | 8 +- .../tools/BaseCornerGeometryReplacerTool.tsx | 206 +++++----------- src/lib/tools/ChamferTool.tsx | 7 +- src/lib/tools/FilletTool.tsx | 38 +-- 5 files changed, 172 insertions(+), 308 deletions(-) rename src/lib/math/{fillet-chamfer.ts => corner-replacement.ts} (62%) diff --git a/src/lib/math/fillet-chamfer.ts b/src/lib/math/corner-replacement.ts similarity index 62% rename from src/lib/math/fillet-chamfer.ts rename to src/lib/math/corner-replacement.ts index 6e2fecf6..a045c726 100644 --- a/src/lib/math/fillet-chamfer.ts +++ b/src/lib/math/corner-replacement.ts @@ -2,14 +2,28 @@ import { type RectangleEndpoint } from '@/lib/entity/rectangle'; import { CubicCurve, LineSegment, type Position, QuadraticCurve } from '@/lib/viewport/types'; import { Vector2 } from './vector'; -/** Return type for applyToPolygon: the modified segment list and a map from old vertex indices to new vertex indices. */ +/** 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: the full polygon segment list and a map from rectangle corner labels to new vertex indices. */ +/** 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. */ @@ -139,15 +153,16 @@ function rectangleToSegments

( upperLeft: P, lowerRight: P, ): Array> { - const corners = RECTANGLE_PERIMETER_ORDER.map((corner) => { - return getRectangleCornerPosition(upperLeft, lowerRight, corner); - }); + 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: corners[0], end: corners[1] }, - { start: corners[1], end: corners[2] }, - { start: corners[2], end: corners[3] }, - { start: corners[3], end: corners[4] }, + { start: ul, end: ur }, + { start: ur, end: lr }, + { start: lr, end: ll }, + { start: ll, end: ul }, ]; } @@ -194,24 +209,64 @@ function buildOldToNewMap(n: number, cornerIndex: number): Map { } // --------------------------------------------------------------------------- -// Fillet namespace +// CornerReplacement namespace // --------------------------------------------------------------------------- -export namespace Fillet { +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 fillet to a rectangle corner, returning the full polygon segment - * list that results from converting the rectangle to a polygon, then - * replacing the specified corner with a cubic bezier arc. + * 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); + const result = applyToPolygon(segments, cornerIndex, offset, segmentFactory); // Convert vertex-index map to RectangleEndpoint map const labelMap = new Map(); @@ -223,37 +278,37 @@ export namespace Fillet { } /** - * Applies a fillet to a polygon corner by replacing the two edge segments - * that meet at the given index with truncated versions of those edges and - * a cubic bezier arc between them. + * 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 for closed loops). Both segments must be plain - * line segments (no control points); if either is a bezier curve, the - * function returns the input and an identity map. + * 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() }; + 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 fillet to be applied + // Both segments must be plain line segments for the replacement to be applied if ( - 'controlPoint' in segA || - 'controlPointA' in segA || - 'controlPoint' in segB || - 'controlPointA' in segB + CubicCurve.isCubicCurve(segA) || + QuadraticCurve.isQuadraticCurve(segA) || + CubicCurve.isCubicCurve(segB) || + QuadraticCurve.isQuadraticCurve(segB) ) { - return { segments, oldPointToNewPoint: new Map() }; + return { segments, oldPointToNewPoint: new Map(), insertedSegmentIndex: -1 }; } const cornerPos = segA.end; @@ -272,128 +327,42 @@ export namespace Fillet { // Tangent at splitB points toward farBPos (same as dirB) const tEnd = dirB; - const { controlPointA, controlPointB } = computeFilletArcControlPoints( + const cornerSegment = segmentFactory( splitA, splitB, tStart, tEnd, offset, - cornerPos, farAPos, + cornerPos, farBPos, ); - // Build the new segments list: truncate edge A at splitA, insert the arc, - // truncate edge B at splitB const truncatedA: LineSegment

= { start: farAPos, end: splitA }; - const arc: CubicCurve

= { start: splitA, end: splitB, controlPointA, controlPointB }; const truncatedB: LineSegment

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

| CubicCurve

>; - if (isWrapping) { - result = [truncatedB, ...segments.slice(nextIdx + 1, cornerIndex), truncatedA, arc]; - } else { - result = [ - ...segments.slice(0, cornerIndex), - truncatedA, - arc, - truncatedB, - ...segments.slice(nextIdx + 1), - ]; - } - - return { segments: result, oldPointToNewPoint: buildOldToNewMap(n, cornerIndex) }; - } -} - -// --------------------------------------------------------------------------- -// Chamfer namespace -// --------------------------------------------------------------------------- + let insertedSegmentIndex: number; -export namespace Chamfer { - /** - * Applies a chamfer to a rectangle corner, returning the full polygon segment - * list that results from converting the rectangle to a polygon, then - * replacing the specified corner with a straight line bevel. - */ - export function applyToRectangle

( - upperLeft: P, - lowerRight: P, - corner: RectangleEndpoint, - offset: number, - ): FilletChamferRectangleResult

{ - const segments = rectangleToSegments(upperLeft, lowerRight); - const cornerIndex = getRectangleCornerSegmentIndex(corner); - const result = applyToPolygon(segments, cornerIndex, offset); - - // 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 chamfer to a polygon corner by replacing the two edge segments - * that meet at the given index with truncated versions of those edges and a - * straight line segment between them. - * - * The corner is at the junction between segments[cornerIndex] and the next - * segment (wrapping around for closed loops). Both segments must be plain - * line segments (no control points); if either is a bezier curve, the - * function returns the input and an identity map. - */ - export function applyToPolygon

( - segments: Array | QuadraticCurve

| CubicCurve

>, - cornerIndex: number, - offset: number, - ): FilletChamferPolygonResult

{ - const n = segments.length; - if (n < 2) { - return { segments, oldPointToNewPoint: new Map() }; - } - const nextIdx = (cornerIndex + 1) % n; - - const segA = segments[cornerIndex]; - const segB = segments[nextIdx]; - - // Both segments must be plain line segments for the chamfer to be applied - if ( - 'controlPoint' in segA || - 'controlPointA' in segA || - 'controlPoint' in segB || - 'controlPointA' in segB - ) { - return { segments, oldPointToNewPoint: new Map() }; - } - - const cornerPos = segA.end; - const farAPos = segA.start; - const farBPos = segB.end; - - const { splitA, splitB } = computeCornerSplitInfo(cornerPos, farAPos, farBPos, offset); - - const truncatedA: LineSegment

= { start: farAPos, end: splitA }; - const chamferLine: LineSegment

= { start: splitA, end: splitB }; - const truncatedB: LineSegment

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

| CubicCurve

>; if (isWrapping) { - result = [truncatedB, ...segments.slice(nextIdx + 1, cornerIndex), truncatedA, chamferLine]; + result = [truncatedB, ...segments.slice(nextIdx + 1, cornerIndex), truncatedA, cornerSegment]; + insertedSegmentIndex = result.length - 1; } else { result = [ ...segments.slice(0, cornerIndex), truncatedA, - chamferLine, + cornerSegment, truncatedB, ...segments.slice(nextIdx + 1), ]; + insertedSegmentIndex = cornerIndex + 1; } - return { segments: result, oldPointToNewPoint: buildOldToNewMap(n, cornerIndex) }; + return { + segments: result, + oldPointToNewPoint: buildOldToNewMap(n, cornerIndex), + insertedSegmentIndex, + }; } } diff --git a/src/lib/math/index.ts b/src/lib/math/index.ts index c1089110..650f4021 100644 --- a/src/lib/math/index.ts +++ b/src/lib/math/index.ts @@ -10,7 +10,13 @@ export { Vector2 } from './vector'; export { BoundingBox } from './bounding-box'; export { type CohenSutherlandOutcode, CohenSutherland } from './cohen-sutherland'; export { DeCasteljau, cubicBezierAt } from './bezier'; -export { Fillet, Chamfer, computeFilletArcControlPoints } from './fillet-chamfer'; +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 { diff --git a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx index bff30bc2..8a0d074e 100644 --- a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx +++ b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx @@ -18,9 +18,10 @@ 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 { CornerReplacement, type CornerSegmentFactory, Vector2 } from '@/lib/math'; import { applyKeyPointSnapping } from '@/lib/snapping'; import { Length } from '@/lib/units/length'; +import { CubicCurve, LineSegment, QuadraticCurve } from '@/lib/viewport/types'; import { ScreenPosition, SheetPosition, type ViewportState } from '@/lib/viewport/types'; import { BaseTool } from './BaseTool'; @@ -566,25 +567,25 @@ export abstract class BaseCornerGeometryReplacerTool extend } 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 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); @@ -979,37 +980,16 @@ export abstract class BaseCornerGeometryReplacerTool extend } /** - * 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. + * Factory that creates the viewport segment (CubicCurve for fillet, LineSegment + * for chamfer) that replaces a polygon corner. Receives the split positions, + * tangents, offset, and corner geometry. */ - protected abstract createCornerSegment( - point: SheetPosition, - p0: SheetPosition, - p3: SheetPosition, - tStart: SheetPosition, - tEnd: SheetPosition, - offset: number, - step2: ValidateOffsetResults, - ): PolygonSegment; + protected abstract cornerSegmentFactory: CornerSegmentFactory; /** - * 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. + * Converts the polygon points to viewport segments, delegates the corner + * replacement to {@link CornerReplacement.applyToPolygon}, and converts + * the result back to PolygonSegments to update the geometry store. */ private buildCornerSegment( step1: ResolveGeometryAndIndicesResults, @@ -1018,110 +998,50 @@ export abstract class BaseCornerGeometryReplacerTool extend ): 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), + + // Use the PRE-SPLIT polygon points (before splitEdgesAtOffset added new vertices). + // applyToPolygon computes its own split points from the corner and far positions, so + // we must feed it the original polygon without the intermediate split vertices. + const preSplitPoints = step1.polygonData.points; + + // Convert pre-split polygon points to viewport segments (i -> (i, i+1) pairing) + 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]), ); - } 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)); + // The corner viewport segment index: the edge that ENDS at the center vertex. + // In a closed polygon with N+1 points (N segments), corner at polygon index k + // sits at the junction of viewport segments (k-1) and k (mod N). + const viewportLength = viewportSegs.length; + const cornerSegIndex = (step1.centerIndex - 1 + viewportLength) % viewportLength; + + const result = CornerReplacement.applyToPolygon( + viewportSegs, + cornerSegIndex, + step2.offset, + this.cornerSegmentFactory, + ); + + // Convert viewport result back to polygon points + 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); } - let addedSegmentIndex = -1; + const addedSegmentIndex = result.insertedSegmentIndex; + 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, }); diff --git a/src/lib/tools/ChamferTool.tsx b/src/lib/tools/ChamferTool.tsx index 14e8b19b..6d50d326 100644 --- a/src/lib/tools/ChamferTool.tsx +++ b/src/lib/tools/ChamferTool.tsx @@ -1,5 +1,5 @@ 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'; @@ -49,7 +49,6 @@ export class ChamferTool extends BaseCornerGeometryReplacerTool<'chamfer'> { return ; } - protected createCornerSegment(point: SheetPosition): PolygonSegment { - return { type: 'point', point }; - } + protected cornerSegmentFactory: CornerSegmentFactory = + CornerReplacement.chamferLine; } diff --git a/src/lib/tools/FilletTool.tsx b/src/lib/tools/FilletTool.tsx index cfd2e603..7730a194 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, computeFilletArcControlPoints } from '@/lib/math'; -import { SheetPosition } from '@/lib/viewport/types'; -import { - BaseCornerGeometryReplacerTool, - type ValidateOffsetResults, -} from './BaseCornerGeometryReplacerTool'; +import { CornerReplacement, type CornerSegmentFactory } from '@/lib/math'; +import { CubicCurve, SheetPosition } from '@/lib/viewport/types'; +import { BaseCornerGeometryReplacerTool } from './BaseCornerGeometryReplacerTool'; /** * A tool for creating fillets (rounded corners) on polygon shapes. @@ -28,31 +24,5 @@ export class FilletTool extends BaseCornerGeometryReplacerTool<'fillet'> { return ; } - protected createCornerSegment( - point: SheetPosition, - p0: SheetPosition, - p3: SheetPosition, - tStart: SheetPosition, - tEnd: SheetPosition, - offset: number, - step2: ValidateOffsetResults, - ): PolygonSegment { - const { controlPointA, controlPointB } = computeFilletArcControlPoints( - p0, - p3, - tStart, - tEnd, - offset, - step2.centerPos, - step2.pointAPos, - step2.pointBPos, - ); - - return { - type: 'arc-cubic' as const, - point, - controlPointA, - controlPointB, - }; - } + protected cornerSegmentFactory: CornerSegmentFactory = CornerReplacement.filletArc; } From 848c4d1927792850ba3ba42d2953198ddc020286 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sun, 19 Jul 2026 09:56:30 -0400 Subject: [PATCH 08/22] refactor: move out mod into math --- src/lib/math/index.ts | 5 +++++ src/lib/tools/BaseCornerGeometryReplacerTool.tsx | 12 ++---------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/lib/math/index.ts b/src/lib/math/index.ts index 650f4021..973dc1b2 100644 --- a/src/lib/math/index.ts +++ b/src/lib/math/index.ts @@ -24,6 +24,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 8a0d074e..f205ea4e 100644 --- a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx +++ b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx @@ -13,12 +13,11 @@ import { 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 { CornerReplacement, type CornerSegmentFactory, Vector2 } from '@/lib/math'; +import { CornerReplacement, type CornerSegmentFactory, mod, Vector2 } from '@/lib/math'; import { applyKeyPointSnapping } from '@/lib/snapping'; import { Length } from '@/lib/units/length'; import { CubicCurve, LineSegment, QuadraticCurve } from '@/lib/viewport/types'; @@ -648,7 +647,7 @@ export abstract class BaseCornerGeometryReplacerTool extend return null; } const step3 = this.splitEdgesAtOffset(step1, step2); - const step4 = this.buildCornerSegment(step1, step2, step3); + const step4 = this.buildCornerSegment(step1, step2); this.addColinearConstraints(step1, step2, step3); this.addRectilinearConstraints(step1, step4); return { outputPolygonId: step1.geometryId }; @@ -695,7 +694,6 @@ export abstract class BaseCornerGeometryReplacerTool extend // 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); @@ -994,7 +992,6 @@ export abstract class BaseCornerGeometryReplacerTool extend private buildCornerSegment( step1: ResolveGeometryAndIndicesResults, step2: ValidateOffsetResults, - step3: SplitEdgesAtOffsetResults, ): BuildCornerSegmentResults { const geometryStore = this.getGeometryStore(); const geometryId = step1.geometryId; @@ -1172,8 +1169,3 @@ export abstract class BaseCornerGeometryReplacerTool extend return -1; } } - -/** Modular arithmetic helper that returns a non-negative remainder. */ -function mod(n: number, m: number): number { - return ((n % m) + m) % m; -} From 126f68ee15af6e0969e4b85c0333b0f8a83b3963 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sun, 19 Jul 2026 10:30:09 -0400 Subject: [PATCH 09/22] feat: move BaseCornerGeometryReplacerTool fillet / chamfer logic into ApplyFilterToGeometryAction This means that fillet / chamfer destructive operations are only applied now once a filter is officially "applied" to the geometry it is associated with --- src/lib/actions/ActionsManager.ts | 3 + .../actions/ApplyFilterToGeometryAction.tsx | 693 ++++++++++++++++++ .../tools/BaseCornerGeometryReplacerTool.tsx | 626 +--------------- src/lib/tools/ChamferTool.tsx | 25 +- src/lib/tools/FilletTool.tsx | 26 +- 5 files changed, 745 insertions(+), 628 deletions(-) create mode 100644 src/lib/actions/ApplyFilterToGeometryAction.tsx 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..1352acc0 --- /dev/null +++ b/src/lib/actions/ApplyFilterToGeometryAction.tsx @@ -0,0 +1,693 @@ +import { Zap as ZapIcon } from 'lucide-react'; +import React from 'react'; +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 { 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 { RectangleData } from '@/lib/entity/geometry/rectangle'; +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; +}; + +const RECTANGLE_ADJACENCY: Partial< + Record +> = { + upperLeft: ['lowerLeft', 'upperRight'], + upperRight: ['lowerRight', 'upperLeft'], + lowerRight: ['lowerLeft', 'upperRight'], + lowerLeft: ['lowerRight', 'upperLeft'], +}; + +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(): Promise { + await this.getHistoryManager().applyTransaction( + this.type, + () => { + const selectedIds = [...this.getSelectionManager().getSelectedIds()]; + + 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.buildCornerState(filter); + 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.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.buildCornerState(filter); + 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.getGeometryStore().deleteById(id); + this.getSelectionManager().deselect(id); + break; + } + case 'mirror': + break; + default: + filter satisfies never; + break; + } + } + }, + { collapseIfSingle: true }, + ); + } + + 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 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, + }; + } + + return null; + } + + 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/tools/BaseCornerGeometryReplacerTool.tsx b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx index f205ea4e..031c1151 100644 --- a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx +++ b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx @@ -23,6 +23,7 @@ import { Length } from '@/lib/units/length'; import { CubicCurve, LineSegment, QuadraticCurve } from '@/lib/viewport/types'; import { ScreenPosition, SheetPosition, type ViewportState } from '@/lib/viewport/types'; import { BaseTool } from './BaseTool'; +import { FilletFilter, FilterTemplate } from '../entity/filters'; export type CornerState = | { @@ -192,6 +193,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 +259,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) { @@ -559,40 +535,15 @@ 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,571 +552,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); - 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 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, - }; - } - - /** - * Factory that creates the viewport segment (CubicCurve for fillet, LineSegment - * for chamfer) that replaces a polygon corner. Receives the split positions, - * tangents, offset, and corner geometry. - */ - protected abstract cornerSegmentFactory: CornerSegmentFactory; - - /** - * Converts the polygon points to viewport segments, delegates the corner - * replacement to {@link CornerReplacement.applyToPolygon}, and converts - * the result back to PolygonSegments to update the geometry store. - */ - private buildCornerSegment( - step1: ResolveGeometryAndIndicesResults, - step2: ValidateOffsetResults, - ): BuildCornerSegmentResults { - const geometryStore = this.getGeometryStore(); - const geometryId = step1.geometryId; - - // Use the PRE-SPLIT polygon points (before splitEdgesAtOffset added new vertices). - // applyToPolygon computes its own split points from the corner and far positions, so - // we must feed it the original polygon without the intermediate split vertices. - const preSplitPoints = step1.polygonData.points; - - // Convert pre-split polygon points to viewport segments (i -> (i, i+1) pairing) - 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]), - ); - } - - // The corner viewport segment index: the edge that ENDS at the center vertex. - // In a closed polygon with N+1 points (N segments), corner at polygon index k - // sits at the junction of viewport segments (k-1) and k (mod N). - const viewportLength = viewportSegs.length; - const cornerSegIndex = (step1.centerIndex - 1 + viewportLength) % viewportLength; - - const result = CornerReplacement.applyToPolygon( - viewportSegs, - cornerSegIndex, - step2.offset, - this.cornerSegmentFactory, - ); - - // Convert viewport result back to polygon points - 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, - }; - } - - /** - * 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; - } } diff --git a/src/lib/tools/ChamferTool.tsx b/src/lib/tools/ChamferTool.tsx index 6d50d326..e9dbde4d 100644 --- a/src/lib/tools/ChamferTool.tsx +++ b/src/lib/tools/ChamferTool.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { CornerReplacement, type CornerSegmentFactory } from '@/lib/math'; import { SheetPosition } from '@/lib/viewport/types'; -import { BaseCornerGeometryReplacerTool } from './BaseCornerGeometryReplacerTool'; +import { BaseCornerGeometryReplacerTool, CornerState } from './BaseCornerGeometryReplacerTool'; +import { Length } from '../units/length'; +import { ChamferFilter } from '../entity/filters/chamfer'; /** * Custom chamfer icon: a square with a 45-degree beveled corner (matches the @@ -49,6 +51,23 @@ export class ChamferTool extends BaseCornerGeometryReplacerTool<'chamfer'> { return ; } - protected cornerSegmentFactory: CornerSegmentFactory = - CornerReplacement.chamferLine; + 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 7730a194..e85c0d6d 100644 --- a/src/lib/tools/FilletTool.tsx +++ b/src/lib/tools/FilletTool.tsx @@ -1,7 +1,7 @@ import { SquareRoundCornerIcon } from 'lucide-react'; -import { CornerReplacement, type CornerSegmentFactory } from '@/lib/math'; -import { CubicCurve, SheetPosition } from '@/lib/viewport/types'; -import { BaseCornerGeometryReplacerTool } from './BaseCornerGeometryReplacerTool'; +import { BaseCornerGeometryReplacerTool, CornerState } from './BaseCornerGeometryReplacerTool'; +import { FilletFilter } from '../entity/filters'; +import { Length } from '../units/length'; /** * A tool for creating fillets (rounded corners) on polygon shapes. @@ -24,5 +24,23 @@ export class FilletTool extends BaseCornerGeometryReplacerTool<'fillet'> { return ; } - protected cornerSegmentFactory: CornerSegmentFactory = CornerReplacement.filletArc; + 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, + ); + } + } } From 930d2ce4d352f630bac47801f487516f217a692e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sun, 19 Jul 2026 11:04:37 -0400 Subject: [PATCH 10/22] feat: update tests to exercise fillet tool and ApplyFilterToGeometryAction seperately --- src/__tests__/FilletTool.test.ts | 834 +++++++++++------- .../tools/BaseCornerGeometryReplacerTool.tsx | 9 +- src/lib/tools/ChamferTool.tsx | 4 +- src/lib/tools/FilletTool.tsx | 2 +- 4 files changed, 511 insertions(+), 338 deletions(-) diff --git a/src/__tests__/FilletTool.test.ts b/src/__tests__/FilletTool.test.ts index e98c0c2b..0fc8b191 100644 --- a/src/__tests__/FilletTool.test.ts +++ b/src/__tests__/FilletTool.test.ts @@ -11,6 +11,8 @@ import { } 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 { SerializationManager } from '@/lib/serialization/SerializationManager'; import { SHEET_UNITS_TO_PIXELS, Sheet } from '@/lib/sheet/Sheet'; @@ -50,7 +52,6 @@ function clickRectangleCorner( } 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; } @@ -91,7 +92,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( @@ -103,12 +104,413 @@ describe('FilletTool', () => { ) as Rectangle; }); - it('upperRight corner: arc at index 2, polygon starts at UL', () => { + function assertFilterCreated( + centerEndpoint: 'upperLeft' | 'upperRight' | 'lowerRight' | 'lowerLeft', + ) { + const filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + const filter = FilterComponent.get(filters[0]); + expect(filter.type).toBe('fillet'); + expect(filter.geometryType).toBe('rectangle'); + expect(filter.pointCenterKeyPoint).toBe(centerEndpoint); + expect(filter.offset.toSheetUnits(Sheet.a4().defaultUnit).magnitude).toBeCloseTo(20); + // Polygon NOT modified + const rectGeom = geometryStore.getByIdWithComponent(rect.id, GeometryComponent); + expect(GeometryComponent.isRectangle(rectGeom)).toBe(true); + } + + it('upperRight corner', () => { clickRectangleCorner(toolManager, geometryStore, rect, 'upperRight', viewport); + filletTool.onChangeCurrentOffset(Length.centimeters(20)); + filletTool.commit(); + assertFilterCreated('upperRight'); + }); + + it('lowerRight corner', () => { + clickRectangleCorner(toolManager, geometryStore, rect, 'lowerRight', viewport); + filletTool.onChangeCurrentOffset(Length.centimeters(20)); + filletTool.commit(); + assertFilterCreated('lowerRight'); + }); + + it('lowerLeft corner', () => { + clickRectangleCorner(toolManager, geometryStore, rect, 'lowerLeft', viewport); + filletTool.onChangeCurrentOffset(Length.centimeters(20)); + filletTool.commit(); + assertFilterCreated('lowerLeft'); + }); + + it('upperLeft corner', () => { + clickRectangleCorner(toolManager, geometryStore, rect, 'upperLeft', viewport); + filletTool.onChangeCurrentOffset(Length.centimeters(20)); + filletTool.commit(); + assertFilterCreated('upperLeft'); + }); + + 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(); + + let filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + expect(FilterComponent.get(filters[0]).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(); + + filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(2); + }); + }); + + 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, + }), + ) as Rectangle; + }); + + it('clicking before current corner commits filter and activates new corner', 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); + + // The first corner's filter should have been committed + const filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + expect(FilterComponent.get(filters[0]).pointCenterKeyPoint).toBe('lowerRight'); + + // Polygon is NOT modified (still a rectangle) + const geomEntities = geometryStore.listWithComponent(GeometryComponent); + expect(geomEntities).toHaveLength(1); + expect(GeometryComponent.isRectangle(geomEntities[0])).toBe(true); + + // The second fillet gets made active (rectangle mode since polygon unchanged) + // 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); + }); + + it('clicking after current corner commits filter and activates new corner', 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 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]).pointCenterKeyPoint).toBe('lowerRight'); + + // Polygon is NOT modified (still a rectangle) + const geomEntities = geometryStore.listWithComponent(GeometryComponent); + expect(geomEntities).toHaveLength(1); + expect(GeometryComponent.isRectangle(geomEntities[0])).toBe(true); + + // The second fillet gets made active (rectangle mode since polygon unchanged) + // 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 corner clicks create filters', () => { + it('middle point of a closed triangular polygon', () => { + geometryStore.addOrdered( + ID_PREFIXES.polygon, + Polygon.create([makePoint(0, 0), makePoint(100, 0), makePoint(100, 100), makePoint(0, 0)], { + closed: true, + }), + ); + + toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); + toolManager.handleMouseDown(sheetToScreen(100, 0, viewport), viewport); + + filletTool.onChangeCurrentOffset(Length.centimeters(20)); + filletTool.commit(); + + const filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + const filter = FilterComponent.get(filters[0]); + expect(filter.type).toBe('fillet'); + expect(filter.geometryType).toBe('polygon'); + expect(filter.pointCenterIndex).toBe(1); + }); + + it('starting point of a closed triangular polygon', () => { + geometryStore.addOrdered( + ID_PREFIXES.polygon, + Polygon.create( + [makePoint(100, 0), makePoint(100, 100), makePoint(0, 0), makePoint(100, 0)], + { closed: true }, + ), + ); + + toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); + toolManager.handleMouseDown(sheetToScreen(100, 0, viewport), viewport); filletTool.onChangeCurrentOffset(Length.centimeters(20)); filletTool.commit(); + const filters = geometryStore.listWithComponent(FilterComponent); + expect(filters).toHaveLength(1); + const filter = FilterComponent.get(filters[0]); + expect(filter.type).toBe('fillet'); + expect(filter.geometryType).toBe('polygon'); + expect(filter.pointCenterIndex).toBe(0); + }); + }); + + 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, + }), + ) as Rectangle; + }); + + 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); + }); + + 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(); + }); +}); + +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); @@ -181,11 +583,19 @@ describe('FilletTool', () => { ).toEqual(['2,3', '4,0']); }); - it('lowerRight corner: arc at index 3', () => { - clickRectangleCorner(toolManager, geometryStore, rect, 'lowerRight', viewport); - - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); + 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); @@ -229,12 +639,6 @@ describe('FilletTool', () => { .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(), @@ -244,23 +648,25 @@ describe('FilletTool', () => { .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); - - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); + 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); @@ -304,12 +710,6 @@ describe('FilletTool', () => { .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(), @@ -319,23 +719,25 @@ describe('FilletTool', () => { .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('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(); + 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); @@ -380,12 +782,6 @@ describe('FilletTool', () => { .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(), @@ -395,24 +791,38 @@ describe('FilletTool', () => { .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']); }); - it('upperRight then lowerRight corner should create two arcs', () => { - toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(100, 0, viewport), viewport); + it.skip('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; - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); + // Apply first filter (upperRight) + selectionManager.select(filter1Id); + await actionsManager.execute('apply-filter-to-geometry'); let polygons = geometryStore.listWithComponent(GeometryComponent); expect(polygons).toHaveLength(1); @@ -428,29 +838,23 @@ describe('FilletTool', () => { 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); + const arcA = points[2] as CubicBezierSegment; + expect(arcA.point.x).toBeCloseTo(100); + expect(arcA.point.y).toBeCloseTo(20); - // Now, click lower right corner - toolManager.handleMouseMove(sheetToScreen(100, 100, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(100, 100, viewport), viewport); - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); + 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); + + // Apply second filter (lowerRight) + // NOTE: This is expected to fail because the first filter already converted the + // rectangle to a polygon, and the second filter's ConstraintEndpoint.lockedToRectangle + // resolution will fail against the now-polygon geometry. + selectionManager.deselect(filter1Id); + selectionManager.select(filter2Id); + await actionsManager.execute('apply-filter-to-geometry'); polygons = geometryStore.listWithComponent(GeometryComponent); expect(polygons).toHaveLength(1); @@ -468,244 +872,30 @@ describe('FilletTool', () => { 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); }); - - describe('pending hover state', () => { - 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); - }); - 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); - - // 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); - }); - }); }); describe('Polygon', () => { - it('middle point of a closed triangular 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; - toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(100, 0, viewport), viewport); - - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); + 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); @@ -720,7 +910,7 @@ describe('FilletTool', () => { expect(points[3].type).toBe('point'); expect(points[4].type).toBe('point'); - // Point positions: UL -> split(80,0) -> arc -> LR -> LL -> UL + // 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); @@ -742,7 +932,8 @@ describe('FilletTool', () => { expect(arc.controlPointB.x).toBeCloseTo(100); expect(arc.controlPointB.y).toBeCloseTo(8.95, 2); }); - it('starting point of a closed triangular polygon', () => { + + it('applies fillet to polygon starting point (wrap)', async () => { geometryStore.addOrdered( ID_PREFIXES.polygon, Polygon.create( @@ -750,12 +941,14 @@ describe('FilletTool', () => { { closed: true }, ), ); + const polygonId = geometryStore.listWithComponent(GeometryComponent)[0].id; - toolManager.handleMouseMove(sheetToScreen(100, 0, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(100, 0, viewport), viewport); - - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); + 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); @@ -794,28 +987,5 @@ describe('FilletTool', () => { expect(arc.controlPointB.x).toBeCloseTo(100); expect(arc.controlPointB.y).toBeCloseTo(8.95, 2); }); - 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(); - }); }); }); diff --git a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx index 031c1151..03c8db6e 100644 --- a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx +++ b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx @@ -17,13 +17,13 @@ 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 { CornerReplacement, type CornerSegmentFactory, mod, Vector2 } from '@/lib/math'; +import { CornerReplacement, type CornerSegmentFactory, Vector2, mod } from '@/lib/math'; import { applyKeyPointSnapping } from '@/lib/snapping'; import { Length } from '@/lib/units/length'; import { CubicCurve, LineSegment, QuadraticCurve } from '@/lib/viewport/types'; import { ScreenPosition, SheetPosition, type ViewportState } from '@/lib/viewport/types'; -import { BaseTool } from './BaseTool'; import { FilletFilter, FilterTemplate } from '../entity/filters'; +import { BaseTool } from './BaseTool'; export type CornerState = | { @@ -540,7 +540,10 @@ export abstract class BaseCornerGeometryReplacerTool extend return; } - this.getGeometryStore().add(ID_PREFIXES.filter, this.createFilter(pending, this.state.currentOffset)); + this.getGeometryStore().add( + ID_PREFIXES.filter, + this.createFilter(pending, this.state.currentOffset), + ); this.lastCommittedOffset = this.state.currentOffset; this.abort(); diff --git a/src/lib/tools/ChamferTool.tsx b/src/lib/tools/ChamferTool.tsx index e9dbde4d..3ba55389 100644 --- a/src/lib/tools/ChamferTool.tsx +++ b/src/lib/tools/ChamferTool.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { CornerReplacement, type CornerSegmentFactory } from '@/lib/math'; import { SheetPosition } from '@/lib/viewport/types'; -import { BaseCornerGeometryReplacerTool, CornerState } from './BaseCornerGeometryReplacerTool'; -import { Length } from '../units/length'; 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 diff --git a/src/lib/tools/FilletTool.tsx b/src/lib/tools/FilletTool.tsx index e85c0d6d..f12496e3 100644 --- a/src/lib/tools/FilletTool.tsx +++ b/src/lib/tools/FilletTool.tsx @@ -1,7 +1,7 @@ import { SquareRoundCornerIcon } from 'lucide-react'; -import { BaseCornerGeometryReplacerTool, CornerState } 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. From 2d69f270831b94dd034ec8ea2476216ac0cbc293 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sun, 19 Jul 2026 11:37:02 -0400 Subject: [PATCH 11/22] feat: get ApplyFilterToGeometryAction to work for multiple filters --- src/__tests__/FilletTool.test.ts | 10 +- .../actions/ApplyFilterToGeometryAction.tsx | 249 ++++++++++++++++-- 2 files changed, 233 insertions(+), 26 deletions(-) diff --git a/src/__tests__/FilletTool.test.ts b/src/__tests__/FilletTool.test.ts index 0fc8b191..37bc2190 100644 --- a/src/__tests__/FilletTool.test.ts +++ b/src/__tests__/FilletTool.test.ts @@ -797,7 +797,7 @@ describe('ApplyFilterToGeometryAction', () => { ).toEqual(['1,2', '3,4']); }); - it.skip('applies two fillets sequentially to rectangle', async () => { + it('applies two fillets sequentially to rectangle', async () => { // Create both filters on the same rectangle const filter1Id = geometryStore.add( ID_PREFIXES.filter, @@ -848,10 +848,12 @@ describe('ApplyFilterToGeometryAction', () => { 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) - // NOTE: This is expected to fail because the first filter already converted the - // rectangle to a polygon, and the second filter's ConstraintEndpoint.lockedToRectangle - // resolution will fail against the now-polygon geometry. selectionManager.deselect(filter1Id); selectionManager.select(filter2Id); await actionsManager.execute('apply-filter-to-geometry'); diff --git a/src/lib/actions/ApplyFilterToGeometryAction.tsx b/src/lib/actions/ApplyFilterToGeometryAction.tsx index 1352acc0..6fd502a6 100644 --- a/src/lib/actions/ApplyFilterToGeometryAction.tsx +++ b/src/lib/actions/ApplyFilterToGeometryAction.tsx @@ -131,6 +131,11 @@ export class ApplyFilterToGeometryAction extends BaseAction { 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); @@ -144,12 +149,13 @@ export class ApplyFilterToGeometryAction extends BaseAction { const offset = filter.offset.toSheetUnits(this.getSheet().defaultUnit).magnitude; const factory: CornerSegmentFactory = CornerReplacement.filletArc; - const pending = this.buildCornerState(filter); + const pending = this.resolvePending(filter, geometryIdRemap, remappedPolygons); if (!pending) { continue; } const step1 = this.resolveGeometryAndIndices(pending); + const step2 = this.validateOffset(step1, offset); if (!step2) { continue; @@ -159,6 +165,8 @@ export class ApplyFilterToGeometryAction extends BaseAction { 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; @@ -167,12 +175,13 @@ export class ApplyFilterToGeometryAction extends BaseAction { const offset = filter.offset.toSheetUnits(this.getSheet().defaultUnit).magnitude; const factory: CornerSegmentFactory = CornerReplacement.chamferLine; - const pending = this.buildCornerState(filter); + const pending = this.resolvePending(filter, geometryIdRemap, remappedPolygons); if (!pending) { continue; } const step1 = this.resolveGeometryAndIndices(pending); + const step2 = this.validateOffset(step1, offset); if (!step2) { continue; @@ -182,6 +191,8 @@ export class ApplyFilterToGeometryAction extends BaseAction { 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; @@ -198,6 +209,80 @@ export class ApplyFilterToGeometryAction extends BaseAction { ); } + /** + * 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.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(); @@ -227,33 +312,153 @@ export class ApplyFilterToGeometryAction extends BaseAction { } if (filter.geometryType === 'rectangle') { - 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) { + const targetGeom = store.getByIdWithComponent(filter.geometryId, GeometryComponent); + if (!targetGeom) { return null; } - return { - mode: 'rectangle', - geometryId: filter.geometryId, - centerEndpoint: filter.pointCenterKeyPoint, - pointAEndpoint: filter.pointAKeyPoint, - pointBEndpoint: filter.pointBKeyPoint, - centerPos, - pointAPos: posA, - pointBPos: posB, - }; + + // 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(); From 8fe72f22aaa531c63364a5bd73e6270b71b6bd76 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sun, 19 Jul 2026 12:20:34 -0400 Subject: [PATCH 12/22] feat: get initial version of chamfer / fillet filter working within getRenderShapes --- src/components/GeometryRenderer.tsx | 17 ++-- .../entity/components/GeometryComponent.ts | 85 ++++++++++++++++++- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/components/GeometryRenderer.tsx b/src/components/GeometryRenderer.tsx index 4e9eec09..a61d0088 100644 --- a/src/components/GeometryRenderer.tsx +++ b/src/components/GeometryRenderer.tsx @@ -6,12 +6,10 @@ import { useDraggingShapeState } from '@/hooks/useDraggingShapeState'; import { useGeometries } from '@/hooks/useGeoemtries'; import { useSelectionManagerSelectedIds } from '@/hooks/useSelectionManagerSelectedIds'; import { FillColorComponent, GeometryComponent, PolygonSegment } from '@/lib/entity'; -import { FilterComponent } from '@/lib/entity/components/FilterComponent'; -import { type Filter } from '@/lib/entity/filters'; import { type Geometry } from '@/lib/entity/geometry'; import { BoundingBox, CohenSutherland } from '@/lib/math'; import { ListLayers, RendererLayers } from '@/lib/renderer'; -import { SHEET_UNITS_TO_PIXELS } from '@/lib/sheet/Sheet'; +import { SHEET_UNITS_TO_PIXELS, Sheet } from '@/lib/sheet/Sheet'; import { HIGHLIGHT_COLOR_FILL, HIGHLIGHT_COLOR_STROKE, @@ -427,6 +425,7 @@ const GeometrySolid: React.FunctionComponent<{ geometry: Geometry }> = ({ geomet mouseScreenPos, highlightedGeometryId, filtersByGeometryId, + sheet, } = useViewportContext(); let fillColor = FillColorComponent.getOptional(geometry); @@ -492,13 +491,21 @@ const GeometrySolid: React.FunctionComponent<{ geometry: Geometry }> = ({ geomet ); }, [activeTool.type, viewportControls, viewportScale, mouseScreenPos]); + const [sheetDefaultUnit, setSheetDefaultUnit] = useState(sheet.defaultUnit); + useEffect(() => { + sheet.on('defaultUnitChange', setSheetDefaultUnit); + return () => { + sheet.off('defaultUnitChange', setSheetDefaultUnit); + }; + }, [sheet]); + const filters = useMemo( () => filtersByGeometryId.get(geometry.id), [filtersByGeometryId, geometry.id], ); const renderShapes = useMemo(() => { - return GeometryComponent.getRenderShapes(geometry, filters); - }, [geometry, filters]); + return GeometryComponent.getRenderShapes(geometry, sheetDefaultUnit, filters); + }, [geometry, sheetDefaultUnit, filters]); return ( <> diff --git a/src/lib/entity/components/GeometryComponent.ts b/src/lib/entity/components/GeometryComponent.ts index 9571b887..fc151d2d 100644 --- a/src/lib/entity/components/GeometryComponent.ts +++ b/src/lib/entity/components/GeometryComponent.ts @@ -1,5 +1,13 @@ -import { BoundingBox, rectangleToPolygon } from '@/lib/math'; -import { KeyPoints, Rect, SheetPosition } from '@/lib/viewport/types'; +import { BoundingBox, CornerReplacement, rectangleToPolygon } from '@/lib/math'; +import { type UnitType } from '@/lib/units/length'; +import { + CubicCurve, + KeyPoints, + LineSegment, + QuadraticCurve, + Rect, + SheetPosition, +} from '@/lib/viewport/types'; import { Filter } from '../filters'; import { type Geometry, type GeometryData } from '../geometry'; import { EllipseData } from '../geometry/ellipse'; @@ -547,6 +555,7 @@ export namespace GeometryComponent { export function getRenderShapes( geometry: Entity>, + sheetDefaultUnit: UnitType, filters: Array = [], ): Array { let shapes; @@ -679,9 +688,77 @@ export namespace GeometryComponent { break; } case 'fillet': - case 'chamfer': - // TODO + case 'chamfer': { + const factory = + data.type === 'fillet' + ? CornerReplacement.filletArc + : CornerReplacement.chamferLine; + const offsetNum = data.offset.toSheetUnits(sheetDefaultUnit).magnitude; + + let resultSegs: Array< + LineSegment | QuadraticCurve | CubicCurve + > | null = null; + + switch (data.geometryType) { + case 'rectangle': + if (GeometryComponent.isRectangle(geometry)) { + const state = GeometryComponent.get(geometry); + resultSegs = CornerReplacement.applyToRectangle( + state.upperLeft, + state.lowerRight, + data.pointCenterKeyPoint, + offsetNum, + factory, + ).segments; + } + break; + case 'polygon': + if (GeometryComponent.isPolygon(geometry)) { + const state = GeometryComponent.get(geometry); + + // Convert polygon points to viewport segments + const n = state.points.length; + const viewportSegs: Array< + LineSegment | QuadraticCurve | CubicCurve + > = []; + for (let i = 0; i < n - 1; i += 1) { + viewportSegs.push( + PolygonSegment.toLineSegmentOrCurve(state.points[i].point, state.points[i + 1]), + ); + } + // Find the viewport segment index whose end is the center vertex + const cornerIndex = + (data.pointCenterIndex - 1 + viewportSegs.length) % viewportSegs.length; + + resultSegs = CornerReplacement.applyToPolygon( + viewportSegs, + cornerIndex, + offsetNum, + factory, + ).segments; + } + break; + default: + data satisfies never; + throw new Error(`GeometryComponent.getRenderShapes: Unknown fillet / chamfer filter geometryType ${(data as any).geometryType}`); + } + + if (!resultSegs) { + break; + } + + // 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); + } + + shapes = [RenderShape.polygon(geometry.id, newPoints, true)]; break; + } default: data satisfies never; break; From cfae9d79435869e3694228d86472ee1bdde15291 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sun, 19 Jul 2026 20:55:46 -0400 Subject: [PATCH 13/22] refactor: break up fillet tool / ApplyFilterToGeometryAction tests --- .../ApplyFilterToGeometryAction.test.ts | 576 ++++++++++++++++++ src/__tests__/FilletTool.test.ts | 551 +---------------- 2 files changed, 588 insertions(+), 539 deletions(-) create mode 100644 src/__tests__/ApplyFilterToGeometryAction.test.ts 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 37bc2190..6aac3b55 100644 --- a/src/__tests__/FilletTool.test.ts +++ b/src/__tests__/FilletTool.test.ts @@ -1,18 +1,15 @@ import { ActionsManager } from '@/lib/actions/ActionsManager'; import { - ConstraintComponent, ConstraintEndpoint, - type CubicBezierSegment, GeometryComponent, PointSegment, Polygon, - type PolygonSegment, 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 { 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'; @@ -111,11 +108,11 @@ describe('FilletTool', () => { expect(filters).toHaveLength(1); const filter = FilterComponent.get(filters[0]); expect(filter.type).toBe('fillet'); - expect(filter.geometryType).toBe('rectangle'); - expect(filter.pointCenterKeyPoint).toBe(centerEndpoint); - expect(filter.offset.toSheetUnits(Sheet.a4().defaultUnit).magnitude).toBeCloseTo(20); + expect((filter as FilletFilterData).geometryType).toBe('rectangle'); + expect((filter as any).pointCenterKeyPoint).toBe(centerEndpoint); + expect((filter as FilletFilterData).offset.toSheetUnits(Sheet.a4().defaultUnit).magnitude).toBeCloseTo(20); // Polygon NOT modified - const rectGeom = geometryStore.getByIdWithComponent(rect.id, GeometryComponent); + const rectGeom = geometryStore.getByIdWithComponent(rect.id, GeometryComponent)!; expect(GeometryComponent.isRectangle(rectGeom)).toBe(true); } @@ -156,7 +153,7 @@ describe('FilletTool', () => { let filters = geometryStore.listWithComponent(FilterComponent); expect(filters).toHaveLength(1); - expect(FilterComponent.get(filters[0]).pointCenterKeyPoint).toBe('upperRight'); + expect((FilterComponent.get(filters[0]) as any).pointCenterKeyPoint).toBe('upperRight'); // Second corner: lowerRight toolManager.handleMouseMove(sheetToScreen(100, 100, viewport), viewport); @@ -200,7 +197,7 @@ describe('FilletTool', () => { // The first corner's filter should have been committed const filters = geometryStore.listWithComponent(FilterComponent); expect(filters).toHaveLength(1); - expect(FilterComponent.get(filters[0]).pointCenterKeyPoint).toBe('lowerRight'); + expect((FilterComponent.get(filters[0]) as any).pointCenterKeyPoint).toBe('lowerRight'); // Polygon is NOT modified (still a rectangle) const geomEntities = geometryStore.listWithComponent(GeometryComponent); @@ -243,7 +240,7 @@ describe('FilletTool', () => { // The first corner's filter should have been committed const filters = geometryStore.listWithComponent(FilterComponent); expect(filters).toHaveLength(1); - expect(FilterComponent.get(filters[0]).pointCenterKeyPoint).toBe('lowerRight'); + expect((FilterComponent.get(filters[0]) as any).pointCenterKeyPoint).toBe('lowerRight'); // Polygon is NOT modified (still a rectangle) const geomEntities = geometryStore.listWithComponent(GeometryComponent); @@ -287,8 +284,8 @@ describe('FilletTool', () => { expect(filters).toHaveLength(1); const filter = FilterComponent.get(filters[0]); expect(filter.type).toBe('fillet'); - expect(filter.geometryType).toBe('polygon'); - expect(filter.pointCenterIndex).toBe(1); + expect((filter as FilletFilterData).geometryType).toBe('polygon'); + expect((filter as any).pointCenterIndex).toBe(1); }); it('starting point of a closed triangular polygon', () => { @@ -310,8 +307,8 @@ describe('FilletTool', () => { expect(filters).toHaveLength(1); const filter = FilterComponent.get(filters[0]); expect(filter.type).toBe('fillet'); - expect(filter.geometryType).toBe('polygon'); - expect(filter.pointCenterIndex).toBe(0); + expect((filter as FilletFilterData).geometryType).toBe('polygon'); + expect((filter as any).pointCenterIndex).toBe(0); }); }); @@ -467,527 +464,3 @@ describe('FilletTool', () => { expect(await events.waitFor('pendingCornerChange')).toBeNull(); }); }); - -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 - .filter((g) => ConstraintComponent.get(g).type === 'horizontal') - .map((g) => { - const c = ConstraintComponent.get(g); - 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); - 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 - .filter((g) => ConstraintComponent.get(g).type === 'horizontal') - .map((g) => { - const c = ConstraintComponent.get(g); - 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); - 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 - .filter((g) => ConstraintComponent.get(g).type === 'horizontal') - .map((g) => { - const c = ConstraintComponent.get(g); - 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); - 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); - }); - }); -}); From 0c218e3bd446194dad6d5085e01fcab857fb8fbb Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Sun, 19 Jul 2026 21:45:38 -0400 Subject: [PATCH 14/22] feat: block adding chamfer / fillet to rectangle corners which already have a filter --- src/__tests__/FilletTool.test.ts | 174 ++++++++++-------- src/lib/entity/components/FilterComponent.ts | 37 ++++ .../tools/BaseCornerGeometryReplacerTool.tsx | 66 +++---- 3 files changed, 155 insertions(+), 122 deletions(-) diff --git a/src/__tests__/FilletTool.test.ts b/src/__tests__/FilletTool.test.ts index 6aac3b55..562a72b6 100644 --- a/src/__tests__/FilletTool.test.ts +++ b/src/__tests__/FilletTool.test.ts @@ -1,7 +1,6 @@ import { ActionsManager } from '@/lib/actions/ActionsManager'; import { ConstraintEndpoint, - GeometryComponent, PointSegment, Polygon, Rectangle, @@ -9,10 +8,10 @@ import { import { GeometryStore, ID_PREFIXES } from '@/lib/entity/GeometryStore'; import { DEFAULT_COLOR } from '@/lib/entity/colors'; import { FilterComponent } from '@/lib/entity/components/FilterComponent'; -import { FilletFilterData } from '@/lib/entity/filters/fillet'; +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'; @@ -24,7 +23,6 @@ import { ScreenPosition, SheetPosition, type ViewportState, - WorldPosition, } from '@/lib/viewport/types'; function makePoint(x: number, y: number): PointSegment { @@ -32,27 +30,11 @@ function makePoint(x: number, y: number): PointSegment { } 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); - return pos; + return new SheetPosition(x, y).toWorld().toScreen(viewport); } describe('FilletTool', () => { + let sheet: Sheet; let historyManager: HistoryManager; let geometryStore: GeometryStore; let selectionManager: SelectionManager; @@ -63,7 +45,7 @@ describe('FilletTool', () => { let viewportControls: ViewportControls; beforeEach(() => { - const sheet = Sheet.a4(); + sheet = Sheet.a4(); historyManager = new HistoryManager(); geometryStore = new GeometryStore(historyManager); historyManager.setGeometryStore(geometryStore); @@ -98,50 +80,39 @@ describe('FilletTool', () => { fillColor: DEFAULT_COLOR, linkDimensions: false, }), - ) as Rectangle; + ); }); - function assertFilterCreated( - centerEndpoint: 'upperLeft' | 'upperRight' | 'lowerRight' | 'lowerLeft', - ) { - 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('rectangle'); - expect((filter as any).pointCenterKeyPoint).toBe(centerEndpoint); - expect((filter as FilletFilterData).offset.toSheetUnits(Sheet.a4().defaultUnit).magnitude).toBeCloseTo(20); - // Polygon NOT modified - const rectGeom = geometryStore.getByIdWithComponent(rect.id, GeometryComponent)!; - expect(GeometryComponent.isRectangle(rectGeom)).toBe(true); - } - - it('upperRight corner', () => { - clickRectangleCorner(toolManager, geometryStore, rect, 'upperRight', viewport); - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); - assertFilterCreated('upperRight'); - }); + 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}`); + } - it('lowerRight corner', () => { - clickRectangleCorner(toolManager, geometryStore, rect, 'lowerRight', viewport); - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); - assertFilterCreated('lowerRight'); - }); + // Click the given corner + toolManager.handleMouseMove(sheetToScreen(pos.x, pos.y, viewport), viewport); + toolManager.handleMouseDown(sheetToScreen(pos.x, pos.y, viewport), viewport); - it('lowerLeft corner', () => { - clickRectangleCorner(toolManager, geometryStore, rect, 'lowerLeft', viewport); + // Enter an offset distance, and commit filletTool.onChangeCurrentOffset(Length.centimeters(20)); filletTool.commit(); - assertFilterCreated('lowerLeft'); - }); - it('upperLeft corner', () => { - clickRectangleCorner(toolManager, geometryStore, rect, 'upperLeft', viewport); - filletTool.onChangeCurrentOffset(Length.centimeters(20)); - filletTool.commit(); - assertFilterCreated('upperLeft'); + // 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', () => { @@ -175,7 +146,7 @@ describe('FilletTool', () => { fillColor: DEFAULT_COLOR, linkDimensions: false, }), - ) as Rectangle; + ); }); it('clicking before current corner commits filter and activates new corner', async () => { @@ -199,12 +170,7 @@ describe('FilletTool', () => { expect(filters).toHaveLength(1); expect((FilterComponent.get(filters[0]) as any).pointCenterKeyPoint).toBe('lowerRight'); - // Polygon is NOT modified (still a rectangle) - const geomEntities = geometryStore.listWithComponent(GeometryComponent); - expect(geomEntities).toHaveLength(1); - expect(GeometryComponent.isRectangle(geomEntities[0])).toBe(true); - - // The second fillet gets made active (rectangle mode since polygon unchanged) + // 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(); @@ -242,12 +208,7 @@ describe('FilletTool', () => { expect(filters).toHaveLength(1); expect((FilterComponent.get(filters[0]) as any).pointCenterKeyPoint).toBe('lowerRight'); - // Polygon is NOT modified (still a rectangle) - const geomEntities = geometryStore.listWithComponent(GeometryComponent); - expect(geomEntities).toHaveLength(1); - expect(GeometryComponent.isRectangle(geomEntities[0])).toBe(true); - - // The second fillet gets made active (rectangle mode since polygon unchanged) + // 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(); @@ -267,7 +228,7 @@ describe('FilletTool', () => { 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, @@ -283,13 +244,14 @@ describe('FilletTool', () => { 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 any).pointCenterIndex).toBe(1); + 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)], @@ -308,6 +270,7 @@ describe('FilletTool', () => { 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); }); }); @@ -321,7 +284,7 @@ describe('FilletTool', () => { fillColor: DEFAULT_COLOR, linkDimensions: false, }), - ) as Rectangle; + ); }); it('should allow hovering over all rectangle corner points', async () => { @@ -463,4 +426,57 @@ describe('FilletTool', () => { 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/lib/entity/components/FilterComponent.ts b/src/lib/entity/components/FilterComponent.ts index a7637b56..32b25795 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,40 @@ 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/tools/BaseCornerGeometryReplacerTool.tsx b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx index 03c8db6e..259b271b 100644 --- a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx +++ b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx @@ -1,29 +1,21 @@ 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 { 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 { CornerReplacement, type CornerSegmentFactory, Vector2, mod } from '@/lib/math'; import { applyKeyPointSnapping } from '@/lib/snapping'; import { Length } from '@/lib/units/length'; -import { CubicCurve, LineSegment, QuadraticCurve } from '@/lib/viewport/types'; import { ScreenPosition, SheetPosition, type ViewportState } from '@/lib/viewport/types'; -import { FilletFilter, FilterTemplate } from '../entity/filters'; +import { FilterTemplate } from '../entity/filters'; import { BaseTool } from './BaseTool'; +import { FilterComponent } from '../entity/components/FilterComponent'; export type CornerState = | { @@ -103,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. */ @@ -400,6 +360,16 @@ 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') { @@ -442,6 +412,16 @@ 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, @@ -450,7 +430,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; From ba33100aeabe12d6e29e07a8635bbb08dd201e94 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 20 Jul 2026 08:19:04 -0400 Subject: [PATCH 15/22] feat: get GeometryComponent.getRenderShapes working for fillet and chamfer --- src/app/components/ViewportRenderer2D.tsx | 2 +- .../entity/components/GeometryComponent.ts | 201 ++++++++++++------ src/lib/entity/rectangle.ts | 2 + src/lib/math/index.ts | 2 +- 4 files changed, 137 insertions(+), 70 deletions(-) diff --git a/src/app/components/ViewportRenderer2D.tsx b/src/app/components/ViewportRenderer2D.tsx index 59fbfd56..c6f6ad23 100644 --- a/src/app/components/ViewportRenderer2D.tsx +++ b/src/app/components/ViewportRenderer2D.tsx @@ -1206,7 +1206,7 @@ export default function ViewportRenderer2D({ ) : null} - {keyPointSnapInfo && viewportControlsState ? ( + {keyPointSnapInfo && viewportControlsState && activeTool.type !== 'edit' ? ( { filterApplicationCounter += 1; @@ -604,7 +604,7 @@ export namespace GeometryComponent { ), ); const flippedCorners = corners.map((point) => - mirrorPointOverLine(point, data.pointA, data.pointB), + mirrorPointOverLine(point, filterData.pointA, filterData.pointB), ); const ul = new SheetPosition( Math.min(...flippedCorners.map((p) => p.x)), @@ -619,8 +619,8 @@ export namespace GeometryComponent { case 'ellipse': { const mirroredCenter = mirrorPointOverLine( renderShape.center, - data.pointA, - data.pointB, + filterData.pointA, + filterData.pointB, ); return [ RenderShape.ellipse(key, mirroredCenter, { @@ -633,8 +633,8 @@ export namespace GeometryComponent { const mirroredPoints = renderShape.points.map((segment) => { const mirroredPoint = mirrorPointOverLine( segment.point, - data.pointA, - data.pointB, + filterData.pointA, + filterData.pointB, ); switch (segment.type) { case 'point': @@ -645,8 +645,8 @@ export namespace GeometryComponent { point: mirroredPoint, controlPoint: mirrorPointOverLine( segment.controlPoint, - data.pointA, - data.pointB, + filterData.pointA, + filterData.pointB, ), }; case 'arc-cubic': @@ -655,13 +655,13 @@ export namespace GeometryComponent { point: mirroredPoint, controlPointA: mirrorPointOverLine( segment.controlPointA, - data.pointA, - data.pointB, + filterData.pointA, + filterData.pointB, ), controlPointB: mirrorPointOverLine( segment.controlPointB, - data.pointA, - data.pointB, + filterData.pointA, + filterData.pointB, ), }; default: @@ -690,77 +690,142 @@ export namespace GeometryComponent { case 'fillet': case 'chamfer': { const factory = - data.type === 'fillet' + filterData.type === 'fillet' ? CornerReplacement.filletArc : CornerReplacement.chamferLine; - const offsetNum = data.offset.toSheetUnits(sheetDefaultUnit).magnitude; - - let resultSegs: Array< - LineSegment | QuadraticCurve | CubicCurve - > | null = null; - - switch (data.geometryType) { - case 'rectangle': - if (GeometryComponent.isRectangle(geometry)) { - const state = GeometryComponent.get(geometry); - resultSegs = CornerReplacement.applyToRectangle( - state.upperLeft, - state.lowerRight, - data.pointCenterKeyPoint, - offsetNum, - factory, - ).segments; - } - break; - case 'polygon': - if (GeometryComponent.isPolygon(geometry)) { - const state = GeometryComponent.get(geometry); + 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 n = state.points.length; + const pointsLength = renderShape.points.length; const viewportSegs: Array< LineSegment | QuadraticCurve | CubicCurve > = []; - for (let i = 0; i < n - 1; i += 1) { + for (let i = 0; i < pointsLength - 1; i += 1) { viewportSegs.push( - PolygonSegment.toLineSegmentOrCurve(state.points[i].point, state.points[i + 1]), + PolygonSegment.toLineSegmentOrCurve(renderShape.points[i].point, renderShape.points[i + 1]), ); } - // Find the viewport segment index whose end is the center vertex - const cornerIndex = - (data.pointCenterIndex - 1 + viewportSegs.length) % viewportSegs.length; - - resultSegs = CornerReplacement.applyToPolygon( - viewportSegs, - cornerIndex, - offsetNum, - factory, - ).segments; + + 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; } - break; - default: - data satisfies never; - throw new Error(`GeometryComponent.getRenderShapes: Unknown fillet / chamfer filter geometryType ${(data as any).geometryType}`); - } + 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) { - break; - } + 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); - } + // 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); + } - shapes = [RenderShape.polygon(geometry.id, newPoints, true)]; + return [RenderShape.polygon(key, newPoints, true)]; + }); break; } default: - data satisfies never; + filterData satisfies never; break; } } 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/math/index.ts b/src/lib/math/index.ts index 973dc1b2..bcbab81e 100644 --- a/src/lib/math/index.ts +++ b/src/lib/math/index.ts @@ -1,4 +1,4 @@ -import { type PointSegment, type PolygonSegment } from '@/lib/entity'; +import { RectangleCorner, RectangleEndpoint, type PointSegment, type PolygonSegment } from '@/lib/entity'; import { CubicCurve, Position, QuadraticCurve, SheetPosition } from '@/lib/viewport/types'; import { DeCasteljau } from './bezier'; import { Vector2 } from './vector'; From 6cb7ed23706abe38297b3f85ab5cbdde3d4c9111 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 20 Jul 2026 08:39:10 -0400 Subject: [PATCH 16/22] feat: add initial geometry component get render shapes test --- src/__tests__/GeometryComponent.test.ts | 128 ++++++++++++++++++ .../entity/components/GeometryComponent.ts | 32 +++-- 2 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/GeometryComponent.test.ts diff --git a/src/__tests__/GeometryComponent.test.ts b/src/__tests__/GeometryComponent.test.ts new file mode 100644 index 00000000..16e9edcd --- /dev/null +++ b/src/__tests__/GeometryComponent.test.ts @@ -0,0 +1,128 @@ +import { ActionsManager } from '@/lib/actions/ActionsManager'; +import { + CubicBezierSegment, + GeometryComponent, + PointSegment, + Rectangle, + RenderShapePolygon, +} from '@/lib/entity'; +import { GeometryStore, ID_PREFIXES } from '@/lib/entity/GeometryStore'; +import { FilletFilter } from '@/lib/entity/filters/fillet'; +import { HistoryManager } from '@/lib/history/HistoryManager'; +import { SerializationManager } from '@/lib/serialization/SerializationManager'; +import { Sheet } from '@/lib/sheet/Sheet'; +import { CornerState } from '@/lib/tools/BaseCornerGeometryReplacerTool'; +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 { + 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 SheetPosition(x, y).toWorld().toScreen(viewport); +} + +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', () => { + 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 upperLeft 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); + }); + }); +}); diff --git a/src/lib/entity/components/GeometryComponent.ts b/src/lib/entity/components/GeometryComponent.ts index 1d9ee4a8..2fa2d0a3 100644 --- a/src/lib/entity/components/GeometryComponent.ts +++ b/src/lib/entity/components/GeometryComponent.ts @@ -17,16 +17,28 @@ import { type Entity, type EntityComponent, ResizeParams } from '../types'; import { ConstraintComponent } from './ConstraintComponent'; import { FilterComponent } from './FilterComponent'; -export type RenderShape = - | { - shape: 'polygon'; - key: string; - primary: boolean; - points: Array; - closed: boolean; - } - | { shape: 'rectangle'; key: string; upperLeft: SheetPosition; lowerRight: SheetPosition } - | { shape: 'ellipse'; key: string; center: SheetPosition; radiusX: number; radiusY: number }; +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( From b7915d21d5e0fa2bc57641b71e98c2b09de56c56 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 20 Jul 2026 08:44:01 -0400 Subject: [PATCH 17/22] fix: make FilletFilterIndicator use fillet generation utilities rather than hand rolling it --- src/app/components/FilletFilterIndicator.tsx | 30 +++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) 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], From b3124b11d50ac51a25866c2d67a0746f7e09fac7 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 20 Jul 2026 08:45:37 -0400 Subject: [PATCH 18/22] fix: run npm run format --- src/__tests__/FilletTool.test.ts | 85 ++++++++----------- src/__tests__/GeometryComponent.test.ts | 10 +-- src/components/GeometryRenderer.tsx | 2 +- src/lib/entity/components/FilterComponent.ts | 12 ++- .../entity/components/GeometryComponent.ts | 15 +++- src/lib/math/index.ts | 7 +- .../tools/BaseCornerGeometryReplacerTool.tsx | 18 ++-- 7 files changed, 80 insertions(+), 69 deletions(-) diff --git a/src/__tests__/FilletTool.test.ts b/src/__tests__/FilletTool.test.ts index 562a72b6..b8948bad 100644 --- a/src/__tests__/FilletTool.test.ts +++ b/src/__tests__/FilletTool.test.ts @@ -1,10 +1,5 @@ import { ActionsManager } from '@/lib/actions/ActionsManager'; -import { - ConstraintEndpoint, - PointSegment, - Polygon, - 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'; @@ -19,11 +14,7 @@ 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, -} 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) }; @@ -83,37 +74,37 @@ describe('FilletTool', () => { ); }); - 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}`); - } - - // Click the given corner - toolManager.handleMouseMove(sheetToScreen(pos.x, pos.y, viewport), viewport); - toolManager.handleMouseDown(sheetToScreen(pos.x, pos.y, viewport), 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.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}`); + } + + // Click the given corner + toolManager.handleMouseMove(sheetToScreen(pos.x, pos.y, viewport), viewport); + toolManager.handleMouseDown(sheetToScreen(pos.x, pos.y, viewport), 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 @@ -444,7 +435,9 @@ describe('FilletTool', () => { ID_PREFIXES.filter, FilletFilter.createOnRectangle( rectangle.id, - 'lowerLeft', 'upperLeft', 'upperRight', + 'lowerLeft', + 'upperLeft', + 'upperRight', Length.centimeters(20), ), ); @@ -468,11 +461,7 @@ describe('FilletTool', () => { // 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), - ), + FilletFilter.createOnPolygon(rectangle.id, 0, 1, 2, Length.centimeters(20)), ); // Hovering over point at index=1 does nothing diff --git a/src/__tests__/GeometryComponent.test.ts b/src/__tests__/GeometryComponent.test.ts index 16e9edcd..334e81a7 100644 --- a/src/__tests__/GeometryComponent.test.ts +++ b/src/__tests__/GeometryComponent.test.ts @@ -17,11 +17,7 @@ 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 { - ScreenPosition, - SheetPosition, - type ViewportState, -} 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) }; @@ -82,7 +78,9 @@ describe('GeometryComponent', () => { ID_PREFIXES.filter, FilletFilter.createOnRectangle( rectangle.id, - 'upperLeft', 'upperRight', 'lowerRight', + 'upperLeft', + 'upperRight', + 'lowerRight', Length.centimeters(20), ), ); diff --git a/src/components/GeometryRenderer.tsx b/src/components/GeometryRenderer.tsx index a61d0088..31607323 100644 --- a/src/components/GeometryRenderer.tsx +++ b/src/components/GeometryRenderer.tsx @@ -491,7 +491,7 @@ const GeometrySolid: React.FunctionComponent<{ geometry: Geometry }> = ({ geomet ); }, [activeTool.type, viewportControls, viewportScale, mouseScreenPos]); - const [sheetDefaultUnit, setSheetDefaultUnit] = useState(sheet.defaultUnit); + const [sheetDefaultUnit, setSheetDefaultUnit] = useState(sheet.defaultUnit); useEffect(() => { sheet.on('defaultUnitChange', setSheetDefaultUnit); return () => { diff --git a/src/lib/entity/components/FilterComponent.ts b/src/lib/entity/components/FilterComponent.ts index 32b25795..34a80bc9 100644 --- a/src/lib/entity/components/FilterComponent.ts +++ b/src/lib/entity/components/FilterComponent.ts @@ -51,7 +51,11 @@ export namespace FilterComponent { }; } - export function isLockedToRectangle(geometry: Entity, rectangleId: Entity['id'], rectanglePoint: RectangleEndpoint) { + export function isLockedToRectangle( + geometry: Entity, + rectangleId: Entity['id'], + rectanglePoint: RectangleEndpoint, + ) { const filter = FilterComponent.get(geometry); switch (filter.type) { case 'fillet': @@ -69,7 +73,11 @@ export namespace FilterComponent { } } - export function isLockedToPolygon(geometry: Entity, polygonId: Entity['id'], pointIndex: number) { + export function isLockedToPolygon( + geometry: Entity, + polygonId: Entity['id'], + pointIndex: number, + ) { const filter = FilterComponent.get(geometry); switch (filter.type) { case 'fillet': diff --git a/src/lib/entity/components/GeometryComponent.ts b/src/lib/entity/components/GeometryComponent.ts index 2fa2d0a3..5b7ce6aa 100644 --- a/src/lib/entity/components/GeometryComponent.ts +++ b/src/lib/entity/components/GeometryComponent.ts @@ -728,7 +728,9 @@ export namespace GeometryComponent { ).segments; break; case 'polygon': - console.warn('GeometryComponent.getRenderShapes: applying fillet/chamfer - geoemtryType of polygon cannot apply to renderShape of rectangle, skipping...'); + console.warn( + 'GeometryComponent.getRenderShapes: applying fillet/chamfer - geoemtryType of polygon cannot apply to renderShape of rectangle, skipping...', + ); break; default: filterData satisfies never; @@ -740,11 +742,16 @@ export namespace GeometryComponent { // Convert polygon points to viewport segments const pointsLength = renderShape.points.length; const viewportSegs: Array< - LineSegment | QuadraticCurve | CubicCurve + | LineSegment + | QuadraticCurve + | CubicCurve > = []; for (let i = 0; i < pointsLength - 1; i += 1) { viewportSegs.push( - PolygonSegment.toLineSegmentOrCurve(renderShape.points[i].point, renderShape.points[i + 1]), + PolygonSegment.toLineSegmentOrCurve( + renderShape.points[i].point, + renderShape.points[i + 1], + ), ); } @@ -762,7 +769,7 @@ export namespace GeometryComponent { // Find the viewport segment index whose end is the center vertex const cornerPositions = BoundingBox.corners( - BoundingBox.fromPoints(renderShape.points.map((p) => p.point)) + BoundingBox.fromPoints(renderShape.points.map((p) => p.point)), ); const centerPos = cornerPositions[filterData.pointCenterKeyPoint]; // Find the center vertex index in the polygon diff --git a/src/lib/math/index.ts b/src/lib/math/index.ts index bcbab81e..c98a5860 100644 --- a/src/lib/math/index.ts +++ b/src/lib/math/index.ts @@ -1,4 +1,9 @@ -import { RectangleCorner, RectangleEndpoint, 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'; diff --git a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx index 259b271b..eddef264 100644 --- a/src/lib/tools/BaseCornerGeometryReplacerTool.tsx +++ b/src/lib/tools/BaseCornerGeometryReplacerTool.tsx @@ -13,9 +13,9 @@ import { type RectangleEndpoint } from '@/lib/entity/rectangle'; 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'; -import { FilterComponent } from '../entity/components/FilterComponent'; export type CornerState = | { @@ -362,9 +362,11 @@ export abstract class BaseCornerGeometryReplacerTool extend // 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) - ); + const existingFilter = this.getGeometryStore() + .findFiltersByGeometryId(keyPointEndpoint.id) + .find((f) => + FilterComponent.isLockedToRectangle(f, keyPointEndpoint.id, keyPointEndpoint.point), + ); if (existingFilter) { this.emit('pendingCornerChange', null); return; @@ -414,9 +416,11 @@ export abstract class BaseCornerGeometryReplacerTool extend // 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) - ); + const existingFilter = this.getGeometryStore() + .findFiltersByGeometryId(keyPointEndpoint.id) + .find((f) => + FilterComponent.isLockedToPolygon(f, keyPointEndpoint.id, keyPointEndpoint.pointIndex), + ); if (existingFilter) { this.emit('pendingCornerChange', null); return; From 0e9cf3500d73fbf0094996fe623ce39778309e0c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 20 Jul 2026 08:57:58 -0400 Subject: [PATCH 19/22] feat: add more geometry component getRenderShapes tests --- src/__tests__/GeometryComponent.test.ts | 137 ++++++++++++++++++++++-- 1 file changed, 131 insertions(+), 6 deletions(-) diff --git a/src/__tests__/GeometryComponent.test.ts b/src/__tests__/GeometryComponent.test.ts index 334e81a7..ecee0953 100644 --- a/src/__tests__/GeometryComponent.test.ts +++ b/src/__tests__/GeometryComponent.test.ts @@ -3,30 +3,27 @@ import { CubicBezierSegment, GeometryComponent, PointSegment, + Polygon, Rectangle, RenderShapePolygon, + RenderShapeRectangle, } from '@/lib/entity'; import { GeometryStore, ID_PREFIXES } from '@/lib/entity/GeometryStore'; import { FilletFilter } from '@/lib/entity/filters/fillet'; import { HistoryManager } from '@/lib/history/HistoryManager'; import { SerializationManager } from '@/lib/serialization/SerializationManager'; import { Sheet } from '@/lib/sheet/Sheet'; -import { CornerState } from '@/lib/tools/BaseCornerGeometryReplacerTool'; 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 { ScreenPosition, SheetPosition, type ViewportState } from '@/lib/viewport/types'; +import { 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 SheetPosition(x, y).toWorld().toScreen(viewport); -} - describe('GeometryComponent', () => { let sheet: Sheet; let historyManager: HistoryManager; @@ -66,6 +63,54 @@ describe('GeometryComponent', () => { }); describe('getRenderShapes', () => { + 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 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); + }); + it('should render rectangles with fillets properly', async () => { // Create a new rectangle const rectangle = geometryStore.addOrdered( @@ -122,5 +167,85 @@ describe('GeometryComponent', () => { 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); + }); }); }); From 7cdbc0ccae6a20a0efe19f4270de0911f3800a1a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 20 Jul 2026 21:36:56 -0400 Subject: [PATCH 20/22] fix: address type error in ApplyFilterToGeometryAction --- src/lib/actions/ApplyFilterToGeometryAction.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/lib/actions/ApplyFilterToGeometryAction.tsx b/src/lib/actions/ApplyFilterToGeometryAction.tsx index 6fd502a6..cbeb7246 100644 --- a/src/lib/actions/ApplyFilterToGeometryAction.tsx +++ b/src/lib/actions/ApplyFilterToGeometryAction.tsx @@ -6,12 +6,10 @@ import { ConstraintComponent, ConstraintEndpoint, Datum, - DatumComponent, Entity, GeometryComponent, HorizontalConstraint, type Id, - Polygon, VerticalConstraint, } from '@/lib/entity'; import { ID_PREFIXES } from '@/lib/entity/GeometryStore'; @@ -19,7 +17,6 @@ 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 { RectangleData } from '@/lib/entity/geometry/rectangle'; import { PolygonSegment } from '@/lib/entity/polygon'; import { type RectangleEndpoint } from '@/lib/entity/rectangle'; import { CornerReplacement, type CornerSegmentFactory, Vector2, mod } from '@/lib/math'; @@ -86,15 +83,6 @@ type BuildCornerSegmentResults = { addedSegmentIndex: number; }; -const RECTANGLE_ADJACENCY: Partial< - Record -> = { - upperLeft: ['lowerLeft', 'upperRight'], - upperRight: ['lowerRight', 'upperLeft'], - lowerRight: ['lowerLeft', 'upperRight'], - lowerLeft: ['lowerRight', 'upperLeft'], -}; - export class ApplyFilterToGeometryAction extends BaseAction { type = 'apply-filter-to-geometry' as const; label = 'Apply Filter'; @@ -126,8 +114,8 @@ export class ApplyFilterToGeometryAction extends BaseAction { }); } - async execute(): Promise { - await this.getHistoryManager().applyTransaction( + async execute() { + this.getHistoryManager().applyTransaction( this.type, () => { const selectedIds = [...this.getSelectionManager().getSelectedIds()]; @@ -232,6 +220,7 @@ export class ApplyFilterToGeometryAction extends BaseAction { for (const f of allFilters) { const otherFilter = FilterComponent.get(f); if ( + (otherFilter.type !== 'fillet' && otherFilter.type !== 'chamfer') || otherFilter.geometryType !== 'rectangle' || otherFilter.geometryId !== pending.geometryId ) { From 85ea017e274a13772b7da7d618b89eaf7c98c675 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 20 Jul 2026 21:53:20 -0400 Subject: [PATCH 21/22] feat: add sort to filter rendering Always render mirror filters after filter / chamfer ones. --- src/lib/entity/components/GeometryComponent.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lib/entity/components/GeometryComponent.ts b/src/lib/entity/components/GeometryComponent.ts index 5b7ce6aa..3561da7b 100644 --- a/src/lib/entity/components/GeometryComponent.ts +++ b/src/lib/entity/components/GeometryComponent.ts @@ -8,7 +8,7 @@ import { Rect, SheetPosition, } from '@/lib/viewport/types'; -import { Filter } from '../filters'; +import { Filter, FilterData } from '../filters'; import { type Geometry, type GeometryData } from '../geometry'; import { EllipseData } from '../geometry/ellipse'; import { PolygonData, PolygonSegment } from '../geometry/polygon'; @@ -565,6 +565,14 @@ export namespace GeometryComponent { 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, @@ -596,7 +604,11 @@ export namespace GeometryComponent { } let filterApplicationCounter = 0; - for (const filter of filters) { + 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': { From 3c1c817585b2fcc3106600a58459166303e2564d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 20 Jul 2026 22:10:22 -0400 Subject: [PATCH 22/22] feat: add mirror filter test --- src/__tests__/GeometryComponent.test.ts | 438 ++++++++++++++++-------- 1 file changed, 302 insertions(+), 136 deletions(-) diff --git a/src/__tests__/GeometryComponent.test.ts b/src/__tests__/GeometryComponent.test.ts index ecee0953..85d7e3d5 100644 --- a/src/__tests__/GeometryComponent.test.ts +++ b/src/__tests__/GeometryComponent.test.ts @@ -1,15 +1,18 @@ 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'; @@ -63,112 +66,250 @@ describe('GeometryComponent', () => { }); describe('getRenderShapes', () => { - 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); + 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); + }); }); - 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 render rectangles with fillets properly', async () => { + 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)), ); - // Add a fillet centered on the upperLeft point of the rectangle const filter = geometryStore.add( ID_PREFIXES.filter, - FilletFilter.createOnRectangle( - rectangle.id, - 'upperLeft', - 'upperRight', - 'lowerRight', - Length.centimeters(20), - ), + 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(1); - expect(shapes[0].shape).toStrictEqual('polygon'); - const polygonShape = shapes[0] as RenderShapePolygon; - expect(polygonShape.points).toHaveLength(6); + expect(shapes).toHaveLength(2); - // 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); + 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 render rectangles with multiple fillets properly (upper right then lower right)', async () => { + it('should mirror rectangle with fillet across a line', async () => { // Create a new rectangle const rectangle = geometryStore.addOrdered( ID_PREFIXES.rectangle, @@ -176,7 +317,7 @@ describe('GeometryComponent', () => { ); // Add a fillet centered on the upperRight point of the rectangle - const filterUpperRight = geometryStore.add( + const filletFilter = geometryStore.add( ID_PREFIXES.filter, FilletFilter.createOnRectangle( rectangle.id, @@ -187,65 +328,90 @@ describe('GeometryComponent', () => { ), ); - // Add a fillet centered on the upperRight point of the rectangle - const filterLowerRight = geometryStore.add( + const mirrorFilter = geometryStore.add( ID_PREFIXES.filter, - FilletFilter.createOnRectangle( - rectangle.id, - 'upperRight', - 'lowerRight', - 'lowerLeft', - Length.centimeters(20), - ), + 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, [ - filterUpperRight, - filterLowerRight, + // 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(1); + expect(shapes).toHaveLength(2); expect(shapes[0].shape).toStrictEqual('polygon'); - const polygonShape = shapes[0] as RenderShapePolygon; - expect(polygonShape.points).toHaveLength(7); + + // 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(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); + 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); }); }); });