From 1ba0c88b9dc3e0d2f4e5b028c39d7e80453bd3df Mon Sep 17 00:00:00 2001 From: WC3D <57880529+WC3D@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:21:12 -0400 Subject: [PATCH 1/5] Add sketch menu drawing and transform tools Expand the sketch toolbar with new geometry and editing commands: Draw tools: center-point circle, two-point circle, corner/center rectangle, inscribed/circumscribed/edge polygon with a side-count stepper, and placed sketch text. Create / Transform: offset paths, mirror across an axis or selected segment, and rectangular/circular patterns driven by a small options panel. Inspect: dimension tool with driving length dimensions, reference distance dimensions between point/midpoint/intersection anchors, and horizontal/vertical/fixed constraints with a lightweight solver. Supporting changes: planar region detection so extrudes can target overlapping profiles (with region-aware OCCT extrude + healing), geometry libs for the new tools, .skf persistence for constraints/ dimensions/texts, and unit plus real-kernel e2e tests. --- apps/web/src/app/globals.css | 387 ++++++++ apps/web/src/components/SketchForgeEditor.tsx | 886 ++++++++++++++++-- apps/web/src/components/SketchWorkspace.tsx | 875 +++++++++++++++-- apps/web/src/lib/sketchCadProfile.ts | 346 ++++++- apps/web/src/lib/sketchCadTypes.ts | 1 + apps/web/src/lib/sketchCircles.ts | 66 ++ apps/web/src/lib/sketchConstraints.ts | 237 +++++ apps/web/src/lib/sketchDimensions.ts | 144 +++ apps/web/src/lib/sketchOffset.ts | 399 ++++++++ apps/web/src/lib/sketchPolygons.ts | 59 ++ apps/web/src/lib/sketchRectangles.ts | 47 + apps/web/src/lib/sketchSnapping.ts | 116 +++ apps/web/src/lib/sketchTextGeometry.ts | 67 ++ apps/web/src/lib/sketchTransforms.ts | 252 +++++ apps/web/src/lib/skfProject.ts | 71 ++ apps/web/src/lib/workplaneShapes.ts | 1 + apps/web/src/types/sketchforge.ts | 29 + apps/web/src/workers/sketchCad.worker.ts | 41 +- tests/e2e/sketchCadExtrusion.e2e.ts | 33 + tests/unit/sketchCadProfile.test.ts | 121 ++- tests/unit/sketchCircles.test.ts | 53 ++ tests/unit/sketchConstraints.test.ts | 111 +++ tests/unit/sketchDimensions.test.ts | 54 ++ tests/unit/sketchOffset.test.ts | 203 ++++ tests/unit/sketchSnapping.test.ts | 53 ++ tests/unit/sketchTransforms.test.ts | 236 +++++ 26 files changed, 4690 insertions(+), 198 deletions(-) create mode 100644 apps/web/src/lib/sketchCircles.ts create mode 100644 apps/web/src/lib/sketchConstraints.ts create mode 100644 apps/web/src/lib/sketchDimensions.ts create mode 100644 apps/web/src/lib/sketchOffset.ts create mode 100644 apps/web/src/lib/sketchPolygons.ts create mode 100644 apps/web/src/lib/sketchRectangles.ts create mode 100644 apps/web/src/lib/sketchSnapping.ts create mode 100644 apps/web/src/lib/sketchTextGeometry.ts create mode 100644 apps/web/src/lib/sketchTransforms.ts create mode 100644 tests/unit/sketchCircles.test.ts create mode 100644 tests/unit/sketchConstraints.test.ts create mode 100644 tests/unit/sketchDimensions.test.ts create mode 100644 tests/unit/sketchOffset.test.ts create mode 100644 tests/unit/sketchSnapping.test.ts create mode 100644 tests/unit/sketchTransforms.test.ts diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 6c95e7a..46d6e87 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -8289,3 +8289,390 @@ 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; + } +} 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..e31866e 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,9 +1162,86 @@ export function SketchWorkspace({ pointerEvents="none" /> ) : null} - - {paths.some((path) => path.closed) ? path.closed).map(pathData).join(" ")} /> : null} + + {operation === "extrude" ? regions.map((region, index) => ( + { + event.preventDefault(); + event.stopPropagation(); + if (isPanGesture(event)) { + beginPan(event); + return; + } + 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} {paths.filter((path) => path.closed).map((path) => ( 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, []); + onSelectMany(pointIds, segmentIds, [], []); beginEntityDrag(event, { kind: "move-selection", pointerId: event.pointerId, origin: point, current: point, startPoints }); }} /> @@ -930,9 +1269,10 @@ export function SketchWorkspace({ {displayProfile.segments.map((segment) => ( { if (tool !== "refine") return; const target = pointFromEvent(event); @@ -948,7 +1288,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 +1298,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 +1312,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 +1329,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 +1518,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 +1532,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 +1650,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 +1674,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 +1705,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 +1794,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); + }); +}); From 550ddf32e3624792507968ea02f166f5b90d6abf Mon Sep 17 00:00:00 2001 From: WC3D <57880529+WC3D@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:31:33 -0400 Subject: [PATCH 2/5] Restore missing sketch menu workspace styles A CSS extraction pass dropped 33 rules for classes that existed upstream but were restyled by the sketch menu work: selectable extrusion regions, sketch points (incl. fixed state), constrained segments, segment dimension labels, drag/curve handles, measurement points, polygon side stepper, constraint inspector, image resize handle, grid settings, and toolbar disabled states. Without them, selected extrusion profiles gave no visual feedback. --- apps/web/src/app/globals.css | 413 +++++++++++++++++++++++++++++++++++ 1 file changed, 413 insertions(+) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 46d6e87..0c63338 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -8676,3 +8676,416 @@ html[data-theme="dark"] .sketch-dimension-status { 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); +} From b1c5fa1abe5693e068674bb7106bd653e5ba5f3c Mon Sep 17 00:00:00 2001 From: WC3D <57880529+WC3D@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:01:47 -0400 Subject: [PATCH 3/5] Remove profile hit-target layer blocking extrude region selection The closed-profile hit-target overlay sat above the region fill layer in the sketch SVG, so with Select active every click inside a profile started a whole-shape drag instead of toggling that region's extrusion selection. Region fills now receive clicks as intended; whole-shape dragging still works via marquee selection and selected-shape handles. --- apps/web/src/components/SketchWorkspace.tsx | 29 +++------------------ 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/SketchWorkspace.tsx b/apps/web/src/components/SketchWorkspace.tsx index e31866e..3b7de9f 100644 --- a/apps/web/src/components/SketchWorkspace.tsx +++ b/apps/web/src/components/SketchWorkspace.tsx @@ -1240,32 +1240,9 @@ export function SketchWorkspace({ {hover.snap.zGuide !== undefined ? : null} {hover.snap.label} - - ) : null} - - {paths.filter((path) => path.closed).map((path) => ( - { - if (event.button === 1) { - 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 }); - }} - /> - ))} - - + + ) : null} + {displayProfile.segments.map((segment) => ( Date: Tue, 25 Aug 2026 02:43:09 -0400 Subject: [PATCH 4/5] Fix extrusion hole assignment for nested sketch regions Assign holes to outlines per selected region instead of inferring nesting from outline areas. The area heuristic mis-grouped profiles inside a sketch (for example an overlapping or unselected inner profile), so editing a sketch and toggling region selection could produce wrong geometry. Regions now carry their own outer/hole paths, and each selected region extrudes exactly as shown. --- apps/web/src/components/SketchForgeEditor.tsx | 36 ++++++------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/SketchForgeEditor.tsx b/apps/web/src/components/SketchForgeEditor.tsx index e869159..75ee03b 100644 --- a/apps/web/src/components/SketchForgeEditor.tsx +++ b/apps/web/src/components/SketchForgeEditor.tsx @@ -380,19 +380,6 @@ function withSmoothSketchHandles(profile: SketchProfile) { return next; } -function pointInSketchPolygon(point: THREE.Vector2, polygon: THREE.Vector2[]) { - let inside = false; - for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index, index += 1) { - const currentPoint = polygon[index]; - const previousPoint = polygon[previous]; - const crosses = currentPoint.y > point.y !== previousPoint.y > point.y; - if (crosses && point.x < ((previousPoint.x - currentPoint.x) * (point.y - currentPoint.y)) / (previousPoint.y - currentPoint.y) + currentPoint.x) { - inside = !inside; - } - } - return inside; -} - async function shapeFromResolvedSketchProfile( profile: SketchProfile, polygons: Array>, @@ -483,7 +470,6 @@ async function shapeFromSketchProfile(profile: SketchProfile, height: number, ex 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)); const maxX = Math.max(...profilePoints.map((point) => point.x)); @@ -517,7 +503,7 @@ async function shapeFromSketchProfile(profile: SketchProfile, height: number, ex }); outline.closePath(); const polygon = outline.extractPoints(16).shape; - return { outline, polygon, area: Math.abs(THREE.ShapeUtils.area(polygon)) }; + return { id: path.id, outline, polygon }; }); const hasCurves = geometryProfile.segments.some((segment) => segment.kind === "bezier" || segment.kind === "smooth"); const longestHandle = geometryProfile.points.reduce((longest, point) => Math.max( @@ -538,17 +524,15 @@ async function shapeFromSketchProfile(profile: SketchProfile, height: number, ex existing, ); } - const sortedOutlines = [...outlineRecords].sort((a, b) => b.area - a.area); - const outlines: THREE.Shape[] = []; - sortedOutlines.forEach((record) => { - const sample = record.polygon[0]; - const parent = sample - ? sortedOutlines - .filter((candidate) => candidate !== record && candidate.area > record.area && pointInSketchPolygon(sample, candidate.polygon)) - .sort((a, b) => a.area - b.area)[0] - : undefined; - if (parent) parent.outline.holes.push(record.outline); - else outlines.push(record.outline); + const outlineById = new Map(outlineRecords.map((record) => [record.id, record.outline])); + const outlines = regions.flatMap((region) => { + const outline = outlineById.get(region.outer.id); + if (!outline) return []; + outline.holes.push(...region.holes.flatMap((hole) => { + const holeOutline = outlineById.get(hole.id); + return holeOutline ? [holeOutline] : []; + })); + return [outline]; }); const geometry = new THREE.ExtrudeGeometry(outlines, { depth: safeHeight, bevelEnabled: false, steps: 1, curveSegments }); geometry.rotateX(-Math.PI / 2); From ae30ed36985ad58af428164ac70daab69778b81e Mon Sep 17 00:00:00 2001 From: WC3D <57880529+WC3D@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:09:53 -0400 Subject: [PATCH 5/5] Add Project and Sweep sketch tools Project meshes or other sketches onto the active sketch plane as linked references (refreshed from their source whenever the sketch opens or rebuilds) or as editable copies, using the new planar projection lib. Sweep extrudes a selected closed profile along a selected open path and rebuilds when the sketch is re-edited. Includes constructionPlanes math helpers for workplane pose conversion (no construction-plane shapes or UI in this change), sweep/projection persistence and validation in .skf projects, guards so projected geometry cannot be dragged or marquee-selected, and unit tests for projection, sweep, and plane math. --- apps/web/src/app/globals.css | 36 ++ apps/web/src/components/SketchForgeEditor.tsx | 385 ++++++++++++++++-- apps/web/src/components/SketchWorkspace.tsx | 15 +- apps/web/src/lib/constructionPlanes.ts | 372 +++++++++++++++++ apps/web/src/lib/sketchProjection.ts | 341 ++++++++++++++++ apps/web/src/lib/sketchSweep.ts | 110 +++++ apps/web/src/lib/skfProject.ts | 19 +- apps/web/src/types/sketchforge.ts | 13 +- tests/unit/constructionPlanes.test.ts | 238 +++++++++++ tests/unit/sketchProjection.test.ts | 247 +++++++++++ tests/unit/sketchSweep.test.ts | 57 +++ 11 files changed, 1790 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/lib/constructionPlanes.ts create mode 100644 apps/web/src/lib/sketchProjection.ts create mode 100644 apps/web/src/lib/sketchSweep.ts create mode 100644 tests/unit/constructionPlanes.test.ts create mode 100644 tests/unit/sketchProjection.test.ts create mode 100644 tests/unit/sketchSweep.test.ts diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 0c63338..45e862d 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -9089,3 +9089,39 @@ html[data-theme="dark"] .sketch-profile-selection-status { border-color: #465d6c; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.32); } + +/* Project/Sweep panel and projected geometry styles */ + +/* Sweep panel copy */ +.sketch-operation-copy { + display: flex; + flex-direction: column; + gap: 6px; + color: #536d7d; + font-size: 12px; + line-height: 1.4; +} + +.sketch-operation-copy p { + margin: 0; +} + +.sketch-operation-copy span { + font-size: 11px; + font-weight: 800; +} + +/* Projected geometry styling */ + +.sketch-points circle.projected { + fill: #f4edfb; + stroke: #8f55c5; + cursor: default; +} + +.sketch-segments path.projected { + stroke: #8f55c5; + stroke-width: 2; + stroke-dasharray: 5 3; + cursor: default; +} diff --git a/apps/web/src/components/SketchForgeEditor.tsx b/apps/web/src/components/SketchForgeEditor.tsx index 75ee03b..846c722 100644 --- a/apps/web/src/components/SketchForgeEditor.tsx +++ b/apps/web/src/components/SketchForgeEditor.tsx @@ -1,6 +1,6 @@ "use client"; -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 { Check, Circle, Circle as CircleIcon, CircleDot, CloudUpload, CopyPlus, Download, Eye, FolderOpen, Grid2X2, Hexagon, Hexagon as HexagonIcon, RotateCw, Route, Ruler, ScanLine, 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, type FormEvent } from "react"; @@ -100,6 +100,9 @@ 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 { intersectMeshWithPlane, projectSketchProfileToPlane, type SketchProjectionResult } from "@/lib/sketchProjection"; +import { buildSketchSweepGeometry } from "@/lib/sketchSweep"; +import { BASE_CONSTRUCTION_PLANE_POSE, type ConstructionPlanePose } from "@/lib/constructionPlanes"; import { cadSketchProfileForRegions, cadSketchRegions, cadSketchSelectableRegions, selectedCadSketchRegions } from "@/lib/sketchCadProfile"; import { sketchDimensionAnchorKey, sketchDistanceDimensionValue } from "@/lib/sketchDimensions"; import { exportSkfProject, SKF_MEDIA_TYPE } from "@/lib/skfProject"; @@ -116,6 +119,7 @@ import { placementWorkplaneCoordinates, placementWorkplaneFromSurface, placementWorkplaneIsBase, + placementWorkplaneQuaternion, translationToWorkplane, type PlacementPoint, type PlacementWorkplane, @@ -141,7 +145,8 @@ type DirectExportFormat = Exclude; type SkfHistoryLimit = EditorHistoryExportLimit; type SkfExportTarget = "download" | "shared"; type ToolbarMode = "geometry" | "sketch"; -type SketchCommandKind = "offset" | "mirror" | "rectangular-pattern" | "circular-pattern"; +type SketchCommandKind = "sweep" | "project" | "offset" | "mirror" | "rectangular-pattern" | "circular-pattern"; +type SketchProjectCommandOptions = { sourceShapeId: string; linked: boolean }; 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 }; @@ -276,6 +281,7 @@ function cloneSketchProfile(profile: SketchProfile): SketchProfile { : { ...dimension, start: { ...dimension.start }, end: { ...dimension.end } }), images: (profile.images ?? []).map((image) => ({ ...image })), texts: (profile.texts ?? []).map((text) => ({ ...text })), + projections: (profile.projections ?? []).map((projection) => ({ ...projection })), }; } @@ -308,6 +314,85 @@ function hasSketchTransformSelection(selection: SketchTransformSelection) { return selection.pointIds.length + selection.segmentIds.length + selection.imageIds.length + selection.textIds.length > 0; } +function constructionPlanePoseForPlacementWorkplane(workplane: PlacementWorkplane): ConstructionPlanePose { + const quaternion = placementWorkplaneQuaternion(workplane); + return { + origin: [workplane.origin.x, workplane.origin.y, workplane.origin.z], + quaternion: [quaternion.x, quaternion.y, quaternion.z, quaternion.w], + }; +} + +function nativeProjectionSourceProfile(profile: SketchProfile): SketchProfile { + const projectedPointIds = new Set(profile.points.filter((point) => point.projectionId).map((point) => point.id)); + return { + ...profile, + points: profile.points.filter((point) => !point.projectionId), + segments: profile.segments.filter((segment) => !segment.projectionId && !projectedPointIds.has(segment.startId) && !projectedPointIds.has(segment.endId)), + constraints: [], + dimensions: [], + images: [], + texts: [], + projections: [], + }; +} + +function sketchProfileCenter(profile: SketchProfile) { + if (!profile.points.length) return { x: 0, z: 0 }; + return { + x: (Math.min(...profile.points.map((point) => point.x)) + Math.max(...profile.points.map((point) => point.x))) / 2, + z: (Math.min(...profile.points.map((point) => point.z)) + Math.max(...profile.points.map((point) => point.z))) / 2, + }; +} + +function projectShapeToSketchPlane( + source: WorkplaneShape, + targetPose: ConstructionPlanePose, + projectionId?: string, +): SketchProjectionResult { + if (source.sketchProfile) { + const profile = nativeProjectionSourceProfile(source.sketchProfile); + const center = sketchProfileCenter(profile); + const localCenter = [source.x, (source.elevation ?? 0) + source.height / 2, source.z]; + return projectSketchProfileToPlane( + profile, + BASE_CONSTRUCTION_PLANE_POSE, + targetPose, + [localCenter[0] - center.x, localCenter[1] - source.height / 2, localCenter[2] - center.z], + createLocalId, + projectionId, + ); + } + const mesh = meshForShape(source); + return intersectMeshWithPlane(mesh.vertices, mesh.faces, targetPose, createLocalId, projectionId); +} + +function refreshLinkedSketchProjections( + profile: SketchProfile, + sceneShapes: WorkplaneShape[], + targetPose: ConstructionPlanePose, +) { + const links = profile.projections ?? []; + if (!links.length) return profile; + const linkedIds = new Set(links.map((link) => link.id)); + const retainedPointIds = new Set(profile.points.filter((point) => !point.projectionId || !linkedIds.has(point.projectionId)).map((point) => point.id)); + let refreshed: SketchProfile = { + ...profile, + points: profile.points.filter((point) => retainedPointIds.has(point.id)), + segments: profile.segments.filter((segment) => (!segment.projectionId || !linkedIds.has(segment.projectionId)) && retainedPointIds.has(segment.startId) && retainedPointIds.has(segment.endId)), + }; + for (const link of links) { + const source = sceneShapes.find((shape) => shape.id === link.sourceShapeId); + if (!source) continue; + const projected = projectShapeToSketchPlane(source, targetPose, link.id); + refreshed = { + ...refreshed, + points: [...refreshed.points, ...projected.points], + segments: [...refreshed.segments, ...projected.segments], + }; + } + return pruneSketchParameters(refreshed); +} + type OrderedSketchStep = { segment: SketchProfile["segments"][number]; from: SketchPoint; to: SketchPoint }; type OrderedSketchPath = { points: SketchPoint[]; steps: OrderedSketchStep[]; closed: boolean }; @@ -578,6 +663,83 @@ async function shapeFromSketchProfile(profile: SketchProfile, height: number, ex } satisfies WorkplaneShape); } +function shapeFromSketchSweep(profile: SketchProfile, selectedSegmentIds: readonly string[], existing?: WorkplaneShape | null) { + const { geometry, section, path } = buildSketchSweepGeometry(profile, selectedSegmentIds); + try { + geometry.computeBoundingBox(); + const bounds = geometry.boundingBox; + if (!bounds) throw new Error("The sweep did not produce any geometry"); + const centerX = (bounds.min.x + bounds.max.x) / 2; + const centerZ = (bounds.min.z + bounds.max.z) / 2; + const minY = bounds.min.y; + const rawWidth = Math.max(MIN_SHAPE_DIMENSION, bounds.max.x - bounds.min.x); + const rawDepth = Math.max(MIN_SHAPE_DIMENSION, bounds.max.z - bounds.min.z); + const rawHeight = Math.max(MIN_SHAPE_DIMENSION, bounds.max.y - bounds.min.y); + const meshGeometry = geometry.index ? geometry.toNonIndexed() : geometry; + try { + const position = meshGeometry.getAttribute("position"); + const normal = meshGeometry.getAttribute("normal"); + if (!position || position.count < 3) throw new Error("The sweep did not produce any triangles"); + const positions = Array.from(position.array as ArrayLike); + for (let index = 0; index + 2 < positions.length; index += 3) { + positions[index] -= centerX; + positions[index + 1] -= minY; + positions[index + 2] -= centerZ; + } + const normals = normal ? Array.from(normal.array as ArrayLike) : undefined; + const width = cleanModelDimension(rawWidth); + const depth = cleanModelDimension(rawDepth); + const height = cleanModelDimension(rawHeight); + return canonicalizeShape({ + ...(existing ?? { + id: createLocalId("sketch-sweep"), + name: "Sketch sweep", + color: "#d41721", + hole: false, + }), + kind: "mesh", + x: cleanNearZero(centerX, 0.0005), + z: cleanNearZero(centerZ, 0.0005), + elevation: cleanNearZero(minY, 0.0005), + size: Math.max(width, depth), + width, + depth, + height, + rotation: 0, + rotationX: 0, + rotationZ: 0, + mirrorX: undefined, + mirrorY: undefined, + mirrorZ: undefined, + importedMesh: { + positions, + normals: normals?.length === positions.length ? normals : undefined, + baseWidth: rawWidth, + baseDepth: rawDepth, + baseHeight: rawHeight, + triangleCount: Math.floor(positions.length / 9), + sourceFormat: "json", + }, + imagePlate: undefined, + sketchProfile: cloneSketchProfile(profile), + sketchFeature: { + kind: "sweep", + sectionSegmentIds: section.steps.map((step) => step.segment.id), + pathSegmentIds: path.steps.map((step) => step.segment.id), + }, + edgeTreatments: undefined, + edgeTreatmentHistory: undefined, + cadDisplayEdges: undefined, + cadBrep: undefined, + } satisfies WorkplaneShape); + } finally { + if (meshGeometry !== geometry) meshGeometry.dispose(); + } + } finally { + geometry.dispose(); + } +} + let sketchCadWorker: Worker | null = null; let sketchCadRequestId = 0; const sketchCadPending = new Map, workplaneOverride?: PlacementWorkplane, ) => { - const initial = cloneSketchProfile(solveSketchProfile(profile ?? emptySketchProfile()).profile); + const sketchWorkplane = normalizePlacementWorkplane(workplaneOverride ?? placementWorkplaneRef.current); + const refreshed = profile ? refreshLinkedSketchProjections(profile, shapes, constructionPlanePoseForPlacementWorkplane(sketchWorkplane)) : emptySketchProfile(); + const initial = cloneSketchProfile(solveSketchProfile(refreshed).profile); setWorkplaneMode(false); - setActiveSketchWorkplane(normalizePlacementWorkplane(workplaneOverride ?? placementWorkplaneRef.current)); + setActiveSketchWorkplane(sketchWorkplane); setToolbarMode("sketch"); setSketchActive(true); setSketchOperation(operation); @@ -6379,7 +6543,7 @@ export function SketchForgeEditor({ 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"); - }, []); + }, [shapes]); const beginSketchEdit = useCallback(() => { if (selectedShapes.length !== 1 || !selectedShape?.sketchProfile) { @@ -6939,6 +7103,15 @@ export function SketchForgeEditor({ }, [commitSketchProfile, sketchProfile]); const moveSketchPoints = useCallback((ids: string[], delta: { x: number; z: number }) => { + const pointIds = new Set(ids); + if (sketchProfile.points.some((point) => pointIds.has(point.id) && point.projectionId)) { + setNotice("Projected profiles cannot be moved"); + return; + } + if ((sketchProfile.constraints ?? []).some((constraint) => constraint.kind === "fixed" && pointIds.has(constraint.pointId))) { + setNotice("Profiles with fixed points cannot be moved"); + return; + } commitSketchProfile(translateSketchPoints(sketchProfile, ids, delta), "Sketch profile moved"); }, [commitSketchProfile, sketchProfile]); @@ -7047,13 +7220,40 @@ export function SketchForgeEditor({ setSketchTool("select"); }, [commitSketchProfile, sketchProfile]); + 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 openSketchCommand = useCallback((command: SketchCommandKind) => { + if (command === "project") { + setSketchTool("select"); + setSketchActivePointId(null); + setSketchCircleDraft(null); + setSketchRectDraft(null); + setSketchPolygonDraft(null); + setSketchTextDraft(null); + setSketchCommand(command); + return; + } if (sketchTool !== "select") { setNotice("Choose Select and select sketch geometry first"); return; } if (!sketchSelection) { - setNotice("Select sketch geometry first"); + setNotice(command === "sweep" ? "Select one closed profile and one open path first" : "Select sketch geometry first"); return; } setSketchCommand(command); @@ -7064,6 +7264,69 @@ export function SketchForgeEditor({ setSketchActivePointId(null); }, []); + const applySketchProject = useCallback((options: SketchProjectCommandOptions) => { + const source = shapes.find((shape) => shape.id === options.sourceShapeId && shape.id !== editingSketchShapeId); + if (!source) { + setNotice("Choose an available sketch or shape to project"); + return; + } + if (options.linked && (sketchProfile.projections ?? []).some((projection) => projection.sourceShapeId === source.id)) { + setNotice(`${source.name} is already linked to this sketch`); + return; + } + const projectionId = options.linked ? createLocalId("sketch-projection") : undefined; + let projected: SketchProjectionResult; + try { + projected = projectShapeToSketchPlane( + source, + constructionPlanePoseForPlacementWorkplane(activeSketchWorkplane), + projectionId, + ); + } catch (error) { + setNotice(error instanceof Error ? error.message : "The source could not be projected into this sketch"); + return; + } + if (!projected.segments.length) { + setNotice(source.sketchProfile ? "The source sketch collapses in this projection" : "The shape does not intersect the active sketch plane"); + return; + } + const next: SketchProfile = { + ...sketchProfile, + points: [...sketchProfile.points, ...projected.points], + segments: [...sketchProfile.segments, ...projected.segments], + projections: projectionId ? [ + ...(sketchProfile.projections ?? []), + { id: projectionId, sourceShapeId: source.id, sourceName: source.name, sourceKind: source.sketchProfile ? "sketch" : "intersection" }, + ] : sketchProfile.projections, + }; + commitSketchProfile(next, `${options.linked ? "Linked" : "Copied"} projection from ${source.name}`); + if (options.linked) setSketchSelection(null); + else selectGeneratedSketchEntities({ + pointIds: projected.points.map((point) => point.id), + segmentIds: projected.segments.map((segment) => segment.id), + imageIds: [], + textIds: [], + }); + setSketchCommand(null); + }, [activeSketchWorkplane, commitSketchProfile, editingSketchShapeId, selectGeneratedSketchEntities, shapes, sketchProfile]); + + const createSketchSweep = useCallback(() => { + const source = sketchTransformSelection(sketchSelection); + const existing = editingSketchShapeId ? shapes.find((shape) => shape.id === editingSketchShapeId) ?? null : null; + setNotice("Building sketch sweep…"); + let swept: WorkplaneShape; + try { + swept = shapeFromSketchSweep(sketchProfile, source.segmentIds, existing); + swept = placeSketchExtrusion(swept, activeSketchWorkplane, existing); + } catch (error) { + setNotice(error instanceof Error ? error.message : "The selected geometry cannot be swept"); + return; + } + const nextShapes = existing ? shapes.map((shape) => shape.id === existing.id ? swept : shape) : [...shapes, swept]; + commitShapes(nextShapes, swept.id, existing ? "Sketch sweep updated" : "Sketch sweep created"); + clearSketchTransientState(); + }, [activeSketchWorkplane, clearSketchTransientState, commitShapes, editingSketchShapeId, shapes, sketchProfile, sketchSelection]); + const applySketchOffset = useCallback((options: SketchOffsetCommandOptions) => { const source = sketchTransformSelection(sketchSelection); if (!source.segmentIds.length) { @@ -7188,37 +7451,41 @@ export function SketchForgeEditor({ 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; + const projectionPose = constructionPlanePoseForPlacementWorkplane(activeSketchWorkplane); + const refreshedProfile = refreshLinkedSketchProjections(sketchProfile, shapes, projectionPose); + if (existing?.sketchFeature?.kind === "sweep") { + setNotice("Rebuilding sketch sweep…"); + let swept: WorkplaneShape; + try { + swept = shapeFromSketchSweep( + refreshedProfile, + [...existing.sketchFeature.sectionSegmentIds, ...existing.sketchFeature.pathSegmentIds], + existing, + ); + swept = placeSketchExtrusion(swept, activeSketchWorkplane, existing); + } catch (error) { + setNotice(error instanceof Error ? error.message : "The sweep profile cannot be converted to 3D"); + return; + } + commitShapes(shapes.map((shape) => shape.id === existing.id ? swept : shape), swept.id, "Sketch sweep updated"); + clearSketchTransientState(); + return; + } let resolved: WorkplaneShape | null = null; try { if (sketchOperation === "revolve") { - resolved = await shapeFromRevolvedSketchProfile(sketchProfile, sketchRevolveSettings, existing); + resolved = await shapeFromRevolvedSketchProfile(refreshedProfile, 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, selectedSketchRegionIds); + const extrusion = await cadShapeFromSketchProfile(refreshedProfile, height, existing, selectedSketchRegionIds); resolved = placeSketchExtrusion(extrusion, activeSketchWorkplane, existing); } } catch (error) { @@ -7231,7 +7498,7 @@ export function SketchForgeEditor({ } resolved = { ...resolved, - sketchProfile: cloneSketchProfile(sketchProfile), + sketchProfile: cloneSketchProfile(refreshedProfile), sketchFeature: sketchOperation === "extrude" ? { kind: "extrusion", regionIds: [...selectedSketchRegionIds] } : undefined, }; const nextShapes = existing ? shapes.map((shape) => (shape.id === existing.id ? resolved : shape)) : [...shapes, resolved]; @@ -9510,6 +9777,7 @@ export function SketchForgeEditor({ sketchCanRedo={sketchHistoryIndex < sketchHistory.length - 1} sketchHasSelection={Boolean(sketchSelection) && sketchTool === "select"} sketchHasSegmentSelection={sketchTool === "select" && sketchTransformSelection(sketchSelection).segmentIds.length > 0} + sketchCanProject={shapes.some((shape) => shape.id !== editingSketchShapeId)} canEditSketch={selectedShapes.length === 1 && Boolean(selectedShape?.sketchProfile)} onStartSketch={(operation) => beginSketch(operation)} onEditSketch={beginSketchEdit} @@ -9525,6 +9793,8 @@ export function SketchForgeEditor({ onSketchPolygonSidesChange={setSketchPolygonSides} onSketchUndo={sketchUndo} onSketchRedo={sketchRedo} + onSketchProject={() => openSketchCommand("project")} + onSketchSweep={() => openSketchCommand("sweep")} onSketchOffset={() => openSketchCommand("offset")} onSketchMirror={() => openSketchCommand("mirror")} onSketchRectangularPattern={() => openSketchCommand("rectangular-pattern")} @@ -9670,10 +9940,13 @@ export function SketchForgeEditor({ command={sketchCommand} profile={sketchProfile} selection={sketchSelection} + projectionSources={shapes.filter((shape) => shape.id !== editingSketchShapeId)} + onProject={applySketchProject} onOffset={applySketchOffset} onMirror={applySketchMirror} onRectangularPattern={applySketchRectangularPattern} onCircularPattern={applySketchCircularPattern} + onSweep={createSketchSweep} onClose={() => setSketchCommand(null)} /> ) : null} @@ -9872,19 +10145,25 @@ function SketchOperationPanel({ command, profile, selection, + projectionSources, + onProject, onOffset, onMirror, onRectangularPattern, onCircularPattern, + onSweep, onClose, }: { command: SketchCommandKind; profile: SketchProfile; selection: SketchSelection; + projectionSources: WorkplaneShape[]; + onProject: (options: SketchProjectCommandOptions) => void; onOffset: (options: SketchOffsetCommandOptions) => void; onMirror: (options: SketchMirrorOptions) => void; onRectangularPattern: (options: SketchRectangularPatternOptions) => void; onCircularPattern: (options: SketchCircularPatternOptions) => void; + onSweep: () => void; onClose: () => void; }) { const selected = sketchTransformSelection(selection); @@ -9902,17 +10181,25 @@ function SketchOperationPanel({ 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 [projectionSourceId, setProjectionSourceId] = useState(projectionSources[0]?.id ?? ""); + const [projectionLinked, setProjectionLinked] = useState(true); + const title = command === "sweep" + ? "Sweep" + : command === "project" + ? "Project geometry" + : 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 }); + if (command === "sweep") onSweep(); + else if (command === "project") onProject({ sourceShapeId: projectionSourceId, linked: projectionLinked }); + else 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 }); @@ -9924,6 +10211,24 @@ function SketchOperationPanel({ {title}
+ {command === "sweep" ? ( +
+

Create a 3D body from one selected closed profile and one selected open path.

+ {selected.segmentIds.length} selected segments +
+ ) : null} + {command === "project" ? ( +
+ + +

{projectionLinked ? "Linked geometry is fixed and refreshes from its source when the sketch is opened or rebuilt." : "Editable geometry is copied once and can be changed independently."}

+
+ ) : null} {command === "offset" ? (
@@ -9970,7 +10275,7 @@ function SketchOperationPanel({ ) : null}
- +
); @@ -10001,6 +10306,7 @@ function SecondaryToolbar({ sketchCanRedo, sketchHasSelection, sketchHasSegmentSelection, + sketchCanProject, canEditSketch, onStartSketch, onEditSketch, @@ -10010,6 +10316,8 @@ function SecondaryToolbar({ onSketchPolygonSidesChange, onSketchUndo, onSketchRedo, + onSketchProject, + onSketchSweep, onSketchOffset, onSketchMirror, onSketchRectangularPattern, @@ -10061,6 +10369,7 @@ function SecondaryToolbar({ sketchCanRedo: boolean; sketchHasSelection: boolean; sketchHasSegmentSelection: boolean; + sketchCanProject: boolean; canEditSketch: boolean; onStartSketch: (operation: SketchOperation) => void; onEditSketch: () => void; @@ -10070,6 +10379,8 @@ function SecondaryToolbar({ onSketchPolygonSidesChange: (sides: number) => void; onSketchUndo: () => void; onSketchRedo: () => void; + onSketchProject: () => void; + onSketchSweep: () => void; onSketchOffset: () => void; onSketchMirror: () => void; onSketchRectangularPattern: () => void; @@ -10512,6 +10823,12 @@ function SecondaryToolbar({
Create / Transform
+ + diff --git a/apps/web/src/components/SketchWorkspace.tsx b/apps/web/src/components/SketchWorkspace.tsx index 3b7de9f..712bbd8 100644 --- a/apps/web/src/components/SketchWorkspace.tsx +++ b/apps/web/src/components/SketchWorkspace.tsx @@ -878,8 +878,9 @@ 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((point) => contains(point)).map((point) => point.id); + const pointIds = profile.points.filter((point) => !point.projectionId && contains(point)).map((point) => point.id); const segmentIds = profile.segments.filter((segment) => { + if (segment.projectionId) return false; 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 }))); @@ -1197,7 +1198,7 @@ export function SketchWorkspace({ const pointIds = center.ownerPointIds ?? []; const movable = pointIds.length > 0 && pointIds.every((id) => { const point = pointById.get(id); - return point && !fixedPointIds.has(id); + return point && !point.projectionId && !fixedPointIds.has(id); }); const dragging = pointerAction?.kind === "move-center" && pointerAction.centerId === center.id; return ( @@ -1246,10 +1247,10 @@ export function SketchWorkspace({ {displayProfile.segments.map((segment) => ( { if (tool !== "refine") return; const target = pointFromEvent(event); @@ -1344,7 +1345,7 @@ export function SketchWorkspace({ } const segment = displayProfile.segments.find((entry) => entry.id === storedDimension.segmentId); const dimension = segment ? segmentDimension(segment, pointById) : null; - if (!segment || !dimension) return null; + if (!segment || !dimension || segment.projectionId) return null; const label = formatDimension(storedDimension.value, workspace.accuracy); const pill = dimensionPillSize(label, screenUnit, 18); const defaultPosition = { @@ -1659,12 +1660,12 @@ export function SketchWorkspace({ {displayProfile.points.map((point) => ( { event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/lib/constructionPlanes.ts b/apps/web/src/lib/constructionPlanes.ts new file mode 100644 index 0000000..b391ac1 --- /dev/null +++ b/apps/web/src/lib/constructionPlanes.ts @@ -0,0 +1,372 @@ +export type Vector3Tuple = [number, number, number]; +export type QuaternionTuple = [number, number, number, number]; + +export type ConstructionPlanePose = { + origin: Vector3Tuple; + quaternion: QuaternionTuple; +}; + +export type PrincipalPlane = "xz" | "xy" | "yz"; + +export type ConstructionPlaneSourceShape = { + x: number; + z: number; + elevation?: number; + height: number; + rotation: number; + rotationX?: number; + rotationZ?: number; + width: number; + depth: number; +}; + +export type ConstructionPlaneAttachment = { + normalizedOrigin: Vector3Tuple; + localQuaternion: QuaternionTuple; +}; + +type Vector3Like = readonly [number, number, number]; +type QuaternionLike = readonly [number, number, number, number]; + +const EPSILON = 1e-12; +const DEGREES_TO_RADIANS = Math.PI / 180; + +export const BASE_CONSTRUCTION_PLANE_POSE: ConstructionPlanePose = { + origin: [0, 0, 0], + quaternion: [0, 0, 0, 1], +}; + +export const IDENTITY_CONSTRUCTION_PLANE_POSE = BASE_CONSTRUCTION_PLANE_POSE; + +function dot(a: Vector3Like, b: Vector3Like) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function cross(a: Vector3Like, b: Vector3Like): Vector3Tuple { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +function normalizeVector(vector: Vector3Like): Vector3Tuple { + const length = Math.hypot(vector[0], vector[1], vector[2]); + if (length < EPSILON) { + throw new RangeError("Cannot normalize a zero-length vector"); + } + return [vector[0] / length, vector[1] / length, vector[2] / length]; +} + +export function identityConstructionPlanePose(): ConstructionPlanePose { + return { origin: [0, 0, 0], quaternion: [0, 0, 0, 1] }; +} + +export function normalizeQuaternion(quaternion: QuaternionLike): QuaternionTuple { + const length = Math.hypot(quaternion[0], quaternion[1], quaternion[2], quaternion[3]); + if (length < EPSILON) { + return [0, 0, 0, 1]; + } + return [ + quaternion[0] / length, + quaternion[1] / length, + quaternion[2] / length, + quaternion[3] / length, + ]; +} + +export function conjugateQuaternion(quaternion: QuaternionLike): QuaternionTuple { + const normalized = normalizeQuaternion(quaternion); + return [-normalized[0], -normalized[1], -normalized[2], normalized[3]]; +} + +export function multiplyQuaternions(a: QuaternionLike, b: QuaternionLike): QuaternionTuple { + const [ax, ay, az, aw] = normalizeQuaternion(a); + const [bx, by, bz, bw] = normalizeQuaternion(b); + return normalizeQuaternion([ + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz, + ]); +} + +export function quaternionFromEulerXYZDegrees(rotationX: number, rotationY: number, rotationZ: number): QuaternionTuple { + const x = rotationX * DEGREES_TO_RADIANS / 2; + const y = rotationY * DEGREES_TO_RADIANS / 2; + const z = rotationZ * DEGREES_TO_RADIANS / 2; + const c1 = Math.cos(x); + const c2 = Math.cos(y); + const c3 = Math.cos(z); + const s1 = Math.sin(x); + const s2 = Math.sin(y); + const s3 = Math.sin(z); + + // Matches THREE.Euler(rx, ry, rz, "XYZ"). + return normalizeQuaternion([ + s1 * c2 * c3 + c1 * s2 * s3, + c1 * s2 * c3 - s1 * c2 * s3, + c1 * c2 * s3 + s1 * s2 * c3, + c1 * c2 * c3 - s1 * s2 * s3, + ]); +} + +export function rotateDirection(direction: Vector3Like, quaternion: QuaternionLike): Vector3Tuple { + const [x, y, z, w] = normalizeQuaternion(quaternion); + const quaternionVector: Vector3Tuple = [x, y, z]; + const uv = cross(quaternionVector, direction); + const uuv = cross(quaternionVector, uv); + return [ + direction[0] + 2 * (w * uv[0] + uuv[0]), + direction[1] + 2 * (w * uv[1] + uuv[1]), + direction[2] + 2 * (w * uv[2] + uuv[2]), + ]; +} + +export function localDirectionToWorld(pose: ConstructionPlanePose, direction: Vector3Like): Vector3Tuple { + return rotateDirection(direction, pose.quaternion); +} + +export function worldDirectionToLocal(pose: ConstructionPlanePose, direction: Vector3Like): Vector3Tuple { + return rotateDirection(direction, conjugateQuaternion(pose.quaternion)); +} + +export function localPointToWorld(pose: ConstructionPlanePose, point: Vector3Like): Vector3Tuple { + const rotated = localDirectionToWorld(pose, point); + return [rotated[0] + pose.origin[0], rotated[1] + pose.origin[1], rotated[2] + pose.origin[2]]; +} + +export function worldPointToLocal(pose: ConstructionPlanePose, point: Vector3Like): Vector3Tuple { + return worldDirectionToLocal(pose, [ + point[0] - pose.origin[0], + point[1] - pose.origin[1], + point[2] - pose.origin[2], + ]); +} + +function quaternionFromBasis(xAxis: Vector3Like, yAxis: Vector3Like, zAxis: Vector3Like): QuaternionTuple { + // Matrix columns are the world-space directions of the local axes. + const m11 = xAxis[0]; + const m12 = yAxis[0]; + const m13 = zAxis[0]; + const m21 = xAxis[1]; + const m22 = yAxis[1]; + const m23 = zAxis[1]; + const m31 = xAxis[2]; + const m32 = yAxis[2]; + const m33 = zAxis[2]; + const trace = m11 + m22 + m33; + let quaternion: QuaternionTuple; + + if (trace > 0) { + const s = 0.5 / Math.sqrt(trace + 1); + quaternion = [(m32 - m23) * s, (m13 - m31) * s, (m21 - m12) * s, 0.25 / s]; + } else if (m11 > m22 && m11 > m33) { + const s = 2 * Math.sqrt(1 + m11 - m22 - m33); + quaternion = [0.25 * s, (m12 + m21) / s, (m13 + m31) / s, (m32 - m23) / s]; + } else if (m22 > m33) { + const s = 2 * Math.sqrt(1 + m22 - m11 - m33); + quaternion = [(m12 + m21) / s, 0.25 * s, (m23 + m32) / s, (m13 - m31) / s]; + } else { + const s = 2 * Math.sqrt(1 + m33 - m11 - m22); + quaternion = [(m13 + m31) / s, (m23 + m32) / s, 0.25 * s, (m21 - m12) / s]; + } + return normalizeQuaternion(quaternion); +} + +export function poseFromWorldOriginAndNormal( + origin: Vector3Like, + normal: Vector3Like, + preferredXAxis: Vector3Like = [1, 0, 0], +): ConstructionPlanePose { + const yAxis = normalizeVector(normal); + const preferred = normalizeVector(preferredXAxis); + const fallbackAxes: Vector3Like[] = [[0, 0, 1], [0, 1, 0], [1, 0, 0]]; + const reference = Math.abs(dot(preferred, yAxis)) < 0.999 + ? preferred + : fallbackAxes.reduce((best, candidate) => ( + Math.abs(dot(candidate, yAxis)) < Math.abs(dot(best, yAxis)) ? candidate : best + )); + const projection = dot(reference, yAxis); + const xAxis = normalizeVector([ + reference[0] - projection * yAxis[0], + reference[1] - projection * yAxis[1], + reference[2] - projection * yAxis[2], + ]); + const zAxis = normalizeVector(cross(xAxis, yAxis)); + + return { + origin: [origin[0], origin[1], origin[2]], + quaternion: quaternionFromBasis(xAxis, yAxis, zAxis), + }; +} + +export const constructionPlanePoseFromNormal = poseFromWorldOriginAndNormal; + +export function angledConstructionPlanePose( + basePose: ConstructionPlanePose, + angleDegrees: number, +): ConstructionPlanePose { + // Local X is the hinge axis; local Y remains the plane normal. + const angleQuaternion = quaternionFromEulerXYZDegrees(angleDegrees, 0, 0); + return { + origin: [...basePose.origin], + quaternion: multiplyQuaternions(basePose.quaternion, angleQuaternion), + }; +} + +export function flipConstructionPlanePose(pose: ConstructionPlanePose): ConstructionPlanePose { + const flipQuaternion = quaternionFromEulerXYZDegrees(180, 0, 0); + return { + origin: [...pose.origin], + quaternion: multiplyQuaternions(pose.quaternion, flipQuaternion), + }; +} + +export function offsetConstructionPlanePose( + pose: ConstructionPlanePose, + offset: number, +): ConstructionPlanePose { + const normal = localDirectionToWorld(pose, [0, 1, 0]); + return { + origin: [ + pose.origin[0] + normal[0] * offset, + pose.origin[1] + normal[1] * offset, + pose.origin[2] + normal[2] * offset, + ], + quaternion: [...pose.quaternion], + }; +} + +export function constructionPlanePosesAreParallel( + poseA: ConstructionPlanePose, + poseB: ConstructionPlanePose, +) { + const normalA = localDirectionToWorld(poseA, [0, 1, 0]); + const normalB = localDirectionToWorld(poseB, [0, 1, 0]); + const dotProduct = normalA[0] * normalB[0] + normalA[1] * normalB[1] + normalA[2] * normalB[2]; + return Math.abs(dotProduct) >= 0.999; +} + +export function midplaneConstructionPlanePose( + poseA: ConstructionPlanePose, + poseB: ConstructionPlanePose, + offset = 0, +): ConstructionPlanePose { + const midOrigin: Vector3Tuple = [ + (poseA.origin[0] + poseB.origin[0]) / 2, + (poseA.origin[1] + poseB.origin[1]) / 2, + (poseA.origin[2] + poseB.origin[2]) / 2, + ]; + let basePose: ConstructionPlanePose = { + origin: midOrigin, + quaternion: [...poseA.quaternion], + }; + if (offset !== 0) basePose = offsetConstructionPlanePose(basePose, offset); + return basePose; +} + +export function principalPlanePose( + plane: PrincipalPlane, + normalOffset = 0, + angleDegrees = 0, + flipped = false, +): ConstructionPlanePose { + let base: ConstructionPlanePose; + if (plane === "xz") { + base = poseFromWorldOriginAndNormal([0, normalOffset, 0], [0, 1, 0]); + } else if (plane === "xy") { + base = poseFromWorldOriginAndNormal([0, 0, normalOffset], [0, 0, 1]); + } else { + base = poseFromWorldOriginAndNormal([normalOffset, 0, 0], [1, 0, 0]); + } + if (angleDegrees !== 0) { + base = angledConstructionPlanePose(base, angleDegrees); + } + if (flipped) { + base = flipConstructionPlanePose(base); + } + return base; +} + +export const principalConstructionPlanePose = principalPlanePose; + +export function sourceShapePose(source: ConstructionPlaneSourceShape): ConstructionPlanePose { + return { + origin: [source.x, (source.elevation ?? 0) + source.height / 2, source.z], + quaternion: quaternionFromEulerXYZDegrees( + source.rotationX ?? 0, + source.rotation, + source.rotationZ ?? 0, + ), + }; +} + +export function shapeCenterInConstructionPlane(source: ConstructionPlaneSourceShape, pose: ConstructionPlanePose): Vector3Tuple { + return worldPointToLocal(pose, [source.x, (source.elevation ?? 0) + source.height / 2, source.z]); +} + +export function reconcileShapeCenterInConstructionPlane( + source: ConstructionPlaneSourceShape, + previous: ConstructionPlaneSourceShape | undefined, + pose: ConstructionPlanePose, + localCenter: Vector3Like, +): Vector3Tuple { + if (!previous || ( + Math.abs(source.x - previous.x) <= 1e-9 + && Math.abs(source.z - previous.z) <= 1e-9 + && Math.abs((source.elevation ?? 0) - (previous.elevation ?? 0)) <= 1e-9 + )) { + return [localCenter[0], localCenter[1], localCenter[2]]; + } + return shapeCenterInConstructionPlane(source, pose); +} + +function normalizedCoordinate(value: number, dimension: number) { + return Math.abs(dimension) < EPSILON ? 0 : value / dimension; +} + +export function constructionPlaneAttachmentFromWorldPose( + worldPose: ConstructionPlanePose, + source: ConstructionPlaneSourceShape, +): ConstructionPlaneAttachment { + const sourcePose = sourceShapePose(source); + const localOrigin = worldPointToLocal(sourcePose, worldPose.origin); + return { + normalizedOrigin: [ + normalizedCoordinate(localOrigin[0], source.width), + normalizedCoordinate(localOrigin[1], source.height), + normalizedCoordinate(localOrigin[2], source.depth), + ], + localQuaternion: multiplyQuaternions(conjugateQuaternion(sourcePose.quaternion), worldPose.quaternion), + }; +} + +export function resolveConstructionPlaneAttachment( + attachment: ConstructionPlaneAttachment, + source: ConstructionPlaneSourceShape, + offset = 0, + angleDegrees = 0, + flipped = false, +): ConstructionPlanePose { + const sourcePose = sourceShapePose(source); + let basePose: ConstructionPlanePose = { + origin: localPointToWorld(sourcePose, [ + attachment.normalizedOrigin[0] * source.width, + attachment.normalizedOrigin[1] * source.height, + attachment.normalizedOrigin[2] * source.depth, + ]), + quaternion: multiplyQuaternions(sourcePose.quaternion, attachment.localQuaternion), + }; + if (offset !== 0) basePose = offsetConstructionPlanePose(basePose, offset); + if (angleDegrees !== 0) { + basePose = angledConstructionPlanePose(basePose, angleDegrees); + } + if (flipped) { + basePose = flipConstructionPlanePose(basePose); + } + return basePose; +} + +export const worldPoseToConstructionPlaneAttachment = constructionPlaneAttachmentFromWorldPose; +export const constructionPlaneAttachmentToWorldPose = resolveConstructionPlaneAttachment; diff --git a/apps/web/src/lib/sketchProjection.ts b/apps/web/src/lib/sketchProjection.ts new file mode 100644 index 0000000..3a7e734 --- /dev/null +++ b/apps/web/src/lib/sketchProjection.ts @@ -0,0 +1,341 @@ +import { + localPointToWorld, + worldPointToLocal, + type ConstructionPlanePose, + type Vector3Tuple, +} from "@/lib/constructionPlanes"; +import type { SketchPoint, SketchProfile, SketchSegment } from "@/types/sketchforge"; + +export type SketchProjectionIdFactory = (prefix: string) => string; +export type ProjectedSketchPoint = SketchPoint & { projectionId?: string }; +export type ProjectedSketchSegment = SketchSegment & { projectionId?: string }; + +export type SketchProjectionResult = { + points: ProjectedSketchPoint[]; + segments: ProjectedSketchSegment[]; +}; + +export type TriangleFace = readonly [number, number, number]; + +type Point2D = { x: number; z: number }; +type InternedPoint = Point2D & { index: number }; +type IndexedEdge = { start: number; end: number }; + +const MIN_GEOMETRY_TOLERANCE = 1e-9; +const RELATIVE_GEOMETRY_TOLERANCE = 64 * Number.EPSILON; + +function toleranceForCoordinates(coordinates: Iterable) { + let scale = 1; + for (const coordinate of coordinates) scale = Math.max(scale, Math.abs(coordinate)); + return Math.max(MIN_GEOMETRY_TOLERANCE, scale * RELATIVE_GEOMETRY_TOLERANCE); +} + +function distanceSquared(left: Point2D, right: Point2D) { + const deltaX = left.x - right.x; + const deltaZ = left.z - right.z; + return deltaX * deltaX + deltaZ * deltaZ; +} + +function edgeKey(start: number, end: number) { + return start < end ? `${start}:${end}` : `${end}:${start}`; +} + +function projectionStamp(projectionId: string | undefined) { + return projectionId === undefined ? {} : { projectionId }; +} + +function freshId(createId: SketchProjectionIdFactory, prefix: string, usedIds: Set) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const id = createId(prefix); + if (id && !usedIds.has(id)) { + usedIds.add(id); + return id; + } + } + throw new Error(`ID factory did not produce a fresh ${prefix} ID`); +} + +function createPointInterner(tolerance: number) { + const points: InternedPoint[] = []; + const cells = new Map(); + const toleranceSquared = tolerance * tolerance; + + const cellKey = (x: number, z: number) => `${x}:${z}`; + const intern = (point: Point2D) => { + const cellX = Math.floor(point.x / tolerance); + const cellZ = Math.floor(point.z / tolerance); + for (let deltaX = -1; deltaX <= 1; deltaX += 1) { + for (let deltaZ = -1; deltaZ <= 1; deltaZ += 1) { + for (const index of cells.get(cellKey(cellX + deltaX, cellZ + deltaZ)) ?? []) { + if (distanceSquared(points[index], point) <= toleranceSquared) return points[index]; + } + } + } + + const interned = { ...point, index: points.length }; + points.push(interned); + const key = cellKey(cellX, cellZ); + cells.set(key, [...(cells.get(key) ?? []), interned.index]); + return interned; + }; + + return { points, intern }; +} + +function farthestPair(indices: readonly number[], points: readonly Point2D[]) { + let pair: IndexedEdge | null = null; + let largestDistance = 0; + for (let first = 0; first < indices.length; first += 1) { + for (let second = first + 1; second < indices.length; second += 1) { + const candidateDistance = distanceSquared(points[indices[first]], points[indices[second]]); + if (candidateDistance > largestDistance) { + largestDistance = candidateDistance; + pair = { start: indices[first], end: indices[second] }; + } + } + } + return pair; +} + +/** Intersects world-space mesh triangles with the target plane's local y=0 plane. */ +export function intersectMeshWithPlane( + worldVertices: readonly (readonly [number, number, number])[], + triangleFaces: readonly TriangleFace[], + targetPose: ConstructionPlanePose, + createId: SketchProjectionIdFactory, + projectionId?: string, +): SketchProjectionResult { + const localVertices = worldVertices.map((vertex, index) => { + if (!vertex.every(Number.isFinite)) throw new Error(`Invalid mesh vertex coordinates at index ${index}`); + const local = worldPointToLocal(targetPose, vertex); + if (!local.every(Number.isFinite)) throw new Error(`Invalid transformed mesh vertex at index ${index}`); + return local; + }); + const tolerance = toleranceForCoordinates(localVertices.flat()); + const { points: internedPoints, intern } = createPointInterner(tolerance); + const regularEdges = new Map(); + const coplanarEdges = new Map(); + + const addRegularEdge = (start: number, end: number) => { + if (start === end) return; + regularEdges.set(edgeKey(start, end), { start, end }); + }; + const addCoplanarEdge = (start: number, end: number) => { + if (start === end) return; + const key = edgeKey(start, end); + const existing = coplanarEdges.get(key); + if (existing) existing.count += 1; + else coplanarEdges.set(key, { start, end, count: 1 }); + }; + + for (let faceIndex = 0; faceIndex < triangleFaces.length; faceIndex += 1) { + const face = triangleFaces[faceIndex]; + const triangle = face.map((vertexIndex) => { + if (!Number.isInteger(vertexIndex) || vertexIndex < 0 || vertexIndex >= localVertices.length) { + throw new Error(`Invalid vertex reference in triangle face ${faceIndex}`); + } + return localVertices[vertexIndex]; + }) as [Vector3Tuple, Vector3Tuple, Vector3Tuple]; + const sides = triangle.map((point) => ( + Math.abs(point[1]) <= tolerance ? 0 : Math.sign(point[1]) + )); + + if (sides.every((side) => side === 0)) { + const indices = triangle.map((point) => intern({ x: point[0], z: point[2] }).index); + addCoplanarEdge(indices[0], indices[1]); + addCoplanarEdge(indices[1], indices[2]); + addCoplanarEdge(indices[2], indices[0]); + continue; + } + + const intersections: number[] = []; + for (let index = 0; index < 3; index += 1) { + if (sides[index] === 0) { + const point = triangle[index]; + intersections.push(intern({ x: point[0], z: point[2] }).index); + } + } + for (const [first, second] of [[0, 1], [1, 2], [2, 0]] as const) { + if (sides[first] * sides[second] >= 0) continue; + const start = triangle[first]; + const end = triangle[second]; + const amount = start[1] / (start[1] - end[1]); + intersections.push(intern({ + x: start[0] + (end[0] - start[0]) * amount, + z: start[2] + (end[2] - start[2]) * amount, + }).index); + } + + const uniqueIntersections = [...new Set(intersections)]; + const pair = farthestPair(uniqueIntersections, internedPoints); + if (pair) addRegularEdge(pair.start, pair.end); + } + + const sectionEdges = new Map(regularEdges); + for (const [key, edge] of coplanarEdges) { + if (edge.count === 1 && !sectionEdges.has(key)) sectionEdges.set(key, edge); + } + if (sectionEdges.size === 0) return { points: [], segments: [] }; + + const usedPointIndices = new Set(); + for (const edge of sectionEdges.values()) { + usedPointIndices.add(edge.start); + usedPointIndices.add(edge.end); + } + const usedIds = new Set(); + const pointIdByIndex = new Map(); + const points: ProjectedSketchPoint[] = []; + for (const point of internedPoints) { + if (!usedPointIndices.has(point.index)) continue; + const id = freshId(createId, "sketch-point", usedIds); + pointIdByIndex.set(point.index, id); + points.push({ id, x: point.x, z: point.z, mode: "corner", ...projectionStamp(projectionId) }); + } + const segments: ProjectedSketchSegment[] = [...sectionEdges.values()].map((edge) => ({ + id: freshId(createId, "sketch-segment", usedIds), + startId: pointIdByIndex.get(edge.start)!, + endId: pointIdByIndex.get(edge.end)!, + kind: "line", + ...projectionStamp(projectionId), + })); + + return { points, segments }; +} + +function allProfileIds(profile: SketchProfile) { + return 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), + ]); +} + +function projectLocalPoint( + point: Point2D, + sourcePose: ConstructionPlanePose, + targetPose: ConstructionPlanePose, + translation: readonly [number, number, number], +) { + const world = localPointToWorld(sourcePose, [ + point.x + translation[0], + translation[1], + point.z + translation[2], + ]); + const target = worldPointToLocal(targetPose, world); + return { x: target[0], z: target[2] }; +} + +function projectedSegmentIsDegenerate( + segment: SketchSegment, + start: ProjectedSketchPoint, + end: ProjectedSketchPoint, + toleranceSquared: number, +) { + if (distanceSquared(start, end) > toleranceSquared) return false; + if (segment.kind === "line" || !start.handleOut || !end.handleIn) return true; + return distanceSquared(start, start.handleOut) <= toleranceSquared + && distanceSquared(start, end.handleIn) <= toleranceSquared; +} + +export function projectSketchProfileToPlane( + source: SketchProfile, + sourcePose: ConstructionPlanePose, + targetPose: ConstructionPlanePose, + createId: SketchProjectionIdFactory, + projectionId?: string, +): SketchProjectionResult; +export function projectSketchProfileToPlane( + source: SketchProfile, + sourcePose: ConstructionPlanePose, + targetPose: ConstructionPlanePose, + sourceLocalTranslation: readonly [number, number, number], + createId: SketchProjectionIdFactory, + projectionId?: string, +): SketchProjectionResult; +/** Orthogonally projects source sketch geometry into the target plane's local x/z coordinates. */ +export function projectSketchProfileToPlane( + source: SketchProfile, + sourcePose: ConstructionPlanePose, + targetPose: ConstructionPlanePose, + translationOrCreateId: readonly [number, number, number] | SketchProjectionIdFactory, + createIdOrProjectionId?: SketchProjectionIdFactory | string, + requestedProjectionId?: string, +): SketchProjectionResult { + const sourceLocalTranslation: readonly [number, number, number] = typeof translationOrCreateId === "function" + ? [0, 0, 0] + : translationOrCreateId; + const createId = typeof translationOrCreateId === "function" + ? translationOrCreateId + : createIdOrProjectionId as SketchProjectionIdFactory; + const projectionId = typeof translationOrCreateId === "function" + ? createIdOrProjectionId as string | undefined + : requestedProjectionId; + if (typeof createId !== "function") throw new Error("A sketch projection ID factory is required"); + if (!sourceLocalTranslation.every(Number.isFinite)) throw new Error("Source sketch translation must be finite"); + + const usedIds = allProfileIds(source); + const sourceIds = new Set(); + const pointBySourceId = new Map(); + const points: ProjectedSketchPoint[] = source.points.map((sourcePoint) => { + if (sourceIds.has(sourcePoint.id)) throw new Error(`Invalid sketch topology: duplicate point ID ${sourcePoint.id}`); + sourceIds.add(sourcePoint.id); + if (![sourcePoint.x, sourcePoint.z].every(Number.isFinite)) { + throw new Error(`Invalid sketch point coordinates at ${sourcePoint.id}`); + } + if (sourcePoint.handleIn && ![sourcePoint.handleIn.x, sourcePoint.handleIn.z].every(Number.isFinite)) { + throw new Error(`Invalid incoming handle coordinates at ${sourcePoint.id}`); + } + if (sourcePoint.handleOut && ![sourcePoint.handleOut.x, sourcePoint.handleOut.z].every(Number.isFinite)) { + throw new Error(`Invalid outgoing handle coordinates at ${sourcePoint.id}`); + } + + const position = projectLocalPoint(sourcePoint, sourcePose, targetPose, sourceLocalTranslation); + const handleIn = sourcePoint.handleIn + ? projectLocalPoint(sourcePoint.handleIn, sourcePose, targetPose, sourceLocalTranslation) + : undefined; + const handleOut = sourcePoint.handleOut + ? projectLocalPoint(sourcePoint.handleOut, sourcePose, targetPose, sourceLocalTranslation) + : undefined; + const projected: ProjectedSketchPoint = { + id: freshId(createId, "sketch-point", usedIds), + ...position, + ...(handleIn ? { handleIn } : {}), + ...(handleOut ? { handleOut } : {}), + ...(sourcePoint.mode ? { mode: sourcePoint.mode } : {}), + ...projectionStamp(projectionId), + }; + pointBySourceId.set(sourcePoint.id, projected); + return projected; + }); + + const coordinates = points.flatMap((point) => [ + point.x, + point.z, + ...(point.handleIn ? [point.handleIn.x, point.handleIn.z] : []), + ...(point.handleOut ? [point.handleOut.x, point.handleOut.z] : []), + ]); + const tolerance = toleranceForCoordinates(coordinates); + const toleranceSquared = tolerance * tolerance; + const segments: ProjectedSketchSegment[] = []; + for (const sourceSegment of source.segments) { + const start = pointBySourceId.get(sourceSegment.startId); + const end = pointBySourceId.get(sourceSegment.endId); + if (!start || !end) throw new Error(`Invalid point reference in sketch segment ${sourceSegment.id}`); + if (projectedSegmentIsDegenerate(sourceSegment, start, end, toleranceSquared)) continue; + segments.push({ + id: freshId(createId, "sketch-segment", usedIds), + startId: start.id, + endId: end.id, + ...(sourceSegment.kind ? { kind: sourceSegment.kind } : {}), + ...projectionStamp(projectionId), + }); + } + + return { points, segments }; +} + +export const intersectMeshWithSketchPlane = intersectMeshWithPlane; +export const projectPreviousSketchToPlane = projectSketchProfileToPlane; diff --git a/apps/web/src/lib/sketchSweep.ts b/apps/web/src/lib/sketchSweep.ts new file mode 100644 index 0000000..ddf0ee4 --- /dev/null +++ b/apps/web/src/lib/sketchSweep.ts @@ -0,0 +1,110 @@ +import * as THREE from "three"; +import { orderedCadSketchPaths, type OrderedCadSketchPath, type OrderedCadSketchStep } from "@/lib/sketchCadProfile"; +import type { SketchProfile } from "@/types/sketchforge"; + +export type ResolvedSketchSweep = { + section: OrderedCadSketchPath; + path: OrderedCadSketchPath; +}; + +export type SketchSweepGeometry = ResolvedSketchSweep & { + geometry: THREE.ExtrudeGeometry; +}; + +export function resolveSketchSweep(profile: SketchProfile, selectedSegmentIds: readonly string[]): ResolvedSketchSweep { + const selectedIds = new Set(selectedSegmentIds); + const selectedSegments = profile.segments.filter((segment) => selectedIds.has(segment.id)); + if (selectedSegments.length < 4) { + throw new Error("Select one closed profile and one open path before sweeping"); + } + + const selectedProfile: SketchProfile = { ...profile, segments: selectedSegments }; + const paths = orderedCadSketchPaths(selectedProfile); + const closed = paths.filter((candidate) => candidate.closed); + const open = paths.filter((candidate) => !candidate.closed); + if (closed.length !== 1 || open.length !== 1 || paths.length !== 2) { + throw new Error("Sweep needs exactly one closed profile and one separate open path"); + } + if (open[0].steps.length < 1) { + throw new Error("The sweep path must contain at least one segment"); + } + return { section: closed[0], path: open[0] }; +} + +function addSectionStep(shape: THREE.Shape, step: OrderedCadSketchStep, centerX: number, centerZ: number) { + const { segment, from, to } = step; + 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) { + shape.bezierCurveTo( + first.x - centerX, + -(first.z - centerZ), + second.x - centerX, + -(second.z - centerZ), + to.x - centerX, + -(to.z - centerZ), + ); + return; + } + shape.lineTo(to.x - centerX, -(to.z - centerZ)); +} + +function orientedPathSteps(path: OrderedCadSketchPath, sectionCenter: { x: number; z: number }) { + const first = path.steps[0]?.from; + const last = path.steps.at(-1)?.to; + if (!first || !last) return path.steps; + const firstDistance = Math.hypot(first.x - sectionCenter.x, first.z - sectionCenter.z); + const lastDistance = Math.hypot(last.x - sectionCenter.x, last.z - sectionCenter.z); + if (firstDistance <= lastDistance) return path.steps; + return [...path.steps].reverse().map((step) => ({ ...step, from: step.to, to: step.from })); +} + +function addSweepPathStep(path: THREE.CurvePath, step: OrderedCadSketchStep) { + const { segment, from, to } = step; + const start = new THREE.Vector3(from.x, 0, from.z); + const end = new THREE.Vector3(to.x, 0, to.z); + 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) { + path.add(new THREE.CubicBezierCurve3( + start, + new THREE.Vector3(first.x, 0, first.z), + new THREE.Vector3(second.x, 0, second.z), + end, + )); + return; + } + path.add(new THREE.LineCurve3(start, end)); +} + +export function buildSketchSweepGeometry(profile: SketchProfile, selectedSegmentIds: readonly string[]): SketchSweepGeometry { + const resolved = resolveSketchSweep(profile, selectedSegmentIds); + const sectionXs = resolved.section.points.map((point) => point.x); + const sectionZs = resolved.section.points.map((point) => point.z); + const center = { + x: (Math.min(...sectionXs) + Math.max(...sectionXs)) / 2, + z: (Math.min(...sectionZs) + Math.max(...sectionZs)) / 2, + }; + const section = new THREE.Shape(); + const first = resolved.section.steps[0]?.from; + if (!first) throw new Error("The sweep profile is empty"); + section.moveTo(first.x - center.x, -(first.z - center.z)); + resolved.section.steps.forEach((step) => addSectionStep(section, step, center.x, center.z)); + section.closePath(); + + const path = new THREE.CurvePath(); + const pathSteps = orientedPathSteps(resolved.path, center); + pathSteps.forEach((step) => addSweepPathStep(path, step)); + const approximateLength = pathSteps.reduce((length, step) => length + Math.hypot(step.to.x - step.from.x, step.to.z - step.from.z), 0); + const steps = Math.min(256, Math.max(12, Math.ceil(approximateLength * 1.5), pathSteps.length * 8)); + const geometry = new THREE.ExtrudeGeometry(section, { + steps, + bevelEnabled: false, + extrudePath: path, + curveSegments: 24, + }); + geometry.computeVertexNormals(); + return { ...resolved, geometry }; +} diff --git a/apps/web/src/lib/skfProject.ts b/apps/web/src/lib/skfProject.ts index c5ade76..cc9de0b 100644 --- a/apps/web/src/lib/skfProject.ts +++ b/apps/web/src/lib/skfProject.ts @@ -461,6 +461,7 @@ async function serializeShapeNode( ...((sketchProfile.constraints?.length ?? 0) > 0 ? { constraints: sketchProfile.constraints } : {}), ...((sketchProfile.dimensions?.length ?? 0) > 0 ? { dimensions: sketchProfile.dimensions } : {}), ...((sketchProfile.texts?.length ?? 0) > 0 ? { texts: sketchProfile.texts } : {}), + ...((sketchProfile.projections?.length ?? 0) > 0 ? { projections: sketchProfile.projections } : {}), ...(images.length ? { images } : {}), }; } @@ -580,7 +581,7 @@ function activeProjectIndexes(state: SkfStateV1) { if (!node) return; (node.groupedShapeNodeIds ?? []).forEach(visit); let previous: string | undefined; - if (node.definition.sketchProfile) { + if (node.definition.sketchProfile && (node.definition.sketchFeature as { kind?: unknown } | undefined)?.kind !== "sweep") { const operation = node.definition.sketchOperation === "revolve" ? "revolve" : "extrude"; const featureType = operation === "revolve" ? "sketch-revolve" : "sketch-extrusion"; const featureId = `feature/${safeNodeToken(node.nodeId)}/${featureType}`; @@ -855,6 +856,7 @@ function validateSketchProfile(value: unknown, label: string) { finiteNumber(offset.x, `${label}.segments[${index}].dimensionLabelOffset.x`); finiteNumber(offset.z, `${label}.segments[${index}].dimensionLabelOffset.z`); } + if (segment.projectionId !== undefined) stringValue(segment.projectionId, `${label}.segments[${index}].projectionId`); }); const parameterIds = new Set(); if (profile.constraints !== undefined && !Array.isArray(profile.constraints)) throw new Error(`${label}.constraints must be an array`); @@ -919,6 +921,21 @@ function validateSketchProfile(value: unknown, label: string) { 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`); }); + if (profile.projections !== undefined && !Array.isArray(profile.projections)) throw new Error(`${label}.projections must be an array`); + const projectionIds = new Set(); + (profile.projections as unknown[] | undefined)?.forEach((rawProjection, index) => { + const projection = objectRecord(rawProjection, `${label}.projections[${index}]`); + const id = stringValue(projection.id, `${label}.projections[${index}].id`); + if (projectionIds.has(id)) throw new Error(`${label} contains duplicate projection ID '${id}'`); + projectionIds.add(id); + stringValue(projection.sourceShapeId, `${label}.projections[${index}].sourceShapeId`); + stringValue(projection.sourceName, `${label}.projections[${index}].sourceName`); + if (projection.sourceKind !== "sketch" && projection.sourceKind !== "intersection") throw new Error(`${label}.projections[${index}] has an unknown source kind`); + }); + profile.points.forEach((rawPoint, index) => { + const point = rawPoint as Record; + if (point.projectionId !== undefined) stringValue(point.projectionId, `${label}.points[${index}].projectionId`); + }); } function validateShapeDefinition(definition: Record, label: string) { diff --git a/apps/web/src/types/sketchforge.ts b/apps/web/src/types/sketchforge.ts index 1fefbd7..ddbadcc 100644 --- a/apps/web/src/types/sketchforge.ts +++ b/apps/web/src/types/sketchforge.ts @@ -80,6 +80,7 @@ export type SketchPoint = { handleIn?: { x: number; z: number }; handleOut?: { x: number; z: number }; mode?: "corner" | "smooth" | "split"; + projectionId?: string; }; export type SketchSegment = { @@ -88,6 +89,14 @@ export type SketchSegment = { endId: string; kind?: "line" | "bezier" | "smooth"; dimensionLabelOffset?: { x: number; z: number }; + projectionId?: string; +}; + +export type SketchProjectionLink = { + id: string; + sourceShapeId: string; + sourceName: string; + sourceKind: "sketch" | "intersection"; }; export type SketchConstraint = @@ -133,10 +142,12 @@ export type SketchProfile = { dimensions?: SketchDimension[]; images?: SketchImage[]; texts?: SketchText[]; + projections?: SketchProjectionLink[]; }; export type SketchFeature = - | { kind: "extrusion"; regionIds?: string[] }; + | { kind: "extrusion"; regionIds?: string[] } + | { kind: "sweep"; sectionSegmentIds: string[]; pathSegmentIds: string[] }; export type SketchOperation = "extrude" | "revolve"; diff --git a/tests/unit/constructionPlanes.test.ts b/tests/unit/constructionPlanes.test.ts new file mode 100644 index 0000000..1a88318 --- /dev/null +++ b/tests/unit/constructionPlanes.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; +import { + BASE_CONSTRUCTION_PLANE_POSE, + angledConstructionPlanePose, + constructionPlaneAttachmentFromWorldPose, + constructionPlanePosesAreParallel, + flipConstructionPlanePose, + identityConstructionPlanePose, + localDirectionToWorld, + localPointToWorld, + midplaneConstructionPlanePose, + normalizeQuaternion, + offsetConstructionPlanePose, + poseFromWorldOriginAndNormal, + principalPlanePose, + quaternionFromEulerXYZDegrees, + reconcileShapeCenterInConstructionPlane, + resolveConstructionPlaneAttachment, + shapeCenterInConstructionPlane, + sourceShapePose, + worldDirectionToLocal, + worldPointToLocal, + type ConstructionPlanePose, + type Vector3Tuple, +} from "@/lib/constructionPlanes"; + +function expectVectorClose(actual: Vector3Tuple, expected: Vector3Tuple, precision = 10) { + actual.forEach((value, index) => expect(value).toBeCloseTo(expected[index], precision)); +} + +function expectSameRotation(actual: ConstructionPlanePose, expected: ConstructionPlanePose) { + expectVectorClose(localDirectionToWorld(actual, [1, 0, 0]), localDirectionToWorld(expected, [1, 0, 0])); + expectVectorClose(localDirectionToWorld(actual, [0, 1, 0]), localDirectionToWorld(expected, [0, 1, 0])); + expectVectorClose(localDirectionToWorld(actual, [0, 0, 1]), localDirectionToWorld(expected, [0, 0, 1])); +} + +describe("construction plane poses", () => { + it("provides identity and normalized quaternion poses", () => { + expect(BASE_CONSTRUCTION_PLANE_POSE).toEqual({ origin: [0, 0, 0], quaternion: [0, 0, 0, 1] }); + expect(identityConstructionPlanePose()).toEqual(BASE_CONSTRUCTION_PLANE_POSE); + expect(normalizeQuaternion([0, 0, 0, 5])).toEqual([0, 0, 0, 1]); + expect(normalizeQuaternion([0, 0, 0, 0])).toEqual([0, 0, 0, 1]); + }); + + it.each([ + { plane: "xz" as const, offset: 7, origin: [0, 7, 0] as Vector3Tuple, normal: [0, 1, 0] as Vector3Tuple }, + { plane: "xy" as const, offset: -3, origin: [0, 0, -3] as Vector3Tuple, normal: [0, 0, 1] as Vector3Tuple }, + { plane: "yz" as const, offset: 11, origin: [11, 0, 0] as Vector3Tuple, normal: [1, 0, 0] as Vector3Tuple }, + ])("orients and offsets the $plane principal plane along its normal", ({ plane, offset, origin, normal }) => { + const pose = principalPlanePose(plane, offset); + + expectVectorClose(pose.origin, origin); + expectVectorClose(localDirectionToWorld(pose, [0, 1, 0]), normal); + const xAxis = localDirectionToWorld(pose, [1, 0, 0]); + const zAxis = localDirectionToWorld(pose, [0, 0, 1]); + expect(xAxis[0] * normal[0] + xAxis[1] * normal[1] + xAxis[2] * normal[2]).toBeCloseTo(0); + expect(zAxis[0] * normal[0] + zAxis[1] * normal[1] + zAxis[2] * normal[2]).toBeCloseTo(0); + }); + + it("angles and flips a plane around its local X axis", () => { + const base = principalPlanePose("xz", 4); + const angled = angledConstructionPlanePose(base, 90); + const flipped = flipConstructionPlanePose(angled); + + expectVectorClose(angled.origin, [0, 4, 0]); + expectVectorClose(localDirectionToWorld(angled, [0, 1, 0]), [0, 0, 1]); + expectVectorClose(localDirectionToWorld(flipped, [0, 1, 0]), [0, 0, -1]); + expectSameRotation(principalPlanePose("xz", 4, 90, true), flipped); + }); + + it("offsets a pose along its normal and creates an offset midpoint", () => { + const first = principalPlanePose("xz", 2); + const second = principalPlanePose("xz", 10, 0, true); + const perpendicular = principalPlanePose("xy", 0); + + expect(constructionPlanePosesAreParallel(first, second)).toBe(true); + expect(constructionPlanePosesAreParallel(first, perpendicular)).toBe(false); + expectVectorClose(offsetConstructionPlanePose(first, 3).origin, [0, 5, 0]); + const midpoint = midplaneConstructionPlanePose(first, second, 3); + expectVectorClose(midpoint.origin, [0, 9, 0]); + expectSameRotation(midpoint, first); + }); + + it("roundtrips points and directions through an arbitrary normalized pose", () => { + const pose: ConstructionPlanePose = { + origin: [4.5, -8, 12], + quaternion: quaternionFromEulerXYZDegrees(27, -41, 13).map((value) => value * 4) as ConstructionPlanePose["quaternion"], + }; + const localPoint: Vector3Tuple = [2.25, -6, 9.5]; + const localDirection: Vector3Tuple = [-0.5, 3, 7]; + + expectVectorClose(worldPointToLocal(pose, localPointToWorld(pose, localPoint)), localPoint); + expectVectorClose(worldDirectionToLocal(pose, localDirectionToWorld(pose, localDirection)), localDirection); + }); + + it("builds a right-handed stable face frame with local Y as its normal", () => { + const normal: Vector3Tuple = [1, 2, -3]; + const pose = poseFromWorldOriginAndNormal([5, 6, 7], normal); + const length = Math.hypot(...normal); + const expectedNormal: Vector3Tuple = normal.map((value) => value / length) as Vector3Tuple; + const xAxis = localDirectionToWorld(pose, [1, 0, 0]); + const yAxis = localDirectionToWorld(pose, [0, 1, 0]); + const zAxis = localDirectionToWorld(pose, [0, 0, 1]); + + expect(pose.origin).toEqual([5, 6, 7]); + expectVectorClose(yAxis, expectedNormal); + expect(xAxis[0]).toBeGreaterThan(0); + expectVectorClose([ + xAxis[1] * yAxis[2] - xAxis[2] * yAxis[1], + xAxis[2] * yAxis[0] - xAxis[0] * yAxis[2], + xAxis[0] * yAxis[1] - xAxis[1] * yAxis[0], + ], zAxis); + + const nearXAxis = poseFromWorldOriginAndNormal([0, 0, 0], [1, 1e-8, 0]); + expectVectorClose(localDirectionToWorld(nearXAxis, [1, 0, 0]), [0, 0, 1], 7); + }); + + it("matches the viewport's Euler XYZ convention", () => { + const pose = sourceShapePose({ + x: 3, + z: -4, + elevation: 2, + height: 8, + width: 10, + depth: 6, + rotationX: 90, + rotation: 90, + rotationZ: 0, + }); + + expectVectorClose(pose.origin, [3, 6, -4]); + // XYZ composes qx*qy*qz, so local X is first yawed toward -Z and then rolled toward +Y. + expectVectorClose(localDirectionToWorld(pose, [1, 0, 0]), [0, 1, 0]); + expectVectorClose(localDirectionToWorld(pose, [0, 1, 0]), [0, 0, 1]); + }); +}); + +describe("construction plane attachments", () => { + it("reconciles a moved sketch body's plane-local center instead of restoring its stale position", () => { + const originalBody = { + x: 2, + z: 4, + elevation: 1, + height: 4, + width: 10, + depth: 8, + rotation: 0, + }; + const movedBody = { + x: 12, + z: -7, + elevation: 3, + height: 4, + width: 10, + depth: 8, + rotation: 0, + }; + + const sidePlane = principalPlanePose("yz", 2); + const staleCenter = shapeCenterInConstructionPlane(originalBody, sidePlane); + const reconciledCenter = reconcileShapeCenterInConstructionPlane(movedBody, originalBody, sidePlane, staleCenter); + + expectVectorClose(localPointToWorld(sidePlane, reconciledCenter), [12, 5, -7]); + expect(reconcileShapeCenterInConstructionPlane(originalBody, undefined, sidePlane, staleCenter)).toEqual(staleCenter); + }); + + it("roundtrips an arbitrary world pose through a source-local attachment", () => { + const source = { + x: -5, + z: 8, + elevation: 3, + height: 12, + width: 20, + depth: 16, + rotationX: 17, + rotation: -32, + rotationZ: 9, + }; + const worldPose = poseFromWorldOriginAndNormal([7, 11, -2], [-2, 5, 3]); + const attachment = constructionPlaneAttachmentFromWorldPose(worldPose, source); + const resolved = resolveConstructionPlaneAttachment(attachment, source); + + expectVectorClose(resolved.origin, worldPose.origin); + expectSameRotation(resolved, worldPose); + expect(Math.hypot(...attachment.localQuaternion)).toBeCloseTo(1); + }); + + it("applies offset, angle, and flip modifiers to an attachment", () => { + const source = { + x: 0, + z: 0, + elevation: 0, + height: 10, + width: 10, + depth: 10, + rotation: 0, + }; + const worldPose = principalPlanePose("xz", 5); + const attachment = constructionPlaneAttachmentFromWorldPose(worldPose, source); + const resolved = resolveConstructionPlaneAttachment(attachment, source, 2, 90, true); + + expectVectorClose(resolved.origin, [0, 7, 0]); + expectVectorClose(localDirectionToWorld(resolved, [0, 1, 0]), [0, 0, -1]); + }); + + it("follows source translation, rotation, and resize using normalized local coordinates", () => { + const initialSource = { + x: 0, + z: 0, + elevation: 2, + height: 8, + width: 10, + depth: 6, + rotationX: 0, + rotation: 0, + rotationZ: 0, + }; + const rightFace = poseFromWorldOriginAndNormal([5, 10, 0], [1, 0, 0]); + const attachment = constructionPlaneAttachmentFromWorldPose(rightFace, initialSource); + + expectVectorClose(attachment.normalizedOrigin, [0.5, 0.5, 0]); + + const changedSource = { + ...initialSource, + x: 10, + z: -3, + elevation: 4, + height: 12, + width: 20, + depth: 12, + rotation: 90, + }; + const resolved = resolveConstructionPlaneAttachment(attachment, changedSource); + + expectVectorClose(resolved.origin, [10, 16, -13]); + expectVectorClose(localDirectionToWorld(resolved, [0, 1, 0]), [0, 0, -1]); + }); +}); diff --git a/tests/unit/sketchProjection.test.ts b/tests/unit/sketchProjection.test.ts new file mode 100644 index 0000000..e3b1d0f --- /dev/null +++ b/tests/unit/sketchProjection.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import { + BASE_CONSTRUCTION_PLANE_POSE, + principalPlanePose, + type Vector3Tuple, +} from "@/lib/constructionPlanes"; +import { + intersectMeshWithPlane, + projectSketchProfileToPlane, + type SketchProjectionResult, + type TriangleFace, +} from "@/lib/sketchProjection"; +import type { SketchProfile } from "@/types/sketchforge"; + +function sequentialIds() { + let next = 0; + return (prefix: string) => `${prefix}-${++next}`; +} + +function boxMesh(): { vertices: Vector3Tuple[]; faces: TriangleFace[] } { + return { + vertices: [ + [-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1], + [-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1], + ], + faces: [ + [0, 2, 1], [0, 3, 2], + [4, 5, 6], [4, 6, 7], + [0, 4, 7], [0, 7, 3], + [1, 2, 6], [1, 6, 5], + [0, 1, 5], [0, 5, 4], + [3, 7, 6], [3, 6, 2], + ], + }; +} + +function expectClosedTopology(result: SketchProjectionResult) { + const pointIds = new Set(result.points.map((point) => point.id)); + const degree = new Map(result.points.map((point) => [point.id, 0])); + const edgeKeys = new Set(); + for (const segment of result.segments) { + expect(pointIds.has(segment.startId)).toBe(true); + expect(pointIds.has(segment.endId)).toBe(true); + expect(segment.startId).not.toBe(segment.endId); + degree.set(segment.startId, degree.get(segment.startId)! + 1); + degree.set(segment.endId, degree.get(segment.endId)! + 1); + edgeKeys.add([segment.startId, segment.endId].sort().join(":")); + } + expect(edgeKeys.size).toBe(result.segments.length); + expect([...degree.values()].every((count) => count === 2)).toBe(true); +} + +function bounds(result: SketchProjectionResult) { + return { + minX: Math.min(...result.points.map((point) => point.x)), + maxX: Math.max(...result.points.map((point) => point.x)), + minZ: Math.min(...result.points.map((point) => point.z)), + maxZ: Math.max(...result.points.map((point) => point.z)), + }; +} + +describe("mesh-plane sketch projection", () => { + it("creates a closed horizontal section through a triangulated box", () => { + const mesh = boxMesh(); + const result = intersectMeshWithPlane(mesh.vertices, mesh.faces, principalPlanePose("xz"), sequentialIds()); + + expect(bounds(result)).toEqual({ minX: -1, maxX: 1, minZ: -1, maxZ: 1 }); + expect(result.points).toHaveLength(8); + expect(result.segments).toHaveLength(8); + expect(result.segments.every((segment) => segment.kind === "line")).toBe(true); + expectClosedTopology(result); + }); + + it("creates a closed vertical section in target-local x/z coordinates", () => { + const mesh = boxMesh(); + const result = intersectMeshWithPlane(mesh.vertices, mesh.faces, principalPlanePose("yz"), sequentialIds()); + + expect(bounds(result)).toEqual({ minX: -1, maxX: 1, minZ: -1, maxZ: 1 }); + expect(result.points).toHaveLength(8); + expect(result.segments).toHaveLength(8); + expectClosedTopology(result); + }); + + it("cancels the shared diagonal of coplanar triangles and stamps projection IDs", () => { + const vertices: Vector3Tuple[] = [[0, 0, 0], [2, 0, 0], [2, 0, 2], [0, 0, 2]]; + const result = intersectMeshWithPlane( + vertices, + [[0, 1, 2], [0, 2, 3]], + BASE_CONSTRUCTION_PLANE_POSE, + sequentialIds(), + "section-1", + ); + const pointById = new Map(result.points.map((point) => [point.id, point])); + const edges = result.segments.map((segment) => { + const start = pointById.get(segment.startId)!; + const end = pointById.get(segment.endId)!; + return [`${start.x},${start.z}`, `${end.x},${end.z}`].sort().join("|"); + }).sort(); + + expect(result.points).toHaveLength(4); + expect(result.segments).toHaveLength(4); + expect(edges).toEqual(["0,0|0,2", "0,0|2,0", "0,2|2,2", "2,0|2,2"]); + expect([...result.points, ...result.segments].every((item) => item.projectionId === "section-1")).toBe(true); + expectClosedTopology(result); + }); + + it("deduplicates a shared edge that lies on the plane", () => { + const vertices: Vector3Tuple[] = [[-1, 0, 0], [1, 0, 0], [0, 1, 1], [0, -1, -1]]; + const result = intersectMeshWithPlane( + vertices, + [[0, 1, 2], [1, 0, 3]], + BASE_CONSTRUCTION_PLANE_POSE, + sequentialIds(), + ); + + expect(result.points.map(({ x, z }) => ({ x, z }))).toEqual([{ x: -1, z: 0 }, { x: 1, z: 0 }]); + expect(result.segments).toHaveLength(1); + expect(new Set([result.segments[0].startId, result.segments[0].endId])).toEqual( + new Set(result.points.map((point) => point.id)), + ); + }); + + it("returns an explicit empty result when the mesh does not intersect", () => { + let idCalls = 0; + const result = intersectMeshWithPlane( + [[0, 2, 0], [1, 2, 0], [0, 2, 1]], + [[0, 1, 2]], + BASE_CONSTRUCTION_PLANE_POSE, + () => `id-${++idCalls}`, + ); + + expect(result).toEqual({ points: [], segments: [] }); + expect(idCalls).toBe(0); + }); +}); + +describe("previous-sketch projection", () => { + it("projects through identical poses with source-local translation and ignores non-geometry data", () => { + const source: SketchProfile = { + points: [{ id: "a", x: 1, z: 2 }, { id: "b", x: 4, z: 2 }], + segments: [{ id: "ab", startId: "a", endId: "b", kind: "line" }], + constraints: [{ id: "horizontal", kind: "horizontal", segmentId: "ab" }], + dimensions: [{ id: "length", kind: "length", segmentId: "ab", value: 3 }], + images: [{ + id: "image", name: "reference", dataUrl: "data:image/png;base64,AA==", mimeType: "image/png", + pixelWidth: 1, pixelHeight: 1, x: 0, z: 0, width: 1, depth: 1, + }], + texts: [{ id: "text", text: "ignore", x: 0, z: 0, fontSize: 12 }], + }; + const snapshot = structuredClone(source); + const result = projectSketchProfileToPlane( + source, + BASE_CONSTRUCTION_PLANE_POSE, + BASE_CONSTRUCTION_PLANE_POSE, + [5, 3, -2], + sequentialIds(), + "previous-1", + ); + + expect(source).toEqual(snapshot); + expect(Object.keys(result).sort()).toEqual(["points", "segments"]); + expect(result.points.map(({ x, z }) => ({ x, z }))).toEqual([{ x: 6, z: 0 }, { x: 9, z: 0 }]); + expect(result.segments[0]).toMatchObject({ + startId: result.points[0].id, + endId: result.points[1].id, + kind: "line", + projectionId: "previous-1", + }); + expect(result.points.every((point) => point.projectionId === "previous-1")).toBe(true); + }); + + it("filters segments that collapse under perpendicular projection without breaking remaining connectivity", () => { + const source: SketchProfile = { + points: [{ id: "a", x: 0, z: 0 }, { id: "b", x: 2, z: 0 }, { id: "c", x: 2, z: 3 }], + segments: [ + { id: "ab", startId: "a", endId: "b", kind: "line" }, + { id: "bc", startId: "b", endId: "c", kind: "line" }, + ], + }; + const result = projectSketchProfileToPlane( + source, + BASE_CONSTRUCTION_PLANE_POSE, + principalPlanePose("yz"), + sequentialIds(), + ); + + expect(result.points.map(({ x, z }) => ({ x, z }))).toEqual([ + { x: 0, z: 0 }, { x: 0, z: 0 }, { x: 3, z: 0 }, + ]); + expect(result.segments).toEqual([{ + id: "sketch-segment-4", + startId: result.points[1].id, + endId: result.points[2].id, + kind: "line", + }]); + }); + + it("transforms Bezier handles and preserves curve kinds and point modes", () => { + const source: SketchProfile = { + points: [ + { id: "a", x: 0, z: 0, mode: "smooth", handleIn: { x: 0, z: -1 }, handleOut: { x: 0, z: 1 } }, + { id: "b", x: 0, z: 4, mode: "split", handleIn: { x: 0, z: 3 }, handleOut: { x: 0, z: 5 } }, + { id: "c", x: 0, z: 8, handleIn: { x: 0, z: 7 } }, + ], + segments: [ + { id: "curve-a", startId: "a", endId: "b", kind: "bezier" }, + { id: "curve-b", startId: "b", endId: "c", kind: "smooth" }, + ], + }; + const result = projectSketchProfileToPlane( + source, + BASE_CONSTRUCTION_PLANE_POSE, + principalPlanePose("yz"), + sequentialIds(), + ); + + expect(result.points).toMatchObject([ + { x: 0, z: 0, mode: "smooth", handleIn: { x: -1, z: 0 }, handleOut: { x: 1, z: 0 } }, + { x: 4, z: 0, mode: "split", handleIn: { x: 3, z: 0 }, handleOut: { x: 5, z: 0 } }, + { x: 8, z: 0, handleIn: { x: 7, z: 0 } }, + ]); + expect(result.segments.map((segment) => segment.kind)).toEqual(["bezier", "smooth"]); + expect(result.segments.map(({ startId, endId }) => ({ startId, endId }))).toEqual([ + { startId: result.points[0].id, endId: result.points[1].id }, + { startId: result.points[1].id, endId: result.points[2].id }, + ]); + }); + + it("retries colliding factory values so all generated IDs are fresh", () => { + const source: SketchProfile = { + points: [{ id: "a", x: 0, z: 0 }, { id: "b", x: 1, z: 0 }], + segments: [{ id: "ab", startId: "a", endId: "b" }], + }; + const ids = ["a", "new-a", "b", "new-b", "ab", "new-ab"]; + const result = projectSketchProfileToPlane( + source, + BASE_CONSTRUCTION_PLANE_POSE, + BASE_CONSTRUCTION_PLANE_POSE, + () => ids.shift() ?? "unused", + ); + const generatedIds = [...result.points.map((point) => point.id), ...result.segments.map((segment) => segment.id)]; + + expect(generatedIds).toEqual(["new-a", "new-b", "new-ab"]); + expect(new Set(generatedIds).size).toBe(generatedIds.length); + expect(generatedIds.some((id) => ["a", "b", "ab"].includes(id))).toBe(false); + }); +}); diff --git a/tests/unit/sketchSweep.test.ts b/tests/unit/sketchSweep.test.ts new file mode 100644 index 0000000..697a3fc --- /dev/null +++ b/tests/unit/sketchSweep.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { buildSketchSweepGeometry, resolveSketchSweep } from "@/lib/sketchSweep"; +import type { SketchProfile } from "@/types/sketchforge"; + +function sweepProfile(): SketchProfile { + return { + points: [ + { id: "a", x: -1, z: -1 }, + { id: "b", x: 1, z: -1 }, + { id: "c", x: 1, z: 1 }, + { id: "d", x: -1, z: 1 }, + { id: "path-a", x: 3, z: 0 }, + { id: "path-b", x: 8, z: 0, handleIn: { x: 6, z: -2 } }, + { id: "path-c", x: 12, z: 4, handleOut: { x: 10, z: 2 } }, + ], + 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" }, + { id: "path-1", startId: "path-a", endId: "path-b", kind: "line" }, + { id: "path-2", startId: "path-b", endId: "path-c", kind: "bezier" }, + ], + }; +} + +describe("sketch sweep", () => { + it("identifies one closed section and one open path from selected segments", () => { + const resolved = resolveSketchSweep(sweepProfile(), ["ab", "bc", "cd", "da", "path-1", "path-2"]); + + expect(resolved.section.closed).toBe(true); + expect(resolved.section.steps.map((step) => step.segment.id)).toEqual(["ab", "bc", "cd", "da"]); + expect(resolved.path.closed).toBe(false); + expect(resolved.path.steps.map((step) => step.segment.id).sort()).toEqual(["path-1", "path-2"]); + }); + + it("builds finite triangle geometry along the selected path", () => { + const result = buildSketchSweepGeometry(sweepProfile(), ["ab", "bc", "cd", "da", "path-1", "path-2"]); + try { + const positions = result.geometry.getAttribute("position"); + expect(positions.count).toBeGreaterThan(12); + expect(Array.from(positions.array as ArrayLike).every(Number.isFinite)).toBe(true); + result.geometry.computeBoundingBox(); + expect(result.geometry.boundingBox?.max.x).toBeGreaterThan(8); + expect(result.geometry.boundingBox?.max.y).toBeGreaterThan(0); + } finally { + result.geometry.dispose(); + } + }); + + it("rejects selections without exactly one closed and one open path", () => { + expect(() => resolveSketchSweep(sweepProfile(), ["ab", "bc", "cd", "da"])) + .toThrow("exactly one closed profile"); + expect(() => resolveSketchSweep(sweepProfile(), ["ab", "bc", "path-1", "path-2"])) + .toThrow("exactly one closed profile"); + }); +});