diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 6c95e7a..0c63338 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -8289,3 +8289,803 @@ html[data-theme="dark"] .nameplate-fillet-coachmark { animation: none; } } + + +/* ===== Sketch menu tools ===== */ + +/* ===== Sketch menu tools ===== */ + +.sketch-operation-header strong { + font-size: 14px; +} +.sketch-operation-header button { + display: grid; + width: 28px; + height: 28px; + padding: 0; + place-items: center; + color: #5f7280; + background: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; +} +.sketch-operation-header button:hover { + background: #e7f1f6; +} +.sketch-operation-panel label { + display: flex; + flex-direction: column; + gap: 5px; + color: #50697a; + font-size: 11px; + font-weight: 800; +} +.sketch-operation-panel input, +.sketch-operation-panel select { + width: 100%; + height: 32px; + padding: 0 8px; + color: #263f51; + background: #fff; + border: 1px solid #b9cad6; + border-radius: 4px; + font: inherit; + font-weight: 700; +} +.sketch-operation-fields .wide { + grid-column: 1 / -1; +} +.sketch-offset-fields .sketch-operation-checkbox { + flex-direction: row; + align-items: center; + gap: 7px; +} +.sketch-offset-fields .sketch-operation-checkbox input { + width: 15px; + height: 15px; + margin: 0; +} +.sketch-offset-fields p { + margin: 0; + color: #617888; + font-size: 10px; + line-height: 1.45; +} +.sketch-operation-actions button { + min-width: 76px; + height: 32px; + padding: 0 12px; + color: #496276; + background: #fff; + border: 1px solid #b9cad6; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + font-weight: 900; +} +.sketch-operation-actions button.primary { + color: #fff; + background: #087fae; + border-color: #087fae; +} +.sketch-profile-selection-status .hint { + color: #688092; +} +.sketch-profile-selection-status button { + padding: 3px 7px; + color: #31566e; + background: #fff; + border: 1px solid #b7cad6; + border-radius: 4px; + cursor: pointer; + font: inherit; + font-weight: 800; +} +.sketch-profile-selection-status button:hover { + color: #fff; + background: #087fae; + border-color: #087fae; +} +.sketch-circle-preview > circle:not(.center) { + fill: rgba(22, 159, 206, 0.08); + stroke: #169fce; + stroke-width: 2; + stroke-dasharray: 6 5; + vector-effect: non-scaling-stroke; +} +.sketch-circle-preview > circle.center { + fill: var(--background); + stroke: #169fce; + stroke-width: 2; + vector-effect: non-scaling-stroke; +} +.sketch-rect-preview > rect { + fill: rgba(22, 159, 206, 0.08); + stroke: #169fce; + stroke-width: 2; + stroke-dasharray: 6 5; + vector-effect: non-scaling-stroke; +} +.sketch-polygon-preview > path { + fill: rgba(22, 159, 206, 0.08); + stroke: #169fce; + stroke-width: 2; + stroke-dasharray: 6 5; + vector-effect: non-scaling-stroke; +} +.sketch-polygon-preview > circle.center { + fill: var(--background); + stroke: #169fce; + stroke-width: 2; + vector-effect: non-scaling-stroke; +} +.sketch-text-item:hover { + fill: #169fce; +} +.sketch-text-input:focus { + border-color: #0d8ecf; + box-shadow: 0 2px 12px rgba(22, 159, 206, 0.3); +} +.sketch-constraint-indicators text { + fill: #087d89; + font-weight: 900; + text-anchor: middle; +} +.sketch-center-points circle { + fill: rgba(234, 249, 245, 0.88); + stroke: #087d89; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} +.sketch-center-points line { + stroke: #087d89; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; + pointer-events: none; +} +.sketch-center-points .movable { + cursor: move; +} +.sketch-center-points .movable:hover circle, +.sketch-center-points .dragging circle { + fill: #8de4d1; + stroke: #075f70; + stroke-width: 2; +} +.sketch-center-points .locked { + cursor: not-allowed; + opacity: 0.58; +} +.sketch-snap-feedback .guide { + stroke: rgba(0, 126, 145, 0.62); + stroke-width: 1; + stroke-dasharray: 5 4; + vector-effect: non-scaling-stroke; +} +.sketch-snap-feedback .marker { + fill: rgba(255, 255, 255, 0.28); + stroke: #008ca3; + stroke-width: 2; + vector-effect: non-scaling-stroke; +} +.sketch-snap-feedback .marker.center { + stroke: #087d89; +} +.sketch-snap-feedback .marker.midpoint { + stroke: #c46b00; +} +.sketch-snap-feedback text { + fill: #075f70; + paint-order: stroke; + stroke: rgba(255, 255, 255, 0.94); + stroke-width: 3px; + font-weight: 850; +} +.sketch-distance-dimension > line { + stroke: #5b7f95; + stroke-width: 1.25; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; +} +.sketch-dimension-anchors .hit { + fill: transparent; + stroke: none; + pointer-events: all; +} +.sketch-dimension-anchors g > circle:not(.hit), +.sketch-dimension-anchors g > rect { + fill: #f8fdff; + stroke: #148eb7; + stroke-width: 1.8; + vector-effect: non-scaling-stroke; + pointer-events: none; +} +.sketch-dimension-anchors g > line { + stroke: #148eb7; + stroke-width: 1.6; + vector-effect: non-scaling-stroke; + pointer-events: none; +} +.sketch-dimension-anchors g.active > circle:not(.hit), +.sketch-dimension-anchors g.active > rect { + fill: #ffb24b; + stroke: #9d4d00; +} +.sketch-dimension-anchors g.active > line { + stroke: #9d4d00; +} +.sketch-snap-mode-buttons button { + height: 24px; + padding: 0 8px; + color: #5d7182; + background: #f5f8fa; + border: 1px solid #cad8e2; + border-radius: 3px; + cursor: pointer; + font-size: 10px; + font-weight: 800; +} +.sketch-snap-mode-buttons button.active { + color: #086954; + background: #e2f6ef; + border-color: #72bda8; +} +.sketch-constraint-length-field > div { + display: flex; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 0 8px; + color: #7b8da0; + background: #fff; + border: 1px solid #c7d4df; + border-radius: 6px; + font-size: 11px; + font-weight: 850; +} +.sketch-constraint-length-field input { + width: 78px; + height: 30px; + padding: 0; + color: #2f4457; + background: transparent; + border: 0; + outline: 0; + font-size: 14px; + font-weight: 750; + text-align: right; +} +.sketch-constraint-length-field > div:focus-within { + border-color: #55add8; + box-shadow: 0 0 0 3px rgba(0, 156, 222, 0.14); +} +.sketch-constraint-buttons button { + display: flex; + min-height: 42px; + align-items: center; + justify-content: center; + gap: 8px; + color: #3f596d; + background: #f6f9fb; + border: 1px solid #cad8e2; + border-radius: 6px; + cursor: pointer; + font-size: 16px; + font-weight: 900; +} +.sketch-constraint-buttons button span { + font-size: 11px; +} +.sketch-constraint-buttons button.active { + color: #086954; + background: #e2f6ef; + border-color: #72bda8; +} +.sketch-constraint-buttons button:disabled, +.sketch-constraint-length-field input:disabled { + cursor: default; + opacity: 0.5; +} +html[data-theme="dark"] .sketch-profile-selection-status .hint { + color: #91a8b7; +} +html[data-theme="dark"] .sketch-profile-selection-status button { + color: #d4e7f1; + background: #263944; + border-color: #526c7b; +} + +.sketch-dimension-status { + position: absolute; + top: 70px; + left: 50%; + z-index: 10; + padding: 6px 10px; + color: #31566e; + background: rgba(250, 253, 255, 0.94); + border: 1px solid #b9ccd9; + border-radius: 7px; + box-shadow: 0 3px 10px rgba(24, 49, 67, 0.12); + font-size: 11px; + font-weight: 750; + transform: translateX(-50%); + white-space: nowrap; + pointer-events: none; +} + +.sketch-text-preview { + fill: rgba(22, 159, 206, 0.6); + pointer-events: none; +} + +.sketch-text-input-overlay { + pointer-events: auto; +} + +.sketch-set-dimension, +.sketch-remove-dimension { + height: 34px; + border-radius: 5px; + cursor: pointer; + font-size: 12px; + font-weight: 800; +} + +.sketch-set-dimension { + color: #086954; + background: #eaf9f5; + border: 1px solid #8ccab8; +} + +.sketch-remove-dimension { + color: #8f3e31; + background: #fff7f5; + border: 1px solid #e3b9b0; +} + +html[data-theme="dark"] .sketch-profile-selection-status { + color: #91a8b7; +} + +html[data-theme="dark"] .sketch-profile-selection-status .hint { + color: #6f8b9c; +} + +html[data-theme="dark"] .sketch-profile-selection-status button { + color: #d4e7f1; + background: #263944; + border-color: #526c7b; +} + +html[data-theme="dark"] .sketch-dimension-status { + color: #d3e4ee; + background: rgba(24, 38, 48, 0.95); + border-color: #465d6c; +} + +@media (max-width: 640px) { + .sketch-profile-selection-status .hint { + display: none; + } + + .sketch-dimension-status { + width: max-content; + max-width: calc(100vw - 96px); + text-align: center; + white-space: normal; + } +} + +/* ===== Sketch menu styles (restored) ===== */ + +.sketch-constraint-inspector .property-card-header.static { + cursor: default; + font-size: 16px; +} + +.sketch-constraint-inspector .shape-inspector-header + .property-card { + margin-top: 10px; +} + +.sketch-cursor-point { + fill: #169fce; + stroke: var(--background); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.sketch-drag-handles circle, .sketch-curve-handles circle { + fill: var(--background); + stroke: #f08036; + stroke-width: 2; + vector-effect: non-scaling-stroke; + cursor: move; +} + +.sketch-grid-settings { + display: grid; + gap: 5px; + padding: 4px 7px; + background: rgba(248, 251, 252, 0.92); + border: 1px solid #d6e2e9; + border-radius: 3px; +} + +.sketch-image-inspector .property-card-header.static:hover { + color: var(--foreground); +} + +.sketch-image-resize-handle { + fill: var(--background); + stroke: #0b75a3; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.sketch-measurement-point.hover { + fill: #169fce; + stroke: var(--background); +} + +.sketch-measurement-point { + fill: var(--background); + stroke: #d56100; + stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +.sketch-point-actions button.active { + color: #086954; + background: #e2f6ef; + border-color: #8ccab8; +} + +.sketch-points circle.fixed { + fill: #d8f1e8; + stroke: #14755e; + stroke-width: 2; +} + +.sketch-points circle { + fill: var(--background); + stroke: #143c5c; + stroke-width: 1.4; + vector-effect: non-scaling-stroke; + cursor: pointer; +} + +.sketch-polygon-side-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.sketch-polygon-side-btn:hover:not(:disabled) { + background: var(--hover, rgba(0, 0, 0, 0.05)); +} + +.sketch-polygon-side-btn { + width: 20px; + height: 20px; + border: 1px solid var(--border, rgba(0, 0, 0, 0.15)); + border-radius: 3px; + background: var(--background, #fff); + color: var(--foreground, #333); + font-size: 13px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.sketch-polygon-side-count { + min-width: 18px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: var(--foreground, #333); + user-select: none; +} + +.sketch-polygon-sides-control { + display: flex; + align-items: center; + gap: 2px; + margin-left: 2px; +} + +.sketch-profile-fills path.selectable.selected { + fill: rgba(70, 156, 207, 0.24); +} + +.sketch-profile-fills path.selectable:hover, .sketch-profile-fills path.selectable:focus-visible { + fill: rgba(59, 177, 219, 0.32); + outline: none; +} + +.sketch-profile-fills path.selectable { + fill: rgba(86, 173, 211, 0.09); + cursor: pointer; + transition: fill 100ms ease; +} + +.sketch-segment-dimensions .movable.dragging { + cursor: grabbing; +} + +.sketch-segment-dimensions .movable:hover rect, .sketch-segment-dimensions .movable.dragging rect { + stroke: #2692bd; + stroke-width: 1.5; +} + +.sketch-segment-dimensions .movable { + cursor: move; +} + +.sketch-segment-dimensions .reference rect { + fill: #edf7fc; + stroke: #4892b5; +} + +.sketch-segment-dimensions .reference text { + fill: #245f7b; + font-weight: 800; +} + +.sketch-segment-dimensions text { + fill: #16283a; + font-weight: 500; + text-anchor: middle; + pointer-events: none; + user-select: none; +} + +.sketch-segment-dimensions.driving rect { + fill: #eaf9f5; + stroke: #3a9d87; +} + +.sketch-segment-dimensions.driving text { + fill: #086954; + font-weight: 800; +} + +.sketch-segments path.constrained { + stroke: #087d89; +} + +.sketch-toolbar-divider { + width: 1px; + height: 24px; + background: var(--border, rgba(0, 0, 0, 0.12)); + margin: 0 4px; + flex-shrink: 0; +} + +.sketch-transform-section { + min-width: 240px; +} + +.toolbar-icon.disabled .sketch-reference-icon, .toolbar-icon:disabled .sketch-reference-icon, .sketch-command-button.disabled .sketch-reference-icon, .sketch-command-button:disabled .sketch-reference-icon { + filter: grayscale(0.85) contrast(0.9) brightness(0.82); + opacity: 0.56; +} + +html[data-theme="dark"] .sketch-grid-settings { + background: rgba(20, 32, 41, 0.94); + border-color: #3a4d5a; +} + +/* ===== Sketch styles completion ===== */ + +.sketch-toolbar-ribbon { + display: flex; + width: 100%; + height: 100%; + align-items: stretch; + background: var(--background); +} + +.sketch-workspace-stage { + position: relative; + flex: 1; + min-width: 0; + height: calc(100vh - var(--editor-toolbar-height)); + overflow: hidden; + background: var(--background); +} + +.sketch-operation-panel { + position: absolute; + top: 18px; + right: 18px; + z-index: 14; + display: flex; + width: min(320px, calc(100vw - 36px)); + padding: 14px; + flex-direction: column; + gap: 13px; + color: #29465a; + background: rgba(250, 253, 255, 0.97); + border: 1px solid #b9ccd9; + border-radius: 8px; + box-shadow: 0 12px 32px rgba(24, 49, 67, 0.2); +} + +.sketch-operation-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sketch-operation-actions { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sketch-operation-header strong { + font-size: 14px; +} + +.sketch-operation-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.sketch-offset-fields { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sketch-operation-copy { + display: flex; + flex-direction: column; + gap: 6px; + color: #536d7d; + font-size: 12px; + line-height: 1.4; +} + +.sketch-operation-actions { + justify-content: flex-end; + gap: 8px; + padding-top: 2px; +} + +.sketch-mode-badge { + position: absolute; + top: 8px; + left: 50%; + z-index: 9; + padding: 4px 10px; + color: var(--background); + background: #37a95b; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(25, 64, 43, 0.2); + font-size: 11px; + font-weight: 900; + transform: translateX(-50%); + pointer-events: none; +} + +.sketch-profile-selection-status { + position: absolute; + top: 34px; + left: 50%; + z-index: 10; + display: flex; + align-items: center; + gap: 8px; + padding: 5px 7px 5px 10px; + color: #29465a; + background: rgba(250, 253, 255, 0.94); + border: 1px solid #b9ccd9; + border-radius: 8px; + box-shadow: 0 3px 10px rgba(24, 49, 67, 0.14); + font-size: 11px; + white-space: nowrap; + transform: translateX(-50%); +} + +.sketch-text-item { + fill: var(--foreground, #333); + cursor: pointer; + user-select: none; +} + +.sketch-text-input { + border: 2px solid #169fce; + border-radius: 4px; + background: var(--background, #fff); + color: var(--foreground, #333); + font-size: 14px; + padding: 4px 8px; + outline: none; + min-width: 120px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.sketch-points circle.projected { + fill: #f4edfb; + stroke: #8f55c5; + cursor: default; +} + +.sketch-dimension-anchors { + cursor: crosshair; +} + +.sketch-snap-mode-buttons { + display: flex; + gap: 4px; + justify-content: flex-end; +} + +.sketch-constraint-inspector .property-card-header.static { + cursor: default; + font-size: 16px; +} + +.sketch-constraint-length-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #586a7d; + font-size: 14px; + font-weight: 750; +} + +.sketch-constraint-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.sketch-image-number-input { + width: 100%; + height: 31px; + padding: 0 10px; + color: #526174; + font: 700 14px/1 var(--font-ui); + background: var(--background); + border: 1px solid #cbd6e1; + border-radius: 999px; + outline: 0; +} + +.sketch-image-position-field input { + width: 100%; + height: 31px; + padding: 0 10px; + color: #526174; + font: 700 14px/1 var(--font-ui); + background: var(--background); + border: 1px solid #cbd6e1; + border-radius: 999px; + outline: 0; +} + +.sketch-image-range > input[type="range"]::-webkit-slider-thumb { + width: 13px; + height: 13px; + margin-top: -4.5px; + appearance: none; + background: var(--background); + border: 2px solid #009fd7; + border-radius: 999px; + box-shadow: 0 1px 3px rgba(23, 59, 90, 0.22); +} + +html[data-theme="dark"] .sketch-profile-selection-status { + color: #d3e4ee; + background: rgba(24, 38, 48, 0.95); + border-color: #465d6c; + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.32); +} diff --git a/apps/web/src/components/SketchForgeEditor.tsx b/apps/web/src/components/SketchForgeEditor.tsx index d433cba..e869159 100644 --- a/apps/web/src/components/SketchForgeEditor.tsx +++ b/apps/web/src/components/SketchForgeEditor.tsx @@ -1,9 +1,9 @@ "use client"; -import { Check, Circle as CircleIcon, CloudUpload, Download, Eye, FolderOpen, Hexagon as HexagonIcon, Square as SquareIcon, Triangle as TriangleIcon, X } from "lucide-react"; +import { Check, Circle, Circle as CircleIcon, CircleDot, CloudUpload, CopyPlus, Download, Eye, FolderOpen, Grid2X2, Hexagon, Hexagon as HexagonIcon, RotateCw, Ruler, Square, Square as SquareIcon, Triangle as TriangleIcon, Type, X } from "lucide-react"; import type manifoldModule from "manifold-3d"; import type { ManifoldToplevel } from "manifold-3d"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { ADDITION, Brush, Evaluator, HOLLOW_INTERSECTION, HOLLOW_SUBTRACTION, INTERSECTION, SUBTRACTION, type CSGOperation } from "three-bvh-csg"; import * as THREE from "three"; import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js"; @@ -48,7 +48,7 @@ import { ToolbarVectorExportIcon, } from "./icons"; import { WorkplaneViewport } from "./WorkplaneViewport"; -import { SketchWorkspace, type SketchMeasurement, type SketchPrimitive, type SketchSelection, type SketchTool } from "./SketchWorkspace"; +import { SketchWorkspace, type SketchCircleDraft, type SketchMeasurement, type SketchPolygonDraft, type SketchPrimitive, type SketchRectDraft, type SketchSelection, type SketchTextDraft, type SketchTool } from "./SketchWorkspace"; import { EdgeModifierPanel } from "./workplane/EdgeModifierPanel"; import { canonicalizeShape, @@ -93,6 +93,15 @@ import { attachProjectAsset, dedupeProjectAssets, projectAssetFromBytes, sourceF import { findSketchOutlineIntersection } from "@/lib/sketchProfileValidation"; import { addLineIntersectionPoints, splitSketchSegment } from "@/lib/sketchPointRefinement"; import { buildSketchRevolveMesh, DEFAULT_SKETCH_REVOLVE_SETTINGS, normalizeSketchRevolveSettings, type SketchRevolveMesh } from "@/lib/sketchRevolve"; +import { circleFromPoints, circleSketchGeometry } from "@/lib/sketchCircles"; +import { moveConstrainedSketchPoint, pruneSketchParameters, setSketchPointFixed, setSketchSegmentConstraint, setSketchSegmentLength, solveSketchProfile } from "@/lib/sketchConstraints"; +import { rectFromPoints, rectangleSketchGeometry } from "@/lib/sketchRectangles"; +import { textSketchGeometry } from "@/lib/sketchTextGeometry"; +import { polygonFromPoints, polygonSketchGeometry } from "@/lib/sketchPolygons"; +import { offsetSketchSegments } from "@/lib/sketchOffset"; +import { reflectionTransform, rotationTransform, transformSketchSelection, translateSketchPoints, translationTransform, type SketchTransformSelection } from "@/lib/sketchTransforms"; +import { cadSketchProfileForRegions, cadSketchRegions, cadSketchSelectableRegions, selectedCadSketchRegions } from "@/lib/sketchCadProfile"; +import { sketchDimensionAnchorKey, sketchDistanceDimensionValue } from "@/lib/sketchDimensions"; import { exportSkfProject, SKF_MEDIA_TYPE } from "@/lib/skfProject"; import { makeShapeFromAsset, sceneShape, toolbarShapeAssets, type ToolbarShapeAsset } from "@/lib/shapeCatalog"; import { importExtensionSupported } from "@/lib/importExtensions"; @@ -122,7 +131,7 @@ import { } from "@/lib/sketchforgeMcpProtocol"; import type { CadModifierComponentMesh, CadModifierDisplayEdge, CadModifierEdge, CadModifierKind, CadModifierMeshPart, CadModifierPrimitivePart, CadModifierQuality, CadModifierWorkerRequest, CadModifierWorkerResponse } from "@/lib/cadModifierTypes"; import type { SketchCadBuildResponse } from "@/lib/sketchCadTypes"; -import type { AlignAxis, AlignHandleStatus, AlignTarget, GridSize, ProjectAsset, ShapeAsset, SketchImage, SketchOperation, SketchPoint, SketchProfile, SketchRevolveSettings, SketchSegment, WorkplaneShape, WorkplaneWorkspaceSettings } from "@/types/sketchforge"; +import type { AlignAxis, AlignHandleStatus, AlignTarget, GridSize, ProjectAsset, ShapeAsset, SketchDimensionAnchor, SketchImage, SketchOperation, SketchPoint, SketchProfile, SketchRevolveSettings, SketchSegment, WorkplaneShape, WorkplaneWorkspaceSettings } from "@/types/sketchforge"; export { importedShapeFromObj, importedShapeFromStl, importedShapeFromSvg }; @@ -132,6 +141,11 @@ type DirectExportFormat = Exclude; type SkfHistoryLimit = EditorHistoryExportLimit; type SkfExportTarget = "download" | "shared"; type ToolbarMode = "geometry" | "sketch"; +type SketchCommandKind = "offset" | "mirror" | "rectangular-pattern" | "circular-pattern"; +type SketchOffsetCommandOptions = { distance: number; includeConnected: boolean }; +type SketchMirrorOptions = { axis: "x" | "z" | "segment"; segmentId?: string }; +type SketchRectangularPatternOptions = { columns: number; rows: number; columnSpacing: number; rowSpacing: number; segmentId?: string }; +type SketchCircularPatternOptions = { count: number; angle: number; pointId?: string }; type Vec3 = [number, number, number]; type MeshData = { name: string; vertices: Vec3[]; faces: [number, number, number][] }; type Cuboid = { minX: number; maxX: number; minY: number; maxY: number; minZ: number; maxZ: number }; @@ -242,7 +256,7 @@ const booleanTextFonts: Record = { let manifoldRuntimePromise: Promise | null = null; function emptySketchProfile(): SketchProfile { - return { points: [], segments: [], images: [] }; + return { points: [], segments: [], constraints: [], dimensions: [], images: [] }; } function cloneSketchProfile(profile: SketchProfile): SketchProfile { @@ -252,11 +266,48 @@ function cloneSketchProfile(profile: SketchProfile): SketchProfile { handleIn: point.handleIn ? { ...point.handleIn } : undefined, handleOut: point.handleOut ? { ...point.handleOut } : undefined, })), - segments: profile.segments.map((segment) => ({ ...segment })), + segments: profile.segments.map((segment) => ({ + ...segment, + dimensionLabelOffset: segment.dimensionLabelOffset ? { ...segment.dimensionLabelOffset } : undefined, + })), + constraints: (profile.constraints ?? []).map((constraint) => ({ ...constraint })), + dimensions: (profile.dimensions ?? []).map((dimension) => dimension.kind === "length" + ? { ...dimension } + : { ...dimension, start: { ...dimension.start }, end: { ...dimension.end } }), images: (profile.images ?? []).map((image) => ({ ...image })), + texts: (profile.texts ?? []).map((text) => ({ ...text })), + }; +} + +function sketchTransformSelection(selection: SketchSelection): SketchTransformSelection { + if (!selection) return { pointIds: [], segmentIds: [], imageIds: [], textIds: [] }; + if (selection.kind === "point") return { pointIds: [selection.id], segmentIds: [], imageIds: [], textIds: [] }; + if (selection.kind === "segment") return { pointIds: [], segmentIds: [selection.id], imageIds: [], textIds: [] }; + if (selection.kind === "image") return { pointIds: [], segmentIds: [], imageIds: [selection.id], textIds: [] }; + if (selection.kind === "text") return { pointIds: [], segmentIds: [], imageIds: [], textIds: [selection.id] }; + return { + pointIds: selection.pointIds, + segmentIds: selection.segmentIds, + imageIds: selection.imageIds ?? [], + textIds: selection.textIds ?? [], + }; +} + +function withoutSketchReferenceSegment(profile: SketchProfile, selection: SketchTransformSelection, segmentId?: string) { + if (!segmentId) return selection; + const reference = profile.segments.find((segment) => segment.id === segmentId); + if (!reference) return selection; + return { + ...selection, + pointIds: selection.pointIds.filter((id) => id !== reference.startId && id !== reference.endId), + segmentIds: selection.segmentIds.filter((id) => id !== segmentId), }; } +function hasSketchTransformSelection(selection: SketchTransformSelection) { + return selection.pointIds.length + selection.segmentIds.length + selection.imageIds.length + selection.textIds.length > 0; +} + type OrderedSketchStep = { segment: SketchProfile["segments"][number]; from: SketchPoint; to: SketchPoint }; type OrderedSketchPath = { points: SketchPoint[]; steps: OrderedSketchStep[]; closed: boolean }; @@ -425,8 +476,13 @@ async function shapeFromResolvedSketchProfile( } } -async function shapeFromSketchProfile(profile: SketchProfile, height: number, existing?: WorkplaneShape | null) { - const closedPaths = orderedSketchPaths(profile).filter((path) => path.closed); +async function shapeFromSketchProfile(profile: SketchProfile, height: number, existing?: WorkplaneShape | null, regionIds?: readonly string[]) { + const geometryProfile = cadSketchProfileForRegions(profile, regionIds); + const regions = selectedCadSketchRegions(profile, regionIds); + if (regions.length === 0) return null; + const closedPaths = [...new Map( + regions.flatMap((region) => [region.outer, ...region.holes]).map((path) => [path.id, path] as const), + ).values()]; if (closedPaths.length === 0) return null; const profilePoints = closedPaths.flatMap((path) => path.points); const minX = Math.min(...profilePoints.map((point) => point.x)); @@ -463,8 +519,8 @@ async function shapeFromSketchProfile(profile: SketchProfile, height: number, ex const polygon = outline.extractPoints(16).shape; return { outline, polygon, area: Math.abs(THREE.ShapeUtils.area(polygon)) }; }); - const hasCurves = profile.segments.some((segment) => segment.kind === "bezier" || segment.kind === "smooth"); - const longestHandle = profile.points.reduce((longest, point) => Math.max( + const hasCurves = geometryProfile.segments.some((segment) => segment.kind === "bezier" || segment.kind === "smooth"); + const longestHandle = geometryProfile.points.reduce((longest, point) => Math.max( longest, point.handleIn ? Math.hypot(point.handleIn.x - point.x, point.handleIn.z - point.z) : 0, point.handleOut ? Math.hypot(point.handleOut.x - point.x, point.handleOut.z - point.z) : 0, @@ -569,7 +625,7 @@ function ensureSketchCadWorker() { return worker; } -async function cadShapeFromSketchProfile(profile: SketchProfile, height: number, existing?: WorkplaneShape | null) { +async function cadShapeFromSketchProfile(profile: SketchProfile, height: number, existing?: WorkplaneShape | null, regionIds?: readonly string[]) { const safeHeight = Math.max(MIN_SHAPE_DIMENSION, height); const worker = ensureSketchCadWorker(); const requestId = ++sketchCadRequestId; @@ -579,7 +635,7 @@ async function cadShapeFromSketchProfile(profile: SketchProfile, height: number, reject(new Error("OpenCascade timed out while building the sketch")); }, 30_000); sketchCadPending.set(requestId, { resolve, reject, timer }); - worker.postMessage({ type: "build", requestId, profile: cloneSketchProfile(profile), height: safeHeight }); + worker.postMessage({ type: "build", requestId, profile: cloneSketchProfile(profile), regionIds: regionIds ? [...regionIds] : undefined, height: safeHeight }); }); if (response.type === "error") throw new Error(response.message); const source = canonicalizeShape({ @@ -5530,8 +5586,15 @@ export function SketchForgeEditor({ const sketchHistoryIndexRef = useRef(sketchHistoryIndex); const [sketchActivePointId, setSketchActivePointId] = useState(null); const [sketchSelection, setSketchSelection] = useState(null); + const [sketchExtrusionRegionIds, setSketchExtrusionRegionIds] = useState(null); const [sketchMeasureStart, setSketchMeasureStart] = useState(null); const [sketchMeasurement, setSketchMeasurement] = useState(null); + const [sketchCircleDraft, setSketchCircleDraft] = useState(null); + const [sketchRectDraft, setSketchRectDraft] = useState(null); + const [sketchPolygonDraft, setSketchPolygonDraft] = useState(null); + const [sketchPolygonSides, setSketchPolygonSides] = useState(6); + const [sketchTextDraft, setSketchTextDraft] = useState(null); + const [sketchCommand, setSketchCommand] = useState(null); const [editingSketchShapeId, setEditingSketchShapeId] = useState(null); const [edgeModifier, setEdgeModifier] = useState(null); const edgeModifierRef = useRef(null); @@ -5551,6 +5614,28 @@ export function SketchForgeEditor({ const cadModifierWorkerRestartRef = useRef<() => Worker | null>(() => null); const lastMcpErrorRef = useRef(null); const executeMcpCommandRef = useRef<((command: SketchForgeMcpCommand) => Promise) | null>(null); + const sketchCadRegions = useMemo(() => { + if (!sketchActive || sketchOperation !== "extrude") return []; + try { + return cadSketchSelectableRegions(sketchProfile); + } catch { + return []; + } + }, [sketchActive, sketchOperation, sketchProfile]); + const sketchCadRegionIds = useMemo(() => sketchCadRegions.map((region) => region.id), [sketchCadRegions]); + const defaultSketchRegionIds = useMemo(() => { + if (!sketchActive || sketchOperation !== "extrude") return []; + try { + return cadSketchRegions(sketchProfile).map((region) => region.id); + } catch { + return []; + } + }, [sketchActive, sketchOperation, sketchProfile]); + const selectedSketchRegionIds = useMemo(() => { + if (sketchExtrusionRegionIds === null) return defaultSketchRegionIds; + const available = new Set(sketchCadRegionIds); + return sketchExtrusionRegionIds.filter((id) => available.has(id)); + }, [defaultSketchRegionIds, sketchCadRegionIds, sketchExtrusionRegionIds]); const clearCadModifierWatchdog = useCallback((requestId?: number) => { const active = cadModifierWatchdogRef.current; @@ -6272,7 +6357,7 @@ export function SketchForgeEditor({ revolveSettings?: Partial, workplaneOverride?: PlacementWorkplane, ) => { - const initial = cloneSketchProfile(profile ?? emptySketchProfile()); + const initial = cloneSketchProfile(solveSketchProfile(profile ?? emptySketchProfile()).profile); setWorkplaneMode(false); setActiveSketchWorkplane(normalizePlacementWorkplane(workplaneOverride ?? placementWorkplaneRef.current)); setToolbarMode("sketch"); @@ -6289,8 +6374,25 @@ export function SketchForgeEditor({ setSketchHistoryIndex(0); setSketchActivePointId(null); setSketchSelection(null); + const editingFeature = editingId ? shapes.find((shape) => shape.id === editingId)?.sketchFeature : undefined; + const storedRegionIds = editingFeature?.kind === "extrusion" ? editingFeature.regionIds : undefined; + let initialRegionIds: string[] | null = storedRegionIds ? [...storedRegionIds] : null; + if (storedRegionIds) { + try { + const availableIds = cadSketchRegions(initial).map((region) => region.id); + if (storedRegionIds.length === availableIds.length && availableIds.every((id) => storedRegionIds.includes(id))) initialRegionIds = null; + } catch { + initialRegionIds = [...storedRegionIds]; + } + } + setSketchExtrusionRegionIds(operation === "extrude" ? initialRegionIds : null); setSketchMeasureStart(null); setSketchMeasurement(null); + setSketchCircleDraft(null); + setSketchRectDraft(null); + setSketchPolygonDraft(null); + setSketchTextDraft(null); + setSketchCommand(null); setEditingSketchShapeId(editingId); setNotice(editingId ? `Editing ${operation} sketch profile` : operation === "revolve" ? "Revolve sketch started: draw on the left side of the axis" : "Sketch started: place the first point"); }, []); @@ -6335,8 +6437,14 @@ export function SketchForgeEditor({ setSketchActive(false); setSketchActivePointId(null); setSketchSelection(null); + setSketchExtrusionRegionIds(null); setSketchMeasureStart(null); setSketchMeasurement(null); + setSketchCircleDraft(null); + setSketchRectDraft(null); + setSketchPolygonDraft(null); + setSketchTextDraft(null); + setSketchCommand(null); setEditingSketchShapeId(null); setSketchRevolvePreview(null); setNotice("Sketch cancelled"); @@ -6354,6 +6462,10 @@ export function SketchForgeEditor({ setSketchHistoryIndex(nextIndex); setSketchProfile(cloneSketchProfile(currentHistory[nextIndex] ?? emptySketchProfile())); setSketchActivePointId(null); + setSketchCircleDraft(null); + setSketchRectDraft(null); + setSketchPolygonDraft(null); + setSketchTextDraft(null); setSketchSelection(null); setNotice("Sketch undo"); }, []); @@ -6370,6 +6482,10 @@ export function SketchForgeEditor({ setSketchHistoryIndex(nextIndex); setSketchProfile(cloneSketchProfile(currentHistory[nextIndex] ?? emptySketchProfile())); setSketchActivePointId(null); + setSketchCircleDraft(null); + setSketchRectDraft(null); + setSketchPolygonDraft(null); + setSketchTextDraft(null); setSketchSelection(null); setNotice("Sketch redo"); }, []); @@ -6377,12 +6493,27 @@ export function SketchForgeEditor({ const setActiveSketchTool = useCallback((tool: SketchTool) => { setSketchTool(tool); setSketchActivePointId(null); + setSketchCircleDraft(null); + setSketchRectDraft(null); + setSketchPolygonDraft(null); + setSketchTextDraft(null); setSketchSelection(null); - if (tool !== "measure") setSketchMeasureStart(null); + if (tool !== "measure") { + setSketchMeasureStart(null); + setSketchMeasurement(null); + } const messages: Record = { line: "Line: click points to draw straight segments", bezier: "Bézier: click and drag points to pull curve handles", smooth: "Smooth curve: click points to build a flowing path", + "circle-center": "Center circle: choose the center, then a radius point", + "circle-diameter": "Two-point circle: choose opposite points on the diameter", + "rect-corner": "Rectangle: click one corner, then the opposite corner", + "rect-center": "Center rectangle: click the center, then a corner", + "poly-inscribed": "Inscribed polygon: click center, then a vertex", + "poly-circumscribed": "Circumscribed polygon: click center, then an edge midpoint", + "poly-edge": "Edge polygon: click two adjacent vertices", + text: "Text: click to place a text annotation on the sketch", rectangle: "Rectangle: drag across the sketch to create a closed rectangle", circle: "Circle: drag a bounding box to create a closed circle", triangle: "Triangle: drag a bounding box to create a closed triangle", @@ -6390,6 +6521,7 @@ export function SketchForgeEditor({ select: "Select: edit sketch geometry or place and scale reference images", refine: "Refine: click a segment to add a point, or a point to remove it", erase: "Erase: click a point or segment to remove it", + dimension: "Dimension: choose a line, then type its driving length", measure: "Measure: choose two points", }; setNotice(messages[tool]); @@ -6455,6 +6587,109 @@ export function SketchForgeEditor({ measureSketchPoint({ id: "measure", ...position }); return; } + if (sketchTool === "circle-center" || sketchTool === "circle-diameter") { + if (!sketchCircleDraft || sketchCircleDraft.tool !== sketchTool) { + setSketchCircleDraft({ tool: sketchTool, first: position }); + setSketchSelection(null); + setNotice(sketchTool === "circle-center" ? "Choose a point on the circle" : "Choose the opposite diameter point"); + return; + } + const { center, radius } = circleFromPoints( + sketchTool === "circle-center" ? "center-radius" : "diameter", + sketchCircleDraft.first, + position, + ); + if (radius < 0.0001) { + setNotice("Circle radius must be greater than zero"); + return; + } + const circle = circleSketchGeometry(center, radius); + const next: SketchProfile = { + ...sketchProfile, + points: [...sketchProfile.points, ...circle.points], + segments: [...sketchProfile.segments, ...circle.segments], + }; + commitSketchProfile(next, sketchTool === "circle-center" ? "Center circle added" : "Two-point circle added"); + setSketchCircleDraft(null); + setSketchSelection({ + kind: "multiple", + pointIds: circle.points.map((point) => point.id), + segmentIds: circle.segments.map((segment) => segment.id), + }); + return; + } + if (sketchTool === "rect-corner" || sketchTool === "rect-center") { + if (!sketchRectDraft || sketchRectDraft.tool !== sketchTool) { + setSketchRectDraft({ tool: sketchTool, first: position }); + setSketchSelection(null); + setNotice(sketchTool === "rect-corner" ? "Choose the opposite corner" : "Choose a corner point"); + return; + } + const bounds = rectFromPoints( + sketchTool === "rect-corner" ? "corner" : "center", + sketchRectDraft.first, + position, + ); + if (bounds.width < 0.0001 || bounds.height < 0.0001) { + setNotice("Rectangle must have non-zero width and height"); + return; + } + const rect = rectangleSketchGeometry(bounds); + const next: SketchProfile = { + ...sketchProfile, + points: [...sketchProfile.points, ...rect.points], + segments: [...sketchProfile.segments, ...rect.segments], + constraints: [ + ...(sketchProfile.constraints ?? []), + ...rect.segments.map((segment, index) => ({ + id: createLocalId(index % 2 === 0 ? "sketch-horizontal" : "sketch-vertical"), + kind: index % 2 === 0 ? "horizontal" as const : "vertical" as const, + segmentId: segment.id, + })), + ], + }; + commitSketchProfile(next, "Rectangle added"); + setSketchRectDraft(null); + setSketchSelection({ + kind: "multiple", + pointIds: rect.points.map((point) => point.id), + segmentIds: rect.segments.map((segment) => segment.id), + }); + return; + } + if (sketchTool === "poly-inscribed" || sketchTool === "poly-circumscribed" || sketchTool === "poly-edge") { + if (!sketchPolygonDraft || sketchPolygonDraft.tool !== sketchTool) { + setSketchPolygonDraft({ tool: sketchTool, first: position, sides: sketchPolygonSides }); + setSketchSelection(null); + setNotice(sketchTool === "poly-edge" ? "Choose the second vertex" : "Choose a defining point"); + return; + } + const mode = sketchTool === "poly-inscribed" ? "inscribed" : sketchTool === "poly-circumscribed" ? "circumscribed" : "edge"; + const { center, circumR, startAngle } = polygonFromPoints(mode, sketchPolygonDraft.sides, sketchPolygonDraft.first, position); + if (circumR < 0.0001) { + setNotice("Polygon radius must be greater than zero"); + return; + } + const polygon = polygonSketchGeometry(center, circumR, startAngle, sketchPolygonDraft.sides); + const next: SketchProfile = { + ...sketchProfile, + points: [...sketchProfile.points, ...polygon.points], + segments: [...sketchProfile.segments, ...polygon.segments], + }; + commitSketchProfile(next, `${sketchPolygonDraft.sides}-sided polygon added`); + setSketchPolygonDraft(null); + setSketchSelection({ + kind: "multiple", + pointIds: polygon.points.map((point) => point.id), + segmentIds: polygon.segments.map((segment) => segment.id), + }); + return; + } + if (sketchTool === "text") { + setSketchTextDraft({ tool: "text", position }); + setNotice("Type text and press Enter to confirm"); + return; + } if (!["line", "bezier", "smooth"].includes(sketchTool)) return; const curveKind = sketchTool as NonNullable; const existing = sketchProfile.points.find((point) => Math.hypot(point.x - position.x, point.z - position.z) < 0.0001); @@ -6466,7 +6701,7 @@ export function SketchForgeEditor({ id: createLocalId("sketch-point"), ...position, ...(handles ?? {}), - mode: sketchTool === "line" ? "corner" : sketchTool === "smooth" ? "smooth" : handles ? "smooth" : "corner", + mode: sketchTool === "line" ? "corner" : sketchTool === "smooth" ? handles ? "smooth" : "corner" : "corner", }; const next: SketchProfile = { ...sketchProfile, points: [...sketchProfile.points, point] }; if (sketchActivePointId) { @@ -6477,7 +6712,7 @@ export function SketchForgeEditor({ setSketchActivePointId(point.id); setSketchSelection({ kind: "point", id: point.id }); }, - [commitSketchProfile, connectSketchPoint, measureSketchPoint, sketchActivePointId, sketchProfile, sketchTool], + [commitSketchProfile, connectSketchPoint, measureSketchPoint, sketchActivePointId, sketchCircleDraft, sketchPolygonDraft, sketchPolygonSides, sketchProfile, sketchRectDraft, sketchTool], ); const addSketchPrimitive = useCallback( @@ -6578,9 +6813,13 @@ export function SketchForgeEditor({ setSketchActivePointId(null); return; } + if (["circle-center", "circle-diameter", "rect-corner", "rect-center", "poly-inscribed", "poly-circumscribed", "poly-edge", "text"].includes(sketchTool)) { + addSketchPlanePoint({ x: point.x, z: point.z }); + return; + } connectSketchPoint(id); }, - [connectSketchPoint, measureSketchPoint, sketchProfile.points, sketchTool], + [addSketchPlanePoint, connectSketchPoint, measureSketchPoint, sketchProfile.points, sketchTool], ); const deleteSketchPoint = useCallback( @@ -6602,11 +6841,11 @@ export function SketchForgeEditor({ }); } } - const next = { + const next = pruneSketchParameters({ ...sketchProfile, points: sketchProfile.points.filter((point) => point.id !== id), segments: remainingSegments, - }; + }); commitSketchProfile(next.segments.some((segment) => segment.kind === "smooth") ? withSmoothSketchHandles(next) : next, "Sketch point removed"); if (sketchActivePointId === id) setSketchActivePointId(null); setSketchSelection(null); @@ -6616,7 +6855,7 @@ export function SketchForgeEditor({ const deleteSketchSegment = useCallback( (id: string) => { - commitSketchProfile({ ...sketchProfile, segments: sketchProfile.segments.filter((segment) => segment.id !== id) }, "Sketch line removed"); + commitSketchProfile(pruneSketchParameters({ ...sketchProfile, segments: sketchProfile.segments.filter((segment) => segment.id !== id) }), "Sketch line removed"); setSketchActivePointId(null); setSketchSelection(null); }, @@ -6682,36 +6921,97 @@ export function SketchForgeEditor({ if (sketchSelection.kind === "point") deleteSketchPoint(sketchSelection.id); else if (sketchSelection.kind === "segment") deleteSketchSegment(sketchSelection.id); else if (sketchSelection.kind === "image") deleteSketchImage(sketchSelection.id); - else { + else if (sketchSelection.kind === "text") { + commitSketchProfile({ + ...sketchProfile, + texts: (sketchProfile.texts ?? []).filter((t) => t.id !== sketchSelection.id), + }, "Selected text removed"); + setSketchActivePointId(null); + setSketchSelection(null); + } else { const pointIds = new Set(sketchSelection.pointIds); const segmentIds = new Set(sketchSelection.segmentIds); const imageIds = new Set(sketchSelection.imageIds ?? []); - commitSketchProfile({ + const textIds = new Set(sketchSelection.textIds ?? []); + commitSketchProfile(pruneSketchParameters({ ...sketchProfile, points: sketchProfile.points.filter((point) => !pointIds.has(point.id)), segments: sketchProfile.segments.filter((segment) => !segmentIds.has(segment.id) && !pointIds.has(segment.startId) && !pointIds.has(segment.endId)), images: (sketchProfile.images ?? []).filter((image) => !imageIds.has(image.id)), - }, "Selected sketch geometry removed"); + texts: (sketchProfile.texts ?? []).filter((text) => !textIds.has(text.id)), + }), "Selected sketch geometry removed"); setSketchActivePointId(null); setSketchSelection(null); } }, [commitSketchProfile, deleteSketchImage, deleteSketchPoint, deleteSketchSegment, sketchProfile, sketchSelection]); const moveSketchPoint = useCallback((id: string, position: { x: number; z: number }) => { - const current = sketchProfile.points.find((point) => point.id === id); - if (!current) return; - const deltaX = position.x - current.x; - const deltaZ = position.z - current.z; - const next = { + if ((sketchProfile.constraints ?? []).some((constraint) => constraint.kind === "fixed" && constraint.pointId === id)) { + setNotice("Fixed points cannot be moved"); + return; + } + const result = moveConstrainedSketchPoint(sketchProfile, id, position); + commitSketchProfile(result.profile, result.conflicts.length ? "Point moved with constraint conflicts" : "Sketch point moved"); + }, [commitSketchProfile, sketchProfile]); + + const moveSketchPoints = useCallback((ids: string[], delta: { x: number; z: number }) => { + commitSketchProfile(translateSketchPoints(sketchProfile, ids, delta), "Sketch profile moved"); + }, [commitSketchProfile, sketchProfile]); + + const moveSketchDimension = useCallback((segmentId: string, offset: { x: number; z: number }) => { + if (!sketchProfile.segments.some((segment) => segment.id === segmentId)) return; + commitSketchProfile({ ...sketchProfile, - points: sketchProfile.points.map((point) => point.id === id ? { - ...point, - ...position, - handleIn: point.handleIn ? { x: point.handleIn.x + deltaX, z: point.handleIn.z + deltaZ } : undefined, - handleOut: point.handleOut ? { x: point.handleOut.x + deltaX, z: point.handleOut.z + deltaZ } : undefined, - } : point), - }; - commitSketchProfile(next, "Sketch point moved"); + segments: sketchProfile.segments.map((segment) => segment.id === segmentId + ? { ...segment, dimensionLabelOffset: { ...offset } } + : segment), + }, "Dimension moved"); + setSketchSelection({ kind: "segment", id: segmentId }); + }, [commitSketchProfile, sketchProfile]); + + const toggleSketchPointFixed = useCallback((id: string) => { + const fixed = (sketchProfile.constraints ?? []).some((constraint) => constraint.kind === "fixed" && constraint.pointId === id); + const result = setSketchPointFixed(sketchProfile, id, !fixed, createLocalId); + commitSketchProfile(result.profile, fixed ? "Point released" : "Point fixed"); + setSketchSelection({ kind: "point", id }); + }, [commitSketchProfile, sketchProfile]); + + const toggleSketchSegmentConstraint = useCallback((id: string, kind: "horizontal" | "vertical") => { + const enabled = !(sketchProfile.constraints ?? []).some((constraint) => constraint.kind === kind && constraint.segmentId === id); + const result = setSketchSegmentConstraint(sketchProfile, id, kind, enabled, createLocalId); + commitSketchProfile(result.profile, `${kind === "horizontal" ? "Horizontal" : "Vertical"} constraint ${enabled ? "added" : "removed"}`); + setSketchSelection({ kind: "segment", id }); + }, [commitSketchProfile, sketchProfile]); + + const updateSketchSegmentLength = useCallback((id: string, value: number | null) => { + const result = setSketchSegmentLength(sketchProfile, id, value, createLocalId); + commitSketchProfile(result.profile, value === null ? "Driving length removed" : result.conflicts.length ? "Length updated with constraint conflicts" : "Driving length updated"); + setSketchSelection({ kind: "segment", id }); + }, [commitSketchProfile, sketchProfile]); + + const addSketchDistanceDimension = useCallback((start: SketchDimensionAnchor, end: SketchDimensionAnchor) => { + const value = sketchDistanceDimensionValue(sketchProfile, start, end); + if (value === null || value < 0.0001) { + setNotice("Choose two different dimension anchors"); + return; + } + const key = [sketchDimensionAnchorKey(start), sketchDimensionAnchorKey(end)].sort().join("|"); + const duplicate = (sketchProfile.dimensions ?? []).some((dimension) => dimension.kind === "distance" + && [sketchDimensionAnchorKey(dimension.start), sketchDimensionAnchorKey(dimension.end)].sort().join("|") === key); + if (duplicate) { + setNotice("That reference dimension already exists"); + return; + } + commitSketchProfile({ + ...sketchProfile, + dimensions: [...(sketchProfile.dimensions ?? []), { id: createLocalId("sketch-distance"), kind: "distance", start, end }], + }, "Reference dimension added"); + setSketchSelection(null); + }, [commitSketchProfile, sketchProfile]); + + const deleteSketchDimension = useCallback((id: string) => { + if (!(sketchProfile.dimensions ?? []).some((dimension) => dimension.id === id)) return; + commitSketchProfile({ ...sketchProfile, dimensions: (sketchProfile.dimensions ?? []).filter((dimension) => dimension.id !== id) }, "Dimension removed"); }, [commitSketchProfile, sketchProfile]); const transformSketchPoints = useCallback((points: SketchPoint[], message = "Sketch geometry transformed") => { @@ -6763,16 +7063,178 @@ export function SketchForgeEditor({ setSketchTool("select"); }, [commitSketchProfile, sketchProfile]); + const openSketchCommand = useCallback((command: SketchCommandKind) => { + if (sketchTool !== "select") { + setNotice("Choose Select and select sketch geometry first"); + return; + } + if (!sketchSelection) { + setNotice("Select sketch geometry first"); + return; + } + setSketchCommand(command); + }, [sketchSelection, sketchTool]); + + const selectGeneratedSketchEntities = useCallback((selection: SketchTransformSelection) => { + setSketchSelection({ kind: "multiple", ...selection }); + setSketchActivePointId(null); + }, []); + + const applySketchOffset = useCallback((options: SketchOffsetCommandOptions) => { + const source = sketchTransformSelection(sketchSelection); + if (!source.segmentIds.length) { + setNotice("Select at least one sketch segment to offset"); + return; + } + let result: ReturnType; + try { + result = offsetSketchSegments(sketchProfile, source.segmentIds, options.distance, { includeConnected: options.includeConnected }); + } catch (error) { + setNotice(error instanceof Error ? error.message : "The selected sketch path cannot be offset"); + return; + } + commitSketchProfile(result.profile, `Created ${options.distance} mm sketch offset`); + selectGeneratedSketchEntities({ pointIds: result.pointIds, segmentIds: result.segmentIds, imageIds: [], textIds: [] }); + setSketchCommand(null); + }, [commitSketchProfile, selectGeneratedSketchEntities, sketchProfile, sketchSelection]); + + const applySketchMirror = useCallback((options: SketchMirrorOptions) => { + let source = sketchTransformSelection(sketchSelection); + let lineStart = { x: 0, z: 0 }; + let lineEnd = options.axis === "x" ? { x: 1, z: 0 } : { x: 0, z: 1 }; + if (options.axis === "segment") { + const reference = sketchProfile.segments.find((segment) => segment.id === options.segmentId); + const start = reference ? sketchProfile.points.find((point) => point.id === reference.startId) : null; + const end = reference ? sketchProfile.points.find((point) => point.id === reference.endId) : null; + if (!reference || !start || !end) { + setNotice("Choose a selected line as the mirror axis"); + return; + } + source = withoutSketchReferenceSegment(sketchProfile, source, reference.id); + lineStart = start; + lineEnd = end; + } + if (!hasSketchTransformSelection(source)) { + setNotice("Select geometry in addition to the mirror axis"); + return; + } + const result = transformSketchSelection(sketchProfile, source, [reflectionTransform(lineStart, lineEnd)]); + commitSketchProfile(result.profile, "Mirrored sketch geometry"); + selectGeneratedSketchEntities(result.selection); + setSketchCommand(null); + }, [commitSketchProfile, selectGeneratedSketchEntities, sketchProfile, sketchSelection]); + + const applySketchRectangularPattern = useCallback((options: SketchRectangularPatternOptions) => { + const columns = Math.max(1, Math.min(20, Math.round(options.columns))); + const rows = Math.max(1, Math.min(20, Math.round(options.rows))); + if (columns * rows < 2) { + setNotice("A rectangular pattern needs at least two instances"); + return; + } + let source = sketchTransformSelection(sketchSelection); + let columnX = 1; + let columnZ = 0; + if (options.segmentId) { + const reference = sketchProfile.segments.find((segment) => segment.id === options.segmentId); + const start = reference ? sketchProfile.points.find((point) => point.id === reference.startId) : null; + const end = reference ? sketchProfile.points.find((point) => point.id === reference.endId) : null; + if (!reference || !start || !end) { + setNotice("Choose a selected line for the pattern direction"); + return; + } + const length = Math.hypot(end.x - start.x, end.z - start.z); + if (length < 0.0001) { + setNotice("The pattern direction line is too short"); + return; + } + source = withoutSketchReferenceSegment(sketchProfile, source, reference.id); + columnX = (end.x - start.x) / length; + columnZ = (end.z - start.z) / length; + } + if (!hasSketchTransformSelection(source)) { + setNotice("Select geometry in addition to the direction line"); + return; + } + const rowX = -columnZ; + const rowZ = columnX; + const transforms = []; + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + if (row === 0 && column === 0) continue; + transforms.push(translationTransform( + column * options.columnSpacing * columnX + row * options.rowSpacing * rowX, + column * options.columnSpacing * columnZ + row * options.rowSpacing * rowZ, + )); + } + } + const result = transformSketchSelection(sketchProfile, source, transforms); + commitSketchProfile(result.profile, `Created ${columns} × ${rows} rectangular pattern`); + selectGeneratedSketchEntities(result.selection); + setSketchCommand(null); + }, [commitSketchProfile, selectGeneratedSketchEntities, sketchProfile, sketchSelection]); + + const applySketchCircularPattern = useCallback((options: SketchCircularPatternOptions) => { + const count = Math.max(2, Math.min(40, Math.round(options.count))); + const angle = Math.max(-360, Math.min(360, options.angle)); + if (Math.abs(angle) < 0.001) { + setNotice("The circular pattern angle must be non-zero"); + return; + } + let source = sketchTransformSelection(sketchSelection); + let center = { x: 0, z: 0 }; + if (options.pointId) { + const point = sketchProfile.points.find((candidate) => candidate.id === options.pointId); + if (!point) { + setNotice("Choose a selected point as the pattern center"); + return; + } + center = point; + source = { ...source, pointIds: source.pointIds.filter((id) => id !== point.id) }; + } + if (!hasSketchTransformSelection(source)) { + setNotice("Select geometry in addition to the center point"); + return; + } + const fullCircle = Math.abs(Math.abs(angle) - 360) < 0.001; + const step = (angle * Math.PI / 180) / (fullCircle ? count : count - 1); + const transforms = Array.from({ length: count - 1 }, (_, index) => rotationTransform(step * (index + 1), center)); + const result = transformSketchSelection(sketchProfile, source, transforms); + commitSketchProfile(result.profile, `Created ${count}-instance circular pattern`); + selectGeneratedSketchEntities(result.selection); + setSketchCommand(null); + }, [commitSketchProfile, selectGeneratedSketchEntities, sketchProfile, sketchSelection]); + + const clearSketchTransientState = useCallback(() => { + setSketchActive(false); + setSketchActivePointId(null); + setSketchSelection(null); + setSketchExtrusionRegionIds(null); + setSketchMeasureStart(null); + setSketchMeasurement(null); + setSketchCircleDraft(null); + setSketchRectDraft(null); + setSketchPolygonDraft(null); + setSketchTextDraft(null); + setSketchCommand(null); + setSketchRevolvePreview(null); + setEditingSketchShapeId(null); + setToolbarMode("geometry"); + }, []); + const finishSketch = useCallback(async () => { const existing = editingSketchShapeId ? shapes.find((shape) => shape.id === editingSketchShapeId) ?? null : null; const height = existing?.height ?? 10; - let resolved: WorkplaneShape | null; + let resolved: WorkplaneShape | null = null; try { if (sketchOperation === "revolve") { resolved = await shapeFromRevolvedSketchProfile(sketchProfile, sketchRevolveSettings, existing); } else { + if (selectedSketchRegionIds.length === 0) { + setNotice("Select at least one closed profile to extrude"); + return; + } setNotice("Building exact sketch geometry…"); - const extrusion = await cadShapeFromSketchProfile(sketchProfile, height, existing); + const extrusion = await cadShapeFromSketchProfile(sketchProfile, height, existing, selectedSketchRegionIds); resolved = placeSketchExtrusion(extrusion, activeSketchWorkplane, existing); } } catch (error) { @@ -6783,14 +7245,16 @@ export function SketchForgeEditor({ setNotice("Close at least one profile before finishing the sketch"); return; } + resolved = { + ...resolved, + sketchProfile: cloneSketchProfile(sketchProfile), + sketchFeature: sketchOperation === "extrude" ? { kind: "extrusion", regionIds: [...selectedSketchRegionIds] } : undefined, + }; const nextShapes = existing ? shapes.map((shape) => (shape.id === existing.id ? resolved : shape)) : [...shapes, resolved]; const action = sketchOperation === "revolve" ? "Revolve sketch" : "Sketch"; commitShapes(nextShapes, resolved.id, existing ? `${action} updated` : sketchOperation === "revolve" ? "Revolved sketch created" : "Exact sketch created at 10 mm height"); - setSketchActive(false); - setSketchRevolvePreview(null); - setEditingSketchShapeId(null); - setToolbarMode("geometry"); - }, [activeSketchWorkplane, commitShapes, editingSketchShapeId, shapes, sketchOperation, sketchProfile, sketchRevolveSettings]); + clearSketchTransientState(); + }, [activeSketchWorkplane, clearSketchTransientState, commitShapes, editingSketchShapeId, selectedSketchRegionIds, shapes, sketchOperation, sketchProfile, sketchRevolveSettings]); useEffect(() => { if (!projectId) { @@ -9057,8 +9521,11 @@ export function SketchForgeEditor({ sketchActive={sketchActive} sketchOperation={sketchOperation} sketchTool={sketchTool} + sketchPolygonSides={sketchPolygonSides} sketchCanUndo={sketchHistoryIndex > 0} sketchCanRedo={sketchHistoryIndex < sketchHistory.length - 1} + sketchHasSelection={Boolean(sketchSelection) && sketchTool === "select"} + sketchHasSegmentSelection={sketchTool === "select" && sketchTransformSelection(sketchSelection).segmentIds.length > 0} canEditSketch={selectedShapes.length === 1 && Boolean(selectedShape?.sketchProfile)} onStartSketch={(operation) => beginSketch(operation)} onEditSketch={beginSketchEdit} @@ -9071,8 +9538,13 @@ export function SketchForgeEditor({ } sketchImageInputRef.current?.click(); }} + onSketchPolygonSidesChange={setSketchPolygonSides} onSketchUndo={sketchUndo} onSketchRedo={sketchRedo} + onSketchOffset={() => openSketchCommand("offset")} + onSketchMirror={() => openSketchCommand("mirror")} + onSketchRectangularPattern={() => openSketchCommand("rectangular-pattern")} + onSketchCircularPattern={() => openSketchCommand("circular-pattern")} onSketchFinish={finishSketch} onSketchCancel={cancelSketch} onHome={onHome} @@ -9105,9 +9577,11 @@ export function SketchForgeEditor({ />
{toolbarMode === "sketch" && sketchActive ? ( - + shape.id !== editingSketchShapeId)} tool={sketchTool} @@ -9115,19 +9589,45 @@ export function SketchForgeEditor({ selected={sketchSelection} measurement={sketchMeasurement} pendingMeasurementStart={sketchMeasureStart} + circleDraft={sketchCircleDraft} + rectDraft={sketchRectDraft} + polygonDraft={sketchPolygonDraft} + textDraft={sketchTextDraft} initialSnap={snapGrid} initialWorkspace={workspaceSettings} onPlanePoint={addSketchPlanePoint} onAddPrimitive={addSketchPrimitive} onPointPress={pressSketchPoint} onSelectSegment={(id) => { + const segment = sketchProfile.segments.find((entry) => entry.id === id); + if (sketchTool === "dimension" && segment?.kind && segment.kind !== "line") { + setNotice("Length dimensions currently apply to straight sketch lines"); + return; + } setSketchSelection({ kind: "segment", id }); setSketchActivePointId(null); }} - onSelectMany={(pointIds, segmentIds, imageIds) => { - setSketchSelection(pointIds.length || segmentIds.length || imageIds.length ? { kind: "multiple", pointIds, segmentIds, imageIds } : null); + onSelectRegion={(id) => { + const next = selectedSketchRegionIds.includes(id) + ? selectedSketchRegionIds.filter((regionId) => regionId !== id) + : [...selectedSketchRegionIds, id]; + setSketchExtrusionRegionIds(next); + setSketchSelection(null); setSketchActivePointId(null); - const count = pointIds.length + segmentIds.length + imageIds.length; + setNotice(next.length ? `${next.length} extrusion profile${next.length === 1 ? "" : "s"} selected` : "No extrusion profiles selected"); + }} + onSelectAllRegions={() => { + setSketchExtrusionRegionIds([...sketchCadRegionIds]); + setNotice(`All ${sketchCadRegionIds.length} extrusion profile${sketchCadRegionIds.length === 1 ? "" : "s"} selected`); + }} + onClearRegionSelection={() => { + setSketchExtrusionRegionIds([]); + setNotice("No extrusion profiles selected"); + }} + onSelectMany={(pointIds, segmentIds, imageIds, textIds) => { + setSketchSelection(pointIds.length || segmentIds.length || imageIds.length || textIds.length ? { kind: "multiple", pointIds, segmentIds, imageIds, textIds } : null); + setSketchActivePointId(null); + const count = pointIds.length + segmentIds.length + imageIds.length + textIds.length; setNotice(count ? `Selected ${count} sketch item${count === 1 ? "" : "s"}` : "Sketch selection cleared"); }} onSelectImage={(id) => { @@ -9135,17 +9635,65 @@ export function SketchForgeEditor({ setSketchActivePointId(null); setNotice("Sketch image selected"); }} + onSelectText={(id) => { + setSketchSelection({ kind: "text", id }); + setSketchActivePointId(null); + setNotice("Sketch text selected"); + }} onUpdateImage={updateSketchImage} onDeleteImage={deleteSketchImage} onDeletePoint={deleteSketchPoint} onDeleteSegment={deleteSketchSegment} onMovePoint={moveSketchPoint} + onMovePoints={moveSketchPoints} onTransformPoints={transformSketchPoints} onMoveHandle={moveSketchHandle} + onMoveDimension={moveSketchDimension} onInsertPoint={insertSketchPoint} onSetPointMode={setSketchPointMode} + onTogglePointFixed={toggleSketchPointFixed} + onToggleSegmentConstraint={toggleSketchSegmentConstraint} + onSetSegmentLength={updateSketchSegmentLength} + onAddDistanceDimension={addSketchDistanceDimension} + onDeleteDimension={deleteSketchDimension} onClearMeasurement={clearSketchMeasurement} + onTextSubmit={(text) => { + if (!sketchTextDraft) return; + const font = booleanTextFonts.Sans ?? booleanTextFonts.Multilanguage; + const geometry = textSketchGeometry(text, font, 10, sketchTextDraft.position); + const next: SketchProfile = { + ...sketchProfile, + points: [...sketchProfile.points, ...geometry.points], + segments: [...sketchProfile.segments, ...geometry.segments], + }; + commitSketchProfile(next, "Text geometry added to sketch"); + setSketchTextDraft(null); + setSketchSelection({ + kind: "multiple", + pointIds: geometry.points.map((p) => p.id), + segmentIds: geometry.segments.map((s) => s.id), + }); + setNotice("Text geometry placed on sketch"); + }} + onTextCancel={() => { + setSketchTextDraft(null); + setNotice("Text cancelled"); + }} /> + {sketchCommand ? ( + setSketchCommand(null)} + /> + ) : null} + ) : ( void; + onMirror: (options: SketchMirrorOptions) => void; + onRectangularPattern: (options: SketchRectangularPatternOptions) => void; + onCircularPattern: (options: SketchCircularPatternOptions) => void; + onClose: () => void; +}) { + const selected = sketchTransformSelection(selection); + const selectedSegments = profile.segments.filter((segment) => selected.segmentIds.includes(segment.id)); + const selectedPoints = profile.points.filter((point) => selected.pointIds.includes(point.id)); + const [mirrorAxis, setMirrorAxis] = useState<"x" | "z" | "segment">("x"); + const [mirrorSegmentId, setMirrorSegmentId] = useState(selectedSegments[0]?.id ?? ""); + const [columns, setColumns] = useState(2); + const [rows, setRows] = useState(1); + const [columnSpacing, setColumnSpacing] = useState(10); + const [rowSpacing, setRowSpacing] = useState(10); + const [directionSegmentId, setDirectionSegmentId] = useState(""); + const [circularCount, setCircularCount] = useState(4); + const [circularAngle, setCircularAngle] = useState(360); + const [centerPointId, setCenterPointId] = useState(""); + const [offsetDistance, setOffsetDistance] = useState(2); + const [offsetConnected, setOffsetConnected] = useState(true); + const title = command === "offset" + ? "Offset" + : command === "mirror" + ? "Mirror" + : command === "rectangular-pattern" + ? "Rectangular pattern" + : "Circular pattern"; + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (command === "offset") onOffset({ distance: offsetDistance, includeConnected: offsetConnected }); + else if (command === "mirror") onMirror({ axis: mirrorAxis, segmentId: mirrorAxis === "segment" ? mirrorSegmentId : undefined }); + else if (command === "rectangular-pattern") onRectangularPattern({ columns, rows, columnSpacing, rowSpacing, segmentId: directionSegmentId || undefined }); + else onCircularPattern({ count: circularCount, angle: circularAngle, pointId: centerPointId || undefined }); + }; + + return ( +
event.stopPropagation()}> +
+ {title} + +
+ {command === "offset" ? ( +
+ + +

Positive offsets closed paths outward and open paths to the left. Use a negative distance for the opposite side.

+
+ ) : null} + {command === "mirror" ? ( + + ) : null} + {command === "rectangular-pattern" ? ( +
+ + + + + +
+ ) : null} + {command === "circular-pattern" ? ( +
+ + + +
+ ) : null} +
+ + +
+
+ ); +} + function SecondaryToolbar({ toolbarMode, onToolbarModeChange, @@ -9352,22 +10008,30 @@ function SecondaryToolbar({ hasSelection, hiddenShapeCount, selectionHidden, - mirrorMode, - sketchActive, - sketchOperation, - sketchTool, - sketchCanUndo, - sketchCanRedo, - canEditSketch, - onStartSketch, - onEditSketch, - onSketchTool, - onSketchPrimitive, - onSketchImage, - onSketchUndo, - onSketchRedo, - onSketchFinish, - onSketchCancel, + mirrorMode, + sketchActive, + sketchOperation, + sketchTool, + sketchPolygonSides, + sketchCanUndo, + sketchCanRedo, + sketchHasSelection, + sketchHasSegmentSelection, + canEditSketch, + onStartSketch, + onEditSketch, + onSketchTool, + onSketchPrimitive, + onSketchImage, + onSketchPolygonSidesChange, + onSketchUndo, + onSketchRedo, + onSketchOffset, + onSketchMirror, + onSketchRectangularPattern, + onSketchCircularPattern, + onSketchFinish, + onSketchCancel, onHome, onAlign, onChamfer, @@ -9405,21 +10069,29 @@ function SecondaryToolbar({ hiddenShapeCount: number; selectionHidden: boolean; mirrorMode: boolean; - sketchActive: boolean; - sketchOperation: SketchOperation; - sketchTool: SketchTool; - sketchCanUndo: boolean; - sketchCanRedo: boolean; - canEditSketch: boolean; - onStartSketch: (operation: SketchOperation) => void; - onEditSketch: () => void; - onSketchTool: (tool: SketchTool) => void; - onSketchPrimitive: (primitive: SketchPrimitive) => void; - onSketchImage: () => void; - onSketchUndo: () => void; - onSketchRedo: () => void; - onSketchFinish: () => void; - onSketchCancel: () => void; + sketchActive: boolean; + sketchOperation: SketchOperation; + sketchTool: SketchTool; + sketchPolygonSides: number; + sketchCanUndo: boolean; + sketchCanRedo: boolean; + sketchHasSelection: boolean; + sketchHasSegmentSelection: boolean; + canEditSketch: boolean; + onStartSketch: (operation: SketchOperation) => void; + onEditSketch: () => void; + onSketchTool: (tool: SketchTool) => void; + onSketchPrimitive: (primitive: SketchPrimitive) => void; + onSketchImage: () => void; + onSketchPolygonSidesChange: (sides: number) => void; + onSketchUndo: () => void; + onSketchRedo: () => void; + onSketchOffset: () => void; + onSketchMirror: () => void; + onSketchRectangularPattern: () => void; + onSketchCircularPattern: () => void; + onSketchFinish: () => void; + onSketchCancel: () => void; onHome?: () => void; onAlign: () => void; onChamfer: () => void; @@ -9782,8 +10454,36 @@ function SecondaryToolbar({ + + + + + +
+ {sketchTool === "poly-inscribed" || sketchTool === "poly-circumscribed" || sketchTool === "poly-edge" ? ( +
+
Sides
+
+ + {sketchPolygonSides} + +
+
+ ) : null}
Shapes
@@ -9821,11 +10521,28 @@ function SecondaryToolbar({ -
-
-
+ + +
+ +
+
Create / Transform
+
+ + + + +
+
+
History
diff --git a/apps/web/src/components/SketchWorkspace.tsx b/apps/web/src/components/SketchWorkspace.tsx index 3619acb..3b7de9f 100644 --- a/apps/web/src/components/SketchWorkspace.tsx +++ b/apps/web/src/components/SketchWorkspace.tsx @@ -1,29 +1,56 @@ "use client"; -import { ChevronUp, CornerDownRight, Home, Link, Link2Off, Minus, Plus, Split, Trash2, Waves } from "lucide-react"; -import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from "react"; +import { ChevronUp, CornerDownRight, Home, Link, Link2Off, LockKeyhole, LockKeyholeOpen, Minus, Plus, Split, Trash2, Waves } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; import { SnapGridControl } from "@/components/workplane/ShapeInspector"; import { SketchRevolvePreview } from "@/components/SketchRevolvePreview"; import { parseMeasurementInput } from "@/lib/measurementUnits"; import { WORKPLANE_MAJOR_GRID_INTERVAL } from "@/lib/workplaneGrid"; import { closestPointOnSketchSegment, type SketchSegmentPlacement } from "@/lib/sketchPointRefinement"; import { mirrorSign, resizedImportedMeshPositions } from "@/lib/workplaneShapes"; +import { circleFromPoints } from "@/lib/sketchCircles"; +import { moveConstrainedSketchPoint } from "@/lib/sketchConstraints"; +import { rectFromPoints } from "@/lib/sketchRectangles"; +import { polygonFromPoints } from "@/lib/sketchPolygons"; +import { cadSketchSelectableRegions } from "@/lib/sketchCadProfile"; +import { sketchDimensionAnchorCandidates, sketchDimensionAnchorKey, sketchDimensionAnchorPoint, sketchDistanceDimensionValue, type SketchDimensionAnchorCandidate } from "@/lib/sketchDimensions"; +import { dedupeSketchSnapCandidates, snapSketchPoint, type SketchSnapCandidate, type SketchSnapResult } from "@/lib/sketchSnapping"; +import { translateSketchPoints } from "@/lib/sketchTransforms"; import { DEFAULT_SNAP_GRID, DEFAULT_WORKPLANE_WORKSPACE, normalizeSnapGrid, normalizeWorkspaceSettings } from "@/lib/workplaneSettings"; -import type { GridSize, SketchImage, SketchOperation, SketchPoint, SketchProfile, SketchSegment, WorkplaneShape, WorkplaneWorkspaceSettings } from "@/types/sketchforge"; +import type { GridSize, SketchDimensionAnchor, SketchImage, SketchOperation, SketchPoint, SketchProfile, SketchSegment, WorkplaneShape, WorkplaneWorkspaceSettings } from "@/types/sketchforge"; export type SketchPrimitive = "rectangle" | "circle" | "triangle" | "hexagon"; -export type SketchTool = "line" | "bezier" | "smooth" | SketchPrimitive | "select" | "refine" | "erase" | "measure"; +export type SketchTool = "line" | "bezier" | "smooth" | "circle-center" | "circle-diameter" | "rect-corner" | "rect-center" | "poly-inscribed" | "poly-circumscribed" | "poly-edge" | "text" | SketchPrimitive | "select" | "refine" | "erase" | "dimension" | "measure"; +export type SketchCircleDraft = { + tool: "circle-center" | "circle-diameter"; + first: { x: number; z: number }; +}; +export type SketchRectDraft = { + tool: "rect-corner" | "rect-center"; + first: { x: number; z: number }; +}; +export type SketchPolygonDraft = { + tool: "poly-inscribed" | "poly-circumscribed" | "poly-edge"; + first: { x: number; z: number }; + sides: number; +}; +export type SketchTextDraft = { + tool: "text"; + position: { x: number; z: number }; +}; export type SketchSelection = | { kind: "point"; id: string } | { kind: "segment"; id: string } | { kind: "image"; id: string } - | { kind: "multiple"; pointIds: string[]; segmentIds: string[]; imageIds?: string[] } + | { kind: "text"; id: string } + | { kind: "multiple"; pointIds: string[]; segmentIds: string[]; imageIds?: string[]; textIds?: string[] } | null; export type SketchMeasurement = { start: SketchPoint; end: SketchPoint } | null; type SketchWorkspaceProps = { profile: SketchProfile; operation?: SketchOperation; + selectedRegionIds: readonly string[]; revolvePreviewPositions?: number[] | null; referenceShapes: WorkplaneShape[]; tool: SketchTool; @@ -31,24 +58,42 @@ type SketchWorkspaceProps = { selected: SketchSelection; measurement: SketchMeasurement; pendingMeasurementStart: SketchPoint | null; + circleDraft: SketchCircleDraft | null; + rectDraft: SketchRectDraft | null; + polygonDraft: SketchPolygonDraft | null; + textDraft: SketchTextDraft | null; initialSnap?: GridSize; initialWorkspace?: WorkplaneWorkspaceSettings; + planeName?: string; onPlanePoint: (point: { x: number; z: number }, handles?: { handleIn: { x: number; z: number }; handleOut: { x: number; z: number } }) => void; onAddPrimitive: (primitive: SketchPrimitive, center: { x: number; z: number }) => void; onPointPress: (id: string) => void; onSelectSegment: (id: string) => void; - onSelectMany: (pointIds: string[], segmentIds: string[], imageIds: string[]) => void; + onSelectRegion: (id: string) => void; + onSelectAllRegions: () => void; + onClearRegionSelection: () => void; + onSelectMany: (pointIds: string[], segmentIds: string[], imageIds: string[], textIds: string[]) => void; onSelectImage: (id: string) => void; + onSelectText: (id: string) => void; onUpdateImage: (id: string, patch: Partial, message?: string) => void; onDeleteImage: (id: string) => void; onDeletePoint: (id: string) => void; onDeleteSegment: (id: string) => void; onMovePoint: (id: string, point: { x: number; z: number }) => void; + onMovePoints: (ids: string[], delta: { x: number; z: number }) => void; onTransformPoints: (points: SketchPoint[], message?: string) => void; onMoveHandle: (id: string, handle: "in" | "out", point: { x: number; z: number }) => void; + onMoveDimension: (segmentId: string, offset: { x: number; z: number }) => void; onInsertPoint: (segmentId: string, point: { x: number; z: number }, amount: number) => void; onSetPointMode: (id: string, mode: "corner" | "smooth" | "split") => void; + onTogglePointFixed: (id: string) => void; + onToggleSegmentConstraint: (id: string, kind: "horizontal" | "vertical") => void; + onSetSegmentLength: (id: string, value: number | null) => void; + onAddDistanceDimension: (start: SketchDimensionAnchor, end: SketchDimensionAnchor) => void; + onDeleteDimension: (id: string) => void; onClearMeasurement: () => void; + onTextSubmit: (text: string) => void; + onTextCancel: () => void; }; type PathStep = { segment: SketchSegment; from: SketchPoint; to: SketchPoint }; @@ -57,9 +102,11 @@ type SketchReferenceFootprint = { fillD: string | null; outlineD: string | null type PointerAction = | { kind: "bezier"; pointerId: number; origin: { x: number; z: number }; current: { x: number; z: number } } | { kind: "move-point"; pointerId: number; pointId: string; current: { x: number; z: number } } + | { kind: "move-center"; pointerId: number; centerId: string; pointIds: string[]; origin: { x: number; z: number }; current: { x: number; z: number } } | { kind: "move-selection"; pointerId: number; origin: { x: number; z: number }; current: { x: number; z: number }; startPoints: SketchPoint[] } | { kind: "resize-selection"; pointerId: number; handle: ResizeHandle; current: { x: number; z: number }; startPoints: SketchPoint[]; bounds: SelectionBounds } | { kind: "move-handle"; pointerId: number; pointId: string; handle: "in" | "out"; current: { x: number; z: number } } + | { kind: "move-dimension"; pointerId: number; segmentId: string; origin: { x: number; z: number }; current: { x: number; z: number }; grabOffset: { x: number; z: number } } | { kind: "pan"; pointerId: number; clientX: number; clientY: number } | { kind: "marquee"; pointerId: number; origin: { x: number; z: number }; current: { x: number; z: number } } | { kind: "move-image"; pointerId: number; imageId: string; origin: { x: number; z: number }; current: { x: number; z: number }; start: SketchImage } @@ -74,10 +121,6 @@ function snapStep(size: GridSize) { return Number.parseFloat(size) || 1; } -function snapValue(value: number, step: number) { - return step > 0 ? Math.round(value / step) * step : value; -} - function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } @@ -153,7 +196,7 @@ function boundsForSketchPoints(points: SketchPoint[]): SelectionBounds | null { }; } -function translateSketchPoints(points: SketchPoint[], dx: number, dz: number) { +function transformSelectedSketchPoints(points: SketchPoint[], dx: number, dz: number) { return points.map((point) => ({ ...point, x: point.x + dx, @@ -467,6 +510,7 @@ function importedMeshFootprint(shape: WorkplaneShape): SketchReferenceFootprint export function SketchWorkspace({ profile, operation = "extrude", + selectedRegionIds, revolvePreviewPositions = null, referenceShapes, tool, @@ -474,35 +518,64 @@ export function SketchWorkspace({ selected, measurement, pendingMeasurementStart, + circleDraft, + rectDraft, + polygonDraft, + textDraft, initialSnap, initialWorkspace, + planeName = "Base XZ plane", onPlanePoint, onAddPrimitive, onPointPress, onSelectSegment, + onSelectRegion, + onSelectAllRegions, + onClearRegionSelection, onSelectMany, onSelectImage, + onSelectText, onUpdateImage, onDeleteImage, onDeletePoint, onDeleteSegment, onMovePoint, + onMovePoints, onTransformPoints, onMoveHandle, + onMoveDimension, onInsertPoint, onSetPointMode, + onTogglePointFixed, + onToggleSegmentConstraint, + onSetSegmentLength, + onAddDistanceDimension, + onDeleteDimension, onClearMeasurement, + onTextSubmit, + onTextCancel, }: SketchWorkspaceProps) { const workspace = useMemo(() => normalizeWorkspaceSettings(initialWorkspace, DEFAULT_WORKPLANE_WORKSPACE), [initialWorkspace]); const [snap, setSnap] = useState(() => normalizeSnapGrid(initialSnap, DEFAULT_SNAP_GRID)); const [snapOpen, setSnapOpen] = useState(false); const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, z: 0 }); - const [hover, setHover] = useState<{ x: number; z: number } | null>(null); + const [hover, setHover] = useState(null); + const [snapToGridLines, setSnapToGridLines] = useState(false); + const [snapToGeometry, setSnapToGeometry] = useState(true); const [refinePreview, setRefinePreview] = useState<{ segmentId: string; placement: SketchSegmentPlacement } | null>(null); const [pointerAction, setPointerAction] = useState(null); const [svgSize, setSvgSize] = useState({ width: 0, height: 0 }); const svgRef = useRef(null); + const textInputRef = useRef(null); + const [textDraftValue, setTextDraftValue] = useState(""); + const [pendingDimensionAnchor, setPendingDimensionAnchor] = useState(null); + useEffect(() => { + if (textDraft) { + setTextDraftValue(""); + requestAnimationFrame(() => textInputRef.current?.focus()); + } + }, [textDraft]); const width = workspace.width / zoom; const depth = workspace.depth / zoom; const screenUnit = useMemo(() => { @@ -514,7 +587,7 @@ export function SketchWorkspace({ }, [depth, svgSize.height, svgSize.width, width]); const displayProfile = useMemo(() => { if (pointerAction?.kind === "move-selection") { - const moved = translateSketchPoints( + const moved = transformSelectedSketchPoints( pointerAction.startPoints, pointerAction.current.x - pointerAction.origin.x, pointerAction.current.z - pointerAction.origin.z, @@ -528,19 +601,13 @@ export function SketchWorkspace({ return { ...profile, points: profile.points.map((point) => resizedById.get(point.id) ?? point) }; } if (pointerAction?.kind === "move-point") { - const source = profile.points.find((point) => point.id === pointerAction.pointId); - if (!source) return profile; - const deltaX = pointerAction.current.x - source.x; - const deltaZ = pointerAction.current.z - source.z; - return { - ...profile, - points: profile.points.map((point) => point.id === source.id ? { - ...point, - ...pointerAction.current, - handleIn: point.handleIn ? { x: point.handleIn.x + deltaX, z: point.handleIn.z + deltaZ } : undefined, - handleOut: point.handleOut ? { x: point.handleOut.x + deltaX, z: point.handleOut.z + deltaZ } : undefined, - } : point), - }; + return moveConstrainedSketchPoint(profile, pointerAction.pointId, pointerAction.current).profile; + } + if (pointerAction?.kind === "move-center") { + return translateSketchPoints(profile, pointerAction.pointIds, { + x: pointerAction.current.x - pointerAction.origin.x, + z: pointerAction.current.z - pointerAction.origin.z, + }); } if (pointerAction?.kind === "move-handle") { return { @@ -575,8 +642,23 @@ export function SketchWorkspace({ }, [pointerAction, profile.images]); const pointById = useMemo(() => new Map(displayProfile.points.map((point) => [point.id, point])), [displayProfile.points]); const paths = useMemo(() => orderedPaths(displayProfile), [displayProfile]); + const dimensionAnchorCandidates = useMemo(() => tool === "dimension" ? sketchDimensionAnchorCandidates(displayProfile) : [], [displayProfile, tool]); + const regions = useMemo(() => { + if (operation !== "extrude") return []; + try { + return cadSketchSelectableRegions(displayProfile); + } catch { + return []; + } + }, [displayProfile, operation]); + const selectedRegionIdSet = useMemo(() => new Set(selectedRegionIds), [selectedRegionIds]); + const fixedPointIds = useMemo(() => new Set((profile.constraints ?? []).flatMap((constraint) => constraint.kind === "fixed" ? [constraint.pointId] : [])), [profile.constraints]); + const horizontalSegmentIds = useMemo(() => new Set((profile.constraints ?? []).flatMap((constraint) => constraint.kind === "horizontal" ? [constraint.segmentId] : [])), [profile.constraints]); + const verticalSegmentIds = useMemo(() => new Set((profile.constraints ?? []).flatMap((constraint) => constraint.kind === "vertical" ? [constraint.segmentId] : [])), [profile.constraints]); + const dimensionBySegmentId = useMemo(() => new Map((profile.dimensions ?? []).flatMap((dimension) => dimension.kind === "length" ? [[dimension.segmentId, dimension] as const] : [])), [profile.dimensions]); const activePoint = activePointId ? pointById.get(activePointId) ?? null : null; const selectedPoint = selected?.kind === "point" ? pointById.get(selected.id) ?? null : null; + const selectedSegment = selected?.kind === "segment" ? displayProfile.segments.find((segment) => segment.id === selected.id) ?? null : null; const selectedImage = selected?.kind === "image" ? displayImages.find((image) => image.id === selected.id) ?? null : null; const selectedGeometryPoints = selected?.kind === "multiple" ? selected.pointIds.map((id) => pointById.get(id)).filter((point): point is SketchPoint => Boolean(point)) @@ -585,6 +667,24 @@ export function SketchWorkspace({ const isPointSelected = (id: string) => selected?.kind === "point" ? selected.id === id : selected?.kind === "multiple" ? selected.pointIds.includes(id) : false; const isSegmentSelected = (id: string) => selected?.kind === "segment" ? selected.id === id : selected?.kind === "multiple" ? selected.segmentIds.includes(id) : false; const gridStep = clamp(workspace.gridBlockSize, 1, 200); + useEffect(() => { + if (tool !== "dimension") setPendingDimensionAnchor(null); + }, [tool]); + useEffect(() => { + if (pendingDimensionAnchor && !dimensionAnchorCandidates.some((candidate) => candidate.id === pendingDimensionAnchor.id)) setPendingDimensionAnchor(null); + }, [dimensionAnchorCandidates, pendingDimensionAnchor]); + const chooseDimensionAnchor = (candidate: SketchDimensionAnchorCandidate) => { + if (!pendingDimensionAnchor) { + setPendingDimensionAnchor(candidate); + return; + } + if (sketchDimensionAnchorKey(pendingDimensionAnchor.anchor) === sketchDimensionAnchorKey(candidate.anchor)) { + setPendingDimensionAnchor(null); + return; + } + onAddDistanceDimension(pendingDimensionAnchor.anchor, candidate.anchor); + setPendingDimensionAnchor(null); + }; const verticalLines = useMemo(() => { const lines: number[] = []; const start = Math.ceil((-workspace.width / 2) / gridStep) * gridStep; @@ -598,7 +698,51 @@ export function SketchWorkspace({ return lines; }, [gridStep, workspace.depth]); - const pointFromEvent = (event: { clientX: number; clientY: number }) => { + const centerSnapCandidates = useMemo(() => paths + .filter((path) => path.closed && path.points.length >= 3) + .map((path) => { + const xs = path.points.map((point) => point.x); + const zs = path.points.map((point) => point.z); + return { + id: `center:${path.id}`, + kind: "center" as const, + label: "Center", + x: (Math.min(...xs) + Math.max(...xs)) / 2, + z: (Math.min(...zs) + Math.max(...zs)) / 2, + ownerPointIds: path.points.map((point) => point.id), + }; + }), [paths]); + const snapCandidates = useMemo(() => dedupeSketchSnapCandidates([ + ...displayProfile.points.map((point) => ({ + id: `point:${point.id}`, + kind: "point" as const, + label: "Point", + x: point.x, + z: point.z, + ownerPointIds: [point.id], + })), + ...displayProfile.segments.flatMap((segment) => { + const dimension = segmentDimension(segment, pointById); + return dimension ? [{ + id: `midpoint:${segment.id}`, + kind: "midpoint" as const, + label: "Midpoint", + x: dimension.midpoint.x, + z: dimension.midpoint.z, + ownerPointIds: [segment.startId, segment.endId], + }] : []; + }), + ...centerSnapCandidates, + ...(profile.texts ?? []).map((text) => ({ + id: `text:${text.id}`, + kind: "center" as const, + label: "Text anchor", + x: text.x, + z: text.z, + })), + ]), [centerSnapCandidates, displayProfile.points, displayProfile.segments, pointById, profile.texts]); + + const pointFromEvent = (event: { clientX: number; clientY: number }, magnetic = true) => { const svg = svgRef.current; const matrix = svg?.getScreenCTM(); if (!svg || !matrix) return null; @@ -606,13 +750,41 @@ export function SketchWorkspace({ screenPoint.x = event.clientX; screenPoint.y = event.clientY; const local = screenPoint.matrixTransform(matrix.inverse()); - const step = snapStep(snap); + let candidates = snapCandidates; + if (pointerAction?.kind === "move-point") { + candidates = candidates.filter((candidate) => !candidate.ownerPointIds?.includes(pointerAction.pointId)); + } else if (pointerAction?.kind === "move-center") { + const movingPointIds = new Set(pointerAction.pointIds); + candidates = candidates.filter((candidate) => !candidate.ownerPointIds?.some((id) => movingPointIds.has(id))); + } + const snapped = snapSketchPoint({ x: local.x, z: local.y }, { + precisionStep: snapStep(snap), + gridStep, + tolerance: 10 * screenUnit, + snapToGridLines: magnetic && snapToGridLines, + snapToGeometry: magnetic && snapToGeometry, + candidates, + }); return { - x: clamp(snapValue(local.x, step), -workspace.width / 2, workspace.width / 2), - z: clamp(snapValue(local.y, step), -workspace.depth / 2, workspace.depth / 2), + ...snapped, + x: clamp(snapped.x, -workspace.width / 2, workspace.width / 2), + z: clamp(snapped.z, -workspace.depth / 2, workspace.depth / 2), }; }; + const svgToScreen = (svgX: number, svgZ: number) => { + const svg = svgRef.current; + const matrix = svg?.getScreenCTM(); + if (!svg || !matrix) return { left: 0, top: 0 }; + const svgPoint = svg.createSVGPoint(); + svgPoint.x = svgX; + svgPoint.y = svgZ; + const screen = svgPoint.matrixTransform(matrix); + const stage = svg.closest(".sketch-workspace-stage")?.getBoundingClientRect(); + if (!stage) return { left: screen.x, top: screen.y }; + return { left: screen.x - stage.left, top: screen.y - stage.top }; + }; + useEffect(() => { if (tool !== "refine") setRefinePreview(null); }, [tool]); @@ -630,6 +802,17 @@ export function SketchWorkspace({ return () => observer.disconnect(); }, []); + useEffect(() => { + const svg = svgRef.current; + if (!svg) return; + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + setZoom((current) => clamp(current * (event.deltaY > 0 ? 0.88 : 1.14), 0.75, 6)); + }; + svg.addEventListener("wheel", handleWheel, { passive: false }); + return () => svg.removeEventListener("wheel", handleWheel); + }, []); + const beginPan = (event: ReactPointerEvent) => { event.preventDefault(); event.stopPropagation(); @@ -637,13 +820,15 @@ export function SketchWorkspace({ setPointerAction({ kind: "pan", pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY }); }; + const isPanGesture = (event: ReactPointerEvent) => event.button === 1 || (event.button === 0 && (event.ctrlKey || event.metaKey)); + const handlePlanePointerDown = (event: ReactPointerEvent) => { - if (event.button === 1) { + if (isPanGesture(event)) { beginPan(event); return; } if (event.button !== 0 || (event.target !== event.currentTarget && (event.target as Element).closest("[data-sketch-entity]"))) return; - const point = pointFromEvent(event); + const point = pointFromEvent(event, tool !== "select"); if (!point) return; event.preventDefault(); if (tool === "bezier") { @@ -652,7 +837,7 @@ export function SketchWorkspace({ } else if (tool === "select") { event.currentTarget.setPointerCapture(event.pointerId); setPointerAction({ kind: "marquee", pointerId: event.pointerId, origin: point, current: point }); - } else if (tool === "line" || tool === "smooth" || tool === "measure") { + } else if (tool === "line" || tool === "smooth" || tool === "circle-center" || tool === "circle-diameter" || tool === "rect-corner" || tool === "rect-center" || tool === "poly-inscribed" || tool === "poly-circumscribed" || tool === "poly-edge" || tool === "text" || tool === "measure") { onPlanePoint(point); } }; @@ -671,7 +856,10 @@ export function SketchWorkspace({ setPointerAction({ ...pointerAction, clientX: event.clientX, clientY: event.clientY }); return; } - const point = pointFromEvent(event); + const magnetic = pointerAction + ? pointerAction.kind === "bezier" || pointerAction.kind === "move-point" || pointerAction.kind === "move-center" || pointerAction.kind === "move-handle" + : tool !== "select"; + const point = pointFromEvent(event, magnetic); setHover(point); if (point && pointerAction) setPointerAction({ ...pointerAction, current: point }); }; @@ -690,13 +878,23 @@ export function SketchWorkspace({ const minZ = Math.min(action.origin.z, action.current.z); const maxZ = Math.max(action.origin.z, action.current.z); const contains = (point: { x: number; z: number }) => point.x >= minX && point.x <= maxX && point.z >= minZ && point.z <= maxZ; - const pointIds = profile.points.filter(contains).map((point) => point.id); + const pointIds = profile.points.filter((point) => contains(point)).map((point) => point.id); const segmentIds = profile.segments.filter((segment) => { const start = pointById.get(segment.startId); const end = pointById.get(segment.endId); return Boolean(start && end && (contains(start) || contains(end) || contains({ x: (start.x + end.x) / 2, z: (start.z + end.z) / 2 }))); }).map((segment) => segment.id); - onSelectMany(pointIds, segmentIds, []); + const imageIds = (profile.images ?? []).filter((image) => { + const imageMinX = image.x - image.width / 2; + const imageMaxX = image.x + image.width / 2; + const imageMinZ = image.z - image.depth / 2; + const imageMaxZ = image.z + image.depth / 2; + return imageMaxX >= minX && imageMinX <= maxX && imageMaxZ >= minZ && imageMinZ <= maxZ; + }).map((image) => image.id); + const textIds = (profile.texts ?? []).filter((text) => { + return text.x >= minX && text.x <= maxX && text.z >= minZ && text.z <= maxZ; + }).map((text) => text.id); + onSelectMany(pointIds, segmentIds, imageIds, textIds); setPointerAction(null); return; } @@ -709,15 +907,29 @@ export function SketchWorkspace({ }); } else if (action.kind === "move-selection") { onTransformPoints( - translateSketchPoints(action.startPoints, action.current.x - action.origin.x, action.current.z - action.origin.z), + transformSelectedSketchPoints(action.startPoints, action.current.x - action.origin.x, action.current.z - action.origin.z), "Sketch shape moved", ); } else if (action.kind === "resize-selection") { onTransformPoints(resizeSketchPoints(action.startPoints, action.bounds, action.handle, action.current), "Sketch shape resized"); } else if (action.kind === "move-point") { onMovePoint(action.pointId, action.current); + } else if (action.kind === "move-center") { + const delta = { x: action.current.x - action.origin.x, z: action.current.z - action.origin.z }; + if (Math.hypot(delta.x, delta.z) > screenUnit * 0.1) onMovePoints(action.pointIds, delta); } else if (action.kind === "move-handle") { onMoveHandle(action.pointId, action.handle, action.current); + } else if (action.kind === "move-dimension") { + if (Math.hypot(action.current.x - action.origin.x, action.current.z - action.origin.z) > screenUnit * 0.5) { + const segment = profile.segments.find((candidate) => candidate.id === action.segmentId); + const dimension = segment ? segmentDimension(segment, pointById) : null; + if (dimension) { + onMoveDimension(action.segmentId, { + x: action.current.x + action.grabOffset.x - dimension.midpoint.x, + z: action.current.z + action.grabOffset.z - dimension.midpoint.z, + }); + } + } } else if (action.kind === "move-image") { onUpdateImage(action.imageId, { x: action.start.x + action.current.x - action.origin.x, @@ -729,11 +941,6 @@ export function SketchWorkspace({ setPointerAction(null); }; - const handleWheel = (event: ReactWheelEvent) => { - event.preventDefault(); - setZoom((current) => clamp(current * (event.deltaY > 0 ? 0.88 : 1.14), 0.75, 6)); - }; - const beginEntityDrag = (event: ReactPointerEvent, action: PointerAction) => { if (event.button !== 0) return; event.preventDefault(); @@ -746,6 +953,20 @@ export function SketchWorkspace({ const measurementLabel = formatDimension(measurementLength, workspace.accuracy); const previewLength = activePoint && hover ? Math.hypot(hover.x - activePoint.x, hover.z - activePoint.z) : 0; const previewLabel = formatDimension(previewLength, workspace.accuracy); + const circlePreview = circleDraft && hover ? (() => { + return circleFromPoints(circleDraft.tool === "circle-center" ? "center-radius" : "diameter", circleDraft.first, hover); + })() : null; + const rectPreview = rectDraft && hover ? (() => { + return rectFromPoints(rectDraft.tool === "rect-corner" ? "corner" : "center", rectDraft.first, hover); + })() : null; + const polygonPreview = polygonDraft && hover ? (() => { + return polygonFromPoints( + polygonDraft.tool === "poly-inscribed" ? "inscribed" : polygonDraft.tool === "poly-circumscribed" ? "circumscribed" : "edge", + polygonDraft.sides, + polygonDraft.first, + hover, + ); + })() : null; const labelOffset = 22 * screenUnit; const pointRadius = 5 * screenUnit; const controlPointRadius = 6 * screenUnit; @@ -785,7 +1006,20 @@ export function SketchWorkspace({ return (
-
{operation === "revolve" ? "Revolve sketch" : "Sketch view"}
+
{operation === "revolve" ? "Revolve sketch" : "Sketch view"} - {planeName}
+ {operation === "extrude" && regions.length > 0 ? ( +
+ {selectedRegionIds.length} of {regions.length} profiles selected + {tool === "select" ? "Click profiles to toggle" : "Use Select to choose profiles"} + + +
+ ) : null} + {tool === "dimension" ? ( +
+ {pendingDimensionAnchor ? `First anchor: ${pendingDimensionAnchor.label}. Choose the second anchor.` : "Choose two endpoints, midpoints, or intersections."} +
+ ) : null} {operation === "revolve" ? : null}
@@ -806,7 +1040,7 @@ export function SketchWorkspace({ if (!pointerAction) setHover(null); setRefinePreview(null); }} - onWheel={handleWheel} + onContextMenu={(event) => event.preventDefault()} onDragOver={(event) => { if (!event.dataTransfer.types.includes("application/x-sketchforge-sketch-primitive")) return; event.preventDefault(); @@ -851,13 +1085,14 @@ export function SketchWorkspace({ onPointerDown={(event) => { event.preventDefault(); event.stopPropagation(); - if (event.button === 1) { + if (isPanGesture(event)) { beginPan(event); return; } if (event.button !== 0 || tool !== "select") return; - const point = pointFromEvent(event); + const point = pointFromEvent(event, false); if (!point) return; + onSelectImage(image.id); beginEntityDrag(event, { kind: "move-image", pointerId: event.pointerId, @@ -870,6 +1105,33 @@ export function SketchWorkspace({ /> ))} + + {(profile.texts ?? []).map((text) => ( + { + event.preventDefault(); + event.stopPropagation(); + if (isPanGesture(event)) { + beginPan(event); + return; + } + if (event.button !== 0 || tool !== "select") return; + onSelectText(text.id); + }} + > + {text.text} + + ))} + {referenceShapes.filter((shape) => !shape.hidden).map((shape) => { const footprint = referenceFootprints.get(shape.id); @@ -900,39 +1162,94 @@ export function SketchWorkspace({ pointerEvents="none" /> ) : null} - - {paths.some((path) => path.closed) ? path.closed).map(pathData).join(" ")} /> : null} - - - {paths.filter((path) => path.closed).map((path) => ( + + {operation === "extrude" ? regions.map((region, index) => ( { - if (event.button === 1) { + event.preventDefault(); + event.stopPropagation(); + if (isPanGesture(event)) { beginPan(event); return; } - if (event.button !== 0 || tool !== "select") return; - const point = pointFromEvent(event); - if (!point) return; - const pointIds = path.points.map((entry) => entry.id); - const segmentIds = path.steps.map((step) => step.segment.id); - const startPoints = pointIds.map((id) => profile.points.find((entry) => entry.id === id)).filter((entry): entry is SketchPoint => Boolean(entry)).map((entry) => ({ ...entry, handleIn: entry.handleIn ? { ...entry.handleIn } : undefined, handleOut: entry.handleOut ? { ...entry.handleOut } : undefined })); - onSelectMany(pointIds, segmentIds, []); - beginEntityDrag(event, { kind: "move-selection", pointerId: event.pointerId, origin: point, current: point, startPoints }); + if (event.button === 0 && tool === "select") onSelectRegion(region.id); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectRegion(region.id); + } }} /> - ))} + )) : paths.some((path) => path.closed) ? path.closed).map(pathData).join(" ")} pointerEvents="none" /> : null} - + {snapToGeometry || tool === "select" ? ( + + {centerSnapCandidates.map((center) => { + const pointIds = center.ownerPointIds ?? []; + const movable = pointIds.length > 0 && pointIds.every((id) => { + const point = pointById.get(id); + return point && !fixedPointIds.has(id); + }); + const dragging = pointerAction?.kind === "move-center" && pointerAction.centerId === center.id; + return ( + { + if (isPanGesture(event)) { + beginPan(event); + return; + } + if (event.button !== 0 || tool !== "select" || !movable) return; + const point = pointFromEvent(event, false); + if (!point) return; + beginEntityDrag(event, { + kind: "move-center", + pointerId: event.pointerId, + centerId: center.id, + pointIds, + origin: point, + current: point, + }); + }} + > + {movable ? "Drag to move this closed profile" : "Fixed or projected profiles cannot be moved"} + + + + + ); + })} + + ) : null} + {hover?.snap ? ( + + {hover.snap.xGuide !== undefined ? : null} + {hover.snap.zGuide !== undefined ? : null} + + {hover.snap.label} + + ) : null} + {displayProfile.segments.map((segment) => ( { if (tool !== "refine") return; const target = pointFromEvent(event); @@ -948,7 +1265,7 @@ export function SketchWorkspace({ const point = pointFromEvent(event); event.preventDefault(); event.stopPropagation(); - if (event.button === 1) beginPan(event); + if (isPanGesture(event)) beginPan(event); else if (tool === "erase") onDeleteSegment(segment.id); else if (event.button === 0 && tool === "refine" && point) { const start = pointById.get(segment.startId); @@ -958,6 +1275,11 @@ export function SketchWorkspace({ onInsertPoint(segment.id, placement.point, placement.amount); } } + else if (event.button === 0 && tool === "bezier" && point) { + svgRef.current?.setPointerCapture(event.pointerId); + setPointerAction({ kind: "bezier", pointerId: event.pointerId, origin: point, current: point }); + } + else if (event.button === 0 && point && (["line", "smooth", "circle-center", "circle-diameter", "rect-corner", "rect-center", "poly-inscribed", "poly-circumscribed", "poly-edge", "text", "measure"] as SketchTool[]).includes(tool)) onPlanePoint(point); else if (event.button === 0 && tool === "select" && point) { const closedPath = paths.find((path) => path.closed && path.steps.some((step) => step.segment.id === segment.id)); if (!closedPath) { @@ -967,7 +1289,7 @@ export function SketchWorkspace({ const pointIds = closedPath.points.map((entry) => entry.id); const segmentIds = closedPath.steps.map((step) => step.segment.id); const startPoints = pointIds.map((id) => profile.points.find((entry) => entry.id === id)).filter((entry): entry is SketchPoint => Boolean(entry)).map((entry) => ({ ...entry, handleIn: entry.handleIn ? { ...entry.handleIn } : undefined, handleOut: entry.handleOut ? { ...entry.handleOut } : undefined })); - onSelectMany(pointIds, segmentIds, []); + onSelectMany(pointIds, segmentIds, [], []); beginEntityDrag(event, { kind: "move-selection", pointerId: event.pointerId, origin: point, current: point, startPoints }); } else if (event.button === 0) onSelectSegment(segment.id); }} @@ -984,20 +1306,135 @@ export function SketchWorkspace({ pointerEvents="none" /> ) : null} - - {selected?.kind === "multiple" ? null : displayProfile.segments.map((segment) => { - const dimension = segmentDimension(segment, pointById); - if (!dimension) return null; - const label = formatDimension(dimension.length, workspace.accuracy); + + {(profile.dimensions ?? []).map((storedDimension) => { + if (storedDimension.kind === "distance") { + const start = sketchDimensionAnchorPoint(displayProfile, storedDimension.start); + const end = sketchDimensionAnchorPoint(displayProfile, storedDimension.end); + const value = sketchDistanceDimensionValue(displayProfile, storedDimension.start, storedDimension.end); + if (!start || !end || value === null) return null; + const deltaX = end.x - start.x; + const deltaZ = end.z - start.z; + const length = Math.max(0.000001, Math.hypot(deltaX, deltaZ)); + const position = { + x: (start.x + end.x) / 2 + deltaZ / length * labelOffset, + z: (start.z + end.z) / 2 - deltaX / length * labelOffset, + }; + const label = formatDimension(value, workspace.accuracy); + const pill = dimensionPillSize(label, screenUnit, 18); + return ( + + + { + event.preventDefault(); + event.stopPropagation(); + if (event.button === 0 && tool === "erase") onDeleteDimension(storedDimension.id); + }} + > + + {label} + + + ); + } + const segment = displayProfile.segments.find((entry) => entry.id === storedDimension.segmentId); + const dimension = segment ? segmentDimension(segment, pointById) : null; + if (!segment || !dimension) return null; + const label = formatDimension(storedDimension.value, workspace.accuracy); const pill = dimensionPillSize(label, screenUnit, 18); + const defaultPosition = { + x: dimension.midpoint.x + (segment.dimensionLabelOffset?.x ?? 0), + z: dimension.midpoint.z + (segment.dimensionLabelOffset?.z ?? -labelOffset), + }; + const action = pointerAction?.kind === "move-dimension" && pointerAction.segmentId === segment.id ? pointerAction : null; + const position = action ? { + x: action.current.x + action.grabOffset.x, + z: action.current.z + action.grabOffset.z, + } : defaultPosition; return ( - + { + if (isPanGesture(event)) { + beginPan(event); + return; + } + if (event.button !== 0 || tool !== "select" && tool !== "dimension" && tool !== "erase") return; + if (tool === "erase") { + event.preventDefault(); + event.stopPropagation(); + onDeleteDimension(storedDimension.id); + return; + } + const point = pointFromEvent(event, false); + if (!point) return; + event.preventDefault(); + event.stopPropagation(); + onSelectSegment(segment.id); + if (tool === "dimension") return; + beginEntityDrag(event, { + kind: "move-dimension", + pointerId: event.pointerId, + segmentId: segment.id, + origin: point, + current: point, + grabOffset: { x: defaultPosition.x - point.x, z: defaultPosition.z - point.z }, + }); + }} + > {label} ); })} + + {selected?.kind === "multiple" ? null : displayProfile.segments + .filter((segment) => !dimensionBySegmentId.has(segment.id)) + .map((segment) => { + const dimension = segmentDimension(segment, pointById); + if (!dimension) return null; + const label = formatDimension(dimension.length, workspace.accuracy); + const pill = dimensionPillSize(label, screenUnit, 18); + return ( + + + {label} + + ); + })} + + + {displayProfile.segments.map((segment) => { + const start = pointById.get(segment.startId); + const end = pointById.get(segment.endId); + const horizontal = horizontalSegmentIds.has(segment.id); + const vertical = verticalSegmentIds.has(segment.id); + if (!start || !end || (!horizontal && !vertical)) return null; + return ( + + {horizontal ? "H" : "V"} + + ); + })} + {displayProfile.points.filter((point) => fixedPointIds.has(point.id)).map((point) => ( + F + ))} + {selectedGeometryBounds && tool === "select" ? (() => { const widthLabel = formatDimension(selectedGeometryBounds.width, workspace.accuracy); const depthLabel = formatDimension(selectedGeometryBounds.depth, workspace.accuracy); @@ -1058,8 +1495,8 @@ export function SketchWorkspace({ ); })() : null} - {activePoint && hover && ["line", "bezier", "smooth"].includes(tool) ? : null} - {activePoint && hover && ["line", "bezier", "smooth"].includes(tool) ? ( + {activePoint && hover && (["line", "bezier", "smooth"] as SketchTool[]).includes(tool) ? : null} + {activePoint && hover && (["line", "bezier", "smooth"] as SketchTool[]).includes(tool) ? ( {(() => { const pill = dimensionPillSize(previewLabel, screenUnit, 18); @@ -1072,13 +1509,114 @@ export function SketchWorkspace({ })()} ) : null} + {circleDraft && hover && circlePreview ? ( + + + + + + {(() => { + const label = `${circleDraft.tool === "circle-center" ? "R" : "Ø"} ${formatDimension(circleDraft.tool === "circle-center" ? circlePreview.radius : circlePreview.radius * 2, workspace.accuracy)}`; + const pill = dimensionPillSize(label, screenUnit, 18); + return ( + <> + + {label} + + ); + })()} + + + ) : null} + {rectDraft && hover && rectPreview ? ( + + + + + {(() => { + const label = `${formatDimension(rectPreview.width, workspace.accuracy)} × ${formatDimension(rectPreview.height, workspace.accuracy)}`; + const pill = dimensionPillSize(label, screenUnit, 18); + return ( + <> + + {label} + + ); + })()} + + + ) : null} + {polygonDraft && hover && polygonPreview ? (() => { + const angleStep = (2 * Math.PI) / polygonDraft.sides; + const vertices = Array.from({ length: polygonDraft.sides }, (_, i) => ({ + x: polygonPreview.center.x + polygonPreview.circumR * Math.cos(polygonPreview.startAngle + i * angleStep), + z: polygonPreview.center.z + polygonPreview.circumR * Math.sin(polygonPreview.startAngle + i * angleStep), + })); + const polyD = vertices.map((v, i) => `${i === 0 ? "M" : "L"}${v.x} ${v.z}`).join(" ") + " Z"; + return ( + + + + + + {(() => { + const label = `${polygonDraft.sides}-gon R ${formatDimension(polygonPreview.circumR, workspace.accuracy)}`; + const pill = dimensionPillSize(label, screenUnit, 18); + return ( + <> + + {label} + + ); + })()} + + + ); + })() : null} + {textDraft ? ( + + + {"Click to place text"} + + + ) : null} {pointerAction?.kind === "bezier" ? ( ) : null} - {measurement ? ( + {tool === "measure" && measurement ? ( @@ -1089,6 +1627,10 @@ export function SketchWorkspace({ aria-label="Remove measurement" transform={`translate(${(measurement.start.x + measurement.end.x) / 2} ${(measurement.start.z + measurement.end.z) / 2 - labelOffset})`} onPointerDown={(event) => { + if (isPanGesture(event)) { + beginPan(event); + return; + } event.preventDefault(); event.stopPropagation(); onClearMeasurement(); @@ -1109,38 +1651,30 @@ export function SketchWorkspace({ ) : null} {selectedPoint && tool === "select" ? ( - {selectedPoint.handleIn ? <> event.button === 1 ? beginPan(event) : beginEntityDrag(event, { kind: "move-handle", pointerId: event.pointerId, pointId: selectedPoint.id, handle: "in", current: selectedPoint.handleIn! })} /> : null} - {selectedPoint.handleOut ? <> event.button === 1 ? beginPan(event) : beginEntityDrag(event, { kind: "move-handle", pointerId: event.pointerId, pointId: selectedPoint.id, handle: "out", current: selectedPoint.handleOut! })} /> : null} + {selectedPoint.handleIn ? <> isPanGesture(event) ? beginPan(event) : beginEntityDrag(event, { kind: "move-handle", pointerId: event.pointerId, pointId: selectedPoint.id, handle: "in", current: selectedPoint.handleIn! })} /> : null} + {selectedPoint.handleOut ? <> isPanGesture(event) ? beginPan(event) : beginEntityDrag(event, { kind: "move-handle", pointerId: event.pointerId, pointId: selectedPoint.id, handle: "out", current: selectedPoint.handleOut! })} /> : null} ) : null} {displayProfile.points.map((point) => ( { event.preventDefault(); event.stopPropagation(); - if (event.button === 1) { + if (isPanGesture(event)) { beginPan(event); } else if (tool === "erase" || tool === "refine") { onDeletePoint(point.id); } else if (event.button === 0 && tool === "select") { - const closedPath = paths.find((path) => path.closed && path.points.some((entry) => entry.id === point.id)); - if (closedPath) { - const pointIds = closedPath.points.map((entry) => entry.id); - const segmentIds = closedPath.steps.map((step) => step.segment.id); - const startPoints = pointIds.map((id) => profile.points.find((entry) => entry.id === id)).filter((entry): entry is SketchPoint => Boolean(entry)).map((entry) => ({ ...entry, handleIn: entry.handleIn ? { ...entry.handleIn } : undefined, handleOut: entry.handleOut ? { ...entry.handleOut } : undefined })); - onSelectMany(pointIds, segmentIds, []); - beginEntityDrag(event, { kind: "move-selection", pointerId: event.pointerId, origin: { x: point.x, z: point.z }, current: { x: point.x, z: point.z }, startPoints }); - } else { - onPointPress(point.id); - beginEntityDrag(event, { kind: "move-point", pointerId: event.pointerId, pointId: point.id, current: { x: point.x, z: point.z } }); - } + onPointPress(point.id); + if (!fixedPointIds.has(point.id)) beginEntityDrag(event, { kind: "move-point", pointerId: event.pointerId, pointId: point.id, current: { x: point.x, z: point.z } }); } else if (event.button === 0) { onPointPress(point.id); } @@ -1148,6 +1682,30 @@ export function SketchWorkspace({ /> ))} + {tool === "dimension" ? ( + + {dimensionAnchorCandidates.map((candidate) => { + const active = pendingDimensionAnchor?.id === candidate.id; + return ( + { + event.preventDefault(); + event.stopPropagation(); + if (isPanGesture(event)) beginPan(event); + else if (event.button === 0) chooseDimensionAnchor(candidate); + }} + > + + {candidate.kind === "midpoint" ? : } + {candidate.kind === "intersection" ? <> : null} + + ); + })} + + ) : null} {selectedImage && selectedImageBounds && tool === "select" ? ( { - if (event.button === 1) { + if (isPanGesture(event)) { beginPan(event); return; } if (event.button !== 0) return; - const point = pointFromEvent(event); + const point = pointFromEvent(event, false); if (!point) return; beginEntityDrag(event, { kind: "resize-image", @@ -1213,32 +1771,164 @@ export function SketchWorkspace({ ))} ) : null} - {hover && ["line", "bezier", "smooth", "measure"].includes(tool) ? : null} + {hover && (["line", "bezier", "smooth", "measure"] as SketchTool[]).includes(tool) ? : null} + {textDraft ? (() => { + const pos = svgToScreen(textDraft.position.x, textDraft.position.z); + return ( +
e.stopPropagation()} + > + setTextDraftValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const trimmed = textDraftValue.trim(); + if (trimmed) onTextSubmit(trimmed); + else onTextCancel(); + } else if (e.key === "Escape") { + e.preventDefault(); + onTextCancel(); + } + e.stopPropagation(); + }} + onBlur={() => { + const trimmed = textDraftValue.trim(); + if (trimmed) onTextSubmit(trimmed); + else onTextCancel(); + }} + placeholder="Type text..." + /> +
+ ); + })() : null} {selectedImage && tool === "select" ? ( onSelectMany([], [], [])} + onClose={() => onSelectMany([], [], [], [])} onUpdate={(patch, message) => onUpdateImage(selectedImage.id, patch, message)} onDelete={() => onDeleteImage(selectedImage.id)} /> ) : null} + {selectedSegment && (tool === "select" || tool === "dimension") ? ( + onSelectMany([], [], [], [])} + onToggleConstraint={(kind) => onToggleSegmentConstraint(selectedSegment.id, kind)} + onSetLength={(value) => onSetSegmentLength(selectedSegment.id, value)} + /> + ) : null} {selectedPoint && tool === "select" ? (
+
) : null} -
+
+
+ + +
); } +function SketchSegmentInspector({ + segment, + length, + accuracy, + horizontal, + vertical, + dimensionValue, + dimensionOnly, + onClose, + onToggleConstraint, + onSetLength, +}: { + segment: SketchSegment; + length: number; + accuracy: 1 | 2 | 3; + horizontal: boolean; + vertical: boolean; + dimensionValue: number | null; + dimensionOnly: boolean; + onClose: () => void; + onToggleConstraint: (kind: "horizontal" | "vertical") => void; + onSetLength: (value: number | null) => void; +}) { + const editable = !segment.kind || segment.kind === "line"; + const [draft, setDraft] = useState(formatDimension(dimensionValue ?? length, accuracy)); + useEffect(() => setDraft(formatDimension(dimensionValue ?? length, accuracy)), [accuracy, dimensionValue, length]); + const commitLength = () => { + const value = parseMeasurementInput(draft); + if (Number.isFinite(value) && value > 0) onSetLength(value); + else setDraft(formatDimension(dimensionValue ?? length, accuracy)); + }; + + return ( + + ); +} + function SketchImageInspector({ image, accuracy, diff --git a/apps/web/src/lib/sketchCadProfile.ts b/apps/web/src/lib/sketchCadProfile.ts index c56c08b..e303d70 100644 --- a/apps/web/src/lib/sketchCadProfile.ts +++ b/apps/web/src/lib/sketchCadProfile.ts @@ -1,8 +1,18 @@ -import type { SketchPoint, SketchProfile, SketchSegment } from "@/types/sketchforge"; +import type { SketchDimensionAnchor, SketchPoint, SketchProfile, SketchSegment } from "@/types/sketchforge"; export type OrderedCadSketchStep = { segment: SketchSegment; from: SketchPoint; to: SketchPoint }; export type OrderedCadSketchPath = { id: string; points: SketchPoint[]; steps: OrderedCadSketchStep[]; closed: boolean }; -export type CadSketchRegion = { outer: OrderedCadSketchPath; holes: OrderedCadSketchPath[] }; +export type CadSketchRegion = { + id: string; + outer: OrderedCadSketchPath; + holes: OrderedCadSketchPath[]; + sourcePathIds?: string[]; + coverage?: number; +}; + +function pathId(steps: OrderedCadSketchStep[]) { + return steps.map((step) => step.segment.id).sort().join("|"); +} export function orderedCadSketchPaths(profile: SketchProfile): OrderedCadSketchPath[] { const pointById = new Map(profile.points.map((point) => [point.id, point])); @@ -50,7 +60,7 @@ export function orderedCadSketchPaths(profile: SketchProfile): OrderedCadSketchP if (currentId === startId) break; points.push(to); } - paths.push({ id: seed.id, points, steps, closed: currentId === startId && steps.length >= 3 }); + paths.push({ id: pathId(steps), points, steps, closed: currentId === startId && steps.length >= 3 }); } return paths; } @@ -96,7 +106,250 @@ function pointInPolygon(point: { x: number; z: number }, polygon: Array<{ x: num return inside; } -export function cadSketchRegions(profile: SketchProfile): CadSketchRegion[] { +type ArrangementEdge = { + pathIndex: number; + pathId: string; + edgeIndex: number; + edgeCount: number; + start: { x: number; z: number }; + end: { x: number; z: number }; + splits: Set; +}; + +function cross2d(a: { x: number; z: number }, b: { x: number; z: number }) { + return a.x * b.z - a.z * b.x; +} + +function clampUnit(value: number) { + return Math.min(1, Math.max(0, value)); +} + +function edgeIntersections(first: ArrangementEdge, second: ArrangementEdge, epsilon: number) { + const r = { x: first.end.x - first.start.x, z: first.end.z - first.start.z }; + const s = { x: second.end.x - second.start.x, z: second.end.z - second.start.z }; + const offset = { x: second.start.x - first.start.x, z: second.start.z - first.start.z }; + const denominator = cross2d(r, s); + const denominatorTolerance = epsilon * Math.max(1, Math.hypot(r.x, r.z), Math.hypot(s.x, s.z)); + if (Math.abs(denominator) > denominatorTolerance) { + const firstAmount = cross2d(offset, s) / denominator; + const secondAmount = cross2d(offset, r) / denominator; + if (firstAmount < -epsilon || firstAmount > 1 + epsilon || secondAmount < -epsilon || secondAmount > 1 + epsilon) return null; + return { + first: [clampUnit(firstAmount)], + second: [clampUnit(secondAmount)], + changesTopology: firstAmount > epsilon && firstAmount < 1 - epsilon || secondAmount > epsilon && secondAmount < 1 - epsilon, + }; + } + if (Math.abs(cross2d(offset, r)) > denominatorTolerance) return null; + const rLengthSquared = r.x * r.x + r.z * r.z; + const sLengthSquared = s.x * s.x + s.z * s.z; + if (rLengthSquared <= epsilon * epsilon || sLengthSquared <= epsilon * epsilon) return null; + const firstStart = (offset.x * r.x + offset.z * r.z) / rLengthSquared; + const firstEnd = firstStart + (s.x * r.x + s.z * r.z) / rLengthSquared; + const overlapStart = Math.max(0, Math.min(firstStart, firstEnd)); + const overlapEnd = Math.min(1, Math.max(firstStart, firstEnd)); + if (overlapEnd < overlapStart - epsilon) return null; + const pointAt = (amount: number) => ({ x: first.start.x + r.x * amount, z: first.start.z + r.z * amount }); + const overlapPoints = [pointAt(clampUnit(overlapStart)), pointAt(clampUnit(overlapEnd))]; + const secondAmounts = overlapPoints.map((point) => clampUnit(((point.x - second.start.x) * s.x + (point.z - second.start.z) * s.z) / sLengthSquared)); + return { + first: [clampUnit(overlapStart), clampUnit(overlapEnd)], + second: secondAmounts, + changesTopology: overlapEnd - overlapStart > epsilon, + }; +} + +function arrangementPath(points: Array<{ x: number; z: number }>, id: string): OrderedCadSketchPath { + const sketchPoints = points.map((point, index) => ({ id: `${id}:p${index}`, x: point.x, z: point.z })); + const steps = sketchPoints.map((from, index) => { + const to = sketchPoints[(index + 1) % sketchPoints.length]; + return { + segment: { id: `${id}:s${index}`, kind: "line" as const, startId: from.id, endId: to.id }, + from, + to, + }; + }); + return { id, points: sketchPoints, steps, closed: true }; +} + +// Split sampled edges at crossings, then walk the planar half-edge graph to +// produce one boundary per bounded face. Non-intersecting sketches keep their +// original curve paths and bypass this approximation entirely. +function arrangementRegions(profile: SketchProfile): CadSketchRegion[] | null { + const paths = orderedCadSketchPaths(profile).filter((path) => path.steps.length > 0); + if (paths.length === 0) return null; + const polylines = paths.map((path) => { + const sampled = sampledPath(path); + if (path.closed && sampled.length > 1 && Math.hypot(sampled[0].x - sampled[sampled.length - 1].x, sampled[0].z - sampled[sampled.length - 1].z) <= 1e-10) sampled.pop(); + return sampled; + }); + const allPoints = polylines.flat(); + if (allPoints.length < 3) return null; + const minX = Math.min(...allPoints.map((point) => point.x)); + const maxX = Math.max(...allPoints.map((point) => point.x)); + const minZ = Math.min(...allPoints.map((point) => point.z)); + const maxZ = Math.max(...allPoints.map((point) => point.z)); + const scale = Math.max(1, Math.hypot(maxX - minX, maxZ - minZ)); + const epsilon = scale * 1e-8; + const edges: ArrangementEdge[] = []; + polylines.forEach((points, pathIndex) => { + const edgeCount = paths[pathIndex].closed ? points.length : Math.max(0, points.length - 1); + for (let edgeIndex = 0; edgeIndex < edgeCount; edgeIndex += 1) { + const start = points[edgeIndex]; + const end = points[(edgeIndex + 1) % points.length]; + if (Math.hypot(end.x - start.x, end.z - start.z) <= epsilon) continue; + edges.push({ pathIndex, pathId: paths[pathIndex].id, edgeIndex, edgeCount, start, end, splits: new Set([0, 1]) }); + } + }); + let topologyChanged = false; + for (let firstIndex = 0; firstIndex < edges.length; firstIndex += 1) { + const first = edges[firstIndex]; + for (let secondIndex = firstIndex + 1; secondIndex < edges.length; secondIndex += 1) { + const second = edges[secondIndex]; + if (first.pathIndex === second.pathIndex) { + const distance = Math.abs(first.edgeIndex - second.edgeIndex); + if (distance <= 1 || distance === first.edgeCount - 1) continue; + } + if (Math.max(first.start.x, first.end.x) + epsilon < Math.min(second.start.x, second.end.x) + || Math.max(second.start.x, second.end.x) + epsilon < Math.min(first.start.x, first.end.x) + || Math.max(first.start.z, first.end.z) + epsilon < Math.min(second.start.z, second.end.z) + || Math.max(second.start.z, second.end.z) + epsilon < Math.min(first.start.z, first.end.z)) continue; + const intersection = edgeIntersections(first, second, epsilon); + if (!intersection) continue; + intersection.first.forEach((amount) => first.splits.add(amount)); + intersection.second.forEach((amount) => second.splits.add(amount)); + topologyChanged ||= intersection.changesTopology; + } + } + if (!topologyChanged) return null; + + const vertices: Array<{ x: number; z: number }> = []; + const buckets = new Map(); + const snap = epsilon * 4; + const intern = (point: { x: number; z: number }) => { + const gridX = Math.round(point.x / snap); + const gridZ = Math.round(point.z / snap); + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + for (let offsetZ = -1; offsetZ <= 1; offsetZ += 1) { + const candidates = buckets.get(`${gridX + offsetX}:${gridZ + offsetZ}`) ?? []; + const match = candidates.find((index) => Math.hypot(vertices[index].x - point.x, vertices[index].z - point.z) <= snap); + if (match !== undefined) return match; + } + } + const index = vertices.length; + vertices.push(point); + const key = `${gridX}:${gridZ}`; + buckets.set(key, [...(buckets.get(key) ?? []), index]); + return index; + }; + const adjacency = new Map>(); + const edgeSources = new Map>(); + const graphEdgeKey = (first: number, second: number) => first < second ? `${first}:${second}` : `${second}:${first}`; + edges.forEach((edge) => { + const amounts = [...edge.splits].sort((a, b) => a - b); + for (let index = 0; index < amounts.length - 1; index += 1) { + const firstAmount = amounts[index]; + const secondAmount = amounts[index + 1]; + if (secondAmount - firstAmount <= 1e-10) continue; + const first = intern({ x: edge.start.x + (edge.end.x - edge.start.x) * firstAmount, z: edge.start.z + (edge.end.z - edge.start.z) * firstAmount }); + const second = intern({ x: edge.start.x + (edge.end.x - edge.start.x) * secondAmount, z: edge.start.z + (edge.end.z - edge.start.z) * secondAmount }); + if (first === second) continue; + adjacency.set(first, new Set([...(adjacency.get(first) ?? []), second])); + adjacency.set(second, new Set([...(adjacency.get(second) ?? []), first])); + const key = graphEdgeKey(first, second); + edgeSources.set(key, new Set([...(edgeSources.get(key) ?? []), edge.pathId])); + } + }); + const sortedNeighbors = new Map([...adjacency].map(([vertex, neighbors]) => [ + vertex, + [...neighbors].sort((a, b) => Math.atan2(vertices[a].z - vertices[vertex].z, vertices[a].x - vertices[vertex].x) + - Math.atan2(vertices[b].z - vertices[vertex].z, vertices[b].x - vertices[vertex].x)), + ])); + const visited = new Set(); + const directedKey = (first: number, second: number) => `${first}>${second}`; + const cycles: Array<{ points: Array<{ x: number; z: number }>; area: number; sources: string[] }> = []; + sortedNeighbors.forEach((neighbors, start) => neighbors.forEach((firstNext) => { + if (visited.has(directedKey(start, firstNext))) return; + const vertexIds: number[] = []; + const sources = new Set(); + let current = start; + let next = firstNext; + let closed = false; + for (let guard = 0; guard <= edges.length * 4; guard += 1) { + const key = directedKey(current, next); + if (visited.has(key)) break; + visited.add(key); + vertexIds.push(current); + edgeSources.get(graphEdgeKey(current, next))?.forEach((source) => sources.add(source)); + const options = sortedNeighbors.get(next) ?? []; + const reverseIndex = options.indexOf(current); + if (reverseIndex < 0 || options.length === 0) break; + const following = options[(reverseIndex - 1 + options.length) % options.length]; + current = next; + next = following; + if (current === start && next === firstNext) { + closed = true; + break; + } + } + if (!closed || vertexIds.length < 3) return; + const points = vertexIds.map((index) => vertices[index]); + const area = signedArea(points); + if (area > scale * scale * 1e-10) cycles.push({ points, area, sources: [...sources].sort() }); + })); + if (cycles.length === 0) return []; + + const closedPolygons = paths.flatMap((path, index) => path.closed && polylines[index].length >= 3 ? [{ id: path.id, points: polylines[index] }] : []); + const insideSamples = cycles.map((cycle) => { + const first = cycle.points[0]; + const second = cycle.points[1]; + const deltaX = second.x - first.x; + const deltaZ = second.z - first.z; + const length = Math.max(epsilon, Math.hypot(deltaX, deltaZ)); + return { + x: (first.x + second.x) / 2 - deltaZ / length * epsilon * 8, + z: (first.z + second.z) / 2 + deltaX / length * epsilon * 8, + }; + }); + const signatures = insideSamples.map((sample) => closedPolygons.filter((polygon) => pointInPolygon(sample, polygon.points)).map((polygon) => polygon.id).sort()); + const parentIndexes = cycles.map((cycle, index) => { + let parent = -1; + for (let candidate = 0; candidate < cycles.length; candidate += 1) { + if (candidate === index || cycles[candidate].area <= cycle.area || !pointInPolygon(insideSamples[index], cycles[candidate].points)) continue; + if (parent < 0 || cycles[candidate].area < cycles[parent].area) parent = candidate; + } + return parent; + }); + const orderedIndexes = cycles.map((_, index) => index).sort((a, b) => { + const signatureCompare = signatures[a].join("&").localeCompare(signatures[b].join("&")); + if (signatureCompare) return signatureCompare; + const aMinX = Math.min(...cycles[a].points.map((point) => point.x)); + const bMinX = Math.min(...cycles[b].points.map((point) => point.x)); + if (aMinX !== bMinX) return aMinX - bMinX; + return Math.min(...cycles[a].points.map((point) => point.z)) - Math.min(...cycles[b].points.map((point) => point.z)); + }); + const baseIds = cycles.map((cycle, index) => `face:${signatures[index].length ? signatures[index].join("&") : `boundary:${cycle.sources.join("&")}`}`); + const baseCounts = new Map(); + baseIds.forEach((id) => baseCounts.set(id, (baseCounts.get(id) ?? 0) + 1)); + const occurrences = new Map(); + const ids: string[] = []; + orderedIndexes.forEach((index) => { + const base = baseIds[index]; + const occurrence = (occurrences.get(base) ?? 0) + 1; + occurrences.set(base, occurrence); + ids[index] = (baseCounts.get(base) ?? 0) > 1 ? `${base}:${occurrence}` : base; + }); + const outlines = cycles.map((cycle, index) => arrangementPath(cycle.points, ids[index])); + return cycles.map((cycle, index) => ({ + id: ids[index], + outer: outlines[index], + holes: parentIndexes.flatMap((parent, childIndex) => parent === index ? [outlines[childIndex]] : []), + sourcePathIds: signatures[index].length > 0 && (baseCounts.get(baseIds[index]) ?? 0) === 1 ? signatures[index] : undefined, + coverage: signatures[index].length, + })).sort((a, b) => a.id.localeCompare(b.id)); +} + +function cadSketchRegionTopology(profile: SketchProfile) { const allPaths = orderedCadSketchPaths(profile); const openCount = allPaths.filter((path) => !path.closed).length; const records = allPaths @@ -106,7 +359,7 @@ export function cadSketchRegions(profile: SketchProfile): CadSketchRegion[] { return { path, polygon, area: Math.abs(signedArea(polygon)) }; }) .filter((record) => record.polygon.length >= 3 && record.area > 1e-8) - .sort((a, b) => b.area - a.area); + .sort((a, b) => b.area - a.area || a.path.id.localeCompare(b.path.id)); // Compute nesting depth: count how many larger closed paths contain each record. // Even depth = solid (outer boundary), odd depth = hole. @@ -120,32 +373,69 @@ export function cadSketchRegions(profile: SketchProfile): CadSketchRegion[] { } return depth; }); - - const regions: CadSketchRegion[] = []; - records.forEach((record, index) => { - const depth = depths[index]; - if (depth % 2 === 0) { - regions.push({ outer: record.path, holes: [] }); - } else { - // Odd depth: find the nearest even-depth ancestor (smallest containing solid) - let bestParent: (typeof records)[number] | null = null; - for (let i = 0; i < records.length; i++) { - if (i === index) continue; - if (depths[i] % 2 === 0 && depths[i] < depth && records[i].area > record.area && pointInPolygon(record.polygon[0], records[i].polygon)) { - if (!bestParent || records[i].area < bestParent.area) { - bestParent = records[i]; - } - } - } - if (bestParent) { - const region = regions.find((r) => r.outer === bestParent!.path); - if (region) region.holes.push(record.path); - } + const parentIndexes = records.map((record, index) => { + let parentIndex = -1; + for (let candidateIndex = 0; candidateIndex < records.length; candidateIndex += 1) { + if (candidateIndex === index || records[candidateIndex].area <= record.area || !pointInPolygon(record.polygon[0], records[candidateIndex].polygon)) continue; + if (parentIndex < 0 || records[candidateIndex].area < records[parentIndex].area) parentIndex = candidateIndex; } + return parentIndex; }); - if (regions.length === 0 && openCount > 0 && allPaths.length > 0) { + if (records.length === 0 && openCount > 0 && allPaths.length > 0) { throw new Error("All profile paths are open. Close at least one loop before finishing the sketch."); } - return regions; + return { records, depths, parentIndexes }; +} + +function regionsAtDepths(profile: SketchProfile, includeDepth: (depth: number) => boolean) { + const { records, depths, parentIndexes } = cadSketchRegionTopology(profile); + const regions = records.flatMap((record, index) => { + if (!includeDepth(depths[index])) return []; + const holes = records.flatMap((candidate, candidateIndex) => parentIndexes[candidateIndex] === index ? [candidate.path] : []); + return [{ id: record.path.id, outer: record.path, holes }]; + }); + regions.forEach((region) => region.holes.sort((a, b) => a.id.localeCompare(b.id))); + return regions.sort((a, b) => a.id.localeCompare(b.id)); +} + +export function cadSketchRegions(profile: SketchProfile): CadSketchRegion[] { + const arranged = arrangementRegions(profile); + if (arranged) return arranged.filter((region) => (region.coverage ?? 0) % 2 === 1); + return regionsAtDepths(profile, (depth) => depth % 2 === 0); +} + +export function cadSketchSelectableRegions(profile: SketchProfile): CadSketchRegion[] { + const arranged = arrangementRegions(profile); + if (arranged) return arranged; + return regionsAtDepths(profile, () => true); +} + +export function selectedCadSketchRegions(profile: SketchProfile, regionIds?: readonly string[]) { + if (regionIds === undefined) return cadSketchRegions(profile); + const regions = cadSketchSelectableRegions(profile); + const selected = new Set(regionIds); + return regions.filter((region) => selected.has(region.id)); +} + +export function cadSketchProfileForRegions(profile: SketchProfile, regionIds?: readonly string[]): SketchProfile { + if (regionIds === undefined) return profile; + const regions = selectedCadSketchRegions(profile, regionIds); + const segmentIds = new Set(regions.flatMap((region) => [region.outer, ...region.holes].flatMap((path) => path.steps.map((step) => step.segment.id)))); + const segments = profile.segments.filter((segment) => segmentIds.has(segment.id)); + const pointIds = new Set(segments.flatMap((segment) => [segment.startId, segment.endId])); + const anchorIncluded = (anchor: SketchDimensionAnchor) => { + if (anchor.kind === "point") return pointIds.has(anchor.pointId); + if (anchor.kind === "midpoint") return segmentIds.has(anchor.segmentId); + return segmentIds.has(anchor.firstSegmentId) && segmentIds.has(anchor.secondSegmentId); + }; + return { + ...profile, + points: profile.points.filter((point) => pointIds.has(point.id)), + segments, + constraints: profile.constraints?.filter((constraint) => constraint.kind === "fixed" ? pointIds.has(constraint.pointId) : segmentIds.has(constraint.segmentId)), + dimensions: profile.dimensions?.filter((dimension) => dimension.kind === "length" + ? segmentIds.has(dimension.segmentId) + : anchorIncluded(dimension.start) && anchorIncluded(dimension.end)), + }; } diff --git a/apps/web/src/lib/sketchCadTypes.ts b/apps/web/src/lib/sketchCadTypes.ts index 076c237..a85537e 100644 --- a/apps/web/src/lib/sketchCadTypes.ts +++ b/apps/web/src/lib/sketchCadTypes.ts @@ -4,6 +4,7 @@ export type SketchCadBuildRequest = { type: "build"; requestId: number; profile: SketchProfile; + regionIds?: string[]; height: number; }; diff --git a/apps/web/src/lib/sketchCircles.ts b/apps/web/src/lib/sketchCircles.ts new file mode 100644 index 0000000..188b723 --- /dev/null +++ b/apps/web/src/lib/sketchCircles.ts @@ -0,0 +1,66 @@ +import { createLocalId } from "@/lib/localIds"; +import type { SketchPoint, SketchSegment } from "@/types/sketchforge"; + +const CIRCLE_BEZIER_KAPPA = 0.5522847498307936; + +export function circleFromPoints( + mode: "center-radius" | "diameter", + first: { x: number; z: number }, + second: { x: number; z: number }, +) { + const center = mode === "center-radius" + ? first + : { x: (first.x + second.x) / 2, z: (first.z + second.z) / 2 }; + const radius = Math.hypot(second.x - first.x, second.z - first.z) / (mode === "center-radius" ? 1 : 2); + return { center, radius }; +} + +export function circleSketchGeometry( + center: { x: number; z: number }, + radius: number, + createId: (prefix: string) => string = createLocalId, +): { points: SketchPoint[]; segments: SketchSegment[] } { + const handleOffset = radius * CIRCLE_BEZIER_KAPPA; + const pointIds = Array.from({ length: 4 }, () => createId("sketch-point")); + const points: SketchPoint[] = [ + { + id: pointIds[0], + x: center.x + radius, + z: center.z, + handleIn: { x: center.x + radius, z: center.z - handleOffset }, + handleOut: { x: center.x + radius, z: center.z + handleOffset }, + mode: "smooth", + }, + { + id: pointIds[1], + x: center.x, + z: center.z + radius, + handleIn: { x: center.x + handleOffset, z: center.z + radius }, + handleOut: { x: center.x - handleOffset, z: center.z + radius }, + mode: "smooth", + }, + { + id: pointIds[2], + x: center.x - radius, + z: center.z, + handleIn: { x: center.x - radius, z: center.z + handleOffset }, + handleOut: { x: center.x - radius, z: center.z - handleOffset }, + mode: "smooth", + }, + { + id: pointIds[3], + x: center.x, + z: center.z - radius, + handleIn: { x: center.x - handleOffset, z: center.z - radius }, + handleOut: { x: center.x + handleOffset, z: center.z - radius }, + mode: "smooth", + }, + ]; + const segments: SketchSegment[] = pointIds.map((startId, index) => ({ + id: createId("sketch-segment"), + startId, + endId: pointIds[(index + 1) % pointIds.length], + kind: "bezier", + })); + return { points, segments }; +} diff --git a/apps/web/src/lib/sketchConstraints.ts b/apps/web/src/lib/sketchConstraints.ts new file mode 100644 index 0000000..49781a8 --- /dev/null +++ b/apps/web/src/lib/sketchConstraints.ts @@ -0,0 +1,237 @@ +import type { SketchConstraint, SketchDimensionAnchor, SketchPoint, SketchProfile, SketchSegment } from "@/types/sketchforge"; +import { sketchDimensionAnchorKey, sketchDimensionAnchorPoint } from "@/lib/sketchDimensions"; + +const SOLVER_TOLERANCE = 0.000001; +const DIMENSION_TOLERANCE = 0.0001; + +type IdFactory = (prefix: string) => string; +type SegmentConstraintKind = Extract["kind"]; + +export type SketchSolveResult = { + profile: SketchProfile; + conflicts: string[]; +}; + +function cloneProfile(profile: SketchProfile): SketchProfile { + return { + ...profile, + points: profile.points.map((point) => ({ + ...point, + handleIn: point.handleIn ? { ...point.handleIn } : undefined, + handleOut: point.handleOut ? { ...point.handleOut } : undefined, + })), + segments: profile.segments.map((segment) => ({ ...segment })), + constraints: (profile.constraints ?? []).map((constraint) => ({ ...constraint })), + dimensions: (profile.dimensions ?? []).map((dimension) => dimension.kind === "length" + ? { ...dimension } + : { ...dimension, start: { ...dimension.start }, end: { ...dimension.end } }), + images: profile.images?.map((image) => ({ ...image })), + texts: profile.texts?.map((text) => ({ ...text })), + }; +} + +function isLineSegment(segment: SketchSegment) { + return !segment.kind || segment.kind === "line"; +} + +function movePoint(point: SketchPoint, x: number, z: number) { + const deltaX = x - point.x; + const deltaZ = z - point.z; + point.x = x; + point.z = z; + if (point.handleIn) point.handleIn = { x: point.handleIn.x + deltaX, z: point.handleIn.z + deltaZ }; + if (point.handleOut) point.handleOut = { x: point.handleOut.x + deltaX, z: point.handleOut.z + deltaZ }; +} + +export function sketchSegmentLength(profile: SketchProfile, segmentId: string) { + const segment = profile.segments.find((entry) => entry.id === segmentId); + if (!segment) return null; + const start = profile.points.find((point) => point.id === segment.startId); + const end = profile.points.find((point) => point.id === segment.endId); + return start && end ? Math.hypot(end.x - start.x, end.z - start.z) : null; +} + +export function pruneSketchParameters(profile: SketchProfile): SketchProfile { + const next = cloneProfile(profile); + const pointIds = new Set(next.points.map((point) => point.id)); + const segmentIds = new Set(next.segments.map((segment) => segment.id)); + const lineSegmentIds = new Set(next.segments.filter(isLineSegment).map((segment) => segment.id)); + const seenConstraints = new Set(); + const seenDimensions = new Set(); + + next.constraints = (next.constraints ?? []).filter((constraint) => { + const targetId = constraint.kind === "fixed" ? constraint.pointId : constraint.segmentId; + const key = `${constraint.kind}:${targetId}`; + if (seenConstraints.has(key)) return false; + const valid = constraint.kind === "fixed" + ? pointIds.has(constraint.pointId) && Number.isFinite(constraint.x) && Number.isFinite(constraint.z) + : lineSegmentIds.has(constraint.segmentId); + if (valid) seenConstraints.add(key); + return valid; + }); + next.dimensions = (next.dimensions ?? []).filter((dimension) => { + const anchorValid = (anchor: SketchDimensionAnchor) => { + if (anchor.kind === "point") return pointIds.has(anchor.pointId); + if (anchor.kind === "midpoint") return segmentIds.has(anchor.segmentId); + return segmentIds.has(anchor.firstSegmentId) + && segmentIds.has(anchor.secondSegmentId) + && anchor.firstSegmentId !== anchor.secondSegmentId + && Number.isInteger(anchor.index) + && anchor.index >= 0 + && sketchDimensionAnchorPoint(next, anchor) !== null; + }; + const key = dimension.kind === "length" + ? `length:${dimension.segmentId}` + : `distance:${[sketchDimensionAnchorKey(dimension.start), sketchDimensionAnchorKey(dimension.end)].sort().join("|")}`; + const valid = !seenDimensions.has(key) && (dimension.kind === "length" + ? lineSegmentIds.has(dimension.segmentId) && Number.isFinite(dimension.value) && dimension.value > SOLVER_TOLERANCE + : anchorValid(dimension.start) && anchorValid(dimension.end) && sketchDimensionAnchorKey(dimension.start) !== sketchDimensionAnchorKey(dimension.end)); + if (valid) seenDimensions.add(key); + return valid; + }); + return next; +} + +export function solveSketchProfile(profile: SketchProfile, anchorPointId?: string): SketchSolveResult { + const next = pruneSketchParameters(profile); + const pointById = new Map(next.points.map((point) => [point.id, point])); + const fixedConstraints = (next.constraints ?? []).filter((constraint): constraint is Extract => constraint.kind === "fixed"); + const segmentConstraints = new Map>(); + const dimensionBySegment = new Map((next.dimensions ?? []).flatMap((dimension) => dimension.kind === "length" ? [[dimension.segmentId, dimension] as const] : [])); + // Decreasing priority propagates changes away from fixed or dragged anchors without closed loops pulling them back. + const priority = new Map(); + const fixedPointIds = new Set(fixedConstraints.map((constraint) => constraint.pointId)); + const basePriority = next.segments.length + 2; + + fixedConstraints.forEach((constraint) => { + const point = pointById.get(constraint.pointId); + if (!point) return; + movePoint(point, constraint.x, constraint.z); + priority.set(point.id, basePriority + 1); + }); + if (anchorPointId && !priority.has(anchorPointId) && pointById.has(anchorPointId)) priority.set(anchorPointId, basePriority); + (next.constraints ?? []).forEach((constraint) => { + if (constraint.kind === "fixed") return; + const kinds = segmentConstraints.get(constraint.segmentId) ?? new Set(); + kinds.add(constraint.kind); + segmentConstraints.set(constraint.segmentId, kinds); + }); + + const constrainedSegments = next.segments.filter((segment) => segmentConstraints.has(segment.id) || dimensionBySegment.has(segment.id)); + for (let pass = 0; pass < Math.max(1, constrainedSegments.length * 2); pass += 1) { + constrainedSegments.forEach((segment) => { + const start = pointById.get(segment.startId); + const end = pointById.get(segment.endId); + if (!start || !end) return; + const startPriority = priority.get(start.id) ?? 0; + const endPriority = priority.get(end.id) ?? 0; + const startFixed = fixedPointIds.has(start.id); + const endFixed = fixedPointIds.has(end.id); + if (startFixed && endFixed) return; + const anchor = startFixed + ? start + : endFixed + ? end + : start.id === anchorPointId + ? start + : end.id === anchorPointId + ? end + : endPriority > startPriority + ? end + : start; + const target = anchor === start ? end : start; + const anchorPriority = Math.max(startPriority, endPriority) || basePriority; + const kinds = segmentConstraints.get(segment.id); + const dimension = dimensionBySegment.get(segment.id); + const dx = target.x - anchor.x; + const dz = target.z - anchor.z; + const horizontal = kinds?.has("horizontal") ?? false; + const vertical = kinds?.has("vertical") ?? false; + let x = target.x; + let z = target.z; + + if (horizontal) z = anchor.z; + if (vertical) x = anchor.x; + if (dimension) { + if (horizontal && !vertical) { + x = anchor.x + (dx < 0 ? -dimension.value : dimension.value); + } else if (vertical && !horizontal) { + z = anchor.z + (dz < 0 ? -dimension.value : dimension.value); + } else { + const length = Math.hypot(dx, dz); + const unitX = length > SOLVER_TOLERANCE ? dx / length : 1; + const unitZ = length > SOLVER_TOLERANCE ? dz / length : 0; + x = anchor.x + unitX * dimension.value; + z = anchor.z + unitZ * dimension.value; + } + } + movePoint(target, x, z); + priority.set(anchor.id, anchorPriority); + priority.set(target.id, Math.max(priority.get(target.id) ?? 0, anchorPriority - 1)); + }); + } + + const conflicts: string[] = []; + (next.constraints ?? []).forEach((constraint) => { + if (constraint.kind === "fixed") { + const point = pointById.get(constraint.pointId); + if (!point || Math.hypot(point.x - constraint.x, point.z - constraint.z) > SOLVER_TOLERANCE) conflicts.push(constraint.id); + return; + } + const segment = next.segments.find((entry) => entry.id === constraint.segmentId); + const start = segment ? pointById.get(segment.startId) : null; + const end = segment ? pointById.get(segment.endId) : null; + const error = start && end ? constraint.kind === "horizontal" ? Math.abs(end.z - start.z) : Math.abs(end.x - start.x) : Number.POSITIVE_INFINITY; + if (error > SOLVER_TOLERANCE) conflicts.push(constraint.id); + }); + (next.dimensions ?? []).forEach((dimension) => { + if (dimension.kind !== "length") return; + const length = sketchSegmentLength(next, dimension.segmentId); + if (length === null || Math.abs(length - dimension.value) > DIMENSION_TOLERANCE) conflicts.push(dimension.id); + }); + return { profile: next, conflicts }; +} + +export function setSketchSegmentConstraint( + profile: SketchProfile, + segmentId: string, + kind: SegmentConstraintKind, + enabled: boolean, + createId: IdFactory, +) { + const segment = profile.segments.find((entry) => entry.id === segmentId); + if (!segment || !isLineSegment(segment)) return solveSketchProfile(profile); + const opposite = kind === "horizontal" ? "vertical" : "horizontal"; + const constraints = (profile.constraints ?? []).filter((constraint) => + constraint.kind === "fixed" || constraint.segmentId !== segmentId || constraint.kind !== kind && (!enabled || constraint.kind !== opposite), + ); + if (enabled) constraints.push({ id: createId(`sketch-${kind}`), kind, segmentId }); + return solveSketchProfile({ ...profile, constraints }, segment.startId); +} + +export function setSketchPointFixed(profile: SketchProfile, pointId: string, fixed: boolean, createId: IdFactory) { + const point = profile.points.find((entry) => entry.id === pointId); + if (!point) return solveSketchProfile(profile); + const constraints = (profile.constraints ?? []).filter((constraint) => constraint.kind !== "fixed" || constraint.pointId !== pointId); + if (fixed) constraints.push({ id: createId("sketch-fixed"), kind: "fixed", pointId, x: point.x, z: point.z }); + return solveSketchProfile({ ...profile, constraints }, pointId); +} + +export function setSketchSegmentLength(profile: SketchProfile, segmentId: string, value: number | null, createId: IdFactory) { + const segment = profile.segments.find((entry) => entry.id === segmentId); + if (!segment || !isLineSegment(segment)) return solveSketchProfile(profile); + const dimensions = (profile.dimensions ?? []).filter((dimension) => dimension.kind !== "length" || dimension.segmentId !== segmentId); + if (value !== null && Number.isFinite(value) && value > SOLVER_TOLERANCE) { + dimensions.push({ id: createId("sketch-length"), kind: "length", segmentId, value }); + } + return solveSketchProfile({ ...profile, dimensions }, segment.startId); +} + +export function moveConstrainedSketchPoint(profile: SketchProfile, pointId: string, position: { x: number; z: number }) { + const next = cloneProfile(profile); + const point = next.points.find((entry) => entry.id === pointId); + const fixed = (next.constraints ?? []).some((constraint) => constraint.kind === "fixed" && constraint.pointId === pointId); + if (!point || fixed) return solveSketchProfile(next); + movePoint(point, position.x, position.z); + return solveSketchProfile(next, pointId); +} diff --git a/apps/web/src/lib/sketchDimensions.ts b/apps/web/src/lib/sketchDimensions.ts new file mode 100644 index 0000000..0a4c97f --- /dev/null +++ b/apps/web/src/lib/sketchDimensions.ts @@ -0,0 +1,144 @@ +import type { SketchDimensionAnchor, SketchPoint, SketchProfile, SketchSegment } from "@/types/sketchforge"; + +export type SketchDimensionAnchorCandidate = { + id: string; + anchor: SketchDimensionAnchor; + kind: SketchDimensionAnchor["kind"]; + label: string; + x: number; + z: number; +}; + +function pointById(profile: SketchProfile, id: string) { + return profile.points.find((point) => point.id === id) ?? null; +} + +function cubicPoint(start: SketchPoint, first: { x: number; z: number }, second: { x: number; z: number }, end: SketchPoint, amount: number) { + const inverse = 1 - amount; + return { + x: inverse ** 3 * start.x + 3 * inverse ** 2 * amount * first.x + 3 * inverse * amount ** 2 * second.x + amount ** 3 * end.x, + z: inverse ** 3 * start.z + 3 * inverse ** 2 * amount * first.z + 3 * inverse * amount ** 2 * second.z + amount ** 3 * end.z, + }; +} + +function segmentSamples(profile: SketchProfile, segment: SketchSegment, divisions = 24) { + const start = pointById(profile, segment.startId); + const end = pointById(profile, segment.endId); + if (!start || !end) return []; + const first = start.handleOut; + const second = end.handleIn; + if (segment.kind === "line" || !first || !second) return [start, end]; + return Array.from({ length: divisions + 1 }, (_, index) => cubicPoint(start, first, second, end, index / divisions)); +} + +function segmentMidpoint(profile: SketchProfile, segment: SketchSegment) { + const start = pointById(profile, segment.startId); + const end = pointById(profile, segment.endId); + if (!start || !end) return null; + const first = start.handleOut; + const second = end.handleIn; + return segment.kind !== "line" && first && second + ? cubicPoint(start, first, second, end, 0.5) + : { x: (start.x + end.x) / 2, z: (start.z + end.z) / 2 }; +} + +function lineIntersection( + firstStart: { x: number; z: number }, + firstEnd: { x: number; z: number }, + secondStart: { x: number; z: number }, + secondEnd: { x: number; z: number }, +) { + const firstX = firstEnd.x - firstStart.x; + const firstZ = firstEnd.z - firstStart.z; + const secondX = secondEnd.x - secondStart.x; + const secondZ = secondEnd.z - secondStart.z; + const denominator = firstX * secondZ - firstZ * secondX; + if (Math.abs(denominator) <= 1e-10) return null; + const offsetX = secondStart.x - firstStart.x; + const offsetZ = secondStart.z - firstStart.z; + const firstAmount = (offsetX * secondZ - offsetZ * secondX) / denominator; + const secondAmount = (offsetX * firstZ - offsetZ * firstX) / denominator; + if (firstAmount < -1e-8 || firstAmount > 1 + 1e-8 || secondAmount < -1e-8 || secondAmount > 1 + 1e-8) return null; + return { x: firstStart.x + firstX * firstAmount, z: firstStart.z + firstZ * firstAmount }; +} + +export function sketchSegmentIntersections(profile: SketchProfile, firstSegmentId: string, secondSegmentId: string) { + const first = profile.segments.find((segment) => segment.id === firstSegmentId); + const second = profile.segments.find((segment) => segment.id === secondSegmentId); + if (!first || !second || first.id === second.id) return []; + const firstSamples = segmentSamples(profile, first); + const secondSamples = segmentSamples(profile, second); + const intersections: Array<{ x: number; z: number }> = []; + for (let firstIndex = 0; firstIndex < firstSamples.length - 1; firstIndex += 1) { + for (let secondIndex = 0; secondIndex < secondSamples.length - 1; secondIndex += 1) { + const point = lineIntersection(firstSamples[firstIndex], firstSamples[firstIndex + 1], secondSamples[secondIndex], secondSamples[secondIndex + 1]); + if (point && !intersections.some((entry) => Math.hypot(entry.x - point.x, entry.z - point.z) <= 1e-5)) intersections.push(point); + } + } + return intersections.sort((a, b) => a.x - b.x || a.z - b.z); +} + +export function sketchDimensionAnchorKey(anchor: SketchDimensionAnchor) { + if (anchor.kind === "point") return `point:${anchor.pointId}`; + if (anchor.kind === "midpoint") return `midpoint:${anchor.segmentId}`; + const segmentIds = [anchor.firstSegmentId, anchor.secondSegmentId].sort(); + return `intersection:${segmentIds[0]}:${segmentIds[1]}:${anchor.index}`; +} + +export function sketchDimensionAnchorPoint(profile: SketchProfile, anchor: SketchDimensionAnchor) { + if (anchor.kind === "point") { + const point = pointById(profile, anchor.pointId); + return point ? { x: point.x, z: point.z } : null; + } + if (anchor.kind === "midpoint") { + const segment = profile.segments.find((entry) => entry.id === anchor.segmentId); + return segment ? segmentMidpoint(profile, segment) : null; + } + return sketchSegmentIntersections(profile, anchor.firstSegmentId, anchor.secondSegmentId)[anchor.index] ?? null; +} + +export function sketchDimensionAnchorCandidates(profile: SketchProfile): SketchDimensionAnchorCandidate[] { + const points: SketchDimensionAnchorCandidate[] = profile.points.map((point) => ({ + id: `point:${point.id}`, + anchor: { kind: "point", pointId: point.id }, + kind: "point", + label: "Endpoint", + x: point.x, + z: point.z, + })); + const midpoints: SketchDimensionAnchorCandidate[] = []; + profile.segments.forEach((segment) => { + const midpoint = segmentMidpoint(profile, segment); + if (midpoint) midpoints.push({ + id: `midpoint:${segment.id}`, + anchor: { kind: "midpoint", segmentId: segment.id }, + kind: "midpoint", + label: "Midpoint", + ...midpoint, + }); + }); + const intersections: SketchDimensionAnchorCandidate[] = []; + for (let firstIndex = 0; firstIndex < profile.segments.length; firstIndex += 1) { + for (let secondIndex = firstIndex + 1; secondIndex < profile.segments.length; secondIndex += 1) { + const first = profile.segments[firstIndex]; + const second = profile.segments[secondIndex]; + const segmentIds = [first.id, second.id].sort(); + sketchSegmentIntersections(profile, segmentIds[0], segmentIds[1]).forEach((point, index) => intersections.push({ + id: `intersection:${segmentIds[0]}:${segmentIds[1]}:${index}`, + anchor: { kind: "intersection", firstSegmentId: segmentIds[0], secondSegmentId: segmentIds[1], index }, + kind: "intersection", + label: "Intersection", + ...point, + })); + } + } + const candidates = [...points, ...intersections, ...midpoints]; + return candidates.filter((candidate, index) => !candidates.some((earlier, earlierIndex) => earlierIndex < index + && Math.hypot(earlier.x - candidate.x, earlier.z - candidate.z) <= 1e-6)); +} + +export function sketchDistanceDimensionValue(profile: SketchProfile, start: SketchDimensionAnchor, end: SketchDimensionAnchor) { + const first = sketchDimensionAnchorPoint(profile, start); + const second = sketchDimensionAnchorPoint(profile, end); + return first && second ? Math.hypot(second.x - first.x, second.z - first.z) : null; +} diff --git a/apps/web/src/lib/sketchOffset.ts b/apps/web/src/lib/sketchOffset.ts new file mode 100644 index 0000000..089e1c6 --- /dev/null +++ b/apps/web/src/lib/sketchOffset.ts @@ -0,0 +1,399 @@ +import { createLocalId } from "@/lib/localIds"; +import type { SketchPoint, SketchProfile, SketchSegment } from "@/types/sketchforge"; + +type Point2D = { x: number; z: number }; +type IdFactory = (prefix: string) => string; + +export type SketchOffsetOptions = { + includeConnected?: boolean; + createId?: IdFactory; +}; + +export type SketchOffsetResult = { + profile: SketchProfile; + pointIds: string[]; + segmentIds: string[]; + closed: boolean; +}; + +type PathStep = { + segment: SketchSegment; + from: SketchPoint; + to: SketchPoint; +}; + +const GEOMETRY_EPSILON = 1e-9; +const MITER_LIMIT = 8; +const MAX_BEZIER_DEPTH = 14; + +function compareIds(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function distance(left: Point2D, right: Point2D) { + return Math.hypot(right.x - left.x, right.z - left.z); +} + +function cross(left: Point2D, right: Point2D) { + return left.x * right.z - left.z * right.x; +} + +function subtract(left: Point2D, right: Point2D): Point2D { + return { x: left.x - right.x, z: left.z - right.z }; +} + +function signedArea(points: readonly Point2D[]) { + let area = 0; + for (let index = 0; index < points.length; index += 1) { + const next = points[(index + 1) % points.length]; + area += points[index].x * next.z - next.x * points[index].z; + } + return area / 2; +} + +function resolvePath( + profile: SketchProfile, + selectedSegmentIds: readonly string[], + includeConnected: boolean, +): { steps: PathStep[]; closed: boolean } { + if (selectedSegmentIds.length === 0) throw new Error("Select at least one sketch segment to offset"); + + const pointById = new Map(); + for (const point of profile.points) { + if (pointById.has(point.id)) throw new Error(`Invalid sketch topology: duplicate point ID ${point.id}`); + pointById.set(point.id, point); + } + + const segmentById = new Map(); + const allIncident = new Map(); + for (const segment of profile.segments) { + if (segmentById.has(segment.id)) throw new Error(`Invalid sketch topology: duplicate segment ID ${segment.id}`); + segmentById.set(segment.id, segment); + for (const pointId of new Set([segment.startId, segment.endId])) { + const incident = allIncident.get(pointId) ?? []; + incident.push(segment); + allIncident.set(pointId, incident); + } + } + + const selectedIds = new Set(selectedSegmentIds); + for (const id of selectedIds) { + if (!segmentById.has(id)) throw new Error(`Invalid sketch segment reference: ${id}`); + } + + let pathIds = selectedIds; + if (includeConnected) { + pathIds = new Set(); + const queue = [segmentById.get(selectedIds.values().next().value as string)!]; + while (queue.length > 0) { + const segment = queue.pop()!; + if (pathIds.has(segment.id)) continue; + pathIds.add(segment.id); + for (const pointId of [segment.startId, segment.endId]) { + for (const connected of allIncident.get(pointId) ?? []) { + if (!pathIds.has(connected.id)) queue.push(connected); + } + } + } + if ([...selectedIds].some((id) => !pathIds.has(id))) { + throw new Error("Selected sketch segments are disconnected"); + } + } + + const adjacency = new Map(); + for (const id of pathIds) { + const segment = segmentById.get(id)!; + const start = pointById.get(segment.startId); + const end = pointById.get(segment.endId); + if (!start || !end) throw new Error(`Invalid point reference in sketch segment ${segment.id}`); + if (![start.x, start.z, end.x, end.z].every(Number.isFinite)) { + throw new Error(`Invalid point coordinates in sketch segment ${segment.id}`); + } + if (segment.startId === segment.endId || distance(start, end) <= GEOMETRY_EPSILON) { + throw new Error(`Zero-length sketch topology at segment ${segment.id}`); + } + adjacency.set(segment.startId, [...(adjacency.get(segment.startId) ?? []), segment]); + adjacency.set(segment.endId, [...(adjacency.get(segment.endId) ?? []), segment]); + } + + for (const [pointId, incident] of adjacency) { + if (incident.length > 2) throw new Error(`Sketch offset path branches at point ${pointId}`); + } + + const endpoints = [...adjacency].filter(([, incident]) => incident.length === 1).map(([id]) => id).sort(compareIds); + const closed = endpoints.length === 0; + if ((!closed && endpoints.length !== 2) || (closed && pathIds.size < 3)) { + throw new Error("Selected sketch segments do not form one non-branching path"); + } + + const startId = closed ? [...adjacency.keys()].sort(compareIds)[0] : endpoints[0]; + const used = new Set(); + const steps: PathStep[] = []; + let currentId = startId; + while (used.size < pathIds.size) { + const next = (adjacency.get(currentId) ?? []) + .filter((segment) => !used.has(segment.id)) + .sort((left, right) => compareIds(left.id, right.id))[0]; + if (!next) throw new Error("Selected sketch segments are disconnected"); + const nextId = next.startId === currentId ? next.endId : next.startId; + steps.push({ segment: next, from: pointById.get(currentId)!, to: pointById.get(nextId)! }); + used.add(next.id); + currentId = nextId; + } + if ((closed && currentId !== startId) || (!closed && currentId === startId)) { + throw new Error("Selected sketch segments do not form one non-branching path"); + } + return { steps, closed }; +} + +function pointLineDistance(point: Point2D, start: Point2D, end: Point2D) { + const chord = subtract(end, start); + const length = Math.hypot(chord.x, chord.z); + if (length <= GEOMETRY_EPSILON) return distance(point, start); + return Math.abs(cross(subtract(point, start), chord)) / length; +} + +function flattenCubic( + start: Point2D, + first: Point2D, + second: Point2D, + end: Point2D, + tolerance: number, + result: Point2D[], + depth = 0, +) { + const controlLength = distance(start, first) + distance(first, second) + distance(second, end); + const flatness = Math.max( + pointLineDistance(first, start, end), + pointLineDistance(second, start, end), + controlLength - distance(start, end), + ); + if (flatness <= tolerance || depth >= MAX_BEZIER_DEPTH) { + result.push({ x: end.x, z: end.z }); + return; + } + + const startFirst = { x: (start.x + first.x) / 2, z: (start.z + first.z) / 2 }; + const firstSecond = { x: (first.x + second.x) / 2, z: (first.z + second.z) / 2 }; + const secondEnd = { x: (second.x + end.x) / 2, z: (second.z + end.z) / 2 }; + const leftControl = { x: (startFirst.x + firstSecond.x) / 2, z: (startFirst.z + firstSecond.z) / 2 }; + const rightControl = { x: (firstSecond.x + secondEnd.x) / 2, z: (firstSecond.z + secondEnd.z) / 2 }; + const midpoint = { x: (leftControl.x + rightControl.x) / 2, z: (leftControl.z + rightControl.z) / 2 }; + flattenCubic(start, startFirst, leftControl, midpoint, tolerance, result, depth + 1); + flattenCubic(midpoint, rightControl, secondEnd, end, tolerance, result, depth + 1); +} + +function flattenPath(steps: readonly PathStep[], closed: boolean, offsetDistance: number) { + const sourceScale = steps.reduce((scale, step) => Math.max(scale, distance(step.from, step.to)), 0); + const tolerance = Math.max(1e-6, Math.min(Math.abs(offsetDistance) * 0.01, sourceScale * 0.001)); + const points: Point2D[] = [{ x: steps[0].from.x, z: steps[0].from.z }]; + + for (const { segment, from, to } of steps) { + const forward = segment.startId === from.id; + const first = forward ? from.handleOut : from.handleIn; + const second = forward ? to.handleIn : to.handleOut; + if (segment.kind !== "line" && first && second) { + if (![first.x, first.z, second.x, second.z].every(Number.isFinite)) { + throw new Error(`Invalid Bezier handle in sketch segment ${segment.id}`); + } + flattenCubic(from, first, second, to, tolerance, points); + } else { + points.push({ x: to.x, z: to.z }); + } + } + + const distinct: Point2D[] = []; + for (const point of points) { + if (!distinct.length || distance(distinct[distinct.length - 1], point) > GEOMETRY_EPSILON) distinct.push(point); + } + if (closed && distinct.length > 1 && distance(distinct[0], distinct[distinct.length - 1]) <= GEOMETRY_EPSILON) distinct.pop(); + if (distinct.length < (closed ? 3 : 2)) throw new Error("Zero-length sketch topology cannot be offset"); + return distinct; +} + +function shiftedPoint(point: Point2D, normal: Point2D, offsetDistance: number): Point2D { + return { x: point.x + normal.x * offsetDistance, z: point.z + normal.z * offsetDistance }; +} + +function offsetPolyline(points: readonly Point2D[], closed: boolean, offsetDistance: number) { + const edgeCount = closed ? points.length : points.length - 1; + const directions: Point2D[] = []; + const normals: Point2D[] = []; + for (let index = 0; index < edgeCount; index += 1) { + const start = points[index]; + const end = points[(index + 1) % points.length]; + const length = distance(start, end); + if (length <= GEOMETRY_EPSILON) throw new Error("Zero-length flattened sketch topology cannot be offset"); + const direction = { x: (end.x - start.x) / length, z: (end.z - start.z) / length }; + directions.push(direction); + normals.push({ x: -direction.z, z: direction.x }); + } + + const result: Point2D[] = []; + const add = (point: Point2D) => { + if (!result.length || distance(result[result.length - 1], point) > GEOMETRY_EPSILON) result.push(point); + }; + for (let index = 0; index < points.length; index += 1) { + if (!closed && index === 0) { + add(shiftedPoint(points[index], normals[0], offsetDistance)); + continue; + } + if (!closed && index === points.length - 1) { + add(shiftedPoint(points[index], normals[normals.length - 1], offsetDistance)); + continue; + } + + const previousEdge = (index - 1 + edgeCount) % edgeCount; + const nextEdge = index % edgeCount; + const previousShift = shiftedPoint(points[index], normals[previousEdge], offsetDistance); + const nextShift = shiftedPoint(points[index], normals[nextEdge], offsetDistance); + const denominator = cross(directions[previousEdge], directions[nextEdge]); + if (Math.abs(denominator) > GEOMETRY_EPSILON) { + const amount = cross(subtract(nextShift, previousShift), directions[nextEdge]) / denominator; + const intersection = { + x: previousShift.x + directions[previousEdge].x * amount, + z: previousShift.z + directions[previousEdge].z * amount, + }; + if (Number.isFinite(intersection.x) && Number.isFinite(intersection.z) + && distance(points[index], intersection) <= Math.abs(offsetDistance) * MITER_LIMIT) { + add(intersection); + continue; + } + } else if (directions[previousEdge].x * directions[nextEdge].x + directions[previousEdge].z * directions[nextEdge].z > 0) { + add({ x: (previousShift.x + nextShift.x) / 2, z: (previousShift.z + nextShift.z) / 2 }); + continue; + } + + // A bevel is safer than an unbounded miter at cusps and nearly parallel turns. + add(previousShift); + add(nextShift); + } + if (closed && result.length > 1 && distance(result[0], result[result.length - 1]) <= GEOMETRY_EPSILON) result.pop(); + return result; +} + +function orientation(first: Point2D, second: Point2D, third: Point2D) { + return cross(subtract(second, first), subtract(third, first)); +} + +function onSegment(point: Point2D, start: Point2D, end: Point2D) { + return Math.abs(orientation(start, end, point)) <= GEOMETRY_EPSILON + && point.x >= Math.min(start.x, end.x) - GEOMETRY_EPSILON + && point.x <= Math.max(start.x, end.x) + GEOMETRY_EPSILON + && point.z >= Math.min(start.z, end.z) - GEOMETRY_EPSILON + && point.z <= Math.max(start.z, end.z) + GEOMETRY_EPSILON; +} + +function segmentsIntersect(firstStart: Point2D, firstEnd: Point2D, secondStart: Point2D, secondEnd: Point2D) { + const firstA = orientation(firstStart, firstEnd, secondStart); + const firstB = orientation(firstStart, firstEnd, secondEnd); + const secondA = orientation(secondStart, secondEnd, firstStart); + const secondB = orientation(secondStart, secondEnd, firstEnd); + if (((firstA > GEOMETRY_EPSILON && firstB < -GEOMETRY_EPSILON) || (firstA < -GEOMETRY_EPSILON && firstB > GEOMETRY_EPSILON)) + && ((secondA > GEOMETRY_EPSILON && secondB < -GEOMETRY_EPSILON) || (secondA < -GEOMETRY_EPSILON && secondB > GEOMETRY_EPSILON))) return true; + return (Math.abs(firstA) <= GEOMETRY_EPSILON && onSegment(secondStart, firstStart, firstEnd)) + || (Math.abs(firstB) <= GEOMETRY_EPSILON && onSegment(secondEnd, firstStart, firstEnd)) + || (Math.abs(secondA) <= GEOMETRY_EPSILON && onSegment(firstStart, secondStart, secondEnd)) + || (Math.abs(secondB) <= GEOMETRY_EPSILON && onSegment(firstEnd, secondStart, secondEnd)); +} + +function hasSelfIntersection(points: readonly Point2D[], closed: boolean) { + const count = closed ? points.length : points.length - 1; + for (let index = 0; index < count - (closed ? 0 : 1); index += 1) { + const previous = subtract(points[(index + 1) % points.length], points[index]); + const next = subtract(points[(index + 2) % points.length], points[(index + 1) % points.length]); + if (Math.abs(cross(previous, next)) <= GEOMETRY_EPSILON + && previous.x * next.x + previous.z * next.z < 0) return true; + } + for (let first = 0; first < count; first += 1) { + for (let second = first + 1; second < count; second += 1) { + if (second === first + 1 || (closed && first === 0 && second === count - 1)) continue; + if (segmentsIntersect( + points[first], + points[(first + 1) % points.length], + points[second], + points[(second + 1) % points.length], + )) return true; + } + } + return false; +} + +function freshId(createId: IdFactory, prefix: string, used: Set) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const id = createId(prefix); + if (id && !used.has(id)) { + used.add(id); + return id; + } + } + throw new Error(`ID factory did not produce a fresh ${prefix} ID`); +} + +export function offsetSketchSegments( + profile: SketchProfile, + selectedSegmentIds: readonly string[], + offsetDistance: number, + options: SketchOffsetOptions = {}, +): SketchOffsetResult { + if (!Number.isFinite(offsetDistance) || Math.abs(offsetDistance) <= GEOMETRY_EPSILON) { + throw new Error("Sketch offset distance must be a finite non-zero value"); + } + + const { steps, closed } = resolvePath(profile, selectedSegmentIds, options.includeConnected ?? true); + const sourcePoints = flattenPath(steps, closed, offsetDistance); + let sideDistance = offsetDistance; + const sourceArea = closed ? signedArea(sourcePoints) : 0; + if (closed) { + if (Math.abs(sourceArea) <= GEOMETRY_EPSILON) throw new Error("Closed sketch path has collapsed area"); + sideDistance = -Math.sign(sourceArea) * offsetDistance; + } + + const offsetPoints = offsetPolyline(sourcePoints, closed, sideDistance); + if (offsetPoints.length < (closed ? 3 : 2)) throw new Error("Sketch offset result collapsed"); + if (offsetPoints.some((point, index) => distance(point, offsetPoints[(index + 1) % offsetPoints.length]) <= GEOMETRY_EPSILON + && (closed || index < offsetPoints.length - 1))) throw new Error("Sketch offset result collapsed"); + if (closed) { + const resultArea = signedArea(offsetPoints); + if (Math.abs(resultArea) <= GEOMETRY_EPSILON || Math.sign(resultArea) !== Math.sign(sourceArea)) { + throw new Error("Closed sketch offset result collapsed"); + } + } + if (hasSelfIntersection(offsetPoints, closed)) throw new Error("Generated sketch offset self-intersects"); + + const createId = options.createId ?? createLocalId; + const usedIds = new Set([ + ...profile.points.map((point) => point.id), + ...profile.segments.map((segment) => segment.id), + ...(profile.constraints ?? []).map((constraint) => constraint.id), + ...(profile.dimensions ?? []).map((dimension) => dimension.id), + ...(profile.images ?? []).map((image) => image.id), + ...(profile.texts ?? []).map((text) => text.id), + ]); + const pointIds = offsetPoints.map(() => freshId(createId, "sketch-point", usedIds)); + const segmentCount = closed ? pointIds.length : pointIds.length - 1; + const segmentIds = Array.from({ length: segmentCount }, () => freshId(createId, "sketch-segment", usedIds)); + const generatedPoints: SketchPoint[] = offsetPoints.map((point, index) => ({ + id: pointIds[index], + x: point.x, + z: point.z, + mode: "corner", + })); + const generatedSegments: SketchSegment[] = segmentIds.map((id, index) => ({ + id, + startId: pointIds[index], + endId: pointIds[(index + 1) % pointIds.length], + kind: "line", + })); + + return { + profile: { + ...profile, + points: [...profile.points, ...generatedPoints], + segments: [...profile.segments, ...generatedSegments], + }, + pointIds, + segmentIds, + closed, + }; +} diff --git a/apps/web/src/lib/sketchPolygons.ts b/apps/web/src/lib/sketchPolygons.ts new file mode 100644 index 0000000..ab1655a --- /dev/null +++ b/apps/web/src/lib/sketchPolygons.ts @@ -0,0 +1,59 @@ +import { createLocalId } from "@/lib/localIds"; +import type { SketchPoint, SketchSegment } from "@/types/sketchforge"; + +export function polygonFromPoints( + mode: "inscribed" | "circumscribed" | "edge", + sides: number, + first: { x: number; z: number }, + second: { x: number; z: number }, +) { + const apothemAngle = Math.PI / sides; + + if (mode === "edge") { + const dx = second.x - first.x; + const dz = second.z - first.z; + const edgeLen = Math.hypot(dx, dz); + const circumR = edgeLen / (2 * Math.sin(apothemAngle)); + const apothem = circumR * Math.cos(apothemAngle); + const midX = (first.x + second.x) / 2; + const midZ = (first.z + second.z) / 2; + const perpX = -dz / edgeLen; + const perpZ = dx / edgeLen; + const center = { x: midX + perpX * apothem, z: midZ + perpZ * apothem }; + const startAngle = Math.atan2(first.z - center.z, first.x - center.x); + return { center, circumR, startAngle }; + } + + const center = first; + const dist = Math.hypot(second.x - first.x, second.z - first.z); + const circumR = mode === "inscribed" + ? dist + : dist / Math.cos(apothemAngle); + const startAngle = Math.atan2(second.z - center.z, second.x - center.x); + + return { center, circumR, startAngle }; +} + +export function polygonSketchGeometry( + center: { x: number; z: number }, + circumR: number, + startAngle: number, + sides: number, + createId: (prefix: string) => string = createLocalId, +): { points: SketchPoint[]; segments: SketchSegment[] } { + const angleStep = (2 * Math.PI) / sides; + const pointIds = Array.from({ length: sides }, () => createId("sketch-point")); + const points: SketchPoint[] = pointIds.map((id, i) => ({ + id, + x: center.x + circumR * Math.cos(startAngle + i * angleStep), + z: center.z + circumR * Math.sin(startAngle + i * angleStep), + mode: "corner" as const, + })); + const segments: SketchSegment[] = pointIds.map((startId, i) => ({ + id: createId("sketch-segment"), + startId, + endId: pointIds[(i + 1) % sides], + kind: "line" as const, + })); + return { points, segments }; +} diff --git a/apps/web/src/lib/sketchRectangles.ts b/apps/web/src/lib/sketchRectangles.ts new file mode 100644 index 0000000..be7416d --- /dev/null +++ b/apps/web/src/lib/sketchRectangles.ts @@ -0,0 +1,47 @@ +import { createLocalId } from "@/lib/localIds"; +import type { SketchPoint, SketchSegment } from "@/types/sketchforge"; + +export function rectFromPoints( + mode: "corner" | "center", + first: { x: number; z: number }, + second: { x: number; z: number }, +) { + if (mode === "corner") { + const minX = Math.min(first.x, second.x); + const maxX = Math.max(first.x, second.x); + const minZ = Math.min(first.z, second.z); + const maxZ = Math.max(first.z, second.z); + return { minX, maxX, minZ, maxZ, width: maxX - minX, height: maxZ - minZ }; + } + const dx = Math.abs(second.x - first.x); + const dz = Math.abs(second.z - first.z); + return { + minX: first.x - dx, + maxX: first.x + dx, + minZ: first.z - dz, + maxZ: first.z + dz, + width: dx * 2, + height: dz * 2, + }; +} + +export function rectangleSketchGeometry( + bounds: { minX: number; maxX: number; minZ: number; maxZ: number }, + createId: (prefix: string) => string = createLocalId, +): { points: SketchPoint[]; segments: SketchSegment[] } { + const { minX, maxX, minZ, maxZ } = bounds; + const pointIds = Array.from({ length: 4 }, () => createId("sketch-point")); + const points: SketchPoint[] = [ + { id: pointIds[0], x: minX, z: minZ, mode: "corner" }, + { id: pointIds[1], x: maxX, z: minZ, mode: "corner" }, + { id: pointIds[2], x: maxX, z: maxZ, mode: "corner" }, + { id: pointIds[3], x: minX, z: maxZ, mode: "corner" }, + ]; + const segments: SketchSegment[] = pointIds.map((startId, index) => ({ + id: createId("sketch-segment"), + startId, + endId: pointIds[(index + 1) % pointIds.length], + kind: "line", + })); + return { points, segments }; +} diff --git a/apps/web/src/lib/sketchSnapping.ts b/apps/web/src/lib/sketchSnapping.ts new file mode 100644 index 0000000..b969a33 --- /dev/null +++ b/apps/web/src/lib/sketchSnapping.ts @@ -0,0 +1,116 @@ +export type SketchSnapCandidateKind = "point" | "midpoint" | "center"; + +export type SketchSnapCandidate = { + id: string; + kind: SketchSnapCandidateKind; + label: string; + x: number; + z: number; + ownerPointIds?: string[]; +}; + +export type SketchSnapMatch = { + kind: SketchSnapCandidateKind | "grid" | "alignment"; + label: string; + xGuide?: number; + zGuide?: number; +}; + +export type SketchSnapResult = { + x: number; + z: number; + snap?: SketchSnapMatch; +}; + +type SketchSnapOptions = { + precisionStep: number; + gridStep: number; + tolerance: number; + snapToGridLines: boolean; + snapToGeometry: boolean; + candidates: SketchSnapCandidate[]; +}; + +function snappedValue(value: number, step: number) { + return step > 0 ? Math.round(value / step) * step : value; +} + +function nearestAxisCandidate(candidates: SketchSnapCandidate[], value: number, axis: "x" | "z") { + return candidates.reduce<{ candidate: SketchSnapCandidate; distance: number } | null>((nearest, candidate) => { + const distance = Math.abs(candidate[axis] - value); + return !nearest || distance < nearest.distance ? { candidate, distance } : nearest; + }, null); +} + +export function dedupeSketchSnapCandidates(candidates: SketchSnapCandidate[], tolerance = 0.000001) { + const result: SketchSnapCandidate[] = []; + candidates.forEach((candidate) => { + if (!result.some((current) => Math.hypot(current.x - candidate.x, current.z - candidate.z) <= tolerance)) result.push(candidate); + }); + return result; +} + +export function snapSketchPoint(raw: { x: number; z: number }, options: SketchSnapOptions): SketchSnapResult { + const candidates = options.candidates; + if (options.snapToGeometry && candidates.length > 0) { + const exact = candidates.reduce<{ candidate: SketchSnapCandidate; distance: number } | null>((nearest, candidate) => { + const distance = Math.hypot(candidate.x - raw.x, candidate.z - raw.z); + return !nearest || distance < nearest.distance ? { candidate, distance } : nearest; + }, null); + if (exact && exact.distance <= options.tolerance) { + return { + x: exact.candidate.x, + z: exact.candidate.z, + snap: { kind: exact.candidate.kind, label: exact.candidate.label }, + }; + } + } + + let x = snappedValue(raw.x, options.precisionStep); + let z = snappedValue(raw.z, options.precisionStep); + let xGuide: number | undefined; + let zGuide: number | undefined; + let label: string | undefined; + let kind: SketchSnapMatch["kind"] = "grid"; + + if (options.snapToGridLines && options.gridStep > 0) { + const gridX = snappedValue(raw.x, options.gridStep); + const gridZ = snappedValue(raw.z, options.gridStep); + if (Math.abs(gridX - raw.x) <= options.tolerance) { + x = gridX; + xGuide = gridX; + label = "Grid line"; + } + if (Math.abs(gridZ - raw.z) <= options.tolerance) { + z = gridZ; + zGuide = gridZ; + label = "Grid line"; + } + } + + if (options.snapToGeometry && candidates.length > 0) { + const nearestX = nearestAxisCandidate(candidates, raw.x, "x"); + const nearestZ = nearestAxisCandidate(candidates, raw.z, "z"); + if (nearestX && nearestX.distance <= options.tolerance) { + x = nearestX.candidate.x; + xGuide = x; + label = `Align X: ${nearestX.candidate.label}`; + kind = "alignment"; + } + if (nearestZ && nearestZ.distance <= options.tolerance) { + z = nearestZ.candidate.z; + zGuide = z; + label = `Align Z: ${nearestZ.candidate.label}`; + kind = "alignment"; + } + if (nearestX && nearestZ && nearestX.distance <= options.tolerance && nearestZ.distance <= options.tolerance) { + label = nearestX.candidate.id === nearestZ.candidate.id ? nearestX.candidate.label : "Geometry alignment"; + } + } + + return { + x, + z, + ...(label ? { snap: { kind, label, ...(xGuide !== undefined ? { xGuide } : {}), ...(zGuide !== undefined ? { zGuide } : {}) } } : {}), + }; +} diff --git a/apps/web/src/lib/sketchTextGeometry.ts b/apps/web/src/lib/sketchTextGeometry.ts new file mode 100644 index 0000000..4ec6cd8 --- /dev/null +++ b/apps/web/src/lib/sketchTextGeometry.ts @@ -0,0 +1,67 @@ +import { createLocalId } from "@/lib/localIds"; +import type { Font } from "three/examples/jsm/loaders/FontLoader.js"; +import type { SketchPoint, SketchSegment } from "@/types/sketchforge"; + +const TEXT_CURVE_SEGMENTS = 8; +const DEDUP_EPS = 1e-6; + +function dedupeContour(points: { x: number; y: number }[]) { + if (points.length < 2) return points; + const result = [points[0]]; + for (let i = 1; i < points.length; i++) { + const prev = result[result.length - 1]; + if (Math.hypot(points[i].x - prev.x, points[i].y - prev.y) > DEDUP_EPS) { + result.push(points[i]); + } + } + if (result.length >= 2) { + const first = result[0]; + const last = result[result.length - 1]; + if (Math.hypot(last.x - first.x, last.y - first.y) <= DEDUP_EPS) { + result.pop(); + } + } + return result; +} + +export function textSketchGeometry( + text: string, + font: Font, + fontSize: number, + position: { x: number; z: number }, + createId: (prefix: string) => string = createLocalId, +): { points: SketchPoint[]; segments: SketchSegment[] } { + const shapes = font.generateShapes(text, fontSize); + const allPoints: SketchPoint[] = []; + const allSegments: SketchSegment[] = []; + + for (const shape of shapes) { + const contours = [dedupeContour(shape.getPoints(TEXT_CURVE_SEGMENTS))]; + for (const hole of shape.holes) { + contours.push(dedupeContour(hole.getPoints(TEXT_CURVE_SEGMENTS))); + } + + for (const contour of contours) { + if (contour.length < 2) continue; + const ids = contour.map(() => createId("sketch-point")); + for (let i = 0; i < contour.length; i++) { + allPoints.push({ + id: ids[i], + x: contour[i].x + position.x, + z: -contour[i].y + position.z, + mode: "corner", + }); + } + for (let i = 0; i < ids.length; i++) { + allSegments.push({ + id: createId("sketch-segment"), + startId: ids[i], + endId: ids[(i + 1) % ids.length], + kind: "line", + }); + } + } + } + + return { points: allPoints, segments: allSegments }; +} diff --git a/apps/web/src/lib/sketchTransforms.ts b/apps/web/src/lib/sketchTransforms.ts new file mode 100644 index 0000000..94ca6ac --- /dev/null +++ b/apps/web/src/lib/sketchTransforms.ts @@ -0,0 +1,252 @@ +import { createLocalId } from "@/lib/localIds"; +import type { SketchConstraint, SketchDimension, SketchImage, SketchPoint, SketchProfile, SketchSegment, SketchText } from "@/types/sketchforge"; + +export type SketchTransformSelection = { + pointIds: string[]; + segmentIds: string[]; + imageIds: string[]; + textIds: string[]; +}; + +export type SketchAffineTransform = { + a: number; + b: number; + c: number; + d: number; + tx: number; + tz: number; +}; + +export type SketchTransformResult = { + profile: SketchProfile; + selection: SketchTransformSelection; +}; + +type Point2D = { x: number; z: number }; +type IdFactory = (prefix: string) => string; + +const AXIS_EPSILON = 1e-9; + +export function applyAffineTransform(transform: SketchAffineTransform, point: Point2D): Point2D { + return { + x: transform.a * point.x + transform.c * point.z + transform.tx, + z: transform.b * point.x + transform.d * point.z + transform.tz, + }; +} + +export function translationTransform(deltaX: number, deltaZ: number): SketchAffineTransform { + return { a: 1, b: 0, c: 0, d: 1, tx: deltaX, tz: deltaZ }; +} + +export function translateSketchPoints( + profile: SketchProfile, + pointIds: readonly string[], + delta: Point2D, +): SketchProfile { + if (pointIds.length === 0 || Math.abs(delta.x) <= Number.EPSILON && Math.abs(delta.z) <= Number.EPSILON) return profile; + const selectedPointIds = new Set(pointIds); + const translate = (point: Point2D) => ({ x: point.x + delta.x, z: point.z + delta.z }); + return { + ...profile, + points: profile.points.map((point) => selectedPointIds.has(point.id) ? { + ...point, + ...translate(point), + handleIn: point.handleIn ? translate(point.handleIn) : undefined, + handleOut: point.handleOut ? translate(point.handleOut) : undefined, + } : point), + constraints: profile.constraints?.map((constraint) => constraint.kind === "fixed" && selectedPointIds.has(constraint.pointId) + ? { ...constraint, ...translate(constraint) } + : constraint), + }; +} + +export function rotationTransform(angleRadians: number, center: Point2D = { x: 0, z: 0 }): SketchAffineTransform { + const cosine = Math.cos(angleRadians); + const sine = Math.sin(angleRadians); + return { + a: cosine, + b: sine, + c: -sine, + d: cosine, + tx: center.x - cosine * center.x + sine * center.z, + tz: center.z - sine * center.x - cosine * center.z, + }; +} + +export function reflectionTransform(lineStart: Point2D, lineEnd: Point2D): SketchAffineTransform { + const deltaX = lineEnd.x - lineStart.x; + const deltaZ = lineEnd.z - lineStart.z; + const length = Math.hypot(deltaX, deltaZ); + if (length <= Number.EPSILON) throw new Error("Reflection line must have two distinct points"); + + const unitX = deltaX / length; + const unitZ = deltaZ / length; + const a = 2 * unitX * unitX - 1; + const b = 2 * unitX * unitZ; + const c = b; + const d = 2 * unitZ * unitZ - 1; + return { + a, + b, + c, + d, + tx: lineStart.x - a * lineStart.x - c * lineStart.z, + tz: lineStart.z - b * lineStart.x - d * lineStart.z, + }; +} + +export function normalizeSketchTransformSelection( + profile: SketchProfile, + selection: SketchTransformSelection, +): SketchTransformSelection { + const selectedPointIds = new Set(selection.pointIds); + const selectedSegmentIds = new Set(selection.segmentIds); + for (const segment of profile.segments) { + if (!selectedSegmentIds.has(segment.id)) continue; + selectedPointIds.add(segment.startId); + selectedPointIds.add(segment.endId); + } + + const selectedImageIds = new Set(selection.imageIds); + const selectedTextIds = new Set(selection.textIds); + return { + pointIds: profile.points.filter((point) => selectedPointIds.has(point.id)).map((point) => point.id), + segmentIds: profile.segments.filter((segment) => selectedSegmentIds.has(segment.id)).map((segment) => segment.id), + imageIds: (profile.images ?? []).filter((image) => selectedImageIds.has(image.id)).map((image) => image.id), + textIds: (profile.texts ?? []).filter((text) => selectedTextIds.has(text.id)).map((text) => text.id), + }; +} + +function transformedAxisConstraint( + kind: "horizontal" | "vertical", + transform: SketchAffineTransform, +): "horizontal" | "vertical" | null { + const x = kind === "horizontal" ? transform.a : transform.c; + const z = kind === "horizontal" ? transform.b : transform.d; + const length = Math.hypot(x, z); + if (length <= Number.EPSILON) return null; + if (Math.abs(z) <= AXIS_EPSILON * length) return "horizontal"; + if (Math.abs(x) <= AXIS_EPSILON * length) return "vertical"; + return null; +} + +export function transformSketchSelection( + profile: SketchProfile, + selection: SketchTransformSelection, + transforms: readonly SketchAffineTransform[], + createId: IdFactory = createLocalId, +): SketchTransformResult { + const normalized = normalizeSketchTransformSelection(profile, selection); + const pointIds = new Set(normalized.pointIds); + const segmentIds = new Set(normalized.segmentIds); + const imageIds = new Set(normalized.imageIds); + const textIds = new Set(normalized.textIds); + const sourcePoints = profile.points.filter((point) => pointIds.has(point.id)); + const sourceSegments = profile.segments.filter((segment) => segmentIds.has(segment.id)); + const sourceImages = (profile.images ?? []).filter((image) => imageIds.has(image.id)); + const sourceTexts = (profile.texts ?? []).filter((text) => textIds.has(text.id)); + + const copiedPoints: SketchPoint[] = []; + const copiedSegments: SketchSegment[] = []; + const copiedConstraints: SketchConstraint[] = []; + const copiedDimensions: SketchDimension[] = []; + const copiedImages: SketchImage[] = []; + const copiedTexts: SketchText[] = []; + const generatedSelection: SketchTransformSelection = { pointIds: [], segmentIds: [], imageIds: [], textIds: [] }; + + for (const transform of transforms) { + const pointIdMap = new Map(); + const segmentIdMap = new Map(); + + for (const point of sourcePoints) { + const id = createId("sketch-point"); + const position = applyAffineTransform(transform, point); + const handleIn = point.handleIn ? applyAffineTransform(transform, point.handleIn) : undefined; + const handleOut = point.handleOut ? applyAffineTransform(transform, point.handleOut) : undefined; + copiedPoints.push({ ...point, ...position, id, handleIn, handleOut }); + pointIdMap.set(point.id, id); + generatedSelection.pointIds.push(id); + } + + for (const segment of sourceSegments) { + const startId = pointIdMap.get(segment.startId); + const endId = pointIdMap.get(segment.endId); + if (!startId || !endId) continue; + const id = createId("sketch-segment"); + const dimensionLabelOffset = segment.dimensionLabelOffset ? { + x: transform.a * segment.dimensionLabelOffset.x + transform.c * segment.dimensionLabelOffset.z, + z: transform.b * segment.dimensionLabelOffset.x + transform.d * segment.dimensionLabelOffset.z, + } : undefined; + copiedSegments.push({ ...segment, id, startId, endId, ...(dimensionLabelOffset ? { dimensionLabelOffset } : {}) }); + segmentIdMap.set(segment.id, id); + generatedSelection.segmentIds.push(id); + } + + for (const constraint of profile.constraints ?? []) { + if (constraint.kind === "fixed") { + const pointId = pointIdMap.get(constraint.pointId); + if (!pointId) continue; + const position = applyAffineTransform(transform, constraint); + copiedConstraints.push({ ...constraint, ...position, id: createId("sketch-fixed"), pointId }); + continue; + } + + const segmentId = segmentIdMap.get(constraint.segmentId); + const kind = transformedAxisConstraint(constraint.kind, transform); + if (!segmentId || !kind) continue; + copiedConstraints.push({ id: createId(`sketch-${kind}`), kind, segmentId }); + } + + for (const dimension of profile.dimensions ?? []) { + if (dimension.kind === "length") { + const segmentId = segmentIdMap.get(dimension.segmentId); + if (!segmentId) continue; + copiedDimensions.push({ ...dimension, id: createId("sketch-length"), segmentId }); + continue; + } + const remapAnchor = (anchor: typeof dimension.start): typeof dimension.start | null => { + if (anchor.kind === "point") { + const pointId = pointIdMap.get(anchor.pointId); + return pointId ? { kind: "point", pointId } : null; + } + if (anchor.kind === "midpoint") { + const segmentId = segmentIdMap.get(anchor.segmentId); + return segmentId ? { kind: "midpoint", segmentId } : null; + } + const firstSegmentId = segmentIdMap.get(anchor.firstSegmentId); + const secondSegmentId = segmentIdMap.get(anchor.secondSegmentId); + return firstSegmentId && secondSegmentId ? { ...anchor, firstSegmentId, secondSegmentId } : null; + }; + const start = remapAnchor(dimension.start); + const end = remapAnchor(dimension.end); + if (start && end) copiedDimensions.push({ id: createId("sketch-distance"), kind: "distance", start, end }); + } + + for (const image of sourceImages) { + const id = createId("sketch-image"); + const position = applyAffineTransform(transform, image); + copiedImages.push({ ...image, ...position, id }); + generatedSelection.imageIds.push(id); + } + + for (const text of sourceTexts) { + const id = createId("sketch-text"); + const position = applyAffineTransform(transform, text); + copiedTexts.push({ ...text, ...position, id }); + generatedSelection.textIds.push(id); + } + } + + return { + profile: { + ...profile, + points: [...profile.points, ...copiedPoints], + segments: [...profile.segments, ...copiedSegments], + constraints: copiedConstraints.length ? [...(profile.constraints ?? []), ...copiedConstraints] : profile.constraints, + dimensions: copiedDimensions.length ? [...(profile.dimensions ?? []), ...copiedDimensions] : profile.dimensions, + images: copiedImages.length ? [...(profile.images ?? []), ...copiedImages] : profile.images, + texts: copiedTexts.length ? [...(profile.texts ?? []), ...copiedTexts] : profile.texts, + }, + selection: generatedSelection, + }; +} diff --git a/apps/web/src/lib/skfProject.ts b/apps/web/src/lib/skfProject.ts index 2889e39..c5ade76 100644 --- a/apps/web/src/lib/skfProject.ts +++ b/apps/web/src/lib/skfProject.ts @@ -458,6 +458,9 @@ async function serializeShapeNode( definition.sketchProfile = { points: sketchProfile.points, segments: sketchProfile.segments, + ...((sketchProfile.constraints?.length ?? 0) > 0 ? { constraints: sketchProfile.constraints } : {}), + ...((sketchProfile.dimensions?.length ?? 0) > 0 ? { dimensions: sketchProfile.dimensions } : {}), + ...((sketchProfile.texts?.length ?? 0) > 0 ? { texts: sketchProfile.texts } : {}), ...(images.length ? { images } : {}), }; } @@ -847,6 +850,74 @@ function validateSketchProfile(value: unknown, label: string) { const startId = stringValue(segment.startId, `${label}.segments[${index}].startId`); const endId = stringValue(segment.endId, `${label}.segments[${index}].endId`); if (!pointIds.has(startId) || !pointIds.has(endId)) throw new Error(`${label} contains a segment with a missing point reference`); + if (segment.dimensionLabelOffset !== undefined) { + const offset = objectRecord(segment.dimensionLabelOffset, `${label}.segments[${index}].dimensionLabelOffset`); + finiteNumber(offset.x, `${label}.segments[${index}].dimensionLabelOffset.x`); + finiteNumber(offset.z, `${label}.segments[${index}].dimensionLabelOffset.z`); + } + }); + const parameterIds = new Set(); + if (profile.constraints !== undefined && !Array.isArray(profile.constraints)) throw new Error(`${label}.constraints must be an array`); + (profile.constraints as unknown[] | undefined)?.forEach((rawConstraint, index) => { + const constraint = objectRecord(rawConstraint, `${label}.constraints[${index}]`); + const id = stringValue(constraint.id, `${label}.constraints[${index}].id`); + if (parameterIds.has(id)) throw new Error(`${label} contains duplicate parameter ID '${id}'`); + parameterIds.add(id); + if (constraint.kind === "fixed") { + const pointId = stringValue(constraint.pointId, `${label}.constraints[${index}].pointId`); + if (!pointIds.has(pointId)) throw new Error(`${label} contains a fixed constraint with a missing point reference`); + finiteNumber(constraint.x, `${label}.constraints[${index}].x`); + finiteNumber(constraint.z, `${label}.constraints[${index}].z`); + } else if (constraint.kind === "horizontal" || constraint.kind === "vertical") { + const segmentId = stringValue(constraint.segmentId, `${label}.constraints[${index}].segmentId`); + if (!segmentIds.has(segmentId)) throw new Error(`${label} contains a constraint with a missing segment reference`); + } else { + throw new Error(`${label}.constraints[${index}] has an unknown constraint kind`); + } + }); + if (profile.dimensions !== undefined && !Array.isArray(profile.dimensions)) throw new Error(`${label}.dimensions must be an array`); + (profile.dimensions as unknown[] | undefined)?.forEach((rawDimension, index) => { + const dimension = objectRecord(rawDimension, `${label}.dimensions[${index}]`); + const id = stringValue(dimension.id, `${label}.dimensions[${index}].id`); + if (parameterIds.has(id)) throw new Error(`${label} contains duplicate parameter ID '${id}'`); + parameterIds.add(id); + if (dimension.kind === "length") { + const segmentId = stringValue(dimension.segmentId, `${label}.dimensions[${index}].segmentId`); + if (!segmentIds.has(segmentId)) throw new Error(`${label} contains a dimension with a missing segment reference`); + if (finiteNumber(dimension.value, `${label}.dimensions[${index}].value`) <= 0) throw new Error(`${label} contains a non-positive dimension`); + } else if (dimension.kind === "distance") { + const validateAnchor = (rawAnchor: unknown, anchorLabel: string) => { + const anchor = objectRecord(rawAnchor, anchorLabel); + if (anchor.kind === "point") { + const pointId = stringValue(anchor.pointId, `${anchorLabel}.pointId`); + if (!pointIds.has(pointId)) throw new Error(`${label} contains a dimension with a missing point reference`); + } else if (anchor.kind === "midpoint") { + const segmentId = stringValue(anchor.segmentId, `${anchorLabel}.segmentId`); + if (!segmentIds.has(segmentId)) throw new Error(`${label} contains a dimension with a missing segment reference`); + } else if (anchor.kind === "intersection") { + const firstSegmentId = stringValue(anchor.firstSegmentId, `${anchorLabel}.firstSegmentId`); + const secondSegmentId = stringValue(anchor.secondSegmentId, `${anchorLabel}.secondSegmentId`); + if (!segmentIds.has(firstSegmentId) || !segmentIds.has(secondSegmentId)) throw new Error(`${label} contains a dimension with a missing intersection segment reference`); + const intersectionIndex = finiteNumber(anchor.index, `${anchorLabel}.index`); + if (!Number.isInteger(intersectionIndex) || intersectionIndex < 0) throw new Error(`${anchorLabel}.index must be a non-negative integer`); + } else { + throw new Error(`${anchorLabel} has an unknown anchor kind`); + } + }; + validateAnchor(dimension.start, `${label}.dimensions[${index}].start`); + validateAnchor(dimension.end, `${label}.dimensions[${index}].end`); + } else { + throw new Error(`${label}.dimensions[${index}] has an unknown dimension kind`); + } + }); + if (profile.texts !== undefined && !Array.isArray(profile.texts)) throw new Error(`${label}.texts must be an array`); + (profile.texts as unknown[] | undefined)?.forEach((rawText, index) => { + const text = objectRecord(rawText, `${label}.texts[${index}]`); + stringValue(text.id, `${label}.texts[${index}].id`); + if (typeof text.text !== "string") throw new Error(`${label}.texts[${index}].text must be a string`); + finiteNumber(text.x, `${label}.texts[${index}].x`); + finiteNumber(text.z, `${label}.texts[${index}].z`); + if (finiteNumber(text.fontSize, `${label}.texts[${index}].fontSize`) <= 0) throw new Error(`${label} contains text with a non-positive font size`); }); } diff --git a/apps/web/src/lib/workplaneShapes.ts b/apps/web/src/lib/workplaneShapes.ts index e0dda2b..9b32c4a 100644 --- a/apps/web/src/lib/workplaneShapes.ts +++ b/apps/web/src/lib/workplaneShapes.ts @@ -267,6 +267,7 @@ export function workplaneShapesEqual(a: WorkplaneShape, b: WorkplaneShape) { a.importedMesh === b.importedMesh && a.imagePlate === b.imagePlate && a.sketchProfile === b.sketchProfile && + a.sketchFeature === b.sketchFeature && a.sketchOperation === b.sketchOperation && a.sketchRevolve === b.sketchRevolve && a.edgeTreatments === b.edgeTreatments && diff --git a/apps/web/src/types/sketchforge.ts b/apps/web/src/types/sketchforge.ts index f63e6db..1fefbd7 100644 --- a/apps/web/src/types/sketchforge.ts +++ b/apps/web/src/types/sketchforge.ts @@ -87,8 +87,22 @@ export type SketchSegment = { startId: string; endId: string; kind?: "line" | "bezier" | "smooth"; + dimensionLabelOffset?: { x: number; z: number }; }; +export type SketchConstraint = + | { id: string; kind: "horizontal" | "vertical"; segmentId: string } + | { id: string; kind: "fixed"; pointId: string; x: number; z: number }; + +export type SketchDimensionAnchor = + | { kind: "point"; pointId: string } + | { kind: "midpoint"; segmentId: string } + | { kind: "intersection"; firstSegmentId: string; secondSegmentId: string; index: number }; + +export type SketchDimension = + | { id: string; kind: "length"; segmentId: string; value: number } + | { id: string; kind: "distance"; start: SketchDimensionAnchor; end: SketchDimensionAnchor }; + export type SketchImage = { id: string; name: string; @@ -104,12 +118,26 @@ export type SketchImage = { lockAspect?: boolean; }; +export type SketchText = { + id: string; + text: string; + x: number; + z: number; + fontSize: number; +}; + export type SketchProfile = { points: SketchPoint[]; segments: SketchSegment[]; + constraints?: SketchConstraint[]; + dimensions?: SketchDimension[]; images?: SketchImage[]; + texts?: SketchText[]; }; +export type SketchFeature = + | { kind: "extrusion"; regionIds?: string[] }; + export type SketchOperation = "extrude" | "revolve"; export type GearType = "spur" | "helical" | "bevel"; @@ -240,6 +268,7 @@ export type WorkplaneShape = { pixelHeight: number; }; sketchProfile?: SketchProfile; + sketchFeature?: SketchFeature; sketchOperation?: SketchOperation; sketchRevolve?: SketchRevolveSettings; edgeTreatments?: EdgeTreatmentFeature[]; diff --git a/apps/web/src/workers/sketchCad.worker.ts b/apps/web/src/workers/sketchCad.worker.ts index f0d3841..e0523a4 100644 --- a/apps/web/src/workers/sketchCad.worker.ts +++ b/apps/web/src/workers/sketchCad.worker.ts @@ -1,7 +1,7 @@ /// import { OcctKernel, type ShapeHandle } from "occt-wasm"; -import { cadSketchRegions, type OrderedCadSketchPath } from "@/lib/sketchCadProfile"; +import { orderedCadSketchPaths, selectedCadSketchRegions, type OrderedCadSketchPath } from "@/lib/sketchCadProfile"; import type { SketchCadBuildRequest, SketchCadBuildResponse } from "@/lib/sketchCadTypes"; let kernelPromise: Promise | null = null; @@ -47,15 +47,46 @@ self.onmessage = async (event: MessageEvent) => { try { cad = await kernel(); cad.releaseAll(); - const regions = cadSketchRegions(request.profile); - if (regions.length === 0) throw new Error("No closed profile found. Draw at least one closed loop and ensure it has no degenerate (zero-area) geometry."); + const regions = selectedCadSketchRegions(request.profile, request.regionIds); + if (regions.length === 0) throw new Error(request.regionIds ? "Select at least one closed profile to extrude." : "No closed profile found. Draw at least one closed loop and ensure it has no degenerate (zero-area) geometry."); + const sourcePaths = orderedCadSketchPaths(request.profile).filter((path) => path.closed); + const sourcePathById = new Map(sourcePaths.map((path) => [path.id, path])); + const sourceSolidById = new Map(); + const sourceSolid = (id: string) => { + const cached = sourceSolidById.get(id); + if (cached) return cached; + const path = sourcePathById.get(id); + if (!path) throw new Error("A selected overlap profile no longer matches the sketch geometry."); + const solid = cad!.extrude(cad!.makeFace(pathWire(cad!, path)), 0, request.height, 0); + sourceSolidById.set(id, solid); + return solid; + }; const solids: ShapeHandle[] = regions.map((region) => { + // Unique overlap faces can retain exact source curves through booleans. + // Faces divided by open geometry fall back to their sampled boundary. + if (region.sourcePathIds?.length) { + const included = new Set(region.sourcePathIds); + const sourceSolids = region.sourcePathIds.map(sourceSolid); + let solid = sourceSolids.slice(1).reduce((result, tool) => cad!.common(result, tool), sourceSolids[0]); + const excluded = sourcePaths.filter((path) => !included.has(path.id)).map((path) => sourceSolid(path.id)); + if (excluded.length > 0) solid = cad!.cutAll(solid, excluded); + return solid; + } let face = cad!.makeFace(pathWire(cad!, region.outer)); if (region.holes.length > 0) face = cad!.addHolesInFace(face, region.holes.map((hole) => pathWire(cad!, hole))); return cad!.extrude(face, 0, request.height, 0); }); - const result = solids.length === 1 ? solids[0] : cad.makeCompound(solids); - if (!cad.isValid(result)) throw new Error("OpenCascade produced invalid sketch topology"); + let result = solids.length === 1 ? solids[0] : cad.makeCompound(solids); + try { + result = cad.fixShape(result); + result = cad.fixFaceOrientations(result); + result = cad.healSolid(result, 1e-4); + result = cad.removeDegenerateEdges(result); + result = cad.unifySameDomain(result); + } catch { + // Continue if healing fails + } + if (!cad.isValid(result) && !cad.isSolid(result)) throw new Error("OpenCascade produced invalid sketch topology"); const mesh = cad.tessellate(result, { linearDeflection: 0.05, angularDeflection: 0.16 }); const positions = new Float32Array(mesh.positions); const normals = new Float32Array(mesh.normals); diff --git a/tests/e2e/sketchCadExtrusion.e2e.ts b/tests/e2e/sketchCadExtrusion.e2e.ts index f62bb52..e5091d5 100644 --- a/tests/e2e/sketchCadExtrusion.e2e.ts +++ b/tests/e2e/sketchCadExtrusion.e2e.ts @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import type { SketchProfile } from "@/types/sketchforge"; +import { circleSketchGeometry } from "@/lib/sketchCircles"; // Load the real OCCT kernel directly (same approach as stepRoundTrip.e2e.ts). vi.mock("@/lib/brepKernel", async () => { @@ -49,6 +50,38 @@ function mergeProfiles(...profiles: SketchProfile[]): SketchProfile { } describe("Sketch CAD profile → B-Rep extrusion (real OCCT kernel)", () => { + it("extrudes a cubic sketch circle into a valid solid", async () => { + const { OcctKernel } = await import("occt-wasm"); + let id = 0; + const profile = circleSketchGeometry({ x: 0, z: 0 }, 12, (prefix) => `${prefix}-${id++}`); + const regions = cadSketchRegions(profile); + expect(regions).toHaveLength(1); + + const wasm = join(dirname(fileURLToPath(import.meta.resolve("occt-wasm"))), "occt-wasm.wasm"); + const kernel = await OcctKernel.init({ wasm }); + const edges = regions[0].outer.steps.map(({ from, to, segment }) => { + const forward = segment.startId === from.id; + const first = forward ? from.handleOut : from.handleIn; + const second = forward ? to.handleIn : to.handleOut; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + return kernel.makeBezierEdge([ + { x: from.x, y: 0, z: from.z }, + { x: first!.x, y: 0, z: first!.z }, + { x: second!.x, y: 0, z: second!.z }, + { x: to.x, y: 0, z: to.z }, + ]); + }); + const wire = kernel.makeWire(edges); + const face = kernel.makeFace(wire); + const solid = kernel.extrude(face, 0, 10, 0); + + expect(kernel.isValid(solid)).toBe(true); + expect(kernel.isSolid(solid)).toBe(true); + expect(kernel.tessellate(solid, { linearDeflection: 0.05, angularDeflection: 0.16 }).triangleCount).toBeGreaterThan(0); + kernel.releaseAll(); + }); + it("produces valid B-Rep from a single closed rectangle", async () => { const { OcctKernel } = await import("occt-wasm"); const profile = rectangle("rect", 0, 0, 20, 10); diff --git a/tests/unit/sketchCadProfile.test.ts b/tests/unit/sketchCadProfile.test.ts index 4d882e8..06341c6 100644 --- a/tests/unit/sketchCadProfile.test.ts +++ b/tests/unit/sketchCadProfile.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { cadSketchRegions, orderedCadSketchPaths } from "@/lib/sketchCadProfile"; +import { cadSketchProfileForRegions, cadSketchRegions, cadSketchSelectableRegions, orderedCadSketchPaths, selectedCadSketchRegions } from "@/lib/sketchCadProfile"; +import { circleSketchGeometry } from "@/lib/sketchCircles"; import type { SketchPoint, SketchProfile, SketchSegment } from "@/types/sketchforge"; function rectangle(id: string, x: number, z: number, width: number, depth: number) { @@ -25,6 +26,11 @@ function profile(...rectangles: ReturnType[]): SketchProfile { }; } +function circle(id: string, x: number, z: number, radius: number) { + let index = 0; + return circleSketchGeometry({ x, z }, radius, (prefix) => `${id}-${prefix}-${index++}`); +} + describe("OCCT sketch profile preparation", () => { it("orders a closed loop even when its segments arrive out of order", () => { const square = rectangle("outer", 0, 0, 20, 10); @@ -44,6 +50,20 @@ describe("OCCT sketch profile preparation", () => { expect(regions[0].holes).toHaveLength(1); }); + it("exposes the inside of a hole as a separately selectable profile", () => { + const source = profile( + rectangle("outer", 0, 0, 20, 20), + rectangle("inner", 5, 5, 4, 4), + ); + const regions = cadSketchSelectableRegions(source); + const outer = regions.find((region) => region.id.includes("outer-s0")); + const inner = regions.find((region) => region.id.includes("inner-s0")); + expect(regions).toHaveLength(2); + expect(outer?.holes[0].id).toContain("inner"); + expect(inner?.holes).toHaveLength(0); + expect(selectedCadSketchRegions(source, [inner!.id])).toEqual([inner]); + }); + it("keeps disjoint loops as separate solids", () => { const regions = cadSketchRegions(profile( rectangle("left", 0, 0, 4, 4), @@ -53,6 +73,105 @@ describe("OCCT sketch profile preparation", () => { expect(regions.every((region) => region.holes.length === 0)).toBe(true); }); + it("splits overlapping loops into separately selectable faces", () => { + const source = profile( + rectangle("first", 0, 0, 10, 10), + rectangle("second", 5, 5, 10, 10), + ); + const selectable = cadSketchSelectableRegions(source); + const overlap = selectable.find((region) => region.sourcePathIds?.length === 2); + const exclusive = selectable.filter((region) => region.sourcePathIds?.length === 1); + expect(selectable).toHaveLength(3); + expect(exclusive).toHaveLength(2); + expect(overlap).toBeDefined(); + expect(cadSketchRegions(source).map((region) => region.id).sort()).toEqual(exclusive.map((region) => region.id).sort()); + expect(selectedCadSketchRegions(source, [overlap!.id])).toEqual([overlap]); + expect(cadSketchSelectableRegions({ ...source, segments: [...source.segments].reverse() }).map((region) => region.id)).toEqual(selectable.map((region) => region.id)); + }); + + it("splits overlapping curved profiles", () => { + const selectable = cadSketchSelectableRegions(profile( + circle("left", 0, 0, 10), + circle("right", 10, 0, 10), + )); + expect(selectable).toHaveLength(3); + expect(selectable.filter((region) => region.sourcePathIds?.length === 1)).toHaveLength(2); + expect(selectable.filter((region) => region.sourcePathIds?.length === 2)).toHaveLength(1); + }); + + it("splits profiles with collinear overlapping edges", () => { + const selectable = cadSketchSelectableRegions(profile( + rectangle("left", 0, 0, 10, 10), + rectangle("right", 5, 0, 10, 10), + )); + expect(selectable).toHaveLength(3); + expect(selectable.filter((region) => region.sourcePathIds?.length === 2)).toHaveLength(1); + }); + + it("uses an open crossing line to divide a closed profile", () => { + const divider = { + points: [ + { id: "divider-start", x: -5, z: 5 }, + { id: "divider-end", x: 15, z: 5 }, + ], + segments: [{ id: "divider-segment", kind: "line" as const, startId: "divider-start", endId: "divider-end" }], + }; + const selectable = cadSketchSelectableRegions(profile(rectangle("outer", 0, 0, 10, 10), divider)); + expect(selectable).toHaveLength(2); + expect(new Set(selectable.map((region) => region.id)).size).toBe(2); + }); + + it("assigns stable region IDs regardless of segment order", () => { + const left = rectangle("left", 0, 0, 4, 4); + const right = rectangle("right", 10, 0, 4, 4); + const source = profile(left, right); + const reversed = { ...source, segments: [...source.segments].reverse() }; + expect(cadSketchRegions(reversed).map((region) => region.id)).toEqual(cadSketchRegions(source).map((region) => region.id)); + }); + + it("filters disjoint regions by ID", () => { + const source = profile( + rectangle("left", 0, 0, 4, 4), + rectangle("right", 10, 0, 4, 4), + ); + const regions = cadSketchRegions(source); + const left = regions.find((region) => region.id.includes("left-s0")); + expect(left).toBeDefined(); + expect(selectedCadSketchRegions(source, [left!.id])).toEqual([left]); + expect(cadSketchProfileForRegions(source, [left!.id]).segments.every((segment) => segment.id.startsWith("left-"))).toBe(true); + }); + + it("retains hole boundaries when filtering a region", () => { + const source = profile( + rectangle("outer", 0, 0, 20, 20), + rectangle("hole", 5, 5, 4, 4), + rectangle("other", 30, 0, 5, 5), + ); + const outer = cadSketchRegions(source).find((region) => region.id.includes("outer-s0")); + expect(outer).toBeDefined(); + const filtered = cadSketchProfileForRegions(source, [outer!.id]); + expect(filtered.segments.some((segment) => segment.id.startsWith("outer-"))).toBe(true); + expect(filtered.segments.some((segment) => segment.id.startsWith("hole-"))).toBe(true); + expect(filtered.segments.some((segment) => segment.id.startsWith("other-"))).toBe(false); + }); + + it("keeps nested islands as independently selectable regions", () => { + const source = profile( + rectangle("outer", 0, 0, 40, 40), + rectangle("hole", 5, 5, 30, 30), + rectangle("island", 12, 12, 16, 16), + ); + const regions = cadSketchRegions(source); + const outer = regions.find((region) => region.id.includes("outer-s0")); + const island = regions.find((region) => region.id.includes("island-s0")); + expect(outer).toBeDefined(); + expect(island).toBeDefined(); + const outerProfile = cadSketchProfileForRegions(source, [outer!.id]); + expect(outerProfile.segments.some((segment) => segment.id.startsWith("hole-"))).toBe(true); + expect(outerProfile.segments.some((segment) => segment.id.startsWith("island-"))).toBe(false); + expect(cadSketchProfileForRegions(source, [island!.id]).segments.every((segment) => segment.id.startsWith("island-"))).toBe(true); + }); + it("rejects open paths with a clear error", () => { const square = rectangle("open", 0, 0, 10, 10); square.segments.pop(); diff --git a/tests/unit/sketchCircles.test.ts b/tests/unit/sketchCircles.test.ts new file mode 100644 index 0000000..63800b4 --- /dev/null +++ b/tests/unit/sketchCircles.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { circleFromPoints, circleSketchGeometry } from "@/lib/sketchCircles"; +import { cadSketchRegions, orderedCadSketchPaths } from "@/lib/sketchCadProfile"; + +describe("sketch circle geometry", () => { + it("creates a closed four-segment cubic circle", () => { + let id = 0; + const geometry = circleSketchGeometry({ x: 12, z: -4 }, 10, (prefix) => `${prefix}-${id++}`); + + expect(geometry.points).toHaveLength(4); + expect(geometry.segments).toHaveLength(4); + expect(geometry.segments.every((segment) => segment.kind === "bezier")).toBe(true); + expect(geometry.points.every((point) => point.mode === "smooth" && point.handleIn && point.handleOut)).toBe(true); + + const paths = orderedCadSketchPaths(geometry); + expect(paths).toHaveLength(1); + expect(paths[0].closed).toBe(true); + expect(paths[0].steps).toHaveLength(4); + }); + + it("uses the requested center and radius", () => { + let id = 0; + const geometry = circleSketchGeometry({ x: 7, z: 11 }, 6, (prefix) => `${prefix}-${id++}`); + const xs = geometry.points.map((point) => point.x); + const zs = geometry.points.map((point) => point.z); + + expect(Math.min(...xs)).toBeCloseTo(1); + expect(Math.max(...xs)).toBeCloseTo(13); + expect(Math.min(...zs)).toBeCloseTo(5); + expect(Math.max(...zs)).toBeCloseTo(17); + }); + + it("derives center-radius and opposite-point diameter circles", () => { + expect(circleFromPoints("center-radius", { x: 2, z: 3 }, { x: 5, z: 7 })).toEqual({ + center: { x: 2, z: 3 }, + radius: 5, + }); + expect(circleFromPoints("diameter", { x: -4, z: 2 }, { x: 8, z: 2 })).toEqual({ + center: { x: 2, z: 2 }, + radius: 6, + }); + }); + + it("is classified as one closed CAD region", () => { + let id = 0; + const geometry = circleSketchGeometry({ x: 0, z: 0 }, 15, (prefix) => `${prefix}-${id++}`); + const regions = cadSketchRegions(geometry); + + expect(regions).toHaveLength(1); + expect(regions[0].outer.closed).toBe(true); + expect(regions[0].holes).toHaveLength(0); + }); +}); diff --git a/tests/unit/sketchConstraints.test.ts b/tests/unit/sketchConstraints.test.ts new file mode 100644 index 0000000..7dff7a1 --- /dev/null +++ b/tests/unit/sketchConstraints.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + moveConstrainedSketchPoint, + pruneSketchParameters, + setSketchPointFixed, + setSketchSegmentConstraint, + setSketchSegmentLength, + solveSketchProfile, +} from "@/lib/sketchConstraints"; +import type { SketchProfile } from "@/types/sketchforge"; + +function profile(): SketchProfile { + return { + points: [ + { id: "a", x: 0, z: 0 }, + { id: "b", x: 8, z: 4 }, + { id: "c", x: 12, z: 12 }, + ], + segments: [ + { id: "ab", startId: "a", endId: "b", kind: "line" }, + { id: "bc", startId: "b", endId: "c", kind: "line" }, + ], + }; +} + +const createId = (prefix: string) => `${prefix}-id`; + +describe("sketch constraints", () => { + it("applies horizontal and vertical constraints without mutating the source", () => { + const source = profile(); + const horizontal = setSketchSegmentConstraint(source, "ab", "horizontal", true, createId).profile; + const vertical = setSketchSegmentConstraint(horizontal, "bc", "vertical", true, createId).profile; + + expect(source.points[1]).toEqual({ id: "b", x: 8, z: 4 }); + expect(horizontal.points[1].z).toBe(0); + expect(vertical.points[2].x).toBe(vertical.points[1].x); + expect(vertical.constraints).toHaveLength(2); + }); + + it("creates and updates a driving segment length", () => { + const horizontal = setSketchSegmentConstraint(profile(), "ab", "horizontal", true, createId).profile; + const dimensioned = setSketchSegmentLength(horizontal, "ab", 25, createId).profile; + const updated = setSketchSegmentLength(dimensioned, "ab", 30, createId).profile; + + expect(dimensioned.points[1]).toMatchObject({ x: 25, z: 0 }); + expect(dimensioned.dimensions).toEqual([{ id: "sketch-length-id", kind: "length", segmentId: "ab", value: 25 }]); + expect(solveSketchProfile(dimensioned).conflicts).toEqual([]); + expect(updated.dimensions).toEqual([{ id: "sketch-length-id", kind: "length", segmentId: "ab", value: 30 }]); + expect(setSketchSegmentLength(updated, "ab", null, createId).profile.dimensions).toEqual([]); + }); + + it("preserves a fixed point while solving connected geometry", () => { + const fixed = setSketchPointFixed(profile(), "a", true, createId).profile; + const constrained = setSketchSegmentConstraint(fixed, "ab", "horizontal", true, createId).profile; + const moved = moveConstrainedSketchPoint(constrained, "b", { x: 20, z: 7 }).profile; + const rejected = moveConstrainedSketchPoint(moved, "a", { x: 50, z: 50 }).profile; + + expect(moved.points.find((point) => point.id === "a")).toMatchObject({ x: 0, z: 0 }); + expect(moved.points.find((point) => point.id === "b")).toMatchObject({ x: 20, z: 0 }); + expect(rejected.points.find((point) => point.id === "a")).toMatchObject({ x: 0, z: 0 }); + }); + + it("prunes parameters that reference deleted geometry", () => { + const source: SketchProfile = { + ...profile(), + constraints: [ + { id: "fixed-a", kind: "fixed", pointId: "a", x: 0, z: 0 }, + { id: "missing-segment", kind: "horizontal", segmentId: "gone" }, + ], + dimensions: [{ id: "missing-dimension", kind: "length", segmentId: "gone", value: 10 }], + }; + + const pruned = pruneSketchParameters(source); + + expect(pruned.constraints).toEqual([{ id: "fixed-a", kind: "fixed", pointId: "a", x: 0, z: 0 }]); + expect(pruned.dimensions).toEqual([]); + }); + + it("propagates a driving length through a closed constrained profile", () => { + const rectangle: SketchProfile = { + points: [ + { id: "a", x: 0, z: 0 }, + { id: "b", x: 10, z: 0 }, + { id: "c", x: 10, z: 5 }, + { id: "d", x: 0, z: 5 }, + ], + segments: [ + { id: "ab", startId: "a", endId: "b", kind: "line" }, + { id: "bc", startId: "b", endId: "c", kind: "line" }, + { id: "cd", startId: "c", endId: "d", kind: "line" }, + { id: "da", startId: "d", endId: "a", kind: "line" }, + ], + constraints: [ + { id: "h1", kind: "horizontal", segmentId: "ab" }, + { id: "v1", kind: "vertical", segmentId: "bc" }, + { id: "h2", kind: "horizontal", segmentId: "cd" }, + { id: "v2", kind: "vertical", segmentId: "da" }, + ], + }; + const dimensioned = setSketchSegmentLength(rectangle, "ab", 20, createId).profile; + const moved = moveConstrainedSketchPoint(dimensioned, "b", { x: 30, z: 2 }); + + expect(moved.conflicts).toEqual([]); + expect(moved.profile.points).toEqual([ + { id: "a", x: 10, z: 2, handleIn: undefined, handleOut: undefined }, + { id: "b", x: 30, z: 2, handleIn: undefined, handleOut: undefined }, + { id: "c", x: 30, z: 5, handleIn: undefined, handleOut: undefined }, + { id: "d", x: 10, z: 5, handleIn: undefined, handleOut: undefined }, + ]); + }); +}); diff --git a/tests/unit/sketchDimensions.test.ts b/tests/unit/sketchDimensions.test.ts new file mode 100644 index 0000000..f753fcf --- /dev/null +++ b/tests/unit/sketchDimensions.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { pruneSketchParameters } from "@/lib/sketchConstraints"; +import { sketchDimensionAnchorCandidates, sketchDimensionAnchorPoint, sketchDistanceDimensionValue } from "@/lib/sketchDimensions"; +import type { SketchProfile } from "@/types/sketchforge"; + +function crossingProfile(): SketchProfile { + return { + points: [ + { id: "left", x: -10, z: 0 }, + { id: "right", x: 10, z: 0 }, + { id: "top", x: 0, z: -8 }, + { id: "bottom", x: 0, z: 12 }, + ], + segments: [ + { id: "horizontal", kind: "line", startId: "left", endId: "right" }, + { id: "vertical", kind: "line", startId: "top", endId: "bottom" }, + ], + }; +} + +describe("sketch dimension anchors", () => { + it("offers endpoints, midpoints, and overlap intersections", () => { + const candidates = sketchDimensionAnchorCandidates(crossingProfile()); + expect(candidates.filter((candidate) => candidate.kind === "point")).toHaveLength(4); + expect(candidates).toContainEqual(expect.objectContaining({ + kind: "intersection", + x: 0, + z: 0, + anchor: { kind: "intersection", firstSegmentId: "horizontal", secondSegmentId: "vertical", index: 0 }, + })); + expect(candidates).toContainEqual(expect.objectContaining({ kind: "midpoint", x: 0, z: 2 })); + }); + + it("resolves midpoint and intersection references from current geometry", () => { + const profile = crossingProfile(); + expect(sketchDimensionAnchorPoint(profile, { kind: "midpoint", segmentId: "vertical" })).toEqual({ x: 0, z: 2 }); + expect(sketchDimensionAnchorPoint(profile, { kind: "intersection", firstSegmentId: "horizontal", secondSegmentId: "vertical", index: 0 })).toEqual({ x: 0, z: 0 }); + expect(sketchDistanceDimensionValue(profile, { kind: "point", pointId: "left" }, { kind: "midpoint", segmentId: "vertical" })).toBeCloseTo(Math.hypot(10, 2)); + }); + + it("prunes reference dimensions when an anchor target disappears", () => { + const profile: SketchProfile = { + ...crossingProfile(), + dimensions: [{ + id: "reference", + kind: "distance", + start: { kind: "point", pointId: "left" }, + end: { kind: "intersection", firstSegmentId: "horizontal", secondSegmentId: "vertical", index: 0 }, + }], + }; + expect(pruneSketchParameters(profile).dimensions).toHaveLength(1); + expect(pruneSketchParameters({ ...profile, segments: profile.segments.filter((segment) => segment.id !== "vertical") }).dimensions).toEqual([]); + }); +}); diff --git a/tests/unit/sketchOffset.test.ts b/tests/unit/sketchOffset.test.ts new file mode 100644 index 0000000..f32f4b7 --- /dev/null +++ b/tests/unit/sketchOffset.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { offsetSketchSegments } from "@/lib/sketchOffset"; +import type { SketchProfile } from "@/types/sketchforge"; + +function sequentialIds() { + let next = 0; + return (prefix: string) => `${prefix}-${++next}`; +} + +function generatedPoints(result: ReturnType) { + const ids = new Set(result.pointIds); + return result.profile.points.filter((point) => ids.has(point.id)); +} + +function square(clockwise = false): SketchProfile { + const points = [ + { id: "a", x: 0, z: 0 }, + { id: "b", x: 4, z: 0 }, + { id: "c", x: 4, z: 4 }, + { id: "d", x: 0, z: 4 }, + ]; + const order = clockwise ? ["a", "d", "c", "b"] : ["a", "b", "c", "d"]; + return { + points, + segments: order.map((startId, index) => ({ + id: `side-${index}`, + startId, + endId: order[(index + 1) % order.length], + kind: "line" as const, + })), + }; +} + +function bounds(points: Array<{ x: number; z: number }>) { + return { + minX: Math.min(...points.map((point) => point.x)), + maxX: Math.max(...points.map((point) => point.x)), + minZ: Math.min(...points.map((point) => point.z)), + maxZ: Math.max(...points.map((point) => point.z)), + }; +} + +describe("sketch offset", () => { + it("offsets an open line left for positive distance and right for negative distance", () => { + const profile: SketchProfile = { + points: [{ id: "a", x: 0, z: 0 }, { id: "b", x: 5, z: 0 }], + segments: [{ id: "line", startId: "a", endId: "b", kind: "line" }], + }; + + const left = offsetSketchSegments(profile, ["line"], 2, { createId: sequentialIds() }); + const right = offsetSketchSegments(profile, ["line"], -2, { createId: sequentialIds() }); + + expect(generatedPoints(left).map(({ x, z }) => ({ x, z }))).toEqual([{ x: 0, z: 2 }, { x: 5, z: 2 }]); + expect(generatedPoints(right).map(({ x, z }) => ({ x, z }))).toEqual([{ x: 0, z: -2 }, { x: 5, z: -2 }]); + expect(left.closed).toBe(false); + }); + + it("expands one selected seed through a connected L chain", () => { + const profile: SketchProfile = { + points: [{ id: "a", x: 0, z: 0 }, { id: "b", x: 4, z: 0 }, { id: "c", x: 4, z: 3 }], + segments: [ + { id: "ab", startId: "a", endId: "b", kind: "line" }, + { id: "bc", startId: "b", endId: "c", kind: "line" }, + ], + }; + + const result = offsetSketchSegments(profile, ["bc"], 1, { createId: sequentialIds() }); + + expect(generatedPoints(result).map(({ x, z }) => ({ x, z }))).toEqual([ + { x: 0, z: 1 }, + { x: 3, z: 1 }, + { x: 3, z: 3 }, + ]); + expect(result.segmentIds).toHaveLength(2); + + const selectedOnly = offsetSketchSegments(profile, ["bc"], 1, { includeConnected: false, createId: sequentialIds() }); + expect(generatedPoints(selectedOnly).map(({ x, z }) => ({ x, z }))).toEqual([{ x: 3, z: 0 }, { x: 3, z: 3 }]); + }); + + it.each([false, true])("offsets a square outward and inward independent of winding (clockwise=%s)", (clockwise) => { + const profile = square(clockwise); + const outward = offsetSketchSegments(profile, ["side-2"], 1, { createId: sequentialIds() }); + const inward = offsetSketchSegments(profile, ["side-0"], -1, { createId: sequentialIds() }); + + expect(bounds(generatedPoints(outward))).toEqual({ minX: -1, maxX: 5, minZ: -1, maxZ: 5 }); + expect(bounds(generatedPoints(inward))).toEqual({ minX: 1, maxX: 3, minZ: 1, maxZ: 3 }); + expect(outward.closed).toBe(true); + expect(outward.segmentIds).toHaveLength(outward.pointIds.length); + }); + + it("adaptively flattens Bezier segments using forward and reverse handles", () => { + const reverse: SketchProfile = { + points: [ + { id: "a", x: 0, z: 0, handleIn: { x: 0, z: 4 } }, + { id: "b", x: 6, z: 0, handleOut: { x: 6, z: 4 } }, + ], + segments: [{ id: "curve", startId: "b", endId: "a", kind: "bezier" }], + }; + const forward: SketchProfile = { + points: [ + { id: "a", x: 0, z: 0, handleOut: { x: 0, z: 4 } }, + { id: "b", x: 6, z: 0, handleIn: { x: 6, z: 4 } }, + ], + segments: [{ id: "curve", startId: "a", endId: "b", kind: "bezier" }], + }; + + const result = offsetSketchSegments(reverse, ["curve"], 0.5, { createId: sequentialIds() }); + const forwardResult = offsetSketchSegments(forward, ["curve"], 0.5, { createId: sequentialIds() }); + const points = generatedPoints(result); + + expect(points.length).toBeGreaterThan(8); + expect(Math.max(...points.map((point) => point.z))).toBeGreaterThan(3); + expect(points.every((point) => Number.isFinite(point.x) && Number.isFinite(point.z))).toBe(true); + expect(generatedPoints(forwardResult).map(({ x, z }) => ({ x, z }))).toEqual(points.map(({ x, z }) => ({ x, z }))); + }); + + it("falls back to a line when a Bezier handle is missing", () => { + const profile: SketchProfile = { + points: [{ id: "a", x: 0, z: 0, handleOut: { x: 2, z: 4 } }, { id: "b", x: 4, z: 0 }], + segments: [{ id: "curve", startId: "a", endId: "b", kind: "bezier" }], + }; + + expect(generatedPoints(offsetSketchSegments(profile, ["curve"], 1, { createId: sequentialIds() }))) + .toMatchObject([{ x: 0, z: 1 }, { x: 4, z: 1 }]); + }); + + it("rejects branches, disconnected selections, invalid references, and zero-length topology", () => { + const branch: SketchProfile = { + points: [ + { id: "a", x: 0, z: 0 }, + { id: "b", x: 2, z: 0 }, + { id: "c", x: 4, z: 0 }, + { id: "d", x: 2, z: 2 }, + ], + segments: [ + { id: "ab", startId: "a", endId: "b" }, + { id: "bc", startId: "b", endId: "c" }, + { id: "bd", startId: "b", endId: "d" }, + ], + }; + const disconnected: SketchProfile = { + points: [ + { id: "a", x: 0, z: 0 }, { id: "b", x: 1, z: 0 }, + { id: "c", x: 3, z: 0 }, { id: "d", x: 4, z: 0 }, + ], + segments: [{ id: "ab", startId: "a", endId: "b" }, { id: "cd", startId: "c", endId: "d" }], + }; + const invalid: SketchProfile = { points: [{ id: "a", x: 0, z: 0 }], segments: [{ id: "bad", startId: "a", endId: "missing" }] }; + const zeroLength: SketchProfile = { + points: [{ id: "a", x: 1, z: 1 }, { id: "b", x: 1, z: 1 }], + segments: [{ id: "zero", startId: "a", endId: "b" }], + }; + + expect(() => offsetSketchSegments(branch, ["ab"], 1)).toThrow(/branches/); + expect(() => offsetSketchSegments(disconnected, ["ab", "cd"], 1)).toThrow(/disconnected/); + expect(() => offsetSketchSegments(invalid, ["bad"], 1)).toThrow(/Invalid point reference/); + expect(() => offsetSketchSegments(zeroLength, ["zero"], 1)).toThrow(/Zero-length/); + expect(() => offsetSketchSegments(disconnected, ["missing"], 1)).toThrow(/Invalid sketch segment reference/); + }); + + it("rejects zero distance and a collapsed closed inward offset", () => { + expect(() => offsetSketchSegments(square(), ["side-0"], 0)).toThrow(/non-zero/); + expect(() => offsetSketchSegments(square(), ["side-0"], -2)).toThrow(/collapsed/); + }); + + it("rejects a generated self-intersecting offset", () => { + const profile: SketchProfile = { + points: [ + { id: "a", x: 0, z: 0 }, + { id: "b", x: 4, z: 4 }, + { id: "c", x: 0, z: 4 }, + { id: "d", x: 4, z: 0 }, + ], + segments: [ + { id: "ab", startId: "a", endId: "b" }, + { id: "bc", startId: "b", endId: "c" }, + { id: "cd", startId: "c", endId: "d" }, + ], + }; + + expect(() => offsetSketchSegments(profile, ["ab"], 0.1)).toThrow(/self-intersects/); + }); + + it("uses fresh unique IDs, appends independent lines, and leaves the source immutable", () => { + const profile = square(); + profile.constraints = [{ id: "constraint", kind: "horizontal", segmentId: "side-0" }]; + profile.dimensions = [{ id: "dimension", kind: "length", segmentId: "side-0", value: 4 }]; + const snapshot = structuredClone(profile); + const ids = ["a", "side-0", "new-point", "new-point-2", "new-point-3", "new-point-4", "new-segment", "new-segment-2", "new-segment-3", "new-segment-4"]; + + const result = offsetSketchSegments(profile, ["side-0"], 1, { createId: () => ids.shift() ?? "unused" }); + + expect(profile).toEqual(snapshot); + expect(result.profile).not.toBe(profile); + expect(result.profile.points.slice(0, profile.points.length)).toEqual(profile.points); + expect(new Set([...result.pointIds, ...result.segmentIds]).size).toBe(result.pointIds.length + result.segmentIds.length); + expect(result.pointIds).not.toContain("a"); + expect(result.segmentIds).not.toContain("side-0"); + expect(result.profile.segments.slice(-result.segmentIds.length).every((segment) => segment.kind === "line")).toBe(true); + expect(result.profile.constraints).toEqual(profile.constraints); + expect(result.profile.dimensions).toEqual(profile.dimensions); + }); +}); diff --git a/tests/unit/sketchSnapping.test.ts b/tests/unit/sketchSnapping.test.ts new file mode 100644 index 0000000..4fd63b7 --- /dev/null +++ b/tests/unit/sketchSnapping.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { dedupeSketchSnapCandidates, snapSketchPoint, type SketchSnapCandidate } from "@/lib/sketchSnapping"; + +const candidates: SketchSnapCandidate[] = [ + { id: "point-a", kind: "point", label: "Endpoint", x: 12, z: 8 }, + { id: "center-a", kind: "center", label: "Center", x: 30, z: 20 }, +]; + +function snap(raw: { x: number; z: number }, patch: Partial[1]> = {}) { + return snapSketchPoint(raw, { + precisionStep: 1, + gridStep: 5, + tolerance: 0.75, + snapToGridLines: true, + snapToGeometry: true, + candidates, + ...patch, + }); +} + +describe("sketch snapping", () => { + it("prioritizes exact geometry anchors over grid lines", () => { + expect(snap({ x: 30.4, z: 20.2 })).toEqual({ x: 30, z: 20, snap: { kind: "center", label: "Center" } }); + }); + + it("magnetically snaps individual axes to visible grid lines", () => { + expect(snap({ x: 10.3, z: 12.2 }, { snapToGeometry: false })).toEqual({ + x: 10, + z: 12, + snap: { kind: "grid", label: "Grid line", xGuide: 10 }, + }); + }); + + it("aligns individual axes with existing geometry", () => { + expect(snap({ x: 12.4, z: 14.2 }, { snapToGridLines: false })).toEqual({ + x: 12, + z: 14, + snap: { kind: "alignment", label: "Align X: Endpoint", xGuide: 12 }, + }); + }); + + it("falls back to precision snapping when magnetic modes are disabled", () => { + expect(snap({ x: 12.4, z: 8.4 }, { snapToGridLines: false, snapToGeometry: false })).toEqual({ x: 12, z: 8 }); + }); + + it("deduplicates overlapping point and center anchors", () => { + expect(dedupeSketchSnapCandidates([ + candidates[0], + { id: "duplicate", kind: "center", label: "Center", x: 12, z: 8 }, + candidates[1], + ])).toEqual(candidates); + }); +}); diff --git a/tests/unit/sketchTransforms.test.ts b/tests/unit/sketchTransforms.test.ts new file mode 100644 index 0000000..a2c4f36 --- /dev/null +++ b/tests/unit/sketchTransforms.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { + applyAffineTransform, + normalizeSketchTransformSelection, + reflectionTransform, + rotationTransform, + transformSketchSelection, + translateSketchPoints, + translationTransform, + type SketchTransformSelection, +} from "@/lib/sketchTransforms"; +import type { SketchProfile } from "@/types/sketchforge"; + +const emptySelection = (): SketchTransformSelection => ({ pointIds: [], segmentIds: [], imageIds: [], textIds: [] }); + +function sequentialIds() { + let next = 0; + return (prefix: string) => `${prefix}-${++next}`; +} + +function lineProfile(): SketchProfile { + return { + points: [ + { id: "a", x: 1, z: 2, mode: "smooth", handleIn: { x: 0, z: 2 }, handleOut: { x: 2, z: 2 } }, + { id: "b", x: 5, z: 2 }, + { id: "unused", x: 20, z: 20 }, + ], + segments: [{ id: "ab", startId: "a", endId: "b", kind: "bezier" }], + constraints: [ + { id: "fixed-a", kind: "fixed", pointId: "a", x: 1, z: 2 }, + { id: "horizontal-ab", kind: "horizontal", segmentId: "ab" }, + { id: "fixed-unused", kind: "fixed", pointId: "unused", x: 20, z: 20 }, + ], + dimensions: [{ id: "length-ab", kind: "length", segmentId: "ab", value: 4 }], + }; +} + +describe("sketch selection transforms", () => { + it("translates a closed profile atomically with its handles and fixed anchors", () => { + const source = lineProfile(); + const moved = translateSketchPoints(source, ["a", "b"], { x: 7, z: -4 }); + + expect(moved.points.slice(0, 2)).toEqual([ + { id: "a", x: 8, z: -2, mode: "smooth", handleIn: { x: 7, z: -2 }, handleOut: { x: 9, z: -2 } }, + { id: "b", x: 12, z: -2, handleIn: undefined, handleOut: undefined }, + ]); + expect(moved.points[2]).toBe(source.points[2]); + expect(moved.constraints?.[0]).toEqual({ id: "fixed-a", kind: "fixed", pointId: "a", x: 8, z: -2 }); + expect(moved.constraints?.[2]).toBe(source.constraints?.[2]); + expect(source.points[0]).toMatchObject({ x: 1, z: 2 }); + }); + + it("normalizes selected segments to their endpoints and discards unknown IDs", () => { + const selection = { ...emptySelection(), pointIds: ["unused", "missing"], segmentIds: ["ab", "missing"] }; + + expect(normalizeSketchTransformSelection(lineProfile(), selection)).toEqual({ + pointIds: ["a", "b", "unused"], + segmentIds: ["ab"], + imageIds: [], + textIds: [], + }); + }); + + it("copies IDs, rewrites references, transforms Bezier handles, and copies only targeted parameters", () => { + const source = lineProfile(); + const result = transformSketchSelection( + source, + { ...emptySelection(), segmentIds: ["ab"] }, + [translationTransform(10, -3)], + sequentialIds(), + ); + + expect(source.points[0]).toMatchObject({ id: "a", x: 1, z: 2 }); + expect(result.selection).toEqual({ + pointIds: ["sketch-point-1", "sketch-point-2"], + segmentIds: ["sketch-segment-3"], + imageIds: [], + textIds: [], + }); + expect(result.profile.points.slice(-2)).toEqual([ + { + id: "sketch-point-1", + x: 11, + z: -1, + mode: "smooth", + handleIn: { x: 10, z: -1 }, + handleOut: { x: 12, z: -1 }, + }, + { id: "sketch-point-2", x: 15, z: -1, handleIn: undefined, handleOut: undefined }, + ]); + expect(result.profile.segments.at(-1)).toEqual({ + id: "sketch-segment-3", + startId: "sketch-point-1", + endId: "sketch-point-2", + kind: "bezier", + }); + expect(result.profile.constraints?.slice(-2)).toEqual([ + { id: "sketch-fixed-4", kind: "fixed", pointId: "sketch-point-1", x: 11, z: -1 }, + { id: "sketch-horizontal-5", kind: "horizontal", segmentId: "sketch-segment-3" }, + ]); + expect(result.profile.constraints?.some((constraint) => constraint.id === "fixed-unused-6")).toBe(false); + expect(result.profile.dimensions?.at(-1)).toEqual({ + id: "sketch-length-6", + kind: "length", + segmentId: "sketch-segment-3", + value: 4, + }); + }); + + it("reflects geometry about an offset line and swaps or preserves axis constraints", () => { + const profile: SketchProfile = { + points: [ + { id: "a", x: 2, z: 1 }, + { id: "b", x: 4, z: 1 }, + { id: "c", x: 2, z: 3 }, + ], + segments: [ + { id: "horizontal", startId: "a", endId: "b", kind: "line" }, + { id: "vertical", startId: "a", endId: "c", kind: "line" }, + ], + constraints: [ + { id: "h", kind: "horizontal", segmentId: "horizontal" }, + { id: "v", kind: "vertical", segmentId: "vertical" }, + ], + }; + const diagonal = reflectionTransform({ x: 1, z: 0 }, { x: 2, z: 1 }); + const selection = { ...emptySelection(), segmentIds: ["horizontal", "vertical"] }; + const result = transformSketchSelection(profile, selection, [diagonal], sequentialIds()); + + const pointOnLine = applyAffineTransform(diagonal, { x: 2, z: 1 }); + expect(pointOnLine.x).toBeCloseTo(2); + expect(pointOnLine.z).toBeCloseTo(1); + const reflectedPoints = result.profile.points.slice(-3); + expect(reflectedPoints[0].x).toBeCloseTo(2); + expect(reflectedPoints[0].z).toBeCloseTo(1); + expect(reflectedPoints[1].x).toBeCloseTo(2); + expect(reflectedPoints[1].z).toBeCloseTo(3); + expect(reflectedPoints[2].x).toBeCloseTo(4); + expect(reflectedPoints[2].z).toBeCloseTo(1); + expect(result.profile.constraints?.slice(-2).map((constraint) => constraint.kind)).toEqual(["vertical", "horizontal"]); + + const verticalMirror = reflectionTransform({ x: 1, z: 0 }, { x: 1, z: 5 }); + const preserved = transformSketchSelection(profile, selection, [verticalMirror], sequentialIds()); + expect(preserved.profile.constraints?.slice(-2).map((constraint) => constraint.kind)).toEqual(["horizontal", "vertical"]); + }); + + it("swaps axis constraints for quarter turns and drops them for arbitrary rotations and reflections", () => { + const selection = { ...emptySelection(), segmentIds: ["ab"] }; + const quarterTurn = transformSketchSelection(lineProfile(), selection, [rotationTransform(Math.PI / 2)], sequentialIds()); + const arbitraryTurn = transformSketchSelection(lineProfile(), selection, [rotationTransform(Math.PI / 4)], sequentialIds()); + const arbitraryMirror = transformSketchSelection( + lineProfile(), + selection, + [reflectionTransform({ x: 0, z: 0 }, { x: 2, z: 1 })], + sequentialIds(), + ); + + expect(quarterTurn.profile.constraints?.at(-1)).toMatchObject({ kind: "vertical" }); + expect(arbitraryTurn.profile.constraints?.filter((constraint) => constraint.kind !== "fixed")).toHaveLength(1); + expect(arbitraryMirror.profile.constraints?.filter((constraint) => constraint.kind !== "fixed")).toHaveLength(1); + const transformedFixed = arbitraryTurn.profile.constraints?.at(-1); + expect(transformedFixed).toMatchObject({ kind: "fixed" }); + if (!transformedFixed || transformedFixed.kind !== "fixed") throw new Error("Expected a copied fixed constraint"); + expect(transformedFixed.x).toBeCloseTo(-Math.SQRT1_2); + expect(transformedFixed.z).toBeCloseTo(3 * Math.SQRT1_2); + }); + + it("transforms image centers and text anchors while preserving image size", () => { + const profile: SketchProfile = { + points: [], + segments: [], + images: [{ + id: "image", + name: "reference.png", + dataUrl: "data:image/png;base64,AA==", + mimeType: "image/png", + pixelWidth: 200, + pixelHeight: 100, + x: 3, + z: 2, + width: 20, + depth: 10, + opacity: 0.5, + }], + texts: [{ id: "text", text: "A", x: 4, z: 2, fontSize: 12 }], + }; + const result = transformSketchSelection( + profile, + { ...emptySelection(), imageIds: ["image"], textIds: ["text"] }, + [rotationTransform(Math.PI / 2, { x: 2, z: 2 })], + sequentialIds(), + ); + + expect(result.profile.images?.at(-1)).toMatchObject({ id: "sketch-image-1", x: 2, z: 3, width: 20, depth: 10, opacity: 0.5 }); + expect(result.profile.texts?.at(-1)).toMatchObject({ id: "sketch-text-2", x: 2, z: 4, text: "A", fontSize: 12 }); + expect(result.selection).toEqual({ pointIds: [], segmentIds: [], imageIds: ["sketch-image-1"], textIds: ["sketch-text-2"] }); + }); + + it("appends independent copies for every pattern transform", () => { + const source: SketchProfile = { + points: [{ id: "point", x: 1, z: 2 }], + segments: [], + }; + const result = transformSketchSelection( + source, + { ...emptySelection(), pointIds: ["point"] }, + [translationTransform(5, 0), translationTransform(10, 0), translationTransform(15, 0)], + sequentialIds(), + ); + + expect(result.profile.points.map(({ x, z }) => ({ x, z }))).toEqual([ + { x: 1, z: 2 }, + { x: 6, z: 2 }, + { x: 11, z: 2 }, + { x: 16, z: 2 }, + ]); + expect(result.selection.pointIds).toEqual(["sketch-point-1", "sketch-point-2", "sketch-point-3"]); + }); + + it("transforms a moved dimension label offset with copied geometry", () => { + const profile: SketchProfile = { + points: [{ id: "a", x: 0, z: 0 }, { id: "b", x: 4, z: 0 }], + segments: [{ id: "ab", startId: "a", endId: "b", kind: "line", dimensionLabelOffset: { x: 2, z: -3 } }], + }; + + const result = transformSketchSelection( + profile, + { ...emptySelection(), segmentIds: ["ab"] }, + [rotationTransform(Math.PI / 2)], + sequentialIds(), + ); + + expect(result.profile.segments.at(-1)?.dimensionLabelOffset?.x).toBeCloseTo(3); + expect(result.profile.segments.at(-1)?.dimensionLabelOffset?.z).toBeCloseTo(2); + }); +});