From c785d9d527af3242771768ffc676227afe1668f3 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Wed, 3 Dec 2025 11:14:38 -0500 Subject: [PATCH 01/51] changed getCompoundPathString to use the new function for compound path in svg-path.service --- .../components/Grid/compound-link/compound-link.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/components/Grid/compound-link/compound-link.component.ts b/src/app/components/Grid/compound-link/compound-link.component.ts index 0164631b..dddafbe0 100644 --- a/src/app/components/Grid/compound-link/compound-link.component.ts +++ b/src/app/components/Grid/compound-link/compound-link.component.ts @@ -49,7 +49,8 @@ export class CompoundLinkComponent extends AbstractInteractiveComponent { let allCoordsAsArray: Coord[] = Array.from(allUniqueJointCoords,(coord,index) =>{ return this.unitConversionService.modelCoordToSVGCoord(coord); }); - return this.svgPathService.getSingleLinkDrawnPath(allCoordsAsArray, radius); + return this.svgPathService.calculateCompoundPath(this.compoundLink.links, allCoordsAsArray, radius); + //return this.svgPathService.getSingleLinkDrawnPath(allCoordsAsArray, radius); } getStrokeColor(): string{ if (this.getInteractor().isSelected) { From 2139abe8d42b25475735ae29a082622895ba4614 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Wed, 3 Dec 2025 11:15:23 -0500 Subject: [PATCH 02/51] currently can draw each individual link correctly in a welded link, so both two joint links and compound links. Does not handle intersections yet --- src/app/services/svg-path.service.ts | 239 ++++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 1 deletion(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index c1eff505..d96e7e2b 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -1,12 +1,16 @@ import {Injectable} from '@angular/core'; import {Coord} from 'src/app/model/coord' +import {Link} from "../model/link"; +import {UnitConversionService} from "./unit-conversion.service"; @Injectable({ providedIn: 'root', }) export class SVGPathService { - constructor() {} + private scale = 1; + + constructor(private unitConversionService: UnitConversionService) {}; // Generates an SVG path string representing a single connection based on provided coordinates and radius. getSingleLinkDrawnPath(allCoords: Coord[], radius: number): string{ @@ -259,4 +263,237 @@ export class SVGPathService { return pathData; } + // checks if the path string is starting or ending a path; returns true if it is + isNewShape(pathString: string): boolean { + return pathString === '' || pathString.substring(pathString.length - 2) === 'Z '; + } + + // used for creating a concave fillet between two lines + computeArcPointsAndRadius(line1: [Coord, Coord, Coord | null, Link], line2:[Coord, Coord, Coord | null, Link], arcRadius: number):[Coord, Coord, number] { + // modify line1 end point and line2 start point to create an arc between them + const arcOffset = Math.min(arcRadius, line1[0].getDistanceTo(line1[1]) / 2, line2[0].getDistanceTo(line2[1]) / 2); + const line1OffsetPoint:Coord = line1[0].clone() + .subtract(line1[1]) + .normalize() + .multiply(arcOffset) + .add(line1[1]); + + const line2OffsetPoint:Coord = line2[1].clone() + .subtract(line2[0]) + .normalize() + .multiply(arcOffset) + .add(line2[0]); + + // find angle between two lines + const line2Angle = Math.atan2(line2[1].y - line2[0].y, line2[1].x - line2[0].x); + const line1Angle = Math.atan2(line1[1].y - line1[0].y, line1[1].x - line1[0].x); + const angleBetweenLines = line2Angle - line1Angle; + const radius = arcOffset * Math.tan((Math.PI - angleBetweenLines) / 2); + + return [line1OffsetPoint, line2OffsetPoint, radius]; + } + + // recalculates angle to be in between -pi and pi. + angleToPI(line1: [Coord, Coord, Coord | null, Link], line2:[Coord, Coord, Coord | null, Link]) { + // calculate angle of each individual line, given in radians + let line1Angle = Math.atan2(line1[1].y - line1[0].y, line1[1].x - line1[0].x); + let line2Angle = Math.atan2(line2[1].y - line2[0].y, line2[1].x - line2[0].x); + + // angle in between the lines + let angle = line1Angle - line2Angle; + + // recalculate the angle to be between -pi and pi + angle = angle % (2 * Math.PI); + if (angle > Math.PI) { + angle -= 2 * Math.PI; + } + if (angle < -Math.PI) { + angle += 2 * Math.PI; + } + return angle; + } + + // This function returns the lines used to calculate and save the lines of a link + solveForExternalLines(subLinks: Map, radius: number): [Coord, Coord, Coord | null, Link][] { + // create a list of all the external lines of all the sublinks + /* First Coord is starting position + Second Coord is ending position + boolean is true if the line is an arc + Last is the center Coord of an arc, null if it is just a line + */ + //radius = 0.15; + let externalLines: [Coord, Coord, Coord | null, Link][] = []; + let allLinkExternalLines: Map = new Map(); + + // find and add all external lines for sublinks + for (let link of subLinks.values()) { + let allCoords: Coord[] = []; + for(let joint of link.joints.values()) { + allCoords.push(joint._coords); + } + allCoords = Array.from(allCoords, (coord) => { + return this.unitConversionService.modelCoordToSVGCoord(coord); + }); + + let linkExternalLines: [Coord, Coord, Coord | null, Link][] = []; + + // find the hull points using the coords + let hullPoints: Coord[] = this.grahamScan(allCoords); + let collinearCoords: Coord[] | undefined = this.findCollinearCoords(allCoords); + + if (collinearCoords !== undefined) { + // Calculate perpendicular direction vectors for the line + const dirFirstToSecond = this.perpendicularDirection(collinearCoords[0], collinearCoords[1]); + const dirSecondToFirst = this.perpendicularDirection(collinearCoords[1], collinearCoords[0]); + + // Line from first to second Point + const point1: Coord = new Coord(collinearCoords[0].x + dirFirstToSecond.x * radius, collinearCoords[0].y + dirFirstToSecond.y * radius); + const point2: Coord = new Coord(collinearCoords[1].x + dirFirstToSecond.x * radius, collinearCoords[1].y + dirFirstToSecond.y * radius); + externalLines.push([point1.clone(), point2.clone(), null, link]); + linkExternalLines.push([point1.clone(), point2.clone(), null, link]); + + // Arc around first joint + const point3: Coord = new Coord(collinearCoords[1].x + dirSecondToFirst.x * radius, collinearCoords[1].y + dirSecondToFirst.y * radius); + externalLines.push([point2.clone(), point3.clone(), collinearCoords[1].clone(), link]); + linkExternalLines.push([point2.clone(), point3.clone(), collinearCoords[1].clone(), link]); + + // Line from second back to first Point + const point4: Coord = new Coord(collinearCoords[0].x + dirSecondToFirst.x * radius, collinearCoords[0].y + dirSecondToFirst.y * radius); + externalLines.push([point3.clone(), point4.clone(), null, link]); + linkExternalLines.push([point3.clone(), point4.clone(), null, link]); + + // Arc around the second joint + const point5: Coord = new Coord(collinearCoords[0].x + dirFirstToSecond.x * radius, collinearCoords[0].y + dirFirstToSecond.y * radius); + externalLines.push([point4.clone(), point5.clone(), collinearCoords[0].clone(), link]); + linkExternalLines.push([point4.clone(), point5.clone(), collinearCoords[0].clone(), link]); + //console.log(externalLines); + } else { + if (hullPoints.length < 3) { + throw new Error('At least three points are required to create a path with rounded corners.'); + } + + console.log("hull points"); + console.log(hullPoints); + + // Start the path, moving the pointer to the first correct point + const dirFirstToSecondInit = this.perpendicularDirection(hullPoints[0], hullPoints[1]); + + // last position + let lastPosition: Coord = new Coord(hullPoints[0].x + dirFirstToSecondInit.x * radius, hullPoints[0].y + dirFirstToSecondInit.y * radius); + + //iterate over all the points, one line and one arc at a time + for (let i = 0; i < hullPoints.length; i++) { + //get current points to look at. + const c0 = hullPoints[i]; + const c1 = hullPoints[(i + 1) % hullPoints.length]; + const c2 = hullPoints[(i + 2) % hullPoints.length]; + //get the Perpendicular Direction for the Path + const dirFirstToSecond = this.perpendicularDirection(c0, c1); + const dirSecondToThird = this.perpendicularDirection(c1, c2); + + // Line from first joint to second joint + let point1: Coord = new Coord(c1.x + dirFirstToSecond.x * radius, c1.y + dirFirstToSecond.y * radius); + externalLines.push([lastPosition, point1, null, link]); + linkExternalLines.push([lastPosition, point1, null, link]); + + // Arc around second joint + let point2: Coord = new Coord(c1.x+ dirSecondToThird.x * radius, c1.y + dirSecondToThird.y * radius); + let point2Center: Coord = c1.clone(); + externalLines.push([point1, point2, point2Center, link]); + linkExternalLines.push([point1, point2, point2Center, link]); + lastPosition = point2.clone(); + } + } + + allLinkExternalLines.set(link, linkExternalLines); + } + + return externalLines; + } + + calculateCompoundPath(subLinks: Map, allCoordsList: Coord[], r: number) { + // allExternalLines will hold the path coordinates for each line and arc for all the coords + let allExternalLines: [Coord, Coord, Coord | null, Link][] = this.solveForExternalLines(subLinks, r); + //console.log(allExternalLines); + + if (allExternalLines.length < 1) { + return ''; + } + + // converting external lines list into a set, which we will delete lines we have used + const externalLinesSet = new Set(allExternalLines); + + let pathString = ''; + + let timeoutCounter = 1000; + + while (externalLinesSet.size > 1) { + // this is the first line from the set + let currentLine: [Coord, Coord, Coord | null, Link] = externalLinesSet.values().next().value; + + let beginningPoint: Coord = currentLine[1].clone(); + + while (!currentLine[1].equals(beginningPoint, 1) || this.isNewShape(pathString)) { + if (timeoutCounter-- < 0) { + return pathString; + } + + // find the next line that starts at the end of the current line + const nextLine = [...externalLinesSet].find((line) => { + return line[0].looselyEquals(currentLine[1], 1); + }); + //console.log(nextLine); + + if (!nextLine) { + break; + } + externalLinesSet.delete(nextLine); + + // when there are two lines intersecting, create a fillet between them + if (currentLine[2] === null && nextLine[2] == null && + // checking if angle between the two lines is greater than 10 degrees + Math.abs(this.angleToPI(nextLine, currentLine)) > (10 * Math.PI / 180)) { + let [currentLineOffsetPoint, nextLineOffsetPoint, radius] = this.computeArcPointsAndRadius(currentLine, nextLine, 1); + + currentLine[1] = currentLineOffsetPoint; + nextLine[0] = nextLineOffsetPoint; + + if (this.isNewShape(pathString)) { + pathString += 'M ' + currentLine[1].x + ', ' + currentLine[1].y + ' '; + } else { + pathString = this.pathStringForLine(currentLine, pathString); + } + + // !!! is 0 0 0 correct? + pathString += 'A ' + radius + ', ' + radius + ' 0 0 0 ' + nextLine[0].x + ', ' + nextLine[0].y + ' '; + } else { + // otherwise, just draw a line between the two points + if (this.isNewShape(pathString)) { + pathString += 'M ' + currentLine[1].x + ', ' + currentLine[1].y + ' '; + } else { + pathString = this.pathStringForLine(currentLine, pathString); + } + } + currentLine = nextLine; + } + pathString = this.pathStringForLine(currentLine, pathString); + pathString += 'Z '; + } + //console.log(pathString); + return pathString; + } + + // calculate and return the path string plus the path of the line + pathStringForLine(line: [Coord, Coord, Coord | null, Link], pathString: string):string { + if (line[2] !== null) { + // if the line is an arc + pathString += 'A ' + line[0].getDistanceTo(line[2]) + ', ' + line[0].getDistanceTo(line[2]) + ' 0 0 1 ' + line[1].x + ', ' + line[1].y + ' '; + } else { + // if the line is just a line + pathString += 'L ' + line[1].x + ', ' + line[1].y + ' '; + } + return pathString; + } + + } From 858b93030499c917440f59a5d1719d2c4025a702 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Thu, 4 Dec 2025 12:27:36 -0500 Subject: [PATCH 03/51] added the intersection methods with for line/line, line/arc, arc/arc, and helper functions line/circle and circle/circle --- src/app/services/svg-path.service.ts | 373 +++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index d96e7e2b..a27b01d8 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -2,6 +2,8 @@ import {Injectable} from '@angular/core'; import {Coord} from 'src/app/model/coord' import {Link} from "../model/link"; import {UnitConversionService} from "./unit-conversion.service"; +import {arc} from "d3-shape"; +import {intersection} from "d3"; @Injectable({ providedIn: 'root', @@ -313,6 +315,344 @@ export class SVGPathService { return angle; } + // checks if two numbers are loosely equal, bounded by a delta + twoNumsLooselyEquals(a: number, b: number, delta: number = 0.00001): boolean { + return Math.abs(a - b) <= delta; + } + + // checks if a point is on a line + // return true if the point is on the line segment defined by lineStart and lineEnd + // return false otherwise + isPointOnLine(point: Coord, lineStart: Coord, lineEnd: Coord): boolean { + // check if the point is within a small range of the line segment. + let range = 0.00001; + if ( + point.x < Math.min(lineStart.x, lineEnd.x) - range || + point.x > Math.max(lineStart.x, lineEnd.x) + range || + point.y < Math.min(lineStart.y, lineEnd.y) - range || + point.y > Math.max(lineStart.y, lineEnd.y) + range + ) { + return false; + } + return true; + } + + // checks if a point is on an arc + // return true if the point is within the arc + // return false if the point is outside the circle or if the point is on the circle, but not within the arc + isPointInArc(intersection: Coord, arcStart: Coord, arcEnd: Coord): boolean { + // the arc always goes from the start point to the end point in a counter-clockwise direction. + // use the cross product to determine if the point is on the left or right side of the line from the start point to the end point. + // if the point is on the left side, then it is within the arc. + // if the point is on the right side, then it is outside the arc. + let crossProduct = + (arcEnd.x - arcStart.x) * (intersection.y - arcStart.y) - + (intersection.x - arcStart.x) * (arcEnd.y - arcStart.y); + + // check if the point is on the left side of the line from the start point to the end point + return crossProduct < 0; + } + + // https://stackoverflow.com/questions/13937782/calculating-the-point-of-intersection-of-two-lines + // line intercept math by Paul Bourke http://paulbourke.net/geometry/pointlineplane/ + // Determine the intersection point of two line segments + // Return undefiend if the lines don't intersect + lineLineIntersect(line1Start: Coord, line1End: Coord, line2Start: Coord, line2End: Coord): Coord | undefined { + let x1 = line1Start.x; + let y1 = line1Start.y; + let x2 = line1End.x; + let y2 = line1End.y; + let x3 = line2Start.x; + let y3 = line2Start.y; + let x4 = line2End.x; + let y4 = line2End.y; + + let delta = 0. + + // check if none of the lines are of length 0 + if ((this.twoNumsLooselyEquals(x1, x2) && this.twoNumsLooselyEquals(y1, y2)) || (this.twoNumsLooselyEquals(x3, x4) && this.twoNumsLooselyEquals(y3, y4))) { + return undefined; + } + + let denominator = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1); + + // check if lines are parallel + if (denominator === 0) { + return undefined; + } + + let ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator; + let ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denominator; + + // check if intersection is not within the segments + if (ua <= 0 || ua >= 1 || ub <= 0 || ub >= 1) { + return undefined; + } + + // return Coord with x and y coordinates of intersection + let x = x1 + ua * (x2 - x1); + let y = y1 + ua * (y2 - y1); + + let intersection = new Coord(x, y); + + // checks if the intersection is an end point of the segments + if (intersection.looselyEquals(line1Start, this.scale) || + intersection.looselyEquals(line1End, this.scale) || + intersection.looselyEquals(line2Start, this.scale) || + intersection.looselyEquals(line2End, this.scale)) { + return undefined; + } + + return intersection; + } + + // return the intersection points between a line and a circle. + // if the line is tangent to the circle, then return the point of tangency. + // if the line does not intersect the circle, return undefined. + lineCircleIntersect(lineStart: Coord, lineEnd: Coord, circleCenter: Coord, circleRadius: number): Coord[] | undefined { + // check if the line is vertical or not + if (this.twoNumsLooselyEquals(lineEnd.x, lineStart.x)) { + // line is vertical so equation is x = c + let c = lineStart.x; + + // find equation of the circle in the form (x - h)^2 + (y - k)^2 = r^2 + let h = circleCenter.x; + let k = circleCenter.y; + let r = circleRadius; + + // substitute x = c into the circle equation and solve for y + // this will give a quadratic equation in the form ay^2 + by + c = 0 + let a = 1; // coefficient of y^2 + let b = -2 * k; // coefficient of y + let d = c - h; // constant term divided by 2 + let e = d * d + k * k - r * r; // constant term + + // find the discriminant of the quadratic equation + // this will determine how many solutions there are + let D = b * b - 4 * a * e; // discriminant + + + if (D < 0) { + // if D is negative, then there are no real solutions and the line does not intersect the circle + return undefined; + } else if (D === 0) { + // if D is zero, then there is one real solution and the line is tangent to the circle + let y = -b / (2 * a); // solution for y + return [new Coord(c, y)]; // return the point of tangency as an array of one coordinate object + } else if (D > 0) { + // if D is positive, then there are two real solutions and the line intersects the circle at two points + let y1 = (-b + Math.sqrt(D)) / (2 * a); // first solution for y + let y2 = (-b - Math.sqrt(D)) / (2 * a); // second solution for y + return [new Coord(c, y1), new Coord(c, y2)]; // return the intersection points as an array of two coordinate objects + } + } else { + let intersections: Coord[] = []; + let slope = (lineStart.y - lineEnd.y) / (lineStart.x - lineEnd.x); + let y_intercept = lineStart.y - slope * lineStart.x; + + let a = 1 + slope * slope; + let b = 2 * slope * (y_intercept - circleCenter.y) - 2 * circleCenter.x; + let c = + circleCenter.x * circleCenter.x + + (y_intercept - circleCenter.y) * (y_intercept - circleCenter.y) - + circleRadius * circleRadius; + + let discriminant = b * b - 4 * a * c; + const tolerance = 0.00001; + if (discriminant < -tolerance) { + // line doesn't touch circle + return undefined; + } else if (discriminant < tolerance) { + // line is tangent to circle + let x = -b / (2 * a); + let y = slope * x + y_intercept; + intersections.push(new Coord(x, y)); + return intersections; + } else { + // line intersects circle in two places + let x1 = (-b + Math.sqrt(discriminant)) / (2 * a); + let y1 = slope * x1 + y_intercept; + intersections.push(new Coord(x1, y1)); + let x2 = (-b - Math.sqrt(discriminant)) / (2 * a); + let y2 = slope * x2 + y_intercept; + intersections.push(new Coord(x2, y2)); + return intersections; + } + } + return undefined; + } + + // return the first intersection point between a line and an arc. The first is the one closest to the lineStart point. + // if the line is tangent to the arc, then return the point of tangency closest to the lineStart point. + // if the line does not intersect the arc, return undefined. + lineArcIntersect(lineStart: Coord, lineEnd: Coord, arcStart: Coord, arcEnd: Coord, arcCenter: Coord, findIntersectionCloseTo: Coord, arcRadius: number): Coord | undefined { + // find the intersection points between the line and the circle defined by the arc + let intersections: Coord[] | undefined = this.lineCircleIntersect(lineStart, lineEnd, arcCenter, arcRadius); + + intersections = intersections?.filter((intersection) => { + return this.isPointOnLine(intersection, lineStart, lineEnd); + }); + + if (intersections === undefined || intersections.length === 0) { + return undefined; + } + + // check if the intersection points are within the arc + let closestIntersection: Coord | undefined; + for (let intersection of intersections) { + if ( + this.isPointInArc(intersection, arcStart, arcEnd) && + !intersection.equals(arcStart, this.scale) && + !intersection.equals(arcEnd, this.scale) + ) { + // if it is, return closest intersection point + if (closestIntersection === undefined) { + closestIntersection = intersection; + } else if ( + closestIntersection.getDistanceTo(findIntersectionCloseTo) > + intersection.getDistanceTo(findIntersectionCloseTo) + ) { + closestIntersection = intersection; + } + } + } + return closestIntersection; + } + + // return the intersection points between two circles. + // if the circles do not intersect, return undefined. + circleCircleIntersect(center: Coord, radius: number, center2: Coord, radius2: number): [Coord[] | undefined, boolean] { + let x1 = center.x; + let y1 = center.y; + let x2 = center2.x; + let y2 = center2.y; + + let dx = x2 - x1; + let dy = y2 - y1; + let d = Math.sqrt(dx * dx + dy * dy); + + // Circles are separate + if (d > radius + radius2) { + return [undefined, false]; + } + + // One circle is contained within the other + if (d < Math.abs(radius - radius2)) { + return [undefined, false]; + } + + // Circles are coincident + if (d === 0 && radius === radius2) { + // console.log('Circles are coincident'); + return [undefined, true]; + } + + let a = (radius * radius - radius2 * radius2 + d * d) / (2 * d); + let h = Math.sqrt(radius * radius - a * a); + let x3 = x1 + (a * dx) / d; + let y3 = y1 + (a * dy) / d; + let x4 = x3 + (h * dy) / d; + let y4 = y3 - (h * dx) / d; + let x5 = x3 - (h * dy) / d; + let y5 = y3 + (h * dx) / d; + + return [[new Coord(x4, y4), new Coord(x5, y5)], false]; + } + + // return the first intersection point between two arcs, the one closest to the startPosition point + // if the arcs are tangent, then return the point of tangency closest to the startPosition point + // if the arcs do not intersect, return undefined + arcArcIntersect(startPosition: Coord, endPosition: Coord, center: Coord, startPosition2: Coord, endPosition2: Coord, center2: Coord, radius: number): Coord | undefined { + // find the intersection points between the two circles defined by the arcs + let [intersections, coincident]: [Coord[] | undefined, boolean] = this.circleCircleIntersect(center, radius, center2, radius); + + if (coincident) { + // circles are coincident, so we need to check if the arcs intersect + // check if the start and end points of the arcs are within the other arc + + let allImportantIntersections: Coord[] = []; + + if (this.isPointInArc(startPosition, startPosition2, endPosition2)) { + allImportantIntersections.push(startPosition); + } + if (this.isPointInArc(endPosition, startPosition2, endPosition2)) { + allImportantIntersections.push(endPosition); + } + if (this.isPointInArc(startPosition2, startPosition, endPosition)) { + allImportantIntersections.push(startPosition2); + } + if (this.isPointInArc(endPosition2, startPosition, endPosition)) { + allImportantIntersections.push(endPosition2); + } + + // if there are no intersections, then the arcs do not intersect. + if (allImportantIntersections.length === 0) { + return undefined; + } + + // else find the intersection closest to the startPosition. + let closestIntersection: Coord | undefined; + for (let intersection of allImportantIntersections) { + if (closestIntersection === undefined) { + closestIntersection = intersection; + } else if ( + intersection.getDistanceTo(startPosition) < closestIntersection.getDistanceTo(startPosition) + ) { + closestIntersection = intersection; + } + } + return closestIntersection; + } + + // check if no intersections + if (intersections === undefined) { + return undefined; + } + + // check if intersection points are within the arcs + let closestIntersection: Coord | undefined; + + for (let intersection of intersections) { + if ( + this.isPointInArc(intersection, startPosition, endPosition) && + this.isPointInArc(intersection, startPosition2, endPosition2) && + !intersection.equals(startPosition, this.scale) && + !intersection.equals(endPosition, this.scale) && + !intersection.equals(startPosition2, this.scale) && + !intersection.equals(endPosition2, this.scale) + ) { + if (!closestIntersection) { + // for first iteration only + closestIntersection = intersection; + } else if (intersection.getDistanceTo(startPosition) < closestIntersection.getDistanceTo(startPosition)) { + closestIntersection = intersection; + } + } + } + + return closestIntersection; + } + + // finds intersection between line/arc and line/arc + intersectsWith(line1: [Coord, Coord, Coord | null, Link], line2: [Coord, Coord, Coord | null, Link], radius: number): Coord | undefined { + if (line1[2] === null && line2[2] === null) { + // line and line intersection + return this.lineLineIntersect(line1[0], line1[1], line2[0], line2[1]); + } else if (line1[2] === null && line2[2] !== null) { + // line and arc intersection + return this.lineArcIntersect(line1[0], line1[1], line2[0], line2[1], line2[2], line1[0], radius); + } else if (line1[2] !== null && line2[2] === null) { + // arc and line intersection + return this.lineArcIntersect(line2[0], line2[1], line1[0], line1[1], line1[2], line1[0], radius); + } else if (line1[2] !== null && line2[2] !== null) { + // arc and arc intersection + return this.arcArcIntersect(line1[0], line1[1], line1[2], line2[0], line2[1], line2[2], radius); + } else { + // error + return undefined; + } + } + // This function returns the lines used to calculate and save the lines of a link solveForExternalLines(subLinks: Map, radius: number): [Coord, Coord, Coord | null, Link][] { // create a list of all the external lines of all the sublinks @@ -408,6 +748,39 @@ export class SVGPathService { allLinkExternalLines.set(link, linkExternalLines); } + // need to process the external lines we got + let stopCounter = 10000; + + // will hold the intersection points of all external lines + // x is the x coord of intersection, y is y coord of intersection, and i is index in external lines array + let allIntersectionPoints: {point: Coord, i: number}[] = []; + + // for each external line, need to check for intersections with all other external lines + for (let i = 0; i < externalLines.length - 1; i++) { + const line1: [Coord, Coord, Coord | null, Link] = externalLines[i]; + for (let j = i + 1; j < externalLines.length; j++) { + if (stopCounter-- < 0) { + console.error("Error, stop counter is at 0"); + return []; + } + + const line2: [Coord, Coord, Coord | null, Link] = externalLines[j]; + + // check if lines intersect, if they do save the intersections + const intersection = this.intersectsWith(line1, line2, radius); + + if (intersection !== undefined) { + if (!(line1[1].equals(intersection, this.scale) || line1[0].equals(intersection, this.scale))) { + allIntersectionPoints.push({point: intersection, i: i}); + } + if (!(line2[0].equals(intersection, this.scale) || line2[1].equals(intersection, this.scale))) { + allIntersectionPoints.push({point: intersection, i: j}); + } + } + + } + } + return externalLines; } From 3c739a9860a507296569994853b519829bc52efa Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Thu, 4 Dec 2025 12:28:53 -0500 Subject: [PATCH 04/51] added checks in solveForExternalLines to check for duplicate intersection points and remove them --- src/app/services/svg-path.service.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index a27b01d8..c98a3fdd 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -781,6 +781,25 @@ export class SVGPathService { } } + // checking if there are any duplicate intersection points for each line + let duplicatePoints: number[] = []; + allIntersectionPoints.forEach((point, index) => { + for (let j = index + 1; j < allIntersectionPoints.length; j++) { + if (this.twoNumsLooselyEquals(point.point.x, allIntersectionPoints[j].point.x) && this.twoNumsLooselyEquals(point.point.y, allIntersectionPoints[j].point.y) && point.i === allIntersectionPoints[j].i) { + duplicatePoints.push(index); + break; + } + } + }); + + // removing the duplicate intersection points, if any + let removedDuplicatePoints: {point: Coord, i: number}[] = []; + allIntersectionPoints.forEach((point, index) => { + if (!duplicatePoints.includes(index)) { + removedDuplicatePoints.push(point); + } + }); + return externalLines; } From 3fe7c067f39fbb46a2efde52095fc248f8796445 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Thu, 4 Dec 2025 13:01:24 -0500 Subject: [PATCH 05/51] added functions to group the intersection points by the lines/segments they belong to and split each arc and line by their intersection points --- src/app/services/svg-path.service.ts | 96 ++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index c98a3fdd..b875f73a 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -653,6 +653,83 @@ export class SVGPathService { } } + // splits the intersections by which lines each one was in + groupIntersectionsByPath(lines: [Coord, Coord, Coord | null, Link][], intersections: {point: Coord, i: number}[]): Coord[][] { + return lines.map((_, index) => { + return intersections.filter(p => p.i === index) + .map(p => (p.point)); + }); + } + + // calculates projection of a point on a line + projectPointOnLine(p: Coord, lineStart: Coord, lineEnd: Coord) { + const startEndX = lineEnd.x - lineStart.x; + const startEndY = lineEnd.y - lineStart.y; + + const startPX = p.x - lineStart.x; + const startPY = p.y - lineStart.y; + + const startEndLenSq = startEndX * startEndX + startEndY * startEndY; + // is normalized, so between 0 and 1 + return (startPX * startEndX + startPY * startEndY) / startEndLenSq; + } + + // split a line by the intersection points + splitLineByIntersections(line: [Coord, Coord, Coord | null, Link], points: Coord[]): [Coord, Coord, Coord | null, Link][] { + // calculate the projection and filter out any points with t below 0 or above 1 + let tPoints = points.map(point => { + let t = this.projectPointOnLine(point, line[0], line[1]); + return {p: point, t: t}; + }).filter(point => { + return point.t > 0 && point.t < 1; + }); + + // sort according to t, the projection + tPoints.sort((a, b) => { + return a.t - b.t; + }); + + // add endpoints + const orderedPoints = [{p: line[0], t: 0}, ...tPoints, {p: line[1], t: 1}]; + + // rebuild line segments + let segments: [Coord, Coord, Coord | null, Link][] = []; + for (let i = 0; i < orderedPoints.length - 1; i++) { + const pointOne = orderedPoints[i]; + const pointTwo = orderedPoints[i + 1]; + segments.push([pointOne.p, pointTwo.p, line[2], line[3]]); + } + return segments; + } + + // split an arc along intersection points + splitArcByIntersection(arc: [Coord, Coord, Coord | null, Link], points: Coord[]): [Coord, Coord, Coord | null, Link][] { + if (arc[2] !== null) { + let intersectionsWithAngles:{p:Coord, angle: number}[] = points.map((p) => ({ + p: p, + angle: Math.atan2(p.y - (arc[2]?.y ?? 0), p.x - (arc[2]?.x ?? 0)) + })); + let resultOrderedPoints = intersectionsWithAngles.sort((a, b) => a.angle - b.angle); + + // removes any intersection points that are at the end points + resultOrderedPoints = resultOrderedPoints.filter((point) => { + return !((this.twoNumsLooselyEquals(point.p.x, arc[0].x) && this.twoNumsLooselyEquals(point.p.y, arc[0].y)) || (this.twoNumsLooselyEquals(point.p.x, arc[1].x) && this.twoNumsLooselyEquals(point.p.y, arc[1].y))); + }); + + resultOrderedPoints = [{p: arc[0], angle: 0}, ...resultOrderedPoints, {p: arc[1], angle: Math.atan2(arc[1].y - arc[2].y, arc[1].x - arc[2].x)}]; + + let segments: [Coord, Coord, Coord | null, Link][] = []; + for (let i = 0; i < resultOrderedPoints.length - 1; i++) { + const pointOne = resultOrderedPoints[i]; + const pointTwo = resultOrderedPoints[i + 1]; + segments.push([pointOne.p, pointTwo.p, arc[2], arc[3]]); + } + return segments; + } else { + return []; + } + } + // This function returns the lines used to calculate and save the lines of a link solveForExternalLines(subLinks: Map, radius: number): [Coord, Coord, Coord | null, Link][] { // create a list of all the external lines of all the sublinks @@ -800,6 +877,25 @@ export class SVGPathService { } }); + // grouping intersections by line + let intersectionsByCoords: Coord[][] = this.groupIntersectionsByPath(externalLines, removedDuplicatePoints); + + // will hold the new external lines with the intersections considered + let intersectionExternalLines: [Coord, Coord, Coord | null, Link][] = []; + + // splitting each line by intersections + for (let i = 0; i < externalLines.length; i++) { + if (externalLines[i][2] === null) { + // segment is a line + let lineSegments = this.splitLineByIntersections(externalLines[i], intersectionsByCoords[i]); + intersectionExternalLines.push(...lineSegments); + } else { + // segment is an arc + let arcSegments = this.splitArcByIntersection(externalLines[i], intersectionsByCoords[i]); + intersectionExternalLines.push(...arcSegments); + } + } + return externalLines; } From 00e60b70483c971d9d0e8c431de8a2a439fcf0bb Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Fri, 5 Dec 2025 10:41:34 -0500 Subject: [PATCH 06/51] Found a few bugs with the isPointOnLine and isPointOnArc functions and fixed them. Added checks to add back in duplicate lines if there is a gap, as well as remove short lines. Still debugging --- src/app/services/svg-path.service.ts | 204 ++++++++++++++++++++++++--- 1 file changed, 183 insertions(+), 21 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index b875f73a..083b7fae 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -323,7 +323,7 @@ export class SVGPathService { // checks if a point is on a line // return true if the point is on the line segment defined by lineStart and lineEnd // return false otherwise - isPointOnLine(point: Coord, lineStart: Coord, lineEnd: Coord): boolean { + isPointOnLine(point: Coord, lineStart: Coord, lineEnd: Coord, delta: number = 0.000001): boolean { // check if the point is within a small range of the line segment. let range = 0.00001; if ( @@ -334,15 +334,46 @@ export class SVGPathService { ) { return false; } + + // check if three points are collinear + const crossProduct = + (point.x - lineStart.x) * (lineEnd.y - lineStart.y) - + (point.y - lineStart.y) * (lineEnd.x - lineStart.x); + + if (Math.abs(crossProduct) > delta) { + return false; + } + + // point is collinear and in between the range return true; } // checks if a point is on an arc // return true if the point is within the arc // return false if the point is outside the circle or if the point is on the circle, but not within the arc - isPointInArc(intersection: Coord, arcStart: Coord, arcEnd: Coord): boolean { - // the arc always goes from the start point to the end point in a counter-clockwise direction. - // use the cross product to determine if the point is on the left or right side of the line from the start point to the end point. + isPointInArc(intersection: Coord, arcStart: Coord, arcEnd: Coord, arcCenter: Coord, radius: number, delta: number = 0.0001): boolean { + // the arc always goes from the start point to the end point in a clockwise direction. + const dx = intersection.x - arcCenter.x; + const dy = intersection.y - arcCenter.y; + + const dist = Math.sqrt(dx * dx + dy * dy); + + // is point on the circle the arc is on? + if (Math.abs(dist - radius) > delta) { + return false; + } + + const crossProduct1 = + (arcStart.x - arcCenter.x) * (intersection.y - arcCenter.y) - + (arcStart.y - arcCenter.y) * (intersection.x - arcCenter.x); + const crossProduct2 = + (intersection.x - arcCenter.x) * (arcEnd.y - arcCenter.y) - + (intersection.y - arcCenter.y) * (arcEnd.x - arcCenter.x); + + // assuming clockwise rotation, sweep flag equals 1 + return crossProduct1 >= -delta && crossProduct2 >= -delta; + + /*// use the cross product to determine if the point is on the left or right side of the line from the start point to the end point. // if the point is on the left side, then it is within the arc. // if the point is on the right side, then it is outside the arc. let crossProduct = @@ -350,7 +381,7 @@ export class SVGPathService { (intersection.x - arcStart.x) * (arcEnd.y - arcStart.y); // check if the point is on the left side of the line from the start point to the end point - return crossProduct < 0; + return crossProduct < 0;*/ } // https://stackoverflow.com/questions/13937782/calculating-the-point-of-intersection-of-two-lines @@ -501,7 +532,7 @@ export class SVGPathService { let closestIntersection: Coord | undefined; for (let intersection of intersections) { if ( - this.isPointInArc(intersection, arcStart, arcEnd) && + this.isPointInArc(intersection, arcStart, arcEnd, arcCenter, arcRadius) && !intersection.equals(arcStart, this.scale) && !intersection.equals(arcEnd, this.scale) ) { @@ -572,16 +603,16 @@ export class SVGPathService { let allImportantIntersections: Coord[] = []; - if (this.isPointInArc(startPosition, startPosition2, endPosition2)) { + if (this.isPointInArc(startPosition, startPosition2, endPosition2, center, radius)) { allImportantIntersections.push(startPosition); } - if (this.isPointInArc(endPosition, startPosition2, endPosition2)) { + if (this.isPointInArc(endPosition, startPosition2, endPosition2, center, radius)) { allImportantIntersections.push(endPosition); } - if (this.isPointInArc(startPosition2, startPosition, endPosition)) { + if (this.isPointInArc(startPosition2, startPosition, endPosition, center, radius)) { allImportantIntersections.push(startPosition2); } - if (this.isPointInArc(endPosition2, startPosition, endPosition)) { + if (this.isPointInArc(endPosition2, startPosition, endPosition, center, radius)) { allImportantIntersections.push(endPosition2); } @@ -614,8 +645,8 @@ export class SVGPathService { for (let intersection of intersections) { if ( - this.isPointInArc(intersection, startPosition, endPosition) && - this.isPointInArc(intersection, startPosition2, endPosition2) && + this.isPointInArc(intersection, startPosition, endPosition, center, radius) && + this.isPointInArc(intersection, startPosition2, endPosition2, center, radius) && !intersection.equals(startPosition, this.scale) && !intersection.equals(endPosition, this.scale) && !intersection.equals(startPosition2, this.scale) && @@ -730,6 +761,80 @@ export class SVGPathService { } } + // determines whether two lines have the same contents, so they are equal + equalLines(line1: [Coord, Coord, Coord | null, Link], line2: [Coord, Coord, Coord | null, Link]): boolean { + if ( + line1[0].equals(line2[0], 1) && + line1[1].equals(line2[1], 1) + ) { + if (line1[2] === null && line2[2] === null) { + return true; + } else if (line1[2] !== null && + line2[2] !== null && + line1[2].equals(line2[2], 1)) { + return true; + } + } + return false; + } + + // checks if point is on the shape outline + isPointOnLink(point: Coord, externalLines:[Coord, Coord, Coord | null, Link][], radius: number): boolean { + let pointOnLink = false; + externalLines.forEach((line) => { + let isSegmentOnLine: boolean; + if (line[2] !== null) { + isSegmentOnLine = this.isPointInArc(point, line[0], line[1], line[2], radius); + } else { + isSegmentOnLine = this.isPointOnLine(point, line[0], line[1]); + } + if (isSegmentOnLine) { + pointOnLink = true; + } + }) + return pointOnLink; + } + + isPointInsideLink(startPosition: Coord, startLink: Link, externalLines:[Coord, Coord, Coord | null, Link][], radius: number): boolean { + // check if the point is inside the shape created by the lines + // draw a line that is infinitely long and check if it intersects with the shape an odd number of times + const infiniteLine: [Coord, Coord, Coord | null, Link] = [startPosition, new Coord(startPosition.x + 10000, startPosition.y), null, startLink]; + const reverseInfiniteLine: [Coord, Coord, Coord | null, Link] = [new Coord(startPosition.x + 10000, startPosition.y), startPosition, null, startLink]; + + let intersections = 0; + externalLines.forEach((line) => { + const intersectionPoint = this.intersectsWith(infiniteLine, line, radius); + const otherIntersectionPoint = this.intersectsWith(reverseInfiniteLine, line, radius); + + //Add two to the intersection count if intersectionPoint and otherIntersectionPoint are not equal + if (intersectionPoint && otherIntersectionPoint) { + if (!intersectionPoint.equals(otherIntersectionPoint, this.scale)) { + intersections += 2; + } else { + intersections += 1; + } + } else if (intersectionPoint || otherIntersectionPoint) { + intersections += 1; + } + }); + + //If the number of intersections is odd, then the point is inside the shape + return intersections % 2 === 1; + } + + // calculates whether a line is contained within a link + // used to determine whether to get rid of a line + isLineContained(line: [Coord, Coord, Coord | null, Link], linkExternalLines: [Coord, Coord, Coord | null, Link][], radius: number, intersectionPoints: {point: Coord, i: number}[]): boolean { + /*let lineAngle = Math.atan2( + line[1].y - line[0].y, + line[1].x - line[0].x + );*/ + + // check if both endpoints of line are inside the link + return (this.isPointInsideLink(line[0], line[3], linkExternalLines, radius) || this.isPointOnLink(line[0], linkExternalLines, radius)) && + (this.isPointInsideLink(line[1], line[3], linkExternalLines, radius) || this.isPointOnLink(line[1], linkExternalLines, radius)); + } + // This function returns the lines used to calculate and save the lines of a link solveForExternalLines(subLinks: Map, radius: number): [Coord, Coord, Coord | null, Link][] { // create a list of all the external lines of all the sublinks @@ -896,7 +1001,62 @@ export class SVGPathService { } } - return externalLines; + // check for duplicate lines + let duplicateLines: [Coord, Coord, Coord | null, Link][] = []; + for (const line of intersectionExternalLines) { + const duplicateFound = intersectionExternalLines.find(line2 => { + return line !== line2 && this.equalLines(line, line2); + }); + const inDuplicateLines = duplicateLines.find((line2) => { + return this.equalLines(line2, line); + }); + if (duplicateFound && !inDuplicateLines) { + duplicateLines.push(line); + } + } + + // if a segment is fully inside another link, remove it + intersectionExternalLines = intersectionExternalLines.filter((line) => { + return ![... subLinks.values()].some((link) => { + if (link === line[3]) { + return false; + } else { + let linkLines = allLinkExternalLines.get(link); + if (linkLines) { + return this.isLineContained(line, linkLines, radius, allIntersectionPoints); + } else { + return false; + } + } + }) + }); + + // duplicate lines are only added if we detect a gap in the path + const newLinesToAdd: [Coord, Coord, Coord | null, Link][] = []; + for (let i = 0; i < intersectionExternalLines.length; i++) { + const pointToSearch: Coord = intersectionExternalLines[i][1]; + const found = intersectionExternalLines.find((line2) => { + return line2[0].equals(pointToSearch, 1); + }); + if (!found) { + const lineToAdd = duplicateLines.find((line2) => { + return line2[0].equals(pointToSearch, 1); + }); + if (lineToAdd !== undefined) { + newLinesToAdd.push(lineToAdd); + } + } + } + + // add in the new lines + intersectionExternalLines.push(...newLinesToAdd); + + //Remove very short lines + intersectionExternalLines = intersectionExternalLines.filter((line) => { + return (line[0].getDistanceTo(line[1]) > 0.00001 * 1); + }); + + return intersectionExternalLines; } calculateCompoundPath(subLinks: Map, allCoordsList: Coord[], r: number) { @@ -923,11 +1083,12 @@ export class SVGPathService { while (!currentLine[1].equals(beginningPoint, 1) || this.isNewShape(pathString)) { if (timeoutCounter-- < 0) { + console.log("Timeout counter reached!") return pathString; } // find the next line that starts at the end of the current line - const nextLine = [...externalLinesSet].find((line) => { + const nextLine: [Coord, Coord, Coord | null, Link] | undefined = [...externalLinesSet].find((line) => { return line[0].looselyEquals(currentLine[1], 1); }); //console.log(nextLine); @@ -938,7 +1099,7 @@ export class SVGPathService { externalLinesSet.delete(nextLine); // when there are two lines intersecting, create a fillet between them - if (currentLine[2] === null && nextLine[2] == null && + /*if (currentLine[2] === null && nextLine[2] == null && // checking if angle between the two lines is greater than 10 degrees Math.abs(this.angleToPI(nextLine, currentLine)) > (10 * Math.PI / 180)) { let [currentLineOffsetPoint, nextLineOffsetPoint, radius] = this.computeArcPointsAndRadius(currentLine, nextLine, 1); @@ -954,14 +1115,15 @@ export class SVGPathService { // !!! is 0 0 0 correct? pathString += 'A ' + radius + ', ' + radius + ' 0 0 0 ' + nextLine[0].x + ', ' + nextLine[0].y + ' '; + } else {*/ + + // otherwise, just draw a line between the two points + if (this.isNewShape(pathString)) { + pathString += 'M ' + currentLine[1].x + ', ' + currentLine[1].y + ' '; } else { - // otherwise, just draw a line between the two points - if (this.isNewShape(pathString)) { - pathString += 'M ' + currentLine[1].x + ', ' + currentLine[1].y + ' '; - } else { - pathString = this.pathStringForLine(currentLine, pathString); - } + pathString = this.pathStringForLine(currentLine, pathString); } + // } currentLine = nextLine; } pathString = this.pathStringForLine(currentLine, pathString); From 1a2453a53e2eab4d63307c657f8fc6ee21af7316 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Fri, 5 Dec 2025 11:21:49 -0500 Subject: [PATCH 07/51] fixed an issue with arc/arc intersection where only one of the two intersection points were being returned --- src/app/services/svg-path.service.ts | 99 ++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 12 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index 083b7fae..cf592756 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -388,7 +388,7 @@ export class SVGPathService { // line intercept math by Paul Bourke http://paulbourke.net/geometry/pointlineplane/ // Determine the intersection point of two line segments // Return undefiend if the lines don't intersect - lineLineIntersect(line1Start: Coord, line1End: Coord, line2Start: Coord, line2End: Coord): Coord | undefined { + lineLineIntersect(line1Start: Coord, line1End: Coord, line2Start: Coord, line2End: Coord): Coord[] | undefined { let x1 = line1Start.x; let y1 = line1Start.y; let x2 = line1End.x; @@ -434,7 +434,7 @@ export class SVGPathService { return undefined; } - return intersection; + return [intersection]; } // return the intersection points between a line and a circle. @@ -516,7 +516,30 @@ export class SVGPathService { // return the first intersection point between a line and an arc. The first is the one closest to the lineStart point. // if the line is tangent to the arc, then return the point of tangency closest to the lineStart point. // if the line does not intersect the arc, return undefined. - lineArcIntersect(lineStart: Coord, lineEnd: Coord, arcStart: Coord, arcEnd: Coord, arcCenter: Coord, findIntersectionCloseTo: Coord, arcRadius: number): Coord | undefined { + lineArcIntersect(lineStart: Coord, lineEnd: Coord, arcStart: Coord, arcEnd: Coord, arcCenter: Coord, findIntersectionCloseTo: Coord, arcRadius: number): Coord[] | undefined { + // find the intersection points between the line and the circle defined by the arc + let intersections: Coord[] | undefined = this.lineCircleIntersect(lineStart, lineEnd, arcCenter, arcRadius); + + intersections = intersections?.filter((intersection) => { + return this.isPointOnLine(intersection, lineStart, lineEnd); + }); + + if (intersections === undefined || intersections.length === 0) { + return undefined; + } + + // check if the intersection points are within the arc + let returnIntersection: Coord[] = []; + for (let intersection of intersections) { + if ( + this.isPointInArc(intersection, arcStart, arcEnd, arcCenter, arcRadius) + ) { + returnIntersection.push(intersection); + } + } + return returnIntersection; + } + /*lineArcIntersect(lineStart: Coord, lineEnd: Coord, arcStart: Coord, arcEnd: Coord, arcCenter: Coord, findIntersectionCloseTo: Coord, arcRadius: number): Coord[] | undefined { // find the intersection points between the line and the circle defined by the arc let intersections: Coord[] | undefined = this.lineCircleIntersect(lineStart, lineEnd, arcCenter, arcRadius); @@ -548,7 +571,7 @@ export class SVGPathService { } } return closestIntersection; - } + }*/ // return the intersection points between two circles. // if the circles do not intersect, return undefined. @@ -593,7 +616,7 @@ export class SVGPathService { // return the first intersection point between two arcs, the one closest to the startPosition point // if the arcs are tangent, then return the point of tangency closest to the startPosition point // if the arcs do not intersect, return undefined - arcArcIntersect(startPosition: Coord, endPosition: Coord, center: Coord, startPosition2: Coord, endPosition2: Coord, center2: Coord, radius: number): Coord | undefined { + arcArcIntersect(startPosition: Coord, endPosition: Coord, center: Coord, startPosition2: Coord, endPosition2: Coord, center2: Coord, radius: number): Coord[] | undefined { // find the intersection points between the two circles defined by the arcs let [intersections, coincident]: [Coord[] | undefined, boolean] = this.circleCircleIntersect(center, radius, center2, radius); @@ -622,7 +645,7 @@ export class SVGPathService { } // else find the intersection closest to the startPosition. - let closestIntersection: Coord | undefined; + /*let closestIntersection: Coord | undefined; for (let intersection of allImportantIntersections) { if (closestIntersection === undefined) { closestIntersection = intersection; @@ -632,7 +655,8 @@ export class SVGPathService { closestIntersection = intersection; } } - return closestIntersection; + return closestIntersection;*/ + return allImportantIntersections; } // check if no intersections @@ -661,11 +685,15 @@ export class SVGPathService { } } - return closestIntersection; + if (closestIntersection === undefined) { + return undefined; + } else { + return [closestIntersection]; + } } // finds intersection between line/arc and line/arc - intersectsWith(line1: [Coord, Coord, Coord | null, Link], line2: [Coord, Coord, Coord | null, Link], radius: number): Coord | undefined { + intersectsWith(line1: [Coord, Coord, Coord | null, Link], line2: [Coord, Coord, Coord | null, Link], radius: number): Coord[] | undefined { if (line1[2] === null && line2[2] === null) { // line and line intersection return this.lineLineIntersect(line1[0], line1[1], line2[0], line2[1]); @@ -796,6 +824,45 @@ export class SVGPathService { } isPointInsideLink(startPosition: Coord, startLink: Link, externalLines:[Coord, Coord, Coord | null, Link][], radius: number): boolean { + // check if the point is inside the shape created by the lines + // draw a line that is infinitely long and check if it intersects with the shape an odd number of times + const infiniteLine: [Coord, Coord, Coord | null, Link] = [startPosition, new Coord(startPosition.x + 10000, startPosition.y), null, startLink]; + //const reverseInfiniteLine: [Coord, Coord, Coord | null, Link] = [new Coord(startPosition.x + 10000, startPosition.y), startPosition, null, startLink]; + + let intersections = 0; + externalLines.forEach((line) => { + const intersectionPoints = this.intersectsWith(infiniteLine, line, radius); + //const otherIntersectionPoint = this.intersectsWith(reverseInfiniteLine, line, radius); + + if (intersectionPoints !== undefined) { + // check for duplicate intersection points + let duplicatePoints: number[] = []; + intersectionPoints.forEach((point, index) => { + for (let j = index + 1; j < intersectionPoints.length; j++) { + if (this.twoNumsLooselyEquals(point.x, intersectionPoints[j].x) && this.twoNumsLooselyEquals(point.y, intersectionPoints[j].y)) { + duplicatePoints.push(index); + break; + } + } + }); + + let removedDuplicateIntersectionPoints: {x: number, y: number}[] = []; + intersectionPoints.forEach((point, i) => { + if (!duplicatePoints.includes(i)) { + removedDuplicateIntersectionPoints.push(point); + } + }) + + intersections += removedDuplicateIntersectionPoints.length; + } + + }); + + //If the number of intersections is odd, then the point is inside the shape + return intersections % 2 === 1; + } + + /*isPointInsideLink(startPosition: Coord, startLink: Link, externalLines:[Coord, Coord, Coord | null, Link][], radius: number): boolean { // check if the point is inside the shape created by the lines // draw a line that is infinitely long and check if it intersects with the shape an odd number of times const infiniteLine: [Coord, Coord, Coord | null, Link] = [startPosition, new Coord(startPosition.x + 10000, startPosition.y), null, startLink]; @@ -820,7 +887,7 @@ export class SVGPathService { //If the number of intersections is odd, then the point is inside the shape return intersections % 2 === 1; - } + }*/ // calculates whether a line is contained within a link // used to determine whether to get rid of a line @@ -952,12 +1019,20 @@ export class SVGPathService { const intersection = this.intersectsWith(line1, line2, radius); if (intersection !== undefined) { - if (!(line1[1].equals(intersection, this.scale) || line1[0].equals(intersection, this.scale))) { + intersection.forEach((point: Coord) => { + if (!(line1[1].equals(point, this.scale) || line1[0].equals(point, this.scale))) { + allIntersectionPoints.push({point: point, i: i}); + } + if (!(line2[0].equals(point, this.scale) || line2[1].equals(point, this.scale))) { + allIntersectionPoints.push({point: point, i: j}); + } + }); + /*if (!(line1[1].equals(intersection, this.scale) || line1[0].equals(intersection, this.scale))) { allIntersectionPoints.push({point: intersection, i: i}); } if (!(line2[0].equals(intersection, this.scale) || line2[1].equals(intersection, this.scale))) { allIntersectionPoints.push({point: intersection, i: j}); - } + }*/ } } From 1e2f86eabe2a93559fe1f71f4edd354de43ba463 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Fri, 5 Dec 2025 11:24:12 -0500 Subject: [PATCH 08/51] got rid of some unneeded commented out code --- src/app/services/svg-path.service.ts | 91 +--------------------------- 1 file changed, 2 insertions(+), 89 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index cf592756..e4ccd639 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -363,6 +363,7 @@ export class SVGPathService { return false; } + // use cross product to determine whether the point is within the arc const crossProduct1 = (arcStart.x - arcCenter.x) * (intersection.y - arcCenter.y) - (arcStart.y - arcCenter.y) * (intersection.x - arcCenter.x); @@ -370,18 +371,8 @@ export class SVGPathService { (intersection.x - arcCenter.x) * (arcEnd.y - arcCenter.y) - (intersection.y - arcCenter.y) * (arcEnd.x - arcCenter.x); - // assuming clockwise rotation, sweep flag equals 1 + // assuming clockwise rotation, so sweep flag equals 1 return crossProduct1 >= -delta && crossProduct2 >= -delta; - - /*// use the cross product to determine if the point is on the left or right side of the line from the start point to the end point. - // if the point is on the left side, then it is within the arc. - // if the point is on the right side, then it is outside the arc. - let crossProduct = - (arcEnd.x - arcStart.x) * (intersection.y - arcStart.y) - - (intersection.x - arcStart.x) * (arcEnd.y - arcStart.y); - - // check if the point is on the left side of the line from the start point to the end point - return crossProduct < 0;*/ } // https://stackoverflow.com/questions/13937782/calculating-the-point-of-intersection-of-two-lines @@ -539,39 +530,6 @@ export class SVGPathService { } return returnIntersection; } - /*lineArcIntersect(lineStart: Coord, lineEnd: Coord, arcStart: Coord, arcEnd: Coord, arcCenter: Coord, findIntersectionCloseTo: Coord, arcRadius: number): Coord[] | undefined { - // find the intersection points between the line and the circle defined by the arc - let intersections: Coord[] | undefined = this.lineCircleIntersect(lineStart, lineEnd, arcCenter, arcRadius); - - intersections = intersections?.filter((intersection) => { - return this.isPointOnLine(intersection, lineStart, lineEnd); - }); - - if (intersections === undefined || intersections.length === 0) { - return undefined; - } - - // check if the intersection points are within the arc - let closestIntersection: Coord | undefined; - for (let intersection of intersections) { - if ( - this.isPointInArc(intersection, arcStart, arcEnd, arcCenter, arcRadius) && - !intersection.equals(arcStart, this.scale) && - !intersection.equals(arcEnd, this.scale) - ) { - // if it is, return closest intersection point - if (closestIntersection === undefined) { - closestIntersection = intersection; - } else if ( - closestIntersection.getDistanceTo(findIntersectionCloseTo) > - intersection.getDistanceTo(findIntersectionCloseTo) - ) { - closestIntersection = intersection; - } - } - } - return closestIntersection; - }*/ // return the intersection points between two circles. // if the circles do not intersect, return undefined. @@ -644,18 +602,6 @@ export class SVGPathService { return undefined; } - // else find the intersection closest to the startPosition. - /*let closestIntersection: Coord | undefined; - for (let intersection of allImportantIntersections) { - if (closestIntersection === undefined) { - closestIntersection = intersection; - } else if ( - intersection.getDistanceTo(startPosition) < closestIntersection.getDistanceTo(startPosition) - ) { - closestIntersection = intersection; - } - } - return closestIntersection;*/ return allImportantIntersections; } @@ -862,33 +808,6 @@ export class SVGPathService { return intersections % 2 === 1; } - /*isPointInsideLink(startPosition: Coord, startLink: Link, externalLines:[Coord, Coord, Coord | null, Link][], radius: number): boolean { - // check if the point is inside the shape created by the lines - // draw a line that is infinitely long and check if it intersects with the shape an odd number of times - const infiniteLine: [Coord, Coord, Coord | null, Link] = [startPosition, new Coord(startPosition.x + 10000, startPosition.y), null, startLink]; - const reverseInfiniteLine: [Coord, Coord, Coord | null, Link] = [new Coord(startPosition.x + 10000, startPosition.y), startPosition, null, startLink]; - - let intersections = 0; - externalLines.forEach((line) => { - const intersectionPoint = this.intersectsWith(infiniteLine, line, radius); - const otherIntersectionPoint = this.intersectsWith(reverseInfiniteLine, line, radius); - - //Add two to the intersection count if intersectionPoint and otherIntersectionPoint are not equal - if (intersectionPoint && otherIntersectionPoint) { - if (!intersectionPoint.equals(otherIntersectionPoint, this.scale)) { - intersections += 2; - } else { - intersections += 1; - } - } else if (intersectionPoint || otherIntersectionPoint) { - intersections += 1; - } - }); - - //If the number of intersections is odd, then the point is inside the shape - return intersections % 2 === 1; - }*/ - // calculates whether a line is contained within a link // used to determine whether to get rid of a line isLineContained(line: [Coord, Coord, Coord | null, Link], linkExternalLines: [Coord, Coord, Coord | null, Link][], radius: number, intersectionPoints: {point: Coord, i: number}[]): boolean { @@ -1027,12 +946,6 @@ export class SVGPathService { allIntersectionPoints.push({point: point, i: j}); } }); - /*if (!(line1[1].equals(intersection, this.scale) || line1[0].equals(intersection, this.scale))) { - allIntersectionPoints.push({point: intersection, i: i}); - } - if (!(line2[0].equals(intersection, this.scale) || line2[1].equals(intersection, this.scale))) { - allIntersectionPoints.push({point: intersection, i: j}); - }*/ } } From 633a17eb318e997456d54106214abaa8743b39af Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Fri, 5 Dec 2025 11:48:40 -0500 Subject: [PATCH 09/51] changed equals to loosely equals for adding duplicate lines back in because the same exact coordinates was giving a distance > 0.0001 --- src/app/services/svg-path.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index e4ccd639..58f6379e 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -1028,7 +1028,7 @@ export class SVGPathService { }); if (!found) { const lineToAdd = duplicateLines.find((line2) => { - return line2[0].equals(pointToSearch, 1); + return line2[0].looselyEquals(pointToSearch, 1); }); if (lineToAdd !== undefined) { newLinesToAdd.push(lineToAdd); From 98fa41acd5c41a755d1a78695536325730c2fe7e Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 9 Dec 2025 11:35:43 -0500 Subject: [PATCH 10/51] changed looselyequals to equals in the line to line intersection because it was throwing off the check for whether a segment was within a link --- src/app/services/svg-path.service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index 58f6379e..fb78cbc7 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -389,7 +389,7 @@ export class SVGPathService { let x4 = line2End.x; let y4 = line2End.y; - let delta = 0. + let delta = 0 // check if none of the lines are of length 0 if ((this.twoNumsLooselyEquals(x1, x2) && this.twoNumsLooselyEquals(y1, y2)) || (this.twoNumsLooselyEquals(x3, x4) && this.twoNumsLooselyEquals(y3, y4))) { @@ -418,10 +418,10 @@ export class SVGPathService { let intersection = new Coord(x, y); // checks if the intersection is an end point of the segments - if (intersection.looselyEquals(line1Start, this.scale) || - intersection.looselyEquals(line1End, this.scale) || - intersection.looselyEquals(line2Start, this.scale) || - intersection.looselyEquals(line2End, this.scale)) { + if (intersection.equals(line1Start, this.scale) || + intersection.equals(line1End, this.scale) || + intersection.equals(line2Start, this.scale) || + intersection.equals(line2End, this.scale)) { return undefined; } From 40b93293ba6b8e4aa4d73175f50e22b81fe441d8 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Thu, 11 Dec 2025 14:23:02 -0500 Subject: [PATCH 11/51] working on identifying whether an intersection between arc and line is tangent and angles for point within an arc --- src/app/services/svg-path.service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index fb78cbc7..6b586ea6 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -511,6 +511,12 @@ export class SVGPathService { // find the intersection points between the line and the circle defined by the arc let intersections: Coord[] | undefined = this.lineCircleIntersect(lineStart, lineEnd, arcCenter, arcRadius); + let isTangent: boolean = false; + if (intersections !== undefined && intersections.length === 1) { + // checks if only one intersection was returned for tangent line + isTangent = true; + } + intersections = intersections?.filter((intersection) => { return this.isPointOnLine(intersection, lineStart, lineEnd); }); From ad59066be79f19e7539466fd4b4c26c8e6f3a46e Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Fri, 16 Jan 2026 13:49:30 -0500 Subject: [PATCH 12/51] Changed point in link function to check for tangent point in arc/line intersection and then remove that point --- src/app/services/svg-path.service.ts | 30 +++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index 6b586ea6..0644cda3 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -798,14 +798,38 @@ export class SVGPathService { } }); - let removedDuplicateIntersectionPoints: {x: number, y: number}[] = []; + let removedDuplicateIntersectionPoints: Coord[] = []; intersectionPoints.forEach((point, i) => { if (!duplicatePoints.includes(i)) { removedDuplicateIntersectionPoints.push(point); } - }) + }); + + // check if the line from the link is an arc and the intersection is perpendicular (tangent) + let tangentIntersectionPoints: number[] = []; + removedDuplicateIntersectionPoints.forEach((point, index) => { + let infiniteLineSlope: number = (point.y - startPosition.y) / (point.x - startPosition.x); + let arcSlope: number = 0; + if (line[2] !== null) { + arcSlope = (point.y - line[2].y) / (point.x - line[2].x); + } + if (this.twoNumsLooselyEquals(arcSlope * infiniteLineSlope, -1)) { + tangentIntersectionPoints.push(index); + } else if (arcSlope === 0 && infiniteLineSlope === Number.NEGATIVE_INFINITY) { + tangentIntersectionPoints.push(index); + } else if (arcSlope === Number.NEGATIVE_INFINITY && infiniteLineSlope === 0) { + tangentIntersectionPoints.push(index); + } + }); + + let finalIntersectionPoints: {x: number, y: number}[] = []; + removedDuplicateIntersectionPoints.forEach((point, i) => { + if (!tangentIntersectionPoints.includes(i)) { + finalIntersectionPoints.push(point); + } + }); - intersections += removedDuplicateIntersectionPoints.length; + intersections += finalIntersectionPoints.length; } }); From 8d51a718de677de9dc3552ebae0f7e4010331ce1 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Fri, 16 Jan 2026 15:39:13 -0500 Subject: [PATCH 13/51] fixed a typo in arc/arc intersect where center should have been center2 --- src/app/services/svg-path.service.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index 0644cda3..7c638af3 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -371,8 +371,23 @@ export class SVGPathService { (intersection.x - arcCenter.x) * (arcEnd.y - arcCenter.y) - (intersection.y - arcCenter.y) * (arcEnd.x - arcCenter.x); + // use angles to determine whether point is within the arc + // angles are calculated to degrees and is counter clockwise + /*const zeroDegrees = arcCenter.clone() + zeroDegrees.x = zeroDegrees.x + radius;*/ + /*const startAngle = Math.atan2(arcStart.y - arcCenter.y, arcStart.x - arcCenter.x); + const endAngle = Math.atan2(arcEnd.y - arcCenter.y, arcEnd.x - arcCenter.x); + const pointAngle = Math.atan2(intersection.y - arcCenter.y, intersection.x - arcCenter.x); + + let angleIsBetween: boolean; + if (startAngle > endAngle) { + angleIsBetween = endAngle >= pointAngle || startAngle <= pointAngle; + } else { + angleIsBetween = startAngle <= pointAngle && endAngle >= pointAngle; + }*/ + // assuming clockwise rotation, so sweep flag equals 1 - return crossProduct1 >= -delta && crossProduct2 >= -delta; + return (crossProduct1 >= -delta && crossProduct2 >= -delta); //&& (angleIsBetween); } // https://stackoverflow.com/questions/13937782/calculating-the-point-of-intersection-of-two-lines @@ -622,7 +637,7 @@ export class SVGPathService { for (let intersection of intersections) { if ( this.isPointInArc(intersection, startPosition, endPosition, center, radius) && - this.isPointInArc(intersection, startPosition2, endPosition2, center, radius) && + this.isPointInArc(intersection, startPosition2, endPosition2, center2, radius) && !intersection.equals(startPosition, this.scale) && !intersection.equals(endPosition, this.scale) && !intersection.equals(startPosition2, this.scale) && From 6b13f8cd1e6d47fe6096d3b8b488e1ed2f3e770f Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Sun, 18 Jan 2026 14:56:11 -0500 Subject: [PATCH 14/51] added positive infinity to the check for tangents --- src/app/services/svg-path.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index 7c638af3..c5cd5db8 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -830,9 +830,9 @@ export class SVGPathService { } if (this.twoNumsLooselyEquals(arcSlope * infiniteLineSlope, -1)) { tangentIntersectionPoints.push(index); - } else if (arcSlope === 0 && infiniteLineSlope === Number.NEGATIVE_INFINITY) { + } else if (arcSlope === 0 && (infiniteLineSlope === Number.NEGATIVE_INFINITY || infiniteLineSlope === Number.POSITIVE_INFINITY)) { tangentIntersectionPoints.push(index); - } else if (arcSlope === Number.NEGATIVE_INFINITY && infiniteLineSlope === 0) { + } else if ((arcSlope === Number.NEGATIVE_INFINITY || arcSlope === Number.POSITIVE_INFINITY) && infiniteLineSlope === 0) { tangentIntersectionPoints.push(index); } }); From a5fc804bc3d9cb2d6b39a00f348637174541f866 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Sun, 18 Jan 2026 15:48:55 -0500 Subject: [PATCH 15/51] fixed issue where new lines added were not checked to see if path was completed. This fixed issue with welding when two links were welded and one link had a length of virtually 0 --- src/app/services/svg-path.service.ts | 39 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index c5cd5db8..9ef14c9b 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -1065,24 +1065,35 @@ export class SVGPathService { }); // duplicate lines are only added if we detect a gap in the path - const newLinesToAdd: [Coord, Coord, Coord | null, Link][] = []; - for (let i = 0; i < intersectionExternalLines.length; i++) { - const pointToSearch: Coord = intersectionExternalLines[i][1]; - const found = intersectionExternalLines.find((line2) => { - return line2[0].equals(pointToSearch, 1); - }); - if (!found) { - const lineToAdd = duplicateLines.find((line2) => { - return line2[0].looselyEquals(pointToSearch, 1); + let addedNewLines: boolean = false; + let firstTime: boolean = true; + let linesToCheck: [Coord, Coord, Coord | null, Link][] = intersectionExternalLines; + let newLinesToAdd: [Coord, Coord, Coord | null, Link][]; + while (firstTime || addedNewLines) { + firstTime = false; + addedNewLines = false; + + newLinesToAdd = []; + for (let i = 0; i < linesToCheck.length; i++) { + const pointToSearch: Coord = linesToCheck[i][1]; + const found = intersectionExternalLines.find((line2) => { + return line2[0].equals(pointToSearch, 1); }); - if (lineToAdd !== undefined) { - newLinesToAdd.push(lineToAdd); + if (!found) { + const lineToAdd = duplicateLines.find((line2) => { + return line2[0].looselyEquals(pointToSearch, 1); + }); + if (lineToAdd !== undefined) { + newLinesToAdd.push(lineToAdd); + addedNewLines = true; + } } } - } + linesToCheck = newLinesToAdd; - // add in the new lines - intersectionExternalLines.push(...newLinesToAdd); + // add in the new lines + intersectionExternalLines.push(...newLinesToAdd); + } //Remove very short lines intersectionExternalLines = intersectionExternalLines.filter((line) => { From dec819fc3d4e30d6055ac23e5deb1736e5e24f65 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 20 Jan 2026 14:07:18 -0500 Subject: [PATCH 16/51] Added code to make the corner where two lines intersect a concave curve --- src/app/services/svg-path.service.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index 9ef14c9b..15df7f47 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -1119,6 +1119,9 @@ export class SVGPathService { let timeoutCounter = 1000; + // this is the offset for concave curve radius + const offsetRadius = 3; + while (externalLinesSet.size > 1) { // this is the first line from the set let currentLine: [Coord, Coord, Coord | null, Link] = externalLinesSet.values().next().value; @@ -1143,10 +1146,10 @@ export class SVGPathService { externalLinesSet.delete(nextLine); // when there are two lines intersecting, create a fillet between them - /*if (currentLine[2] === null && nextLine[2] == null && + if (currentLine[2] === null && nextLine[2] === null && // checking if angle between the two lines is greater than 10 degrees Math.abs(this.angleToPI(nextLine, currentLine)) > (10 * Math.PI / 180)) { - let [currentLineOffsetPoint, nextLineOffsetPoint, radius] = this.computeArcPointsAndRadius(currentLine, nextLine, 1); + let [currentLineOffsetPoint, nextLineOffsetPoint, radius] = this.computeArcPointsAndRadius(currentLine, nextLine, r * offsetRadius); currentLine[1] = currentLineOffsetPoint; nextLine[0] = nextLineOffsetPoint; @@ -1157,17 +1160,16 @@ export class SVGPathService { pathString = this.pathStringForLine(currentLine, pathString); } - // !!! is 0 0 0 correct? pathString += 'A ' + radius + ', ' + radius + ' 0 0 0 ' + nextLine[0].x + ', ' + nextLine[0].y + ' '; - } else {*/ - - // otherwise, just draw a line between the two points - if (this.isNewShape(pathString)) { - pathString += 'M ' + currentLine[1].x + ', ' + currentLine[1].y + ' '; } else { - pathString = this.pathStringForLine(currentLine, pathString); + + // otherwise, just draw a line between the two points + if (this.isNewShape(pathString)) { + pathString += 'M ' + currentLine[1].x + ', ' + currentLine[1].y + ' '; + } else { + pathString = this.pathStringForLine(currentLine, pathString); + } } - // } currentLine = nextLine; } pathString = this.pathStringForLine(currentLine, pathString); From 896597262d82e0f264de1701915014ba6836f3fd Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 20 Jan 2026 14:19:44 -0500 Subject: [PATCH 17/51] Removed some unnecessary code and added a few comments for clarity --- src/app/services/svg-path.service.ts | 44 +++++----------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index 15df7f47..ed67c90d 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -371,29 +371,14 @@ export class SVGPathService { (intersection.x - arcCenter.x) * (arcEnd.y - arcCenter.y) - (intersection.y - arcCenter.y) * (arcEnd.x - arcCenter.x); - // use angles to determine whether point is within the arc - // angles are calculated to degrees and is counter clockwise - /*const zeroDegrees = arcCenter.clone() - zeroDegrees.x = zeroDegrees.x + radius;*/ - /*const startAngle = Math.atan2(arcStart.y - arcCenter.y, arcStart.x - arcCenter.x); - const endAngle = Math.atan2(arcEnd.y - arcCenter.y, arcEnd.x - arcCenter.x); - const pointAngle = Math.atan2(intersection.y - arcCenter.y, intersection.x - arcCenter.x); - - let angleIsBetween: boolean; - if (startAngle > endAngle) { - angleIsBetween = endAngle >= pointAngle || startAngle <= pointAngle; - } else { - angleIsBetween = startAngle <= pointAngle && endAngle >= pointAngle; - }*/ - - // assuming clockwise rotation, so sweep flag equals 1 - return (crossProduct1 >= -delta && crossProduct2 >= -delta); //&& (angleIsBetween); + // if the crossProducts are both greater than negative delta, then the point is within the arc + return (crossProduct1 >= -delta && crossProduct2 >= -delta); } // https://stackoverflow.com/questions/13937782/calculating-the-point-of-intersection-of-two-lines // line intercept math by Paul Bourke http://paulbourke.net/geometry/pointlineplane/ // Determine the intersection point of two line segments - // Return undefiend if the lines don't intersect + // Return undefined if the lines don't intersect lineLineIntersect(line1Start: Coord, line1End: Coord, line2Start: Coord, line2End: Coord): Coord[] | undefined { let x1 = line1Start.x; let y1 = line1Start.y; @@ -526,12 +511,6 @@ export class SVGPathService { // find the intersection points between the line and the circle defined by the arc let intersections: Coord[] | undefined = this.lineCircleIntersect(lineStart, lineEnd, arcCenter, arcRadius); - let isTangent: boolean = false; - if (intersections !== undefined && intersections.length === 1) { - // checks if only one intersection was returned for tangent line - isTangent = true; - } - intersections = intersections?.filter((intersection) => { return this.isPointOnLine(intersection, lineStart, lineEnd); }); @@ -576,10 +555,10 @@ export class SVGPathService { // Circles are coincident if (d === 0 && radius === radius2) { - // console.log('Circles are coincident'); return [undefined, true]; } + // calculate intersection points let a = (radius * radius - radius2 * radius2 + d * d) / (2 * d); let h = Math.sqrt(radius * radius - a * a); let x3 = x1 + (a * dx) / d; @@ -794,12 +773,10 @@ export class SVGPathService { // check if the point is inside the shape created by the lines // draw a line that is infinitely long and check if it intersects with the shape an odd number of times const infiniteLine: [Coord, Coord, Coord | null, Link] = [startPosition, new Coord(startPosition.x + 10000, startPosition.y), null, startLink]; - //const reverseInfiniteLine: [Coord, Coord, Coord | null, Link] = [new Coord(startPosition.x + 10000, startPosition.y), startPosition, null, startLink]; let intersections = 0; externalLines.forEach((line) => { const intersectionPoints = this.intersectsWith(infiniteLine, line, radius); - //const otherIntersectionPoint = this.intersectsWith(reverseInfiniteLine, line, radius); if (intersectionPoints !== undefined) { // check for duplicate intersection points @@ -849,18 +826,13 @@ export class SVGPathService { }); - //If the number of intersections is odd, then the point is inside the shape + // If the number of intersections is odd, then the point is inside the shape return intersections % 2 === 1; } // calculates whether a line is contained within a link // used to determine whether to get rid of a line isLineContained(line: [Coord, Coord, Coord | null, Link], linkExternalLines: [Coord, Coord, Coord | null, Link][], radius: number, intersectionPoints: {point: Coord, i: number}[]): boolean { - /*let lineAngle = Math.atan2( - line[1].y - line[0].y, - line[1].x - line[0].x - );*/ - // check if both endpoints of line are inside the link return (this.isPointInsideLink(line[0], line[3], linkExternalLines, radius) || this.isPointOnLink(line[0], linkExternalLines, radius)) && (this.isPointInsideLink(line[1], line[3], linkExternalLines, radius) || this.isPointOnLink(line[1], linkExternalLines, radius)); @@ -874,7 +846,6 @@ export class SVGPathService { boolean is true if the line is an arc Last is the center Coord of an arc, null if it is just a line */ - //radius = 0.15; let externalLines: [Coord, Coord, Coord | null, Link][] = []; let allLinkExternalLines: Map = new Map(); @@ -919,7 +890,6 @@ export class SVGPathService { const point5: Coord = new Coord(collinearCoords[0].x + dirFirstToSecond.x * radius, collinearCoords[0].y + dirFirstToSecond.y * radius); externalLines.push([point4.clone(), point5.clone(), collinearCoords[0].clone(), link]); linkExternalLines.push([point4.clone(), point5.clone(), collinearCoords[0].clone(), link]); - //console.log(externalLines); } else { if (hullPoints.length < 3) { throw new Error('At least three points are required to create a path with rounded corners.'); @@ -982,6 +952,8 @@ export class SVGPathService { // check if lines intersect, if they do save the intersections const intersection = this.intersectsWith(line1, line2, radius); + // make sure intersection is not the end points of a segment before adding in the intersection for each segment + // intersection points are saved along with the segment the intersection is on if (intersection !== undefined) { intersection.forEach((point: Coord) => { if (!(line1[1].equals(point, this.scale) || line1[0].equals(point, this.scale))) { @@ -1065,6 +1037,7 @@ export class SVGPathService { }); // duplicate lines are only added if we detect a gap in the path + // need to loop again and check for a gap if a new segment was added let addedNewLines: boolean = false; let firstTime: boolean = true; let linesToCheck: [Coord, Coord, Coord | null, Link][] = intersectionExternalLines; @@ -1175,7 +1148,6 @@ export class SVGPathService { pathString = this.pathStringForLine(currentLine, pathString); pathString += 'Z '; } - //console.log(pathString); return pathString; } From 198ac84831c3c8e5c1568d9edf51266ac94ee4d6 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Wed, 21 Jan 2026 18:27:17 -0500 Subject: [PATCH 18/51] fixed issue where an arc with multiple intersections had segment split in wrong direction. Made sure that intersection points were sorted in order of how close they are to the arc start position --- src/app/services/svg-path.service.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index ed67c90d..b0310857 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -710,11 +710,24 @@ export class SVGPathService { // split an arc along intersection points splitArcByIntersection(arc: [Coord, Coord, Coord | null, Link], points: Coord[]): [Coord, Coord, Coord | null, Link][] { if (arc[2] !== null) { + // getting angles of every point from the center of the arc let intersectionsWithAngles:{p:Coord, angle: number}[] = points.map((p) => ({ p: p, angle: Math.atan2(p.y - (arc[2]?.y ?? 0), p.x - (arc[2]?.x ?? 0)) })); - let resultOrderedPoints = intersectionsWithAngles.sort((a, b) => a.angle - b.angle); + + // change range from [0 to 2pi] and compare angle distance from start angle of arc + const twoPI = 2 * Math.PI; + // making sure arc angle is between [0, 2pi] + const startArcAngle = (Math.atan2(arc[0].y - (arc[2]?.y ?? 0), arc[0].x - (arc[2]?.x ?? 0)) % twoPI + twoPI) % twoPI; + let resultOrderedPoints = intersectionsWithAngles.sort((a, b) => { + const aFromStart = (a.angle % twoPI + twoPI) % twoPI - startArcAngle; + const bFromStart = (b.angle % twoPI + twoPI) % twoPI - startArcAngle; + + const posAAngle = (aFromStart + twoPI) % twoPI; + const posBAngle = (bFromStart + twoPI) % twoPI; + return posAAngle - posBAngle; + }); // removes any intersection points that are at the end points resultOrderedPoints = resultOrderedPoints.filter((point) => { @@ -895,8 +908,8 @@ export class SVGPathService { throw new Error('At least three points are required to create a path with rounded corners.'); } - console.log("hull points"); - console.log(hullPoints); + // console.log("hull points"); + // console.log(hullPoints); // Start the path, moving the pointer to the first correct point const dirFirstToSecondInit = this.perpendicularDirection(hullPoints[0], hullPoints[1]); @@ -1111,7 +1124,6 @@ export class SVGPathService { const nextLine: [Coord, Coord, Coord | null, Link] | undefined = [...externalLinesSet].find((line) => { return line[0].looselyEquals(currentLine[1], 1); }); - //console.log(nextLine); if (!nextLine) { break; From f6b30144c81868673b1d0b6eb9f3b68dc75166b5 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 26 Jan 2026 13:42:34 -0500 Subject: [PATCH 19/51] changed welded joint shape to bring icon forward and center it --- .../Grid/joint/joint.component.html | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/app/components/Grid/joint/joint.component.html b/src/app/components/Grid/joint/joint.component.html index 00baab50..0e087e4b 100644 --- a/src/app/components/Grid/joint/joint.component.html +++ b/src/app/components/Grid/joint/joint.component.html @@ -52,26 +52,6 @@ /> } - - @if(isWelded()){ - - - - - - } @if (isRef() && !this.joint.isHidden){ } + @if(isWelded()){ + + + + + + } + @if(isHovered() || isSelected() || showIDLabels) { Date: Wed, 18 Feb 2026 17:53:21 -0500 Subject: [PATCH 20/51] fixed the bug where a segment where the endpoints are both intersections is automatically deleted. Now, it calculates the outline correctly --- src/app/services/svg-path.service.ts | 68 +++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index b0310857..3fa2cfdf 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -846,9 +846,73 @@ export class SVGPathService { // calculates whether a line is contained within a link // used to determine whether to get rid of a line isLineContained(line: [Coord, Coord, Coord | null, Link], linkExternalLines: [Coord, Coord, Coord | null, Link][], radius: number, intersectionPoints: {point: Coord, i: number}[]): boolean { + const origStartOnLink: boolean = this.isPointOnLink(line[0], linkExternalLines, radius); + const origEndOnLink: boolean = this.isPointOnLink(line[1], linkExternalLines, radius); + if (origStartOnLink && origEndOnLink) { + let tempShortenedLine:[Coord, Coord, Coord | null, Link] = this.shortenBy(line, 0.02 * this.scale); + + const startOnLink: boolean = this.isPointOnLink(tempShortenedLine[0], linkExternalLines, radius); + const endOnLink: boolean = this.isPointOnLink(tempShortenedLine[1], linkExternalLines, radius); + + return (this.isPointInsideLink(tempShortenedLine[0], tempShortenedLine[3], linkExternalLines, radius) || startOnLink) && + (this.isPointInsideLink(tempShortenedLine[1], tempShortenedLine[3], linkExternalLines, radius) || endOnLink); + } else { + return (this.isPointInsideLink(line[0], line[3], linkExternalLines, radius) || origStartOnLink) && + (this.isPointInsideLink(line[1], line[3], linkExternalLines, radius) || origEndOnLink); + } + + + + // check if both endpoints are on link and then check for line/line, arc/arc, or arc/line + // if line/line or arc/arc, is contained + // else if arc/line, is not contained + // else if line/arc, is contained + /*if (startOnLink && endOnLink) { + if (tempShortenedLine[2] !== null) { + + } + }*/ + // check if both endpoints of line are inside the link - return (this.isPointInsideLink(line[0], line[3], linkExternalLines, radius) || this.isPointOnLink(line[0], linkExternalLines, radius)) && - (this.isPointInsideLink(line[1], line[3], linkExternalLines, radius) || this.isPointOnLink(line[1], linkExternalLines, radius)); + /*return (this.isPointInsideLink(tempShortenedLine[0], tempShortenedLine[3], linkExternalLines, radius) || startOnLink) && + (this.isPointInsideLink(tempShortenedLine[1], tempShortenedLine[3], linkExternalLines, radius) || endOnLink);*/ + } + + // this shortens segments by shortenNum and returns new segment + shortenBy(line: [Coord, Coord, Coord | null, Link], shortenNum: number): [Coord, Coord, Coord | null, Link] { + let shortenedLine:[Coord, Coord, Coord | null, Link]; + if (line[2] == null) { + shortenedLine = [line[0].clone(), line[1].clone(), line[2], line[3]]; + const angle = Math.atan2(line[1].y - line[0].y, line[1].x - line[0].x); + const shortenByVector = new Coord(Math.cos(angle), Math.sin(angle)).scale( + shortenNum / 2 + ); + shortenedLine[0] = shortenedLine[0].add(shortenByVector); + shortenedLine[1] = shortenedLine[1].subtract(shortenByVector); + return shortenedLine; + } else { + shortenedLine = [line[0].clone(), line[1].clone(), line[2]?.clone(), line[3]]; + //const angleA = Math.atan2(a.y - startPoint.y, a.x - startPoint.x); + + let radius = line[0].getDistanceTo(line[2]); + let angleToShortenBy = shortenNum / radius; + angleToShortenBy /= 2; + + let startAngle = line[0].getAngleTo(line[2]); + let endAngle = line[1].getAngleTo(line[2]); + + startAngle += angleToShortenBy; + endAngle -= angleToShortenBy; + + shortenedLine[0] = line[2].clone()?.add( + new Coord(Math.cos(startAngle), Math.sin(startAngle)).scale(radius) + ); + shortenedLine[1] = line[2].clone()?.add( + new Coord(Math.cos(endAngle), Math.sin(endAngle)).scale(radius) + ); + + return shortenedLine; + } } // This function returns the lines used to calculate and save the lines of a link From 4c5ee595e4c184bc20304663c7cb9445cfcf3f14 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Wed, 18 Feb 2026 17:55:33 -0500 Subject: [PATCH 21/51] I deleted some unneeded code --- src/app/services/svg-path.service.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index 3fa2cfdf..9f26ff21 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -860,22 +860,6 @@ export class SVGPathService { return (this.isPointInsideLink(line[0], line[3], linkExternalLines, radius) || origStartOnLink) && (this.isPointInsideLink(line[1], line[3], linkExternalLines, radius) || origEndOnLink); } - - - - // check if both endpoints are on link and then check for line/line, arc/arc, or arc/line - // if line/line or arc/arc, is contained - // else if arc/line, is not contained - // else if line/arc, is contained - /*if (startOnLink && endOnLink) { - if (tempShortenedLine[2] !== null) { - - } - }*/ - - // check if both endpoints of line are inside the link - /*return (this.isPointInsideLink(tempShortenedLine[0], tempShortenedLine[3], linkExternalLines, radius) || startOnLink) && - (this.isPointInsideLink(tempShortenedLine[1], tempShortenedLine[3], linkExternalLines, radius) || endOnLink);*/ } // this shortens segments by shortenNum and returns new segment @@ -892,7 +876,6 @@ export class SVGPathService { return shortenedLine; } else { shortenedLine = [line[0].clone(), line[1].clone(), line[2]?.clone(), line[3]]; - //const angleA = Math.atan2(a.y - startPoint.y, a.x - startPoint.x); let radius = line[0].getDistanceTo(line[2]); let angleToShortenBy = shortenNum / radius; From 9c516c40cab6b5de4624ec54e01b811945e2bf88 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Thu, 19 Feb 2026 08:49:11 -0500 Subject: [PATCH 22/51] Changing visuals of tracer and welded joints --- .../components/Grid/joint/joint.component.css | 2 +- .../Grid/joint/joint.component.html | 35 ++++++++++++------- src/app/controllers/joint-interactor.ts | 1 + src/app/controllers/link-interactor.ts | 3 ++ src/app/model/joint.ts | 7 ++++ 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/app/components/Grid/joint/joint.component.css b/src/app/components/Grid/joint/joint.component.css index 7492bee3..f4906834 100644 --- a/src/app/components/Grid/joint/joint.component.css +++ b/src/app/components/Grid/joint/joint.component.css @@ -9,5 +9,5 @@ .weld-icon{ padding: 3px; - filter: invert(100%) sepia(100%) saturate(1%) hue-rotate(343deg) brightness(102%) contrast(101%); + filter: sepia(100%) saturate(1%) hue-rotate(343deg) brightness(102%) contrast(101%); } diff --git a/src/app/components/Grid/joint/joint.component.html b/src/app/components/Grid/joint/joint.component.html index 0e087e4b..f1033611 100644 --- a/src/app/components/Grid/joint/joint.component.html +++ b/src/app/components/Grid/joint/joint.component.html @@ -52,7 +52,7 @@ /> } - @if (isRef() && !this.joint.isHidden){ + @if (isRef() && !this.joint.isHidden && !this.joint.isTracer){ + } @else if (this.joint.isTracer) { + + } @else { + } - @else { - - } @if(isWelded()){ - + diff --git a/src/app/components/Grid/graph/graph.component.ts b/src/app/components/Grid/graph/graph.component.ts index cf7eee07..7e4badfd 100644 --- a/src/app/components/Grid/graph/graph.component.ts +++ b/src/app/components/Grid/graph/graph.component.ts @@ -113,6 +113,15 @@ export class GraphComponent { return Array.from(this.stateService.getMechanism().getTrajectories()); } + public getOneJoint(id: number): Joint | null { + for (const joint of this.stateService.getMechanism().getJoints()) { + if (joint.id === id) { + return joint; + } + } + return null; + } + public getLinks(): Link[] { return Array.from(this.stateService.getMechanism().getIndependentLinks()); } From f1e234ebdfdfdbecc8c7116e596db6800a923b86 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 23 Feb 2026 15:54:37 -0500 Subject: [PATCH 26/51] changed trajectory to a dashed line based on whether the joint is a tracer or not --- .../Grid/trajectory/trajectory.component.html | 21 +++++++++++++++++-- .../Grid/trajectory/trajectory.component.ts | 2 ++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/app/components/Grid/trajectory/trajectory.component.html b/src/app/components/Grid/trajectory/trajectory.component.html index 31e3f3ed..1fce83e1 100644 --- a/src/app/components/Grid/trajectory/trajectory.component.html +++ b/src/app/components/Grid/trajectory/trajectory.component.html @@ -1,11 +1,28 @@ - + } @else { + + } + diff --git a/src/app/components/Grid/trajectory/trajectory.component.ts b/src/app/components/Grid/trajectory/trajectory.component.ts index bd36c385..205d1232 100644 --- a/src/app/components/Grid/trajectory/trajectory.component.ts +++ b/src/app/components/Grid/trajectory/trajectory.component.ts @@ -2,6 +2,7 @@ import { Component, Input } from '@angular/core'; import { Coord } from 'src/app/model/coord'; import { SVGPathService } from 'src/app/services/svg-path.service'; import { UnitConversionService } from 'src/app/services/unit-conversion.service'; +import {Joint} from "../../../model/joint"; @Component({ selector: '[app-trajectory]', @@ -11,6 +12,7 @@ import { UnitConversionService } from 'src/app/services/unit-conversion.service' export class TrajectoryComponent { @Input() trajectory!: Coord[]; + @Input() joint!: Joint | null; constructor( private svgPathService: SVGPathService, From a82857039e5bdd7511e07a24d08d6ea78b63e701 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 3 Mar 2026 15:42:44 -0500 Subject: [PATCH 27/51] Added a context menu option to link interactor for the circular input --- src/app/controllers/link-interactor.ts | 20 +++++++++++++++++++ .../contextMenuIcons/circleInputLink.svg | 1 + 2 files changed, 21 insertions(+) create mode 100644 src/assets/contextMenuIcons/circleInputLink.svg diff --git a/src/app/controllers/link-interactor.ts b/src/app/controllers/link-interactor.ts index a31dd16d..af95ff7a 100644 --- a/src/app/controllers/link-interactor.ts +++ b/src/app/controllers/link-interactor.ts @@ -348,6 +348,26 @@ export class LinkInteractor extends Interactor { }, disabled: false, + }, + { + icon: 'assets/contextMenuIcons/circleInputLink.svg', + label: 'Circular Input', + action: () => { + let hasInputJoint = false; + for (const joint of this.link.getJoints()) { + if (joint.isInput) { + hasInputJoint = true; + } + } + + if (!hasInputJoint) { + // prevents click if link does not have an input joint + return; + } + + }, + disabled: false, + } ); } diff --git a/src/assets/contextMenuIcons/circleInputLink.svg b/src/assets/contextMenuIcons/circleInputLink.svg new file mode 100644 index 00000000..f9c74c99 --- /dev/null +++ b/src/assets/contextMenuIcons/circleInputLink.svg @@ -0,0 +1 @@ + \ No newline at end of file From d6a2e921d5c3655ea7af69dd358c92b8ac0cc84f Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 3 Mar 2026 15:53:35 -0500 Subject: [PATCH 28/51] changed disabled for circular input menu option so that it is disabled if the link does not have an input joint --- src/app/controllers/link-interactor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/controllers/link-interactor.ts b/src/app/controllers/link-interactor.ts index af95ff7a..e2dafd09 100644 --- a/src/app/controllers/link-interactor.ts +++ b/src/app/controllers/link-interactor.ts @@ -366,7 +366,7 @@ export class LinkInteractor extends Interactor { } }, - disabled: false, + disabled:!this.link.getJoints().some(joint => joint.isInput), } ); From ababa75f02ff61ab838a5667fc2a910bc9cbfc84 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 3 Mar 2026 18:28:17 -0500 Subject: [PATCH 29/51] added private field to check if link is a circular link --- src/app/model/link.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app/model/link.ts b/src/app/model/link.ts index 8951b99a..755fc3f0 100644 --- a/src/app/model/link.ts +++ b/src/app/model/link.ts @@ -18,6 +18,7 @@ export class Link implements RigidBody { private _color: string = ''; private _isLocked: boolean; private _angle: number; + private _isCircle: boolean = false; private linkColorOptions = [ '#727FD5', @@ -109,6 +110,10 @@ export class Link implements RigidBody { return parseFloat(posangle.toFixed(3)); } + get isCircle(): boolean { + return this._isCircle; + } + //setters set name(value: string) { this._name = value; @@ -130,6 +135,10 @@ export class Link implements RigidBody { this._angle = ((value % 360) + 360) % 360; } + set isCircle(value: boolean) { + this._isCircle = value; + } + addTracer(newJoint: Joint) { this._joints.set(newJoint.id, newJoint); this.calculateCenterOfMass(); From 77df6c8e0d1f73f79697a734f8b6185a66fbae03 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 3 Mar 2026 18:28:47 -0500 Subject: [PATCH 30/51] added a method to convert a number in model into svg scale --- src/app/services/unit-conversion.service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/services/unit-conversion.service.ts b/src/app/services/unit-conversion.service.ts index cd3290e0..19cca1c8 100644 --- a/src/app/services/unit-conversion.service.ts +++ b/src/app/services/unit-conversion.service.ts @@ -31,4 +31,10 @@ export class UnitConversionService{ return new Coord(convertedX,convertedY); } + // Converts model number to SVG number by applying the defined scale factor. + public modelNumToSVGNum(modelNum: number): number{ + let convertedNum: number = modelNum * this.MODEL_TO_SVG_SCALE; + return convertedNum; + } + } From d76bf9772439b055701bcfb42b98deae478d7128 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 3 Mar 2026 18:29:35 -0500 Subject: [PATCH 31/51] added code so that when context menu for circular input is clicked turns link into circular link --- src/app/controllers/link-interactor.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/controllers/link-interactor.ts b/src/app/controllers/link-interactor.ts index e2dafd09..2c73420c 100644 --- a/src/app/controllers/link-interactor.ts +++ b/src/app/controllers/link-interactor.ts @@ -365,6 +365,8 @@ export class LinkInteractor extends Interactor { return; } + this.link.isCircle = true; + }, disabled:!this.link.getJoints().some(joint => joint.isInput), From e350aeb45c70ea6203bd0d2c9a30b5243c33f943 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 3 Mar 2026 18:30:04 -0500 Subject: [PATCH 32/51] added code to draw a circular link --- .../components/Grid/link/link.component.ts | 21 ++++++++++++++++++- src/app/services/svg-path.service.ts | 18 ++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/app/components/Grid/link/link.component.ts b/src/app/components/Grid/link/link.component.ts index b3f0659c..f38575ac 100644 --- a/src/app/components/Grid/link/link.component.ts +++ b/src/app/components/Grid/link/link.component.ts @@ -447,7 +447,26 @@ export class LinkComponent coord = this.unitConversionService.modelCoordToSVGCoord(coord); allCoords.push(coord); } - return this.svgPathService.getSingleLinkDrawnPath(allCoords, radius); + if (this.link.isCircle) { + let pathStr = ''; + let i: number = 0; + let foundInput: boolean = false; + let inputCoords: Coord; + while (!foundInput && i < this.link.joints.size) { + if (this.link.joints.get(i)?.isInput) { + inputCoords = this.unitConversionService.modelCoordToSVGCoord(this.link.joints.get(i)!!.coords); + pathStr = this.svgPathService.getCircularLink(inputCoords, this.link.length, 18 + 10); + foundInput = true; + } + i++; + } + + return pathStr; + + } else { + return this.svgPathService.getSingleLinkDrawnPath(allCoords, radius); + } + } getLengthSVG() { diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index 9f26ff21..bdbc9a95 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -29,6 +29,23 @@ export class SVGPathService { return this.calculateConvexPath(hullCoords,radius); } + // draws the circular link for an input link + // radius should be positive and inputCoords should already be converted to SVG + // offset is accounting for joint radius + getCircularLink(inputCoords: Coord, radius: number, offset: number): string { + let pathData = ''; + const convertedRadius = this.unitConversionService.modelNumToSVGNum(radius) + offset; + pathData += `M ${inputCoords.x - convertedRadius},${inputCoords.y} `; // moving to center of the circle + pathData += `A ${convertedRadius},${convertedRadius} 0 1 0 ${inputCoords.x + convertedRadius},${inputCoords.y} `; // drawing first semicircle + pathData += `A ${convertedRadius},${convertedRadius} 0 1 0 ${inputCoords.x - convertedRadius},${inputCoords.y} Z`; // drawing second semicircle + + // pathData += `m -${convertedRadius},0 `; // moving to left of center by radius + // pathData += `a ${convertedRadius},${convertedRadius} 0 1,0 90,0 `; // drawing first semicircle + // pathData += `a ${convertedRadius},${convertedRadius} 0 1,0 -90,0`; // drawing second semicircle + console.log(pathData); + return pathData; + } + // Creates an SVG path string tracing the trajectory through all provided coordinates with the given radius. getTrajectoryDrawnPath(allCoords: Coord[], radius: number): string { if (allCoords.length === 0) { @@ -66,6 +83,7 @@ export class SVGPathService { return this.calculateConvexPath(hullCoords,radius); } + // Identifies if all coordinates lie on a straight line and returns the endpoints if they are collinear. private findCollinearCoords(coords: Coord[]): Coord[] | undefined { From 9b39953710cf7e019b15a217387e5f3979b37011 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 3 Mar 2026 18:32:53 -0500 Subject: [PATCH 33/51] removed unnecessary code --- src/app/services/svg-path.service.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/app/services/svg-path.service.ts b/src/app/services/svg-path.service.ts index bdbc9a95..2ab11f34 100644 --- a/src/app/services/svg-path.service.ts +++ b/src/app/services/svg-path.service.ts @@ -39,10 +39,6 @@ export class SVGPathService { pathData += `A ${convertedRadius},${convertedRadius} 0 1 0 ${inputCoords.x + convertedRadius},${inputCoords.y} `; // drawing first semicircle pathData += `A ${convertedRadius},${convertedRadius} 0 1 0 ${inputCoords.x - convertedRadius},${inputCoords.y} Z`; // drawing second semicircle - // pathData += `m -${convertedRadius},0 `; // moving to left of center by radius - // pathData += `a ${convertedRadius},${convertedRadius} 0 1,0 90,0 `; // drawing first semicircle - // pathData += `a ${convertedRadius},${convertedRadius} 0 1,0 -90,0`; // drawing second semicircle - console.log(pathData); return pathData; } From 80b5b8e67883f0332ba870fa77c5751cd7d839e9 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Wed, 4 Mar 2026 15:25:35 -0500 Subject: [PATCH 34/51] added undo and redo for adding a circular input link --- src/app/controllers/link-interactor.ts | 6 ++++++ src/app/services/undo-redo.service.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/app/controllers/link-interactor.ts b/src/app/controllers/link-interactor.ts index 2c73420c..476384f5 100644 --- a/src/app/controllers/link-interactor.ts +++ b/src/app/controllers/link-interactor.ts @@ -367,6 +367,12 @@ export class LinkInteractor extends Interactor { this.link.isCircle = true; + this.undoRedoService.recordAction({ + //specifies that it only needs the action name and link id + type: "circleLink", + linkId: this.link.id, + }); + }, disabled:!this.link.getJoints().some(joint => joint.isInput), diff --git a/src/app/services/undo-redo.service.ts b/src/app/services/undo-redo.service.ts index cd625b9b..492358a7 100644 --- a/src/app/services/undo-redo.service.ts +++ b/src/app/services/undo-redo.service.ts @@ -297,6 +297,13 @@ export class UndoRedoService { //reverses the lock link.locked = !locked break; + } case 'circleLink': { + //get the specified link from the action and see if it's a circle + const link = this.mechanism.getLink(action.linkId!)!; + const circular = link.isCircle; + //reverse the lock + link.isCircle = !circular + break; } default: @@ -539,6 +546,14 @@ export class UndoRedoService { link.locked = !locked break; } + case 'circleLink': { + //get the specified link from the action and see if it's a circle + const link = this.mechanism.getLink(action.linkId!)!; + const circular = link.isCircle + //reverse the lock + link.isCircle = !circular + break; + } default: console.error('No inverse defined for action type:', action.type); } From 2194ee7b5216b930847308b3807c07e08b708b3d Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 16 Mar 2026 11:45:55 -0400 Subject: [PATCH 35/51] Added icon for undoing circular --- src/app/controllers/link-interactor.ts | 9 ++++++--- src/assets/contextMenuIcons/uncircular.svg | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 src/assets/contextMenuIcons/uncircular.svg diff --git a/src/app/controllers/link-interactor.ts b/src/app/controllers/link-interactor.ts index 476384f5..31a603dd 100644 --- a/src/app/controllers/link-interactor.ts +++ b/src/app/controllers/link-interactor.ts @@ -350,8 +350,10 @@ export class LinkInteractor extends Interactor { }, { - icon: 'assets/contextMenuIcons/circleInputLink.svg', - label: 'Circular Input', + icon: !this.link.isCircle + ? 'assets/contextMenuIcons/circleInputLink.svg' + : 'assets/contextMenuIcons/uncircular.svg', + label: !this.link.isCircle ? 'Circular Link' : 'Straight Link', action: () => { let hasInputJoint = false; for (const joint of this.link.getJoints()) { @@ -365,7 +367,8 @@ export class LinkInteractor extends Interactor { return; } - this.link.isCircle = true; + + this.link.isCircle = !this.link.isCircle; this.undoRedoService.recordAction({ //specifies that it only needs the action name and link id diff --git a/src/assets/contextMenuIcons/uncircular.svg b/src/assets/contextMenuIcons/uncircular.svg new file mode 100644 index 00000000..00f29921 --- /dev/null +++ b/src/assets/contextMenuIcons/uncircular.svg @@ -0,0 +1 @@ + \ No newline at end of file From 6317869b781f5f23eb38eb09c82dc408e08ca154 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 16 Mar 2026 11:47:21 -0400 Subject: [PATCH 36/51] Updated wording for context menu and also updated the circular links --- src/app/components/Grid/link/link.component.ts | 2 +- src/app/components/ToolBar/undo-redo-panel/action.ts | 1 + src/app/controllers/link-interactor.ts | 10 +++++----- src/app/controllers/svg-interactor.ts | 3 ++- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/components/Grid/link/link.component.ts b/src/app/components/Grid/link/link.component.ts index f38575ac..8e7524f2 100644 --- a/src/app/components/Grid/link/link.component.ts +++ b/src/app/components/Grid/link/link.component.ts @@ -453,7 +453,7 @@ export class LinkComponent let foundInput: boolean = false; let inputCoords: Coord; while (!foundInput && i < this.link.joints.size) { - if (this.link.joints.get(i)?.isInput) { + if (this.link.isCircle) { inputCoords = this.unitConversionService.modelCoordToSVGCoord(this.link.joints.get(i)!!.coords); pathStr = this.svgPathService.getCircularLink(inputCoords, this.link.length, 18 + 10); foundInput = true; diff --git a/src/app/components/ToolBar/undo-redo-panel/action.ts b/src/app/components/ToolBar/undo-redo-panel/action.ts index 7d0a5076..a614c267 100644 --- a/src/app/components/ToolBar/undo-redo-panel/action.ts +++ b/src/app/components/ToolBar/undo-redo-panel/action.ts @@ -57,6 +57,7 @@ export interface LinkSnapshot { angle: number; locked: boolean; color: string; + isCircle?: boolean; } export interface JointSnapshot { diff --git a/src/app/controllers/link-interactor.ts b/src/app/controllers/link-interactor.ts index 31a603dd..1da50d3b 100644 --- a/src/app/controllers/link-interactor.ts +++ b/src/app/controllers/link-interactor.ts @@ -316,6 +316,7 @@ export class LinkInteractor extends Interactor { angle: this.link.angle, locked: this.link.locked, color: this.link.color, + isCircle: this.link.isCircle, }; const extraJointsData: JointSnapshot[] = Array.from( @@ -350,10 +351,10 @@ export class LinkInteractor extends Interactor { }, { - icon: !this.link.isCircle - ? 'assets/contextMenuIcons/circleInputLink.svg' - : 'assets/contextMenuIcons/uncircular.svg', - label: !this.link.isCircle ? 'Circular Link' : 'Straight Link', + icon: this.link.isCircle + ? 'assets/contextMenuIcons/uncircular.svg' + : 'assets/contextMenuIcons/circleInputLink.svg', + label: this.link.isCircle ? 'Make Bar' : 'Make Circular', action: () => { let hasInputJoint = false; for (const joint of this.link.getJoints()) { @@ -367,7 +368,6 @@ export class LinkInteractor extends Interactor { return; } - this.link.isCircle = !this.link.isCircle; this.undoRedoService.recordAction({ diff --git a/src/app/controllers/svg-interactor.ts b/src/app/controllers/svg-interactor.ts index 4cd36859..d1ca2a7e 100644 --- a/src/app/controllers/svg-interactor.ts +++ b/src/app/controllers/svg-interactor.ts @@ -105,7 +105,8 @@ export class SvgInteractor extends Interactor { mass: createdLink.mass, angle: createdLink.angle, locked: createdLink.locked, - color: createdLink.color + color: createdLink.color, + isCircle: createdLink.isCircle, }; this.undoRedoService.recordAction({ From 32c388a25a01db7fc1c8055558e2aa128984ce5a Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 16 Mar 2026 11:56:08 -0400 Subject: [PATCH 37/51] now any link with a ground joint can be made circular --- src/app/controllers/link-interactor.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/controllers/link-interactor.ts b/src/app/controllers/link-interactor.ts index 1da50d3b..a7342295 100644 --- a/src/app/controllers/link-interactor.ts +++ b/src/app/controllers/link-interactor.ts @@ -356,15 +356,15 @@ export class LinkInteractor extends Interactor { : 'assets/contextMenuIcons/circleInputLink.svg', label: this.link.isCircle ? 'Make Bar' : 'Make Circular', action: () => { - let hasInputJoint = false; + let hasGroundJoint = false; for (const joint of this.link.getJoints()) { - if (joint.isInput) { - hasInputJoint = true; + if (joint.isGrounded) { + hasGroundJoint = true; } } - if (!hasInputJoint) { - // prevents click if link does not have an input joint + if (!hasGroundJoint) { + // prevents click if link does not have a ground joint return; } @@ -377,7 +377,7 @@ export class LinkInteractor extends Interactor { }); }, - disabled:!this.link.getJoints().some(joint => joint.isInput), + disabled:!this.link.getJoints().some(joint => joint.isGrounded), } ); From af90bfcc82cb9eef5f688e6116a63f623b1f2ba9 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 16 Mar 2026 12:26:21 -0400 Subject: [PATCH 38/51] fixed issue with circle not forming for grounded link. Need to fix issue of circle not forming for any bar besides first created --- src/app/components/Grid/link/link.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/Grid/link/link.component.ts b/src/app/components/Grid/link/link.component.ts index 8e7524f2..e6b311cf 100644 --- a/src/app/components/Grid/link/link.component.ts +++ b/src/app/components/Grid/link/link.component.ts @@ -453,7 +453,7 @@ export class LinkComponent let foundInput: boolean = false; let inputCoords: Coord; while (!foundInput && i < this.link.joints.size) { - if (this.link.isCircle) { + if (this.link.joints.get(i)?.isGrounded) { inputCoords = this.unitConversionService.modelCoordToSVGCoord(this.link.joints.get(i)!!.coords); pathStr = this.svgPathService.getCircularLink(inputCoords, this.link.length, 18 + 10); foundInput = true; From dc6bd880c90977d2df4cbb9fd63a1f1488cd5c19 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Wed, 18 Mar 2026 09:18:03 -0400 Subject: [PATCH 39/51] fixed issue with circular link not being created for any link besides first one and issue with tracer points for circular links --- .../components/Grid/link/link.component.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/app/components/Grid/link/link.component.ts b/src/app/components/Grid/link/link.component.ts index e6b311cf..300309fa 100644 --- a/src/app/components/Grid/link/link.component.ts +++ b/src/app/components/Grid/link/link.component.ts @@ -452,13 +452,12 @@ export class LinkComponent let i: number = 0; let foundInput: boolean = false; let inputCoords: Coord; - while (!foundInput && i < this.link.joints.size) { - if (this.link.joints.get(i)?.isGrounded) { - inputCoords = this.unitConversionService.modelCoordToSVGCoord(this.link.joints.get(i)!!.coords); - pathStr = this.svgPathService.getCircularLink(inputCoords, this.link.length, 18 + 10); + for (const [jID, currentJ] of this.link.joints) { + if (!foundInput && currentJ.isGrounded) { + inputCoords = this.unitConversionService.modelCoordToSVGCoord(currentJ.coords); + pathStr = this.svgPathService.getCircularLink(inputCoords, this.farthestPoint(currentJ.coords), 18 + 10); foundInput = true; } - i++; } return pathStr; @@ -469,6 +468,17 @@ export class LinkComponent } + farthestPoint(center: Coord): number { + let radius: number = 0; + for (const [jID, currentJ] of this.link.joints) { + const currentR = center.getDistanceTo(currentJ.coords); + if (currentR > radius) { + radius = currentR; + } + } + return radius; + } + getLengthSVG() { const joints = this.link.getJoints(); const allCoords: Coord[] = []; From 64782b8bebcf2a873eb47e7e247e36594af83373 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 23 Mar 2026 14:33:42 -0400 Subject: [PATCH 40/51] added compoundlinksnapshot and fields into interface for deleting compound links --- .../components/ToolBar/undo-redo-panel/action.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app/components/ToolBar/undo-redo-panel/action.ts b/src/app/components/ToolBar/undo-redo-panel/action.ts index a614c267..caaf39b8 100644 --- a/src/app/components/ToolBar/undo-redo-panel/action.ts +++ b/src/app/components/ToolBar/undo-redo-panel/action.ts @@ -24,6 +24,10 @@ export interface Action { linkTracerData?: LinkTracerSnapshot; linkForceData?: LinkForceSnapshot; + compoundLinkData?: CompoundLinkSnapshot; + compoundExtraLinkData?: LinkSnapshot[]; + compoundExtraJointsData?: JointSnapshot[][]; + oldJointPositions?: Array<{ jointId: number; coords: { x: number; y: number }; @@ -49,6 +53,15 @@ export interface Action { newAngle?: number; } +export interface CompoundLinkSnapshot { + id: number; + linkIds: number[]; + name: string; + mass: number; + locked: boolean; + color: string; +} + export interface LinkSnapshot { id: number; jointIds: number[]; From f4d2286d50bf6f6fe01945f1f0bdac46ddf50d5a Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 23 Mar 2026 14:34:14 -0400 Subject: [PATCH 41/51] added a function to remove compound links by their id --- src/app/model/mechanism.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/model/mechanism.ts b/src/app/model/mechanism.ts index f990c539..7af0ea51 100644 --- a/src/app/model/mechanism.ts +++ b/src/app/model/mechanism.ts @@ -936,6 +936,18 @@ export class Mechanism { } } + // first removes every link within the compound link, then the compound link itself; finds compound link from id + public removeCompoundLinkByID(compoundLinkID: number) { + var compoundLink = this._compoundLinks.get(compoundLinkID); + if (compoundLink != undefined) { + for (let link of compoundLink.links.values()) { + this.removeLink(link.id); + } + this._compoundLinks.delete(compoundLink.id); + } + + } + // first removes every link within the compound link, then the compound link itself public removeCompoundLink(compoundLink: CompoundLink) { for (let link of compoundLink.links.values()) { From 2119e6d1a0699a8d20b96d5bc239c19d57a35930 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 23 Mar 2026 14:35:48 -0400 Subject: [PATCH 42/51] passing in undo/redo service to compound link interactor --- .../Grid/compound-link/compound-link.component.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/components/Grid/compound-link/compound-link.component.ts b/src/app/components/Grid/compound-link/compound-link.component.ts index dddafbe0..582ce87b 100644 --- a/src/app/components/Grid/compound-link/compound-link.component.ts +++ b/src/app/components/Grid/compound-link/compound-link.component.ts @@ -12,6 +12,7 @@ import { UnitConversionService } from 'src/app/services/unit-conversion.service' import { NotificationService } from 'src/app/services/notification.service'; import { AnimationService } from 'src/app/services/animation.service'; +import {UndoRedoService} from "../../../services/undo-redo.service"; @Component({ selector: '[app-compound-link]', @@ -27,13 +28,14 @@ export class CompoundLinkComponent extends AbstractInteractiveComponent { private svgPathService: SVGPathService, private unitConversionService: UnitConversionService, private notificationService: NotificationService, - public override animationService: AnimationService + public override animationService: AnimationService, + private undoRedoService: UndoRedoService ) { super(interactionService,animationService); } override registerInteractor(): Interactor { - return new CompoundLinkInteractor(this.compoundLink, this.stateService, this.notificationService); + return new CompoundLinkInteractor(this.compoundLink, this.stateService, this.notificationService, this.undoRedoService); } getColor():string{ From b9b8cc3edd806882a2c8d8a5bf58cd0cffb7d956 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 23 Mar 2026 14:37:00 -0400 Subject: [PATCH 43/51] edited delete compound link context menu so that it records information about the compound link, links, and joints being deleted --- .../controllers/compound-link-interactor.ts | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/src/app/controllers/compound-link-interactor.ts b/src/app/controllers/compound-link-interactor.ts index 66b5bfe9..6d104bb5 100644 --- a/src/app/controllers/compound-link-interactor.ts +++ b/src/app/controllers/compound-link-interactor.ts @@ -6,6 +6,8 @@ import { Mechanism } from "../model/mechanism"; import { StateService } from "../services/state.service"; import { ContextMenuOption, Interactor } from "./interactor"; import { NotificationService } from "../services/notification.service"; +import type {CompoundLinkSnapshot, JointSnapshot, LinkSnapshot} from "../components/ToolBar/undo-redo-panel/action"; +import {UndoRedoService} from "../services/undo-redo.service"; /* This interactor defines the following behaviors: @@ -17,7 +19,7 @@ export class CompoundLinkInteractor extends Interactor { private jointsStartPosModel: Map = new Map(); private lastNotificationTime = 0; - constructor(public compoundLink: CompoundLink, private stateService: StateService, private notificationService: NotificationService) { + constructor(public compoundLink: CompoundLink, private stateService: StateService, private notificationService: NotificationService, private undoRedoService: UndoRedoService) { super(true, true); this.onDragStart$.subscribe(() => { @@ -98,15 +100,70 @@ export class CompoundLinkInteractor extends Interactor { disabled: false }, { - label: this.compoundLink.lock ? "Unlock Compound Link" : "Lock Compound Link", + label: this.compoundLink.lock ? "Unlock Welded Link" : "Lock Welded Link", icon: this.compoundLink.lock ? "assets/contextMenuIcons/unlock.svg" : "assets/contextMenuIcons/lock.svg", action: () => { this.compoundLink.lock = (!this.compoundLink.lock) }, disabled: false }, { icon: "assets/contextMenuIcons/trash.svg", - label: "Delete Link", - action: () => { mechanism.removeLink(this.compoundLink.id) }, + label: "Delete Welded Link", + action: () => { + + const compoundLinkData: CompoundLinkSnapshot = { + id: this.compoundLink.id, + linkIds: Array.from(this.compoundLink.links.values()).map((l) => l.id), + name: this.compoundLink.name, + mass: this.compoundLink.mass, + locked: this.compoundLink.lock, + color: this.compoundLink.color, + } + + const linkData: LinkSnapshot[] = Array.from( + this.compoundLink.links.values() + ).map((l) => ({ + id: l.id, + jointIds: Array.from(l.joints.values()).map((j) => j.id), + name: l.name, + mass: l.mass, + angle: l.angle, + locked: l.locked, + color: l.color, + isCircle: l.isCircle, + })); + + let extraJointsData: JointSnapshot[][] = Array.from( + this.compoundLink.links.values()).map((l, index) => ( + Array.from( + l.joints.values() + ).map((j) => ({ + id: j.id, + coords: { x: j.coords.x, y: j.coords.y }, + name: j.name, + type: j.type, + angle: j.angle, + isGrounded: j.isGrounded, + isWelded: j.isWelded, + isInput: j.isInput, + inputSpeed: j.speed, + locked: j.locked, + isHidden: j.hidden, + isReference: j.reference, + })) + )); + + this.undoRedoService.recordAction({ + type: "deleteCompoundLink", + compoundLinkData, + compoundExtraLinkData: linkData, + compoundExtraJointsData: extraJointsData, + }); + + + mechanism.removeCompoundLinkByID(this.compoundLink.id) + this.stateService.getMechanism().notifyChange(); + + }, disabled: false }, ); From 9d975e21f439d5f31e645c39a24f289e42f7341b Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 23 Mar 2026 14:37:45 -0400 Subject: [PATCH 44/51] edited undo redo service so that the undo for delete compound link rebuilds the joints, links, and compound link that was deleted --- src/app/services/undo-redo.service.ts | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/app/services/undo-redo.service.ts b/src/app/services/undo-redo.service.ts index 492358a7..ed53102e 100644 --- a/src/app/services/undo-redo.service.ts +++ b/src/app/services/undo-redo.service.ts @@ -9,6 +9,7 @@ import { StateService } from './state.service'; import { isUndefined } from 'lodash'; import { Position } from '../model/position'; import { Force } from '../model/force'; +import {CompoundLink} from "../model/compound-link"; @Injectable({ providedIn: 'root', @@ -554,6 +555,58 @@ export class UndoRedoService { link.isCircle = !circular break; } + case 'deleteCompoundLink': { + // rebuilding joints + if (action.compoundExtraJointsData) { + action.compoundExtraJointsData!.forEach((joints) => { + joints.forEach((j) => { + if (!this.mechanism.getJoint(j.id)) { + this.restoreJointFromSnapshot(j); + } + }) + }) + } + + // rebuilding links + if (action.compoundExtraLinkData) { + action.compoundExtraLinkData!.forEach((link) => { + // check that joints exist + const jointObjs = link.jointIds.map((id) => { + const j = this.mechanism.getJoint(id); + if (!j) console.warn(`undo deleteLink: joint ${id} still missing`); + return j!; + }); + + if (jointObjs.every((j) => j != null)) { + const linkRestored = new Link(link.id, jointObjs); + linkRestored.name = link.name; + linkRestored.mass = link.mass; + linkRestored.angle = link.angle; + linkRestored.locked = link.locked; + linkRestored.color = link.color; + this.mechanism._addLink(linkRestored); + } + }) + } + + // rebuilding compound link + const cl = action.compoundLinkData!; + const linkObjs = cl.linkIds.map((id) => { + const l = this.mechanism.getLink(id); + if (!l) console.warn(`undo deleteCompoundLink: link ${id} still missing`); + return l!; + }); + if (linkObjs.every((l) => l != null)) { + const linkRestored = new CompoundLink(cl.id, linkObjs); + linkRestored.name = cl.name; + linkRestored.mass = cl.mass; + linkRestored.lock = cl.locked; + linkRestored.color = cl.color; + this.mechanism._addCompoundLink(linkRestored); + } + + break; + } default: console.error('No inverse defined for action type:', action.type); } From 3f80d3d701ee2b8680e1838cf2244ae18185975e Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Mon, 23 Mar 2026 14:39:50 -0400 Subject: [PATCH 45/51] Added redo for delete compound link --- src/app/services/undo-redo.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/services/undo-redo.service.ts b/src/app/services/undo-redo.service.ts index ed53102e..3ddaa905 100644 --- a/src/app/services/undo-redo.service.ts +++ b/src/app/services/undo-redo.service.ts @@ -305,6 +305,9 @@ export class UndoRedoService { //reverse the lock link.isCircle = !circular break; + }case 'deleteCompoundLink': { + this.mechanism.removeCompoundLinkByID(action.compoundLinkData!.id); + break; } default: From da7610554a2013bd6eab1b08026d1b87a0e4e076 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 24 Mar 2026 17:52:09 -0400 Subject: [PATCH 46/51] fixed undo/redo of tracer points, adding. Need to fix deleting of tracer points for welded links --- .../ToolBar/undo-redo-panel/action.ts | 3 ++ .../controllers/compound-link-interactor.ts | 36 ++++++++++++- src/app/controllers/joint-interactor.ts | 3 ++ src/app/controllers/link-interactor.ts | 2 + src/app/controllers/svg-interactor.ts | 3 +- src/app/model/mechanism.ts | 6 ++- src/app/services/undo-redo.service.ts | 50 ++++++++++++++++++- 7 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/app/components/ToolBar/undo-redo-panel/action.ts b/src/app/components/ToolBar/undo-redo-panel/action.ts index caaf39b8..1e11b0d5 100644 --- a/src/app/components/ToolBar/undo-redo-panel/action.ts +++ b/src/app/components/ToolBar/undo-redo-panel/action.ts @@ -86,6 +86,7 @@ export interface JointSnapshot { locked: boolean; isHidden: boolean; isReference: boolean; + isTracer: boolean; } export interface LinkLockSnapshot { @@ -108,6 +109,8 @@ export interface LinkTracerSnapshot { linkId: number; jointId: number; coords: { x: number; y: number }; + tracerModelPos?: Coord; + tracerSVGPos?: Coord; } export interface LinkForceSnapshot { diff --git a/src/app/controllers/compound-link-interactor.ts b/src/app/controllers/compound-link-interactor.ts index 6d104bb5..296c0873 100644 --- a/src/app/controllers/compound-link-interactor.ts +++ b/src/app/controllers/compound-link-interactor.ts @@ -90,7 +90,40 @@ export class CompoundLinkInteractor extends Interactor { { icon: "assets/contextMenuIcons/addTracer.svg", label: "Attach Tracer Point", - action: () => { mechanism.addTracerPointWelded(this.compoundLink.id, modelPosAtRightClick, svgPosAtRightClick) }, + action: () => { + // snapshot existing joint‐IDs + let beforeIds: number[] = []; + Array.from(this.compoundLink.links.values()).forEach((l) => { + Array.from(l.joints.keys()).forEach((jID) => { + beforeIds.push(jID); + }) + }); + + mechanism.addTracerPointWelded(this.compoundLink.id, modelPosAtRightClick, svgPosAtRightClick) + + // find exactly which joint is new + let afterIds: number[] = []; + Array.from(this.compoundLink.links.values()).forEach((l) => { + Array.from(l.joints.keys()).forEach((jID) => { + afterIds.push(jID); + }) + }); + const newId = afterIds.find((id) => !beforeIds.includes(id))!; + const newJoint = mechanism.getJoint(newId); + newJoint.isTracer = true; + + // recordAction with a real jointId + this.undoRedoService.recordAction({ + type: 'addTracerCompound', + linkTracerData: { + linkId: this.compoundLink.id, + jointId: newId, + coords: { x: newJoint.coords.x, y: newJoint.coords.y }, + tracerModelPos: modelPosAtRightClick, + tracerSVGPos: svgPosAtRightClick, + } + }); + }, disabled: false }, { @@ -149,6 +182,7 @@ export class CompoundLinkInteractor extends Interactor { locked: j.locked, isHidden: j.hidden, isReference: j.reference, + isTracer: j.isTracer, })) )); diff --git a/src/app/controllers/joint-interactor.ts b/src/app/controllers/joint-interactor.ts index 01de9123..a95828f4 100644 --- a/src/app/controllers/joint-interactor.ts +++ b/src/app/controllers/joint-interactor.ts @@ -300,6 +300,7 @@ export class JointInteractor extends Interactor { locked: jointToDelete.locked, isHidden: jointToDelete.isHidden, isReference: jointToDelete.isReference, + isTracer: jointToDelete.isTracer, }; const connectedLinks = @@ -336,6 +337,7 @@ export class JointInteractor extends Interactor { locked: j.locked, isHidden: j.isHidden, isReference: j.isReference, + isTracer: j.isTracer, })); new Set(mechanism.getArrayOfJoints().map((j) => j.id)); mechanism.removeJoint(this.joint.id); @@ -407,6 +409,7 @@ export class JointInteractor extends Interactor { locked: newJoint.locked, isHidden: newJoint.hidden, isReference: newJoint.reference, + isTracer: newJoint.isTracer, }); } diff --git a/src/app/controllers/link-interactor.ts b/src/app/controllers/link-interactor.ts index a7342295..fcf947fb 100644 --- a/src/app/controllers/link-interactor.ts +++ b/src/app/controllers/link-interactor.ts @@ -219,6 +219,7 @@ export class LinkInteractor extends Interactor { locked: j.locked, isHidden: j.hidden, isReference: j.reference, + isTracer: j.isTracer, }; }); @@ -334,6 +335,7 @@ export class LinkInteractor extends Interactor { locked: j.locked, isHidden: j.hidden, isReference: j.reference, + isTracer: j.isTracer, })); this.undoRedoService.recordAction({ diff --git a/src/app/controllers/svg-interactor.ts b/src/app/controllers/svg-interactor.ts index d1ca2a7e..ed95b9d5 100644 --- a/src/app/controllers/svg-interactor.ts +++ b/src/app/controllers/svg-interactor.ts @@ -93,7 +93,8 @@ export class SvgInteractor extends Interactor { inputSpeed: j.speed, locked: j.locked, isHidden: j.hidden, - isReference:j.reference + isReference:j.reference, + isTracer: j.isTracer, }; }); diff --git a/src/app/model/mechanism.ts b/src/app/model/mechanism.ts index 7af0ea51..3af8a675 100644 --- a/src/app/model/mechanism.ts +++ b/src/app/model/mechanism.ts @@ -725,13 +725,17 @@ export class Mechanism { * * @param {number} linkID * @param {Coord} coord + * @param {isTracer} boolean * @memberof Mechanism */ - addJointToLink(linkID: number, coord: Coord) { + addJointToLink(linkID: number, coord: Coord, isTracer?: boolean) { this.executeLinkAction(linkID, (link) => { let jointA = new Joint(this._jointIDCount, coord); this._jointIDCount++; this._joints.set(jointA.id, jointA); + if (isTracer) { + jointA.isTracer = true; + } link.addTracer(jointA); }); } diff --git a/src/app/services/undo-redo.service.ts b/src/app/services/undo-redo.service.ts index 3ddaa905..4461525d 100644 --- a/src/app/services/undo-redo.service.ts +++ b/src/app/services/undo-redo.service.ts @@ -261,7 +261,8 @@ export class UndoRedoService { const tr = action.linkTracerData!; this.mechanism.addJointToLink( tr.linkId, - new Coord(tr.coords.x, tr.coords.y) + new Coord(tr.coords.x, tr.coords.y), + true ); break; case 'addLinkToLink': @@ -305,9 +306,17 @@ export class UndoRedoService { //reverse the lock link.isCircle = !circular break; - }case 'deleteCompoundLink': { + } case 'deleteCompoundLink': { this.mechanism.removeCompoundLinkByID(action.compoundLinkData!.id); break; + } case 'addTracerCompound': { + const tr = action.linkTracerData!; + this.mechanism.addTracerPointWelded( + tr.linkId, + tr.tracerModelPos!, + tr.tracerSVGPos!, + ); + break; } default: @@ -610,6 +619,42 @@ export class UndoRedoService { break; } + case 'addTracerCompound': { + const tr = action.linkTracerData!; + const allCompoundLinks = this.mechanism.getCompoundLinks(); + let linkObj; + for (const c of allCompoundLinks) { + if (c.id == tr.linkId) { + linkObj = c; + } + } + + if (linkObj == undefined) { + console.warn( + `Undo addTracerCompound: no compound link found)` + ); + break; + } + + let allJoints: Joint[] = []; + Array.from(linkObj!.links!.values()).forEach((l) => { + Array.from(l.joints.values()).forEach((j) => { + allJoints.push(j); + }) + }); + + const toRemove = allJoints.find( + (j) => j.coords.x === tr.coords.x && j.coords.y === tr.coords.y + ); + if (toRemove) { + this.mechanism.removeJoint(toRemove.id); + } else { + console.warn( + `Undo addTracerCompound: no tracer found at (${tr.coords.x},${tr.coords.y})` + ); + } + break; + } default: console.error('No inverse defined for action type:', action.type); } @@ -632,6 +677,7 @@ export class UndoRedoService { restoredJoint.locked = jointSnapshot!.locked; restoredJoint.hidden = jointSnapshot!.isHidden; restoredJoint.reference = jointSnapshot!.isReference; + restoredJoint.isTracer = jointSnapshot!.isTracer; this.mechanism._addJoint(restoredJoint); } } From d1a5ec8d56eede35e78dde8c6228efbbcf7f67b8 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 31 Mar 2026 09:42:58 -0400 Subject: [PATCH 47/51] fixed delete tracer point for welded (compound) links --- .../ToolBar/undo-redo-panel/action.ts | 2 + .../controllers/compound-link-interactor.ts | 1 + src/app/controllers/joint-interactor.ts | 24 +++++++++++- src/app/controllers/link-interactor.ts | 1 + src/app/controllers/svg-interactor.ts | 1 + src/app/model/joint.ts | 9 +++++ src/app/model/mechanism.ts | 37 ++++++++++++++++++- src/app/services/undo-redo.service.ts | 26 +++++++++++++ 8 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/app/components/ToolBar/undo-redo-panel/action.ts b/src/app/components/ToolBar/undo-redo-panel/action.ts index 1e11b0d5..1bef510a 100644 --- a/src/app/components/ToolBar/undo-redo-panel/action.ts +++ b/src/app/components/ToolBar/undo-redo-panel/action.ts @@ -25,6 +25,7 @@ export interface Action { linkForceData?: LinkForceSnapshot; compoundLinkData?: CompoundLinkSnapshot; + compoundLinkDataArray?: CompoundLinkSnapshot[]; compoundExtraLinkData?: LinkSnapshot[]; compoundExtraJointsData?: JointSnapshot[][]; @@ -87,6 +88,7 @@ export interface JointSnapshot { isHidden: boolean; isReference: boolean; isTracer: boolean; + isPartOfWelded: boolean; } export interface LinkLockSnapshot { diff --git a/src/app/controllers/compound-link-interactor.ts b/src/app/controllers/compound-link-interactor.ts index 296c0873..cbed1c2b 100644 --- a/src/app/controllers/compound-link-interactor.ts +++ b/src/app/controllers/compound-link-interactor.ts @@ -183,6 +183,7 @@ export class CompoundLinkInteractor extends Interactor { isHidden: j.hidden, isReference: j.reference, isTracer: j.isTracer, + isPartOfWelded: j.isPartOfWelded, })) )); diff --git a/src/app/controllers/joint-interactor.ts b/src/app/controllers/joint-interactor.ts index a95828f4..e63b7281 100644 --- a/src/app/controllers/joint-interactor.ts +++ b/src/app/controllers/joint-interactor.ts @@ -5,7 +5,7 @@ import { InteractionService } from '../services/interaction.service'; import { StateService } from '../services/state.service'; import { CreateLinkFromJointCapture } from './click-capture/create-link-from-joint-capture'; import { ContextMenuOption, Interactor } from './interactor'; -import { Action } from '../components/ToolBar/undo-redo-panel/action'; +import {Action, CompoundLinkSnapshot} from '../components/ToolBar/undo-redo-panel/action'; import { NotificationService } from '../services/notification.service'; import { UndoRedoService } from '../services/undo-redo.service'; @@ -287,6 +287,8 @@ export class JointInteractor extends Interactor { const mechanism = this.stateService.getMechanism(); const jointToDelete = mechanism.getJoint(this.joint.id); + + const jointData = { id: jointToDelete.id, coords: { x: jointToDelete.coords.x, y: jointToDelete.coords.y }, @@ -301,6 +303,7 @@ export class JointInteractor extends Interactor { isHidden: jointToDelete.isHidden, isReference: jointToDelete.isReference, isTracer: jointToDelete.isTracer, + isPartOfWelded: jointToDelete.isPartOfWelded, }; const connectedLinks = @@ -321,6 +324,22 @@ export class JointInteractor extends Interactor { }; }); + const connectedWeldedLinks = mechanism.getConnectedCompoundLinks(jointToDelete); + const weldedlinksData: CompoundLinkSnapshot[] = connectedWeldedLinks.map((clink) => { + // If link.joints is a Map, convert to Array first + const linkIdsArray: number[] = Array.from(clink.links.values()).map( + (l) => l.id + ); + return { + id: clink.id, + linkIds: linkIdsArray, + name: clink.name, + mass: clink.mass, + locked: clink.lock, + color: clink.color, + }; + }); + const allJointsSnapshot = mechanism .getArrayOfJoints() .filter((j) => j.id !== jointToDelete.id) @@ -338,6 +357,7 @@ export class JointInteractor extends Interactor { isHidden: j.isHidden, isReference: j.isReference, isTracer: j.isTracer, + isPartOfWelded: j.isPartOfWelded, })); new Set(mechanism.getArrayOfJoints().map((j) => j.id)); mechanism.removeJoint(this.joint.id); @@ -353,6 +373,7 @@ export class JointInteractor extends Interactor { jointId: jointToDelete.id, jointData, linksData, + compoundLinkDataArray: weldedlinksData, extraJointsData: extraJointsSnapshots, }; @@ -410,6 +431,7 @@ export class JointInteractor extends Interactor { isHidden: newJoint.hidden, isReference: newJoint.reference, isTracer: newJoint.isTracer, + isPartOfWelded: newJoint.isPartOfWelded, }); } diff --git a/src/app/controllers/link-interactor.ts b/src/app/controllers/link-interactor.ts index fcf947fb..3169ea31 100644 --- a/src/app/controllers/link-interactor.ts +++ b/src/app/controllers/link-interactor.ts @@ -336,6 +336,7 @@ export class LinkInteractor extends Interactor { isHidden: j.hidden, isReference: j.reference, isTracer: j.isTracer, + isPartOfWelded: j.isPartOfWelded, })); this.undoRedoService.recordAction({ diff --git a/src/app/controllers/svg-interactor.ts b/src/app/controllers/svg-interactor.ts index ed95b9d5..eed92661 100644 --- a/src/app/controllers/svg-interactor.ts +++ b/src/app/controllers/svg-interactor.ts @@ -95,6 +95,7 @@ export class SvgInteractor extends Interactor { isHidden: j.hidden, isReference:j.reference, isTracer: j.isTracer, + isPartOfWelded: j.isPartOfWelded, }; }); diff --git a/src/app/model/joint.ts b/src/app/model/joint.ts index ecb7ae1f..2609dadb 100644 --- a/src/app/model/joint.ts +++ b/src/app/model/joint.ts @@ -22,6 +22,7 @@ export class Joint { private _isGenerated: boolean; private _rpmSpeed: number; private _isTracer: boolean = false; + private _isPartOfWelded: boolean = false; isInput$ = this._isInput.asObservable(); isGrounded$ = this._isGrounded.asObservable(); @@ -115,6 +116,10 @@ export class Joint { return this._isTracer; } + get isPartOfWelded(): boolean { + return this._isPartOfWelded; + } + getInputObservable() { return this.isInput$; } @@ -162,6 +167,10 @@ export class Joint { this._isTracer = val; } + set isPartOfWelded(val: boolean) { + this._isPartOfWelded = val; + } + //----------------------------Joint Modification with modifying other variables---------------------------- addGround() { this._type = JointType.Revolute; diff --git a/src/app/model/mechanism.ts b/src/app/model/mechanism.ts index 3af8a675..d5e2481a 100644 --- a/src/app/model/mechanism.ts +++ b/src/app/model/mechanism.ts @@ -769,6 +769,7 @@ export class Mechanism { this._jointIDCount++; if (this.isMechanismWelded()) { jointA.isTracer = true; + jointA.isPartOfWelded = true; } let foundLink: boolean = false; @@ -795,6 +796,40 @@ export class Mechanism { }); } + /** + * attaches a tracer point(effectively a joint) to an existing link, but this is from a welded link. + * + * @param {number} linkID + * @param {Coord} coord + * @memberof Mechanism + */ + _addJointToWelded(linkID: number, jointA: Joint, coordSVG: Coord) { + this.executeLinkAction(linkID, (link) => { + + let foundLink: boolean = false; + let i: number = 0; + while (i < this._links.size && !foundLink) { + let currentLink = this._links.get(i); + const inLink = currentLink?.containsCoord(coordSVG); + + if (inLink) { + foundLink = true; + } else { + i++; + } + } + + this._joints.set(jointA.id, jointA); + + const theLink = this._links.get(i); + if (foundLink && theLink != undefined) { + theLink.addTracer(jointA); + } else { + link.addTracer(jointA); + } + }); + } + /** * determines whether the current mechanism is a welded linkage. * @@ -1199,7 +1234,7 @@ export class Mechanism { } return connectedLinks; } - private getConnectedCompoundLinks(joint: Joint): CompoundLink[] { + getConnectedCompoundLinks(joint: Joint): CompoundLink[] { let connectedCompoundLinks: CompoundLink[] = []; for (let link of this._compoundLinks.values()) { if (link.containsJoint(joint.id)) { diff --git a/src/app/services/undo-redo.service.ts b/src/app/services/undo-redo.service.ts index 4461525d..152218d9 100644 --- a/src/app/services/undo-redo.service.ts +++ b/src/app/services/undo-redo.service.ts @@ -412,6 +412,15 @@ export class UndoRedoService { } break; case 'deleteJoint': + // let weldedLink = undefined; + // if (action.linksData) { + // action.linksData.forEach((link) => { + // if (weldedLink === undefined) { + // weldedLink = link; + // } + // }) + // } + // Restore main joint: if (action.jointData) { this.restoreJointFromSnapshot(action.jointData); @@ -439,6 +448,22 @@ export class UndoRedoService { } }); } + + if (action.compoundLinkDataArray) { + action.compoundLinkDataArray.forEach((cLinkSnap) => { + const cLinks = cLinkSnap.linkIds.map((lid) => + this.mechanism.getLink(lid) + ); + if (cLinks.every((l) => l !== undefined)) { + const restoredCompoundLink = new CompoundLink(cLinkSnap.id, cLinks); + restoredCompoundLink.name = cLinkSnap.name; + restoredCompoundLink.mass = cLinkSnap.mass; + restoredCompoundLink.lock = cLinkSnap.locked; + restoredCompoundLink.color = cLinkSnap.color; + this.mechanism._addCompoundLink(restoredCompoundLink); + } + }); + } break; case 'moveJoint': if (action.jointId != null && action.oldCoords) { @@ -678,6 +703,7 @@ export class UndoRedoService { restoredJoint.hidden = jointSnapshot!.isHidden; restoredJoint.reference = jointSnapshot!.isReference; restoredJoint.isTracer = jointSnapshot!.isTracer; + restoredJoint.isPartOfWelded = jointSnapshot!.isPartOfWelded; this.mechanism._addJoint(restoredJoint); } } From 41e11163aacf7e4755e6b85448f9822fef75263f Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Tue, 31 Mar 2026 09:53:20 -0400 Subject: [PATCH 48/51] fixed tracer point outline still being dashed even after making the joint a ground joint --- src/app/components/Grid/joint/joint.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/Grid/joint/joint.component.html b/src/app/components/Grid/joint/joint.component.html index f1033611..e40eeec2 100644 --- a/src/app/components/Grid/joint/joint.component.html +++ b/src/app/components/Grid/joint/joint.component.html @@ -60,7 +60,7 @@ - } @else if (this.joint.isTracer) { + } @else if (this.joint.isTracer && !this.joint.isGrounded) { Date: Thu, 9 Apr 2026 09:25:10 -0400 Subject: [PATCH 49/51] fixed a line of code that was breaking, which is adding a few fields of prevJointData to match JointSnapshot --- src/app/controllers/joint-interactor.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/controllers/joint-interactor.ts b/src/app/controllers/joint-interactor.ts index 67692f4f..257c6a5b 100644 --- a/src/app/controllers/joint-interactor.ts +++ b/src/app/controllers/joint-interactor.ts @@ -134,6 +134,8 @@ export class JointInteractor extends Interactor { locked: prevJoint.locked, isHidden: prevJoint.isHidden, isReference: prevJoint.isReference, + isTracer: prevJoint.isTracer, + isPartOfWelded: prevJoint.isPartOfWelded, }; let prevLinksData = connectedLinks.map((link) => { From d228f4fd6d32e0e0c958779efbd877d2e101d4fe Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Wed, 15 Apr 2026 20:11:14 -0400 Subject: [PATCH 50/51] Fixed issue with url sharing service of welded links not being created. Now they are created --- src/app/services/decoder.service.ts | 4 ++++ src/app/services/encoder.service.ts | 2 ++ src/app/services/state.service.ts | 7 +++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app/services/decoder.service.ts b/src/app/services/decoder.service.ts index 29355727..f6cca4eb 100644 --- a/src/app/services/decoder.service.ts +++ b/src/app/services/decoder.service.ts @@ -98,6 +98,8 @@ export class DecoderService { } // Pass the reconstructed mechanism data to the state service. + console.log('fullData: Compound Link'); + console.log(fullData.decodedCompoundLinks); stateService.reconstructMechanism(fullData); if (compactData.u) { @@ -210,6 +212,8 @@ export class DecoderService { isHidden: this.convertBoolean(row[11]), isReference: this.convertBoolean(row[12]), isGenerated: this.convertBoolean(row[13]), + isPartOfWelded: this.convertBoolean(row[14]), + isTracer: this.convertBoolean(row[15]), })); const decodedLinks: any[] = (compactData.l || []).map((row: any[]) => ({ diff --git a/src/app/services/encoder.service.ts b/src/app/services/encoder.service.ts index 1f4b7557..396a9585 100644 --- a/src/app/services/encoder.service.ts +++ b/src/app/services/encoder.service.ts @@ -195,6 +195,8 @@ export class EncoderService { j.isHidden, j.isReference, j.isGenerated, + j.isPartOfWelded, + j.isTracer, ]), l: mechanism.getArrayOfLinks().map((l: Link) => [ l.id, diff --git a/src/app/services/state.service.ts b/src/app/services/state.service.ts index 9ae5f47e..9af95cb1 100644 --- a/src/app/services/state.service.ts +++ b/src/app/services/state.service.ts @@ -119,6 +119,8 @@ export class StateService { newJoint.hidden = Boolean(joint.isHidden); newJoint.reference = Boolean(joint.isReference); newJoint.generated = Boolean(joint.isGenerated); + newJoint.isTracer = Boolean(joint.isTracer); + newJoint.isPartOfWelded = Boolean(joint.isPartOfWelded); if (Boolean(joint.isInput)) { newJoint.addInput(); } @@ -145,8 +147,8 @@ export class StateService { for (const x of jointsArray) { console.log(x.id); } - console.log(link, link.id); - let newLink = new Link(link.id, jointsArray); + console.log(link, Number(link.id)); + let newLink = new Link(Number(link.id), jointsArray); newLink.name = link.name; newLink.mass = link.mass; newLink.angle = link.angle; @@ -159,6 +161,7 @@ export class StateService { //Compound Links if (rawData.decodedCompoundLinks) { + console.log("WELDED LINKS") for (const compoundlink of rawData.decodedCompoundLinks) { let linksArray: Link[] = compoundlink.links .split('|') From d70644b2035b5079cf90081ae4de3def6fc878a6 Mon Sep 17 00:00:00 2001 From: Esther Kim Date: Wed, 15 Apr 2026 20:15:21 -0400 Subject: [PATCH 51/51] added in encoding and decoding/reconstruction of circular links --- src/app/services/decoder.service.ts | 1 + src/app/services/encoder.service.ts | 1 + src/app/services/state.service.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/src/app/services/decoder.service.ts b/src/app/services/decoder.service.ts index f6cca4eb..4373b6f2 100644 --- a/src/app/services/decoder.service.ts +++ b/src/app/services/decoder.service.ts @@ -228,6 +228,7 @@ export class DecoderService { locked: this.convertBoolean(row[8]), length: this.convertNumber(row[9], useDecoding), angle: this.convertNumber(row[10], useDecoding), + isCircle: this.convertBoolean(row[11]), })); const decodedCompoundLinks: any[] = (compactData.c || []).map( diff --git a/src/app/services/encoder.service.ts b/src/app/services/encoder.service.ts index 396a9585..0161618f 100644 --- a/src/app/services/encoder.service.ts +++ b/src/app/services/encoder.service.ts @@ -214,6 +214,7 @@ export class EncoderService { l.locked, l.length, l.angle, + l.isCircle, ]), c: mechanism.getArrayOfCompoundLinks().map((cl: CompoundLink) => [ cl.id, diff --git a/src/app/services/state.service.ts b/src/app/services/state.service.ts index 9af95cb1..cb3aa983 100644 --- a/src/app/services/state.service.ts +++ b/src/app/services/state.service.ts @@ -154,6 +154,7 @@ export class StateService { newLink.angle = link.angle; newLink.locked = Boolean(link.locked); newLink.color = link.color; + newLink.isCircle = Boolean(link.isCircle); this.mechanism._addLink(newLink); }