From 5f0ccd36430eab92c55f7f88a71506bfb8a06b05 Mon Sep 17 00:00:00 2001 From: Kohmei Kadoya Date: Thu, 20 Aug 2026 17:26:23 -0700 Subject: [PATCH 01/29] Ask the three positions for every four-bar they admit, not just one Three positions of a rigid body fix three positions of every point on it, and three points determine a circle -- so the centre of that circle is a ground pivot the point can be pinned to. Synthesis has always done that twice, for the two ends of the end-effector link, which is why it has always had exactly one answer. The coupler does not have to be pinned at the ends. Sliding the two pins along the link, or past it, moves both circle centres and gives a genuinely different machine through the same three positions. Enumerating those turns synthesis from "here is the answer" into "here are the answers, compare them". Each one is then asked the question the construction cannot answer for itself. It closes at all three positions by definition; whether it reaches all three on *one assembly* is a different matter, and a linkage that has to be taken apart between two of them is useless as a machine however exactly it fits. That is a branch defect, and it is the first thing a reader comparing candidates needs to be told. Co-Authored-By: Claude Opus 5 --- .../synthesis/synthesis-candidates.spec.ts | 227 +++++++++ .../synthesis/synthesis-candidates.ts | 437 ++++++++++++++++++ 2 files changed, 664 insertions(+) create mode 100644 src/app/services/synthesis/synthesis-candidates.spec.ts create mode 100644 src/app/services/synthesis/synthesis-candidates.ts diff --git a/src/app/services/synthesis/synthesis-candidates.spec.ts b/src/app/services/synthesis/synthesis-candidates.spec.ts new file mode 100644 index 00000000..3c214a43 --- /dev/null +++ b/src/app/services/synthesis/synthesis-candidates.spec.ts @@ -0,0 +1,227 @@ +import { Coord } from '../../model/coord'; +import { MODEL_SCALE } from '../../model/render-scale'; +import { + POSE_TOLERANCE, + PosePoint, + circumcenter, + drivenFromFarPin, + enumerateCandidates, + rankCandidates, + solveFourBar, +} from './synthesis-candidates'; + +/** + * Three positions of a rigid body fix three positions of every point on it, + * and three points determine a circle -- so every pair of points on the + * end-effector link names a four-bar that closes exactly at all three. These + * check that the enumeration finds those, tells the ones that are the same + * machine apart from the ones that are not, and is honest about the one thing + * the construction cannot promise: that all three can be reached without + * taking the linkage apart. + */ + +const S = MODEL_SCALE; +const LENGTH = 5 * S; + +/** A position of the link: a back end, and a front end LENGTH away at theta. */ +function pose(x: number, y: number, degrees: number): PosePoint { + const t = (degrees * Math.PI) / 180; + return { + back: new Coord(x * S, y * S), + front: new Coord(x * S + LENGTH * Math.cos(t), y * S + LENGTH * Math.sin(t)), + }; +} + +/** + * Three positions that are genuinely a motion. + * + * The back ends must not be collinear and neither must the fronts: three + * points on a line have no circle through them, which is the one case the + * construction has no answer for -- and it is easy to write by accident, which + * is what the straight-line case below checks deliberately. + */ +const MOTION: PosePoint[] = [pose(0, 0, 0), pose(4, 2, 25), pose(7, 7, 50)]; + +function distance(a: Coord, b: Coord): number { + return Math.hypot(b.x - a.x, b.y - a.y); +} + +describe('circumcenter', () => { + it('is the same distance from all three points', () => { + const p1 = new Coord(0, 0); + const p2 = new Coord(4 * S, 1 * S); + const p3 = new Coord(1 * S, 5 * S); + const centre = circumcenter(p1, p2, p3)!; + expect(centre).not.toBeNull(); + expect(distance(centre, p2)).toBeCloseTo(distance(centre, p1), 3); + expect(distance(centre, p3)).toBeCloseTo(distance(centre, p1), 3); + }); + + it('has no answer for three points on one line', () => { + expect( + circumcenter(new Coord(0, 0), new Coord(1 * S, 1 * S), new Coord(2 * S, 2 * S)) + ).toBeNull(); + }); +}); + +describe('enumerateCandidates', () => { + it('pinned to the link ends, finds the two assemblies of one construction', () => { + const { candidates } = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: true, + }); + expect(candidates.length).toBe(2); + expect(candidates[0].pair).toBe(candidates[1].pair); + expect([candidates[0].branch, candidates[1].branch].sort()).toEqual(['Crossed', 'Open']); + }); + + it('pinned to the link ends, the coupler is the length that was typed', () => { + const { candidates } = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: true, + }); + candidates.forEach((c) => expect(c.d).toBeCloseTo(LENGTH, 3)); + }); + + it('closes exactly at every position it says it reaches', () => { + const { candidates } = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: true, + }); + candidates.forEach((cand) => { + cand.onBranch.forEach((reached, i) => { + if (!reached) return; + const solved = solveFourBar(cand, cand.thetas[i], cand.sign)!; + expect(solved).not.toBeNull(); + expect(distance(solved.B, cand.ptsA[i])).toBeLessThan(POSE_TOLERANCE); + expect(distance(solved.C, cand.ptsB[i])).toBeLessThan(POSE_TOLERANCE); + }); + }); + }); + + it('calls a candidate defect-free exactly when all three are on one assembly', () => { + const { candidates } = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: true, + }); + candidates.forEach((c) => { + expect(c.defectFree).toBe(c.onBranchCount === 3); + expect(c.onBranchCount).toBe(c.onBranch.filter(Boolean).length); + }); + }); + + it('the crank and rocker are the radii the construction solved for', () => { + const { candidates } = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: true, + }); + candidates.forEach((c) => { + expect(distance(c.A, c.ptsA[0])).toBeCloseTo(c.r1, 3); + expect(distance(c.A, c.ptsA[2])).toBeCloseTo(c.r1, 3); + expect(distance(c.D, c.ptsB[0])).toBeCloseTo(c.r2, 3); + expect(distance(c.D, c.ptsB[2])).toBeCloseTo(c.r2, 3); + }); + }); + + it('letting the pins slide finds machines the ends alone cannot', () => { + const ends = enumerateCandidates({ poses: MOTION, length: LENGTH, endsOnly: true }); + const slid = enumerateCandidates({ poses: MOTION, length: LENGTH, endsOnly: false }); + expect(slid.candidates.length).toBeGreaterThan(ends.candidates.length); + // And they are different machines, not the same one listed again. + const couplers = new Set(slid.candidates.map((c) => Math.round(c.d))); + expect(couplers.size).toBeGreaterThan(1); + }); + + it('finds nothing when the three positions lie on one line', () => { + const straight = [pose(0, 0, 0), pose(3, 3, 0), pose(6, 6, 0)]; + const { candidates, rejections } = enumerateCandidates({ + poses: straight, + length: LENGTH, + endsOnly: true, + }); + expect(candidates.length).toBe(0); + expect(rejections.degenerate).toBeGreaterThan(0); + }); + + it('keeps only the linkages whose ground pivots fall inside the region', () => { + const all = enumerateCandidates({ poses: MOTION, length: LENGTH, endsOnly: false }); + expect(all.candidates.length).toBeGreaterThan(0); + const region = { x: -100 * S, y: -100 * S, w: 1 * S, h: 1 * S }; + const boxed = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: false, + region, + }); + expect(boxed.candidates.length).toBe(0); + expect(boxed.rejections.outsideRegion).toBeGreaterThan(0); + }); + + it('never offers a construction that cannot be closed at position 1', () => { + const { candidates } = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: false, + }); + candidates.forEach((c) => { + expect(isFinite(c.errors[0]) || c.onBranchCount > 0).toBe(true); + }); + }); +}); + +describe('rankCandidates', () => { + it('puts the defect-free ones first and names them in order', () => { + const { candidates } = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: false, + }); + const ranked = rankCandidates(candidates); + expect(ranked.length).toBeGreaterThan(0); + expect(ranked.length).toBeLessThanOrEqual(8); + expect(ranked[0].name).toBe('A'); + ranked.forEach((c, i) => { + if (i === 0) return; + const before = ranked[i - 1]; + expect(Number(before.defectFree)).toBeGreaterThanOrEqual(Number(c.defectFree)); + }); + }); +}); + +describe('drivenFromFarPin', () => { + it('reads the same four bars from the other end', () => { + const { candidates } = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: true, + }); + const original = candidates[0]; + const swapped = drivenFromFarPin(original); + expect(swapped.A).toEqual(original.D); + expect(swapped.D).toEqual(original.A); + expect(swapped.r1).toBeCloseTo(original.r2, 6); + expect(swapped.r2).toBeCloseTo(original.r1, 6); + // Same machine, so the same four lengths -- only the input has moved. + expect(swapped.d).toBeCloseTo(original.d, 6); + expect(swapped.g).toBeCloseTo(original.g, 6); + }); + + it('leaves the original untouched', () => { + const { candidates } = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: true, + }); + const original = candidates[0]; + const before = { r1: original.r1, r2: original.r2, sign: original.sign }; + drivenFromFarPin(original); + expect(original.r1).toBe(before.r1); + expect(original.r2).toBe(before.r2); + expect(original.sign).toBe(before.sign); + }); +}); diff --git a/src/app/services/synthesis/synthesis-candidates.ts b/src/app/services/synthesis/synthesis-candidates.ts new file mode 100644 index 00000000..f6d39d87 --- /dev/null +++ b/src/app/services/synthesis/synthesis-candidates.ts @@ -0,0 +1,437 @@ +import { Coord } from 'src/app/model/coord'; +import { MODEL_SCALE } from 'src/app/model/render-scale'; + +/** + * Every four-bar that carries a coupler through three given positions. + * + * Three positions of a rigid body fix, for any point on that body, three + * positions of that point -- and three points determine a circle. So the + * centre of that circle is a ground pivot the point can be pinned to, and the + * radius is the link that holds it. Do that for two points on the body and the + * result is a four-bar that closes exactly at all three positions. + * + * That construction has exactly one answer per pair of points, which is why + * synthesis used to produce exactly one linkage: the two points it used were + * the ends of the end-effector link. But the coupler does not have to be + * pinned at the ends. Sliding the two pins along the link -- or past it -- + * moves both circle centres and gives a genuinely different machine through + * the same three positions. Enumerating those is what turns synthesis from + * "here is the answer" into "here are the answers, compare them". + * + * Everything in this file is in model units (render-scale.ts), like the rest + * of the geometry the app computes with, and knows nothing about how it is + * drawn. + */ + +/** How near a solved coupler pin has to land to count as the same point. */ +export const POSE_TOLERANCE = 0.18 * MODEL_SCALE; + +/** + * Where the two coupler pins may sit on the end-effector link, as fractions of + * its length from the back end. 0 and 1 are its ends; outside that range the + * pin is on an extension of the link, which is a real and often better + * machine. + */ +const PIN_OFFSETS = [-0.6, -0.3, 0, 0.2, 0.5, 0.8, 1, 1.3, 1.6]; + +/** The least a pair of pins may be apart, as a fraction of the link. */ +const MIN_PIN_SPAN = 0.5; + +export interface PosePoint { + /** The back end of the end-effector link in this position. */ + back: Coord; + /** Its front end. */ + front: Coord; +} + +export interface FourBarCandidate { + /** Identifies this candidate across a rebuild: pin offsets and branch. */ + key: string; + /** A letter, assigned by rank when the list is handed to the panel. */ + name: string; + /** Ground pivot of the input crank, and of the output rocker. */ + A: Coord; + D: Coord; + /** The two coupler pins in position 1. */ + B: Coord; + C: Coord; + /** Crank, rocker, coupler and ground lengths. */ + r1: number; + r2: number; + d: number; + g: number; + /** Where the pins sit on the end-effector link. */ + uA: number; + uB: number; + /** The pins' three positions, in order. */ + ptsA: Coord[]; + ptsB: Coord[]; + /** Which of the two circle intersections this assembly uses. */ + sign: number; + branch: 'Open' | 'Crossed'; + /** The two branches of one pin pair share this, so they can be swapped. */ + pair: string; + /** The crank angle, in degrees, at each of the three positions. */ + thetas: number[]; + /** How far the solved coupler pin misses each position by, on this branch. */ + errors: number[]; + onBranch: boolean[]; + onBranchCount: number; + defectFree: boolean; + /** How far the crank can turn from position 1 without the loop opening. */ + range: { from: number; to: number; full: boolean }; + /** The worst transmission angle over the working stroke, in degrees. */ + minTransmission: number; + kind: string; + size: number; +} + +export interface CandidateSearch { + poses: PosePoint[]; + /** Length of the end-effector link, in model units. */ + length: number; + /** Whether the coupler must be pinned to the link's own two ends. */ + endsOnly: boolean; + /** When set, both ground pivots must fall inside this box. */ + region?: { x: number; y: number; w: number; h: number }; +} + +/** Why nothing was found, for a panel that has to explain an empty list. */ +export interface CandidateRejections { + tried: number; + degenerate: number; + tooBig: number; + alike: number; + outsideRegion: number; +} + +export interface CandidateResult { + candidates: FourBarCandidate[]; + rejections: CandidateRejections; +} + +function distance(a: Coord, b: Coord): number { + return Math.hypot(b.x - a.x, b.y - a.y); +} + +/** The centre of the circle through three points, or nothing if they line up. */ +export function circumcenter(p1: Coord, p2: Coord, p3: Coord): Coord | null { + const d = 2 * (p1.x * (p2.y - p3.y) + p2.x * (p3.y - p1.y) + p3.x * (p1.y - p2.y)); + if (Math.abs(d) < 1e-9) return null; + const s1 = p1.x * p1.x + p1.y * p1.y; + const s2 = p2.x * p2.x + p2.y * p2.y; + const s3 = p3.x * p3.x + p3.y * p3.y; + return new Coord( + (s1 * (p2.y - p3.y) + s2 * (p3.y - p1.y) + s3 * (p1.y - p2.y)) / d, + (s1 * (p3.x - p2.x) + s2 * (p1.x - p3.x) + s3 * (p2.x - p1.x)) / d + ); +} + +/** Where two circles cross, or nothing if they do not reach each other. */ +export function meet(c1: Coord, r1: number, c2: Coord, r2: number): [Coord, Coord] | null { + const span = distance(c1, c2); + if (span === 0 || span > r1 + r2 || span < Math.abs(r1 - r2)) return null; + const a = (span * span + r1 * r1 - r2 * r2) / (2 * span); + const h = Math.sqrt(Math.max(0, r1 * r1 - a * a)); + const ux = (c2.x - c1.x) / span; + const uy = (c2.y - c1.y) / span; + const mx = c1.x + a * ux; + const my = c1.y + a * uy; + return [new Coord(mx - h * uy, my + h * ux), new Coord(mx + h * uy, my - h * ux)]; +} + +function cross(o: Coord, a: Coord, b: Coord): number { + return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); +} + +function pointOn(centre: Coord, radius: number, angleRad: number): Coord { + return new Coord(centre.x + radius * Math.cos(angleRad), centre.y + radius * Math.sin(angleRad)); +} + +/** + * Close the loop with the crank at a given angle. + * + * `sign` is the assembly: the two circle intersections are the two ways the + * same four bars can be pinned together, and a linkage cannot cross from one + * to the other without being taken apart. + */ +export function solveFourBar( + cand: Pick, + thetaDeg: number, + sign?: number +): { A: Coord; B: Coord; C: Coord; D: Coord } | null { + const B = pointOn(cand.A, cand.r1, (thetaDeg * Math.PI) / 180); + const pair = meet(B, cand.d, cand.D, cand.r2); + if (!pair) return null; + const want = sign === undefined ? cand.sign : sign; + const C = pair.find((p) => Math.sign(cross(B, cand.D, p)) === want); + if (!C) return null; + return { A: cand.A, B, C, D: cand.D }; +} + +/** A point a fraction `u` along the end-effector link, from its back end. */ +function attach(pose: PosePoint, u: number): Coord { + return new Coord( + pose.back.x + u * (pose.front.x - pose.back.x), + pose.back.y + u * (pose.front.y - pose.back.y) + ); +} + +/** + * What this candidate can actually do with the three positions. + * + * The circumcentre construction makes the loop close exactly at all three -- + * that is what it is for -- so the question is never whether a position is + * reached. It is whether all three are reached on ONE assembly. A position + * that only closes on the other intersection can be got to only by taking the + * linkage apart and putting it back together, and that is what makes an + * otherwise perfect construction useless as a machine. It is called a branch + * defect, and it is the single most important thing to tell a reader + * comparing candidates. + */ +export function assess(cand: FourBarCandidate): void { + const branch = cand.sign; + cand.thetas = cand.ptsA.map((p) => (Math.atan2(p.y - cand.A.y, p.x - cand.A.x) * 180) / Math.PI); + cand.errors = cand.ptsB.map((target, i) => { + const sol = solveFourBar(cand, cand.thetas[i], branch); + return sol ? distance(sol.C, target) : Infinity; + }); + cand.onBranch = cand.errors.map((e) => e < POSE_TOLERANCE); + cand.onBranchCount = cand.onBranch.filter(Boolean).length; + cand.defectFree = cand.onBranchCount === 3; + + // How far the crank turns from position 1 before the loop can no longer be + // closed. Walked rather than solved because the limit is where two circles + // stop reaching, and walking outward from a pose we know closes cannot + // wander onto a disconnected stretch of the same curve. + const start = cand.thetas[0]; + let from = start; + let to = start; + for (let k = 1; k <= 360; k++) { + if (!solveFourBar(cand, start + k, branch)) break; + to = start + k; + } + for (let k = 1; k <= 360; k++) { + if (!solveFourBar(cand, start - k, branch)) break; + from = start - k; + } + // A linkage that turns fully has no start of travel, so its track begins at + // position 1 and runs one revolution forward. A rocking one does have ends, + // and those are the ends that were walked. + const full = to - from >= 359; + cand.range = full ? { from: start, to: start + 360, full: true } : { from, to, full: false }; + + // The transmission angle over the stroke that matters -- the span the three + // positions actually occupy, not the whole range. It is how squarely the + // coupler pushes the rocker, and a four-bar that passes through the poses at + // five degrees will stall there in real life. + let minMu = 180; + const strokeFrom = Math.max(cand.range.from, Math.min(...cand.thetas) - 5); + const strokeTo = Math.min(cand.range.to, Math.max(...cand.thetas) + 5); + for (let deg = strokeFrom; deg <= strokeTo; deg += 2) { + const sol = solveFourBar(cand, deg, branch); + if (!sol) continue; + const v1 = { x: sol.B.x - sol.C.x, y: sol.B.y - sol.C.y }; + const v2 = { x: cand.D.x - sol.C.x, y: cand.D.y - sol.C.y }; + const dot = (v1.x * v2.x + v1.y * v2.y) / (Math.hypot(v1.x, v1.y) * Math.hypot(v2.x, v2.y)); + let mu = (Math.acos(Math.max(-1, Math.min(1, dot))) * 180) / Math.PI; + if (mu > 90) mu = 180 - mu; + minMu = Math.min(minMu, mu); + } + cand.minTransmission = Math.round(minMu); + cand.kind = cand.range.full ? 'crank-rocker' : 'double-rocker'; + cand.size = Math.max(cand.r1, cand.r2, cand.g); +} + +/** + * Drive the linkage from its other ground pin. + * + * The same four bars, read from the far end: what was the rocker becomes the + * crank. A four-bar that will not turn from one ground pin often turns freely + * from the other, so this is a real second machine rather than a relabelling, + * and it is re-assessed as one. + */ +export function drivenFromFarPin(cand: FourBarCandidate): FourBarCandidate { + const swapped: FourBarCandidate = { + ...cand, + A: cand.D, + D: cand.A, + r1: cand.r2, + r2: cand.r1, + B: cand.C, + C: cand.B, + ptsA: cand.ptsB, + ptsB: cand.ptsA, + sign: Math.sign(cross(cand.ptsB[0], cand.A, cand.ptsA[0])) || 1, + }; + assess(swapped); + return swapped; +} + +function inRegion(p: Coord, region: { x: number; y: number; w: number; h: number }): boolean { + return ( + p.x >= region.x && p.x <= region.x + region.w && p.y >= region.y && p.y <= region.y + region.h + ); +} + +/** How the two pins sit on the link, in words, for the dimensions list. */ +function describePins(uA: number, uB: number, length: number): string { + const part = (u: number, end: 0 | 1): string => { + if (Math.abs(u - end) < 1e-9) return ''; + const away = Math.abs(u - end) * length; + const outside = end === 0 ? u < 0 : u > 1; + return ( + (away / MODEL_SCALE).toFixed(1) + + ' ' + + (outside ? 'past' : 'inside') + + ' the ' + + (end === 0 ? 'back' : 'front') + ); + }; + const parts = [part(uA, 0), part(uB, 1)].filter(Boolean); + return parts.length ? parts.join(', ') : 'at both ends'; +} + +/** + * Every buildable four-bar through the three positions, best first. + * + * "Buildable" is doing real work here: the construction has an answer for + * almost every pair of pins, but as the three positions approach a straight + * line the circle centres run off towards infinity, and a ground pivot a + * hundred link-lengths away is not a machine anybody can make. Those are + * counted rather than silently dropped, so the panel can say which way the + * positions need to move. + */ +export function enumerateCandidates(search: CandidateSearch): CandidateResult { + const { poses, length } = search; + const rejections: CandidateRejections = { + tried: 0, + degenerate: 0, + tooBig: 0, + alike: 0, + outsideRegion: 0, + }; + if (poses.length !== 3 || !(length > 0)) return { candidates: [], rejections }; + + const pairs: [number, number][] = []; + if (search.endsOnly) { + pairs.push([0, 1]); + } else { + PIN_OFFSETS.forEach((uA) => + PIN_OFFSETS.forEach((uB) => { + if (uB - uA >= MIN_PIN_SPAN) pairs.push([uA, uB]); + }) + ); + } + + const centre = new Coord( + poses.reduce((sum, p) => sum + (p.back.x + p.front.x) / 2, 0) / 3, + poses.reduce((sum, p) => sum + (p.back.y + p.front.y) / 2, 0) / 3 + ); + const spread = Math.max( + distance(poses[0].back, poses[1].back), + distance(poses[1].back, poses[2].back), + distance(poses[0].back, poses[2].back) + ); + const reach = Math.max(6 * length, 2.5 * spread); + + const out: FourBarCandidate[] = []; + pairs.forEach(([uA, uB]) => { + rejections.tried++; + const ptsA = poses.map((p) => attach(p, uA)); + const ptsB = poses.map((p) => attach(p, uB)); + const A = circumcenter(ptsA[0], ptsA[1], ptsA[2]); + const D = circumcenter(ptsB[0], ptsB[1], ptsB[2]); + if (!A || !D) { + rejections.degenerate++; + return; + } + const r1 = distance(A, ptsA[0]); + const r2 = distance(D, ptsB[0]); + const g = distance(A, D); + if ( + distance(A, centre) > reach || + distance(D, centre) > reach || + r1 > reach || + r2 > reach || + g > reach + ) { + rejections.tooBig++; + return; + } + if (search.region && (!inRegion(A, search.region) || !inRegion(D, search.region))) { + rejections.outsideRegion++; + return; + } + // Two constructions that put their pivots within a link-length of each + // other and hold near-identical bars are the same machine drawn twice. + const alike = out.some( + (other) => + distance(other.A, A) < length * 0.9 && + distance(other.D, D) < length * 0.9 && + Math.abs(other.r1 - r1) / Math.max(other.r1, r1) < 0.12 && + Math.abs(other.r2 - r2) / Math.max(other.r2, r2) < 0.12 + ); + if (alike) { + rejections.alike++; + return; + } + + const openSign = Math.sign(cross(ptsA[0], D, ptsB[0])) || 1; + [openSign, -openSign].forEach((sign) => { + const variant: FourBarCandidate = { + key: uA + ':' + uB + ':' + sign, + name: '?', + A, + D, + B: ptsA[0], + C: ptsB[0], + r1, + r2, + d: distance(ptsA[0], ptsB[0]), + g, + uA, + uB, + ptsA, + ptsB, + sign, + branch: sign === openSign ? 'Open' : 'Crossed', + pair: uA + '/' + uB, + thetas: [], + errors: [], + onBranch: [], + onBranchCount: 0, + defectFree: false, + range: { from: 0, to: 0, full: false }, + minTransmission: 0, + kind: '', + size: 0, + }; + assess(variant); + // A construction that closes at none of the three positions on this + // assembly, and cannot even be solved at the first, is not a second + // branch of anything -- it is the intersection that does not exist. + if (!isFinite(variant.errors[0]) && variant.onBranchCount === 0) return; + out.push(variant); + }); + }); + + return { candidates: out, rejections }; +} + +/** Best first: defect-free, then most positions on one assembly, then roomiest. */ +export function rankCandidates(list: FourBarCandidate[], limit = 8): FourBarCandidate[] { + const sorted = list.slice().sort((a, b) => { + if (a.defectFree !== b.defectFree) return a.defectFree ? -1 : 1; + if (b.onBranchCount !== a.onBranchCount) return b.onBranchCount - a.onBranchCount; + return b.minTransmission - a.minTransmission; + }); + const best = sorted.slice(0, limit); + best.forEach((c, i) => (c.name = 'ABCDEFGH'[i] ?? '?')); + return best; +} + +/** Where the two coupler pins sit on the link, in the reader's own words. */ +export function describeCouplerPins(cand: FourBarCandidate, length: number): string { + return describePins(cand.uA, cand.uB, length); +} From b08464c867f7431cb26e35f6c52010a065061b96 Mon Sep 17 00:00:00 2001 From: Kohmei Kadoya Date: Thu, 20 Aug 2026 17:26:36 -0700 Subject: [PATCH 02/29] Keep the answer out of the drawing until someone asks for it Synthesis used to write a four-bar onto the grid on every nudge of a coordinate. That made the one thing this mode is for impossible: looking at a second solution destroyed the first, so there was never anything to compare. So the design and the answer are now two things. SynthesisBuilderService holds what was asked for -- three positions, the coupler they belong to, and what a solution has to satisfy -- and gains the state the redesign needs: which screen the reader is on, whether the next click drops a position and which way it is turned, and the ground-pivot region. SynthesisSolutionService holds what came back, and touches the grid exactly once, when Insert says so. Positions are placed rather than created at the origin, so removing one closes the gap it leaves: the panel fills three numbered rows in order, and a hole in the middle is a state the gesture cannot produce and cannot repair. The pose gizmo's old parts go with it. The X/Y arrows, the rotation circle and the status colours were the whole of SynthesisConstants and most of SynthesisPose; the handles that replace them are drawn from the design itself. Co-Authored-By: Claude Opus 5 --- src/app/selected-tab.service.ts | 18 +- src/app/services/grid-utils.service.ts | 9 - .../synthesis/synthesis-builder.service.ts | 269 ++++++++++-- .../services/synthesis/synthesis-constants.ts | 50 --- .../synthesis/synthesis-solution.service.ts | 390 ++++++++++++++++++ src/app/services/synthesis/synthesis-util.ts | 151 ++----- 6 files changed, 654 insertions(+), 233 deletions(-) delete mode 100644 src/app/services/synthesis/synthesis-constants.ts create mode 100644 src/app/services/synthesis/synthesis-solution.service.ts diff --git a/src/app/selected-tab.service.ts b/src/app/selected-tab.service.ts index adfa216a..21588cef 100644 --- a/src/app/selected-tab.service.ts +++ b/src/app/selected-tab.service.ts @@ -76,6 +76,18 @@ export class SelectedTabService { return tab === TabID.ANALYZE || tab === TabID.FORCE; } + /** + * Whether the current mode's panel needs the wide drawer. + * + * The two analyses need it for their tables and graphs; Synthesis needs it + * for a row of three numbers per position and a gallery of candidate + * linkages read side by side. Edit is the only mode that still fits in the + * narrow one. + */ + public isWidePanel(tab: TabID = this.getCurrentTab()) { + return this.isAnalysisMode(tab) || tab === TabID.SYNTHESIZE; + } + private onNewTab(previousTab: TabID) { // A setup drawer answers a question about one mode, so it goes when that // mode does -- otherwise the Force list sits over the Synthesis canvas @@ -92,12 +104,6 @@ export class SelectedTabService { if (this.getCurrentTab() === TabID.SYNTHESIZE) { // reset flag this.synthesis.modifiedMechanism = false; - // A fresh visit synthesises a new linkage rather than editing the one the - // last visit left behind, so nothing here belongs to this one yet. - this.synthesis.synthesisedIds = { joints: [], links: [] }; - this.synthesis.driverWanted = false; - this.synthesis.driveOnFarPin = false; - this.synthesis.driverRefusal = undefined; } else if (previousTab === TabID.SYNTHESIZE && this.getCurrentTab() === TabID.EDIT) { // save mechanism state if modified in synthesis tab this.mechanism.save(); diff --git a/src/app/services/grid-utils.service.ts b/src/app/services/grid-utils.service.ts index 87bd6f7c..cb78170f 100644 --- a/src/app/services/grid-utils.service.ts +++ b/src/app/services/grid-utils.service.ts @@ -20,7 +20,6 @@ import { Force } from '../model/force'; import { Arc, Line } from '../model/line'; import { SynthesisPose } from './synthesis/synthesis-util'; import { SynthesisBuilderService } from './synthesis/synthesis-builder.service'; -import { SynthesisClickMode } from './synthesis/synthesis-constants'; import { SvgGridService } from './svg-grid.service'; import { ColorService } from './color.service'; @@ -928,14 +927,6 @@ export class GridUtilsService { return selectedForce; } - setPoseTheta(pose: SynthesisPose, thetaRadians: number) { - this.synthesisBuilder.setPoseTheta(pose, thetaRadians); - } - - dragPose(pose: SynthesisPose, dx: number, dy: number, mode: SynthesisClickMode) { - this.synthesisBuilder.movePoseByOffset(pose, mode, dx, dy); - } - isAttachedToSlider(lastRightClick: Joint | Link | Force | String) { if (lastRightClick instanceof Joint && lastRightClick instanceof RevJoint) { return lastRightClick.connectedJoints.some((j) => j instanceof PrisJoint); diff --git a/src/app/services/synthesis/synthesis-builder.service.ts b/src/app/services/synthesis/synthesis-builder.service.ts index 3fdf1d24..4763b475 100644 --- a/src/app/services/synthesis/synthesis-builder.service.ts +++ b/src/app/services/synthesis/synthesis-builder.service.ts @@ -2,10 +2,10 @@ import { Injectable, inject } from '@angular/core'; import { Subject } from 'rxjs'; import { COR, SynthesisPose } from './synthesis-util'; import { Coord } from 'src/app/model/coord'; -import { SynthesisClickMode, SynthesisConstants } from './synthesis-constants'; import { NumberUnitParserService } from '../number-unit-parser.service'; import { SettingsService } from '../settings.service'; import { MODEL_SCALE } from 'src/app/model/render-scale'; +import { CandidateSearch, PosePoint } from './synthesis-candidates'; /* Service responsible for storing end effector poses to be synthesized @@ -21,38 +21,60 @@ export class SynthesisBuilderService { public valueChanges: Subject; - public constants: SynthesisConstants; - // whether the mechanism has been modified since last synthesis // if so, when switching back to edit mode, save state public modifiedMechanism: boolean = false; /** - * What this visit to Synthesis has put on the grid. + * Which screen of Synthesis the reader is on. + * + * 'chooser' asks what kind of synthesis this is; 'working' is the one kind + * that exists. It is a screen rather than a setting because the answer + * decides what every control below it means, and because the second kind -- + * fitting a linkage to a path -- is coming and has to have somewhere to go. + */ + public stage: 'chooser' | 'working' = 'chooser'; + + /** + * Whether the next click on the grid drops a position. * - * Synthesis adds a mechanism now rather than replacing the drawing, and it - * re-runs on every change to a pose -- so it has to be able to take back its - * own previous answer without touching anything else. Cleared on entering the - * tab, so each visit leaves one linkage behind rather than editing the one - * from last time. + * Placing is opt-in. Synthesis shares the canvas with the drawing, and a + * mode where every click makes something is a mode where every click a + * reader meant as "look at this" makes something instead. */ - public synthesisedIds: { joints: string[]; links: string[] } = { joints: [], links: [] }; + public armed = false; + + /** Which way the position about to be dropped is turned, in degrees. */ + public placeAngleDeg = 8; + + /** Whether the grid is waiting for the ground-pivot region to be drawn. */ + public regionDraw = false; + + // --- what a solution has to satisfy to be listed ------------------------ /** - * Whether the linkage should be built with a driver on it, and which of its - * two ground pins the drive belongs to. + * Whether the coupler must be pinned to the end-effector link's own ends. * - * Held as intent rather than applied to the drawing, because synthesis re-runs - * from scratch on every change to a pose: a driver bolted on afterwards would - * be thrown away by the next nudge of a coordinate, and a drive pin moved by - * hand would move back. Both are read at build time instead, so they survive - * every rebuild and there is only ever one way the linkage came to be. + * On, the coupler is exactly the length that was typed. Off, the pins may + * slide along the link or past it, which moves both ground pivots and gives + * genuinely different machines through the same three positions -- far more + * of them, and often better ones. */ - public driverWanted: boolean = false; - public driveOnFarPin: boolean = false; + public endsOnly = true; + + /** Whether linkages that need reassembling between positions are listed. */ + public allowDefect = false; + + /** Whether both ground pivots must land inside the region below. */ + public constrain = false; - /** Why the last build could not fit a driver, for the panel to show. */ - public driverRefusal: string | undefined; + /** The region, in model units, as a box with its origin at bottom-left. */ + public region = { + x: -6 * MODEL_SCALE, + y: -14 * MODEL_SCALE, + w: 26 * MODEL_SCALE, + h: 12 * MODEL_SCALE, + }; _COR: COR; _length: number; // length of the end-effector link @@ -62,7 +84,6 @@ export class SynthesisBuilderService { constructor() { this.valueChanges = new Subject(); - this.constants = new SynthesisConstants(); // start with a length of 5 user units, held in model units this._COR = COR.CENTER; @@ -129,26 +150,6 @@ export class SynthesisBuilderService { return this.getAllPoses().length === 3; } - setPoseTheta(pose: SynthesisPose, thetaRadians: number) { - pose.thetaRadians = thetaRadians; - this.valueChanges.next(true); - } - - movePoseByOffset(pose: SynthesisPose, mode: SynthesisClickMode, dx: number, dy: number) { - // if dragging by coordinate axis, project onto axis - if (mode !== SynthesisClickMode.NORMAL) { - let theta = pose.thetaRadians; - if (mode === SynthesisClickMode.Y) theta += Math.PI / 2; - - let d = dx * Math.cos(theta) + dy * Math.sin(theta); - dx = d * Math.cos(theta); - dy = d * Math.sin(theta); - } - - pose.position = new Coord(pose.position.x + dx, pose.position.y + dy); - this.valueChanges.next(true); - } - // return all existing poses getAllPoses(): SynthesisPose[] { let poses: SynthesisPose[] = []; @@ -226,4 +227,188 @@ export class SynthesisBuilderService { // if we get here, form is valid return true; } + + /** The three positions as pin-carrying bars, for the candidate search. */ + posePoints(): PosePoint[] { + return this.getAllPoses().map((pose) => ({ back: pose.posBack, front: pose.posFront })); + } + + /** Everything the enumeration needs, and nothing it does not. */ + search(): CandidateSearch { + return { + poses: this.posePoints(), + length: this.length, + endsOnly: this.endsOnly, + region: this.constrain ? { ...this.region } : undefined, + }; + } + + /** + * What the candidate list was computed for. + * + * `allowDefect` is deliberately absent: it filters a list rather than + * changing what is in it, so switching it does not have to pay for a + * re-enumeration. + */ + searchKey(): string { + const poses = this.getAllPoses().map((p) => [ + Math.round(p.position.x), + Math.round(p.position.y), + Math.round(p.thetaDegrees * 100), + ]); + return JSON.stringify([ + poses, + Math.round(this.length), + this._COR, + this.endsOnly, + this.constrain ? this.region : null, + ]); + } + + /** + * Drop a position where the reader clicked, turned the way the ghost was. + * + * Positions fill 1, 2, 3 in the order they are placed, and placing stays + * armed until the third: three clicks is the whole gesture, and disarming + * between them would put a button press between every one. + */ + placePose(at: Coord): void { + const next = this.getFirstUndefinedPose(); + if (next === undefined) return; + this.poses[next] = new SynthesisPose( + next, + at, + (this.placeAngleDeg * Math.PI) / 180, + () => this.COR, + () => this.length + ); + const more = this.getFirstUndefinedPose() !== undefined; + this.armed = more; + this.selectedPose = more ? next + 1 : next; + this.placeAngleDeg -= 22; + this.valueChanges.next(true); + } + + /** Arm or disarm placing, and select the row that is about to be filled. */ + setArmed(armed: boolean): void { + const next = this.getFirstUndefinedPose(); + if (armed && next === undefined) return; + this.armed = armed; + // Only arming cancels a region being drawn: the two gestures both own the + // canvas, so one has to give way -- but disarming is also how *starting* to + // draw a region reports itself, and clearing it here unconditionally meant + // the Redraw button switched the mode off in the same breath it asked for + // it. + if (armed) this.regionDraw = false; + if (armed) { + this.selectedPose = next!; + const placed = this.getAllPoses(); + if (placed.length) this.placeAngleDeg = placed[placed.length - 1].thetaDegrees - 22; + } + this.valueChanges.next(false); + } + + /** + * Copy the last position and offset it slightly. + * + * A quick start for three similar positions, which is what most designs + * actually are -- and it saves a reader from discovering that three + * positions in a straight line have no solutions at all. + */ + duplicateLastPose(): void { + const placed = this.getAllPoses(); + const next = this.getFirstUndefinedPose(); + if (!placed.length || next === undefined) return; + const last = placed[placed.length - 1]; + this.poses[next] = new SynthesisPose( + next, + new Coord(last.position.x + 6 * MODEL_SCALE, last.position.y + 5 * MODEL_SCALE), + ((last.thetaDegrees - 22) * Math.PI) / 180, + () => this.COR, + () => this.length + ); + this.armed = false; + this.selectedPose = next; + this.valueChanges.next(true); + } + + /** + * Remove one position, and close the gap it leaves. + * + * The panel shows three numbered rows and fills them in order, so a hole in + * the middle would leave "Position 2" blank under a filled "Position 3" -- + * a state the placing gesture cannot produce and has no way to repair. + */ + removePose(id: number): void { + const kept = this.getAllPoses().filter((pose) => pose.id !== id); + this.poses = {}; + kept.forEach((pose, index) => { + this.poses[index + 1] = new SynthesisPose( + index + 1, + pose.position, + pose.thetaRadians, + () => this.COR, + () => this.length + ); + }); + this.armed = false; + this.selectedPose = Math.min(this.selectedPose, Math.max(1, kept.length)); + this.valueChanges.next(true); + } + + /** Nothing designed, nothing asked for -- what a fresh visit looks like. */ + clearDesign(): void { + this.poses = {}; + this._COR = COR.CENTER; + this._length = 5 * MODEL_SCALE; + this._selectedPose = 1; + this.stage = 'chooser'; + this.armed = false; + this.regionDraw = false; + this.endsOnly = true; + this.allowDefect = false; + this.constrain = false; + this.valueChanges.next(true); + } + + /** + * Replace the whole design with one that came out of a URL. + * + * One notification at the end rather than one per field: a decode is a + * single event -- a link opened, or a step through history -- and reporting + * it as eleven would have the panel re-read a half-applied design ten times. + */ + applyDecoded(decoded: { + length: number; + reference: COR; + endsOnly: boolean; + allowDefect: boolean; + constrain: boolean; + stage: 'chooser' | 'working'; + poses: { at: Coord; thetaDegrees: number }[]; + region?: { x: number; y: number; w: number; h: number }; + }): void { + this._COR = decoded.reference; + this._length = decoded.length > 0 ? decoded.length : 5 * MODEL_SCALE; + this.endsOnly = decoded.endsOnly; + this.allowDefect = decoded.allowDefect; + this.constrain = decoded.constrain; + this.stage = decoded.stage; + this.armed = false; + this.regionDraw = false; + if (decoded.region) this.region = decoded.region; + + this.poses = {}; + decoded.poses.slice(0, 3).forEach((pose, index) => { + this.poses[index + 1] = new SynthesisPose( + index + 1, + pose.at, + (pose.thetaDegrees * Math.PI) / 180, + () => this.COR, + () => this.length + ); + }); + this._selectedPose = Math.min(this._selectedPose, Math.max(1, decoded.poses.length)); + this.valueChanges.next(true); + } } diff --git a/src/app/services/synthesis/synthesis-constants.ts b/src/app/services/synthesis/synthesis-constants.ts deleted file mode 100644 index a59b491d..00000000 --- a/src/app/services/synthesis/synthesis-constants.ts +++ /dev/null @@ -1,50 +0,0 @@ -export enum SynthesisStatus { - DISABLED = 'DISABLED', - INVALID = 'INVALID', - VALID = 'VALID', -} - -export enum SynthesisClickMode { - NORMAL = 'NORMAL', - X = 'X', - Y = 'Y', - ROTATE = 'ROTATE', -} - -export class SynthesisConstants { - public CIRCLE_RADIUS: number = 0.15; - public LINK_CIRCLE_RADIUS: number = 0.25; - public CIRCLE_COLOR: string = 'rgb(255,255,255)'; - public CIRCLE_COLOR_H: string = 'rgb(245,245,245)'; - - public LINK_COLOR: { [key in SynthesisStatus]: string } = { - [SynthesisStatus.DISABLED]: 'rgb(100,100,100)', - [SynthesisStatus.INVALID]: 'rgb(255,0,0)', - [SynthesisStatus.VALID]: 'rgb(0,255,0)', - }; - - // slightly darkened when hovering - public LINK_COLOR_H: { [key in SynthesisStatus]: string } = { - [SynthesisStatus.DISABLED]: 'rgb(80,80,80)', - [SynthesisStatus.INVALID]: 'rgb(200,0,0)', - [SynthesisStatus.VALID]: 'rgb(0,200,0)', - }; - - public COR_RADIUS: number = 0.1; - public COR_COLOR: string = 'black'; - - public ROTATION_SIZE: number = 0.2; - - public ARROW_X_COLOR: string = 'rgba(255,0,0,0.3)'; - public ARROW_X_COLOR_H: string = 'rgba(255,0,0,1)'; - - public ARROW_Y_COLOR: string = 'rgba(0,0,230,0.3)'; - public ARROW_Y_COLOR_H: string = 'rgba(0,0,230,1)'; - - public ROTATION_CIRCLE_RADIUS: number = 0.15; - public ROTATION_CIRCLE_LOCATION_SCALAR = 0.5; - public ROTATION_CIRCLE_COLOR: string = 'rgb(0, 125, 0)'; - public ROTATION_CIRCLE_COLOR_H: string = 'rgb(0,255,0)'; - - constructor() {} -} diff --git a/src/app/services/synthesis/synthesis-solution.service.ts b/src/app/services/synthesis/synthesis-solution.service.ts new file mode 100644 index 00000000..ef61dc01 --- /dev/null +++ b/src/app/services/synthesis/synthesis-solution.service.ts @@ -0,0 +1,390 @@ +import { Injectable, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { Coord } from 'src/app/model/coord'; +import { RealLink } from 'src/app/model/link'; +import { RevJoint } from 'src/app/model/joint'; +import { MechanismService } from '../mechanism.service'; +import { ColorService } from '../color.service'; +import { SynthesisBuilderService } from './synthesis-builder.service'; +import { driverDyadFor, DriverDyad } from './driver-dyad'; +import { + CandidateRejections, + FourBarCandidate, + drivenFromFarPin, + enumerateCandidates, + meet, + rankCandidates, + solveFourBar, +} from './synthesis-candidates'; + +/** + * The answers, as opposed to the question. + * + * SynthesisBuilderService owns what the reader asked for -- three positions of + * an end-effector link, and what a solution has to satisfy. This owns what + * comes back: the candidate four-bars, which one is being looked at, how it is + * being driven, where the preview has been scrubbed to, and the single moment + * the answer stops being a preview and becomes part of the drawing. + * + * Nothing here touches the grid until `insert()` is called. That is the whole + * point of the redesign: synthesis used to rebuild the mechanism on every + * nudge of a coordinate, which made comparing two solutions impossible -- + * looking at the second one destroyed the first. + */ +@Injectable({ providedIn: 'root' }) +export class SynthesisSolutionService { + private design = inject(SynthesisBuilderService); + private mechanismSrv = inject(MechanismService); + private colors = inject(ColorService); + + /** Fires when the answer changes, for the grid and the panel to redraw. */ + public changed = new Subject(); + + /** Whether a search has been run against the design as it now stands. */ + public generated = false; + /** Whether one is running: the panel shows it as work, once. */ + public generating = false; + + /** Which candidate the reader has picked, and which they are hovering. */ + public candidateKey: string | null = null; + public hoverKey: string | null = null; + public showAll = false; + public dimensionsOpen = false; + + /** Drive from the far ground pin, and put a driver dyad on the input. */ + public driveOnFarPin = false; + public driverWanted = false; + /** Why the last driver could not be fitted, for the panel to show. */ + public driverRefusal: string | undefined; + + /** Where the preview stands, in crank degrees, and whether it is running. */ + public phase: number | null = null; + public playing = false; + public clockwise = true; + + /** Whether this design has been committed to the drawing. */ + public inserted = false; + + /** What the last insert put on the grid, so it can be taken back. */ + private insertedIds: { joints: string[]; links: string[] } = { joints: [], links: [] }; + + private cacheKey = ''; + private cached: FourBarCandidate[] = []; + private cachedRejections: CandidateRejections = { + tried: 0, + degenerate: 0, + tooBig: 0, + alike: 0, + outsideRegion: 0, + }; + /** How many of the candidates found work on a single assembly. */ + public strictCount = 0; + + private timer: ReturnType | undefined; + + /** + * Throw away the answer because the question changed. + * + * Anything that moves a position, or changes what a solution has to satisfy, + * lands here: the candidates on screen were computed for a design that no + * longer exists, and showing them against the new one would be a lie the + * reader has no way to spot. + */ + invalidate(): void { + this.generated = false; + this.generating = false; + this.candidateKey = null; + this.hoverKey = null; + this.phase = null; + this.playing = false; + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + this.changed.next(); + } + + /** A change that leaves the candidates standing -- a different pick, say. */ + private touch(): void { + this.phase = null; + this.inserted = false; + this.changed.next(); + } + + /** + * Run the search. + * + * The first search is the one worth showing as work: it is the moment three + * positions become a set of machines. It is deliberately deferred a beat so + * the button can say so, and because the enumeration walks a full crank + * revolution for every candidate and would otherwise freeze the frame it was + * clicked on. + */ + generate(): void { + if (this.generating || !this.design.isFullyDefined()) return; + this.generating = true; + this.changed.next(); + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = undefined; + this.generating = false; + this.generated = true; + this.candidateKey = null; + this.changed.next(); + }, 60); + } + + /** Every candidate the current design admits, best first, at most eight. */ + candidates(): FourBarCandidate[] { + if (!this.generated || !this.design.isFullyDefined()) return []; + const key = this.design.searchKey(); + if (key !== this.cacheKey) { + const result = enumerateCandidates(this.design.search()); + this.cacheKey = key; + this.cached = result.candidates; + this.cachedRejections = result.rejections; + } + let list = this.cached; + this.strictCount = list.filter((c) => c.defectFree).length; + if (!this.design.allowDefect) list = list.filter((c) => c.defectFree); + return rankCandidates(list); + } + + rejections(): CandidateRejections { + return this.cachedRejections; + } + + /** The candidate on screen: what is hovered wins over what is picked. */ + chosen(): FourBarCandidate | null { + const list = this.candidates(); + if (!list.length) return null; + const hovered = this.hoverKey ? list.find((c) => c.key === this.hoverKey) : undefined; + if (hovered) return hovered; + return list.find((c) => c.key === this.candidateKey) ?? list[0]; + } + + /** The candidate that was picked, ignoring the hover, for the ghost to show. */ + picked(): FourBarCandidate | null { + const list = this.candidates(); + if (!list.length) return null; + return list.find((c) => c.key === this.candidateKey) ?? list[0]; + } + + /** The chosen candidate as it is actually driven -- from A, or from D. */ + driven(cand: FourBarCandidate | null = this.chosen()): FourBarCandidate | null { + if (!cand) return null; + if (!this.driveOnFarPin) return cand; + const swapped = drivenFromFarPin(cand); + swapped.name = cand.name; + swapped.branch = cand.branch; + swapped.key = cand.key; + swapped.pair = cand.pair; + return swapped; + } + + /** The driver dyad for the current solution, or why there is not one. */ + dyad(): DriverDyad | undefined { + this.driverRefusal = undefined; + const cand = this.driven(); + if (!cand || !this.driverWanted) return undefined; + const result = driverDyadFor(cand.A, cand.ptsA); + if ('refusal' in result) { + this.driverRefusal = result.refusal; + return undefined; + } + return result.dyad; + } + + /** Where the preview stands now, in crank degrees. */ + currentPhase(): number { + const cand = this.driven(); + if (!cand) return 0; + return this.phase === null ? cand.thetas[0] : this.phase; + } + + /** The four pin positions of the preview at the current phase. */ + previewPose(): { A: Coord; B: Coord; C: Coord; D: Coord } | null { + const cand = this.driven(); + if (!cand) return null; + return solveFourBar(cand, this.currentPhase(), cand.sign); + } + + pick(key: string): void { + this.candidateKey = key; + this.hoverKey = null; + this.touch(); + } + + setHover(key: string | null): void { + this.hoverKey = key; + this.changed.next(); + } + + setDriveOnFarPin(far: boolean): void { + this.driveOnFarPin = far; + this.touch(); + } + + toggleDriver(): void { + this.driverWanted = !this.driverWanted; + this.touch(); + } + + setPhase(phase: number): void { + this.phase = phase; + this.playing = false; + this.changed.next(); + } + + /** + * Let go of the linkage a previous visit inserted, without removing it. + * + * Called on entering the tab. What was inserted is part of the drawing now, + * like anything else drawn by hand -- offering to undo it a session later, + * from a panel that has since been reset, would take away a machine the + * reader has been working on. + */ + forgetInsert(): void { + this.insertedIds = { joints: [], links: [] }; + this.inserted = false; + } + + /** Take back everything: the answer and the question both. */ + reset(): void { + this.invalidate(); + this.driverWanted = false; + this.driveOnFarPin = false; + this.driverRefusal = undefined; + this.showAll = false; + this.inserted = false; + this.insertedIds = { joints: [], links: [] }; + } + + // --- committing to the drawing ----------------------------------------- + + /** As many ids as asked for, none of which anything on the grid is using. */ + private nextLetters(count: number): string[] { + const taken: string[] = []; + for (let i = 0; i < count; i++) { + taken.push(this.mechanismSrv.determineNextLetter(taken)); + } + return taken; + } + + /** + * Put the chosen solution on the grid, as a machine of its own. + * + * The one moment synthesis writes to the drawing. It builds the whole + * linkage -- driver included -- before handing it over, so that one solve + * sees the finished six-bar rather than a four-bar that grows a motor a + * frame later. + */ + insert(): boolean { + const cand = this.driven(); + if (!cand || this.inserted) return false; + const solution = solveFourBar(cand, cand.thetas[0], cand.sign); + if (!solution) return false; + + const dyad = this.dyad(); + const [idA, idB, idC, idD, idE, idF] = this.nextLetters(6); + // With a driver on the linkage neither ground pin is the input at all; the + // motor sits on the driver's own ground and turns the whole train. + const drivenDirectly = !dyad; + + const jointA = new RevJoint(idA, solution.A.x, solution.A.y, drivenDirectly, true); + const jointB = new RevJoint(idB, solution.B.x, solution.B.y, false, false); + const jointC = new RevJoint(idC, solution.C.x, solution.C.y, false, false); + const jointD = new RevJoint(idD, solution.D.x, solution.D.y, false, true); + + jointA.connectedJoints.push(jointB); + jointB.connectedJoints.push(jointA, jointC); + jointC.connectedJoints.push(jointB, jointD); + jointD.connectedJoints.push(jointC); + + const crank = new RealLink(idA + idB, [jointA, jointB]); + crank.fill = this.colors.getLinkColorFromIndex(0); + const coupler = new RealLink(idB + idC, [jointB, jointC]); + coupler.fill = this.colors.getLinkColorFromIndex(1); + const rocker = new RealLink(idC + idD, [jointC, jointD]); + rocker.fill = this.colors.getLinkColorFromIndex(0); + + jointA.links.push(crank); + jointB.links.push(crank, coupler); + jointC.links.push(coupler, rocker); + jointD.links.push(rocker); + + const joints = [jointA, jointB, jointC, jointD]; + const links = [crank, coupler, rocker]; + + if (dyad) { + // The two lengths the sizing solved for are the distances between these + // three points, so placing the pins is all it takes to realise them. + const elbow = meet(dyad.ground, dyad.crankLength, solution.B, dyad.couplerLength); + if (elbow) { + const motor = new RevJoint(idE, dyad.ground.x, dyad.ground.y, true, true); + const knee = new RevJoint(idF, elbow[0].x, elbow[0].y, false, false); + motor.connectedJoints.push(knee); + knee.connectedJoints.push(motor, jointB); + jointB.connectedJoints.push(knee); + + const driverCrank = new RealLink(idE + idF, [motor, knee]); + driverCrank.fill = this.colors.getLinkColorFromIndex(2); + const driverCoupler = new RealLink(idF + idB, [knee, jointB]); + driverCoupler.fill = this.colors.getLinkColorFromIndex(3); + + motor.links.push(driverCrank); + knee.links.push(driverCrank, driverCoupler); + jointB.links.push(driverCoupler); + + joints.push(motor, knee); + links.push(driverCrank, driverCoupler); + } else { + // Sized but not assemblable in the position the linkage is drawn in. + // The four-bar still stands and still passes through the positions, so + // it is left drivable by hand rather than made useless by the refusal. + jointA.input = true; + } + } + + this.mechanismSrv.mergeToJoints(joints); + this.mechanismSrv.mergeToLinks(links); + this.insertedIds = { + joints: joints.map((j) => j.id), + links: links.map((l) => l.id), + }; + this.inserted = true; + this.playing = false; + this.mechanismSrv.mechanismTimeStep = 0; + this.mechanismSrv.updateMechanism(true); + this.changed.next(); + return true; + } + + /** + * Take back the linkage the last insert put on the grid. + * + * By id, and only the ids that insert recorded: anything else on the grid + * was drawn by hand or left by an earlier insert and is not this one's to + * remove. Forces on a removed link go with it -- a force on a link that no + * longer exists belongs to no mechanism. + */ + undoInsert(): void { + const { joints, links } = this.insertedIds; + if (!joints.length && !links.length) { + this.inserted = false; + this.changed.next(); + return; + } + const goneLinks = new Set(links); + const goneJoints = new Set(joints); + this.mechanismSrv.forces = this.mechanismSrv.forces.filter( + (force) => !goneLinks.has(force.link?.id ?? '') + ); + this.mechanismSrv.links = this.mechanismSrv.links.filter((link) => !goneLinks.has(link.id)); + this.mechanismSrv.joints = this.mechanismSrv.joints.filter( + (joint) => !goneJoints.has(joint.id) + ); + this.insertedIds = { joints: [], links: [] }; + this.inserted = false; + this.mechanismSrv.updateMechanism(true); + this.changed.next(); + } +} diff --git a/src/app/services/synthesis/synthesis-util.ts b/src/app/services/synthesis/synthesis-util.ts index aaa21a4f..e6f59c32 100644 --- a/src/app/services/synthesis/synthesis-util.ts +++ b/src/app/services/synthesis/synthesis-util.ts @@ -1,32 +1,25 @@ import { Coord } from 'src/app/model/coord'; -import { SynthesisConstants, SynthesisStatus } from './synthesis-constants'; -import { SettingsService } from '../settings.service'; +/** Which point on the end-effector link its coordinates describe. */ export enum COR { BACK = 'BACK', CENTER = 'CENTER', FRONT = 'FRONT', } -// storing state for a pose +/** + * One position of the end-effector link. + * + * A position is a point and an angle; where the link's two ends land follows + * from those plus the length and the reference point, both of which belong to + * the design as a whole rather than to any one position. They are read through + * callbacks so that changing either moves all three positions at once, which + * is what a reader means by "the link is 6 cm long". + */ export class SynthesisPose { - // cached values for graphical display private _posBack: Coord; - private _posCenter: Coord; private _posFront: Coord; - // string for SVG link - private _pathString: string = ''; - - public showHighlight: boolean = false; - public showHighlightX: boolean = false; - public showHighlightY: boolean = false; - public showHighlightR: boolean = false; - - private sConstants = new SynthesisConstants(); - - private _status: SynthesisStatus = SynthesisStatus.DISABLED; - constructor( public id: number, private _position: Coord, @@ -34,13 +27,9 @@ export class SynthesisPose { private getCOR: () => COR, private getLength: () => number ) { - // dummy values to be overwritten by recompute this._posBack = new Coord(0, 0); - this._posCenter = new Coord(0, 0); this._posFront = new Coord(0, 0); - this._thetaRadians %= Math.PI * 2; - this.recompute(); } @@ -48,142 +37,52 @@ export class SynthesisPose { return this._position; } - get thetaDegrees(): number { - return (this._thetaRadians * 180) / Math.PI; + set position(position: Coord) { + this._position = position; + this.recompute(); } get thetaRadians(): number { return this._thetaRadians; } - get posBack(): Coord { - return this._posBack; - } - - get posCenter(): Coord { - return this._posCenter; - } - - get posFront(): Coord { - return this._posFront; - } - - get pathString(): string { - return this._pathString; - } - - set position(position: Coord) { - this._position = position; - this.recompute(); - } - set thetaRadians(thetaRadians: number) { this._thetaRadians = thetaRadians; this.recompute(); } - get rotationCircleX(): number { - return ( - this.position.x + - this.sConstants.ROTATION_CIRCLE_LOCATION_SCALAR * - SettingsService.objectScale * - Math.cos(this.thetaRadians) - ); - } - - get rotationCircleY(): number { - return ( - this.position.y + - this.sConstants.ROTATION_CIRCLE_LOCATION_SCALAR * - SettingsService.objectScale * - Math.sin(this.thetaRadians) - ); + get thetaDegrees(): number { + return (this._thetaRadians * 180) / Math.PI; } set thetaDegrees(thetaDegrees: number) { - thetaDegrees %= 360; - this._thetaRadians = (thetaDegrees * Math.PI) / 180; + this._thetaRadians = ((thetaDegrees % 360) * Math.PI) / 180; this.recompute(); } - get status(): SynthesisStatus { - return this._status; - } - - set status(status: SynthesisStatus) { - this._status = status; + get posBack(): Coord { + return this._posBack; } - isAnyHighlight(): boolean { - return this.showHighlight || this.showHighlightX || this.showHighlightY || this.showHighlightR; + get posFront(): Coord { + return this._posFront; } - // recompute cached data like endpoint positions - recompute() { - let halfLength = this.getLength() / 2; - - let dx = Math.cos(this.thetaRadians) * halfLength; - let dy = Math.sin(this.thetaRadians) * halfLength; + /** Where the link's two ends are, given where this position is measured from. */ + recompute(): void { + const half = this.getLength() / 2; + const dx = Math.cos(this.thetaRadians) * half; + const dy = Math.sin(this.thetaRadians) * half; if (this.getCOR() === COR.BACK) { this._posBack = new Coord(this.position.x, this.position.y); - this._posCenter = new Coord(this.position.x + dx, this.position.y + dy); this._posFront = new Coord(this.position.x + dx * 2, this.position.y + dy * 2); } else if (this.getCOR() === COR.CENTER) { this._posBack = new Coord(this.position.x - dx, this.position.y - dy); - this._posCenter = new Coord(this.position.x, this.position.y); this._posFront = new Coord(this.position.x + dx, this.position.y + dy); } else { this._posBack = new Coord(this.position.x - dx * 2, this.position.y - dy * 2); - this._posCenter = new Coord(this.position.x - dx, this.position.y - dy); this._posFront = new Coord(this.position.x, this.position.y); } - - this._pathString = this._createPath( - this.posBack.x, - this.posBack.y, - this.posFront.x, - this.posFront.y, - this.sConstants.LINK_CIRCLE_RADIUS - ); - } - - // generate SVG path for a link given two points and a radius - private _createPath(x1: number, y1: number, x2: number, y2: number, r: number): string { - r = r * SettingsService.objectScale; - - const dx = x2 - x1; - const dy = y2 - y1; - - // calculate angle between the two points - const theta = Math.atan2(dy, dx); - - // calculate points for the rectangle - const p1x = x1 - r * Math.sin(theta); - const p1y = y1 + r * Math.cos(theta); - const p2x = x2 - r * Math.sin(theta); - const p2y = y2 + r * Math.cos(theta); - const p3x = x2 + r * Math.sin(theta); - const p3y = y2 - r * Math.cos(theta); - const p4x = x1 + r * Math.sin(theta); - const p4y = y1 - r * Math.cos(theta); - - // draw the path - return ` - M ${p1x} ${p1y} - A ${r} ${r} 0 1 1 ${p4x} ${p4y} - L ${p3x} ${p3y} - A ${r} ${r} 0 1 1 ${p2x} ${p2y} - Z - `; } } - -// cached graphics data for a pose to be displayed as an SVG -export class PoseGraphicsData { - constructor( - public pose: SynthesisPose, - public pointA: Coord, - public pointB: Coord - ) {} -} From 255b959c655d99a40d2ba4aebeca5fac1fe5c787 Mon Sep 17 00:00:00 2001 From: Kohmei Kadoya Date: Thu, 20 Aug 2026 17:26:49 -0700 Subject: [PATCH 03/29] Rebuild the Synthesis panel around choosing between solutions The panel used to be a form: type a length, create three poses, and a linkage appeared. It is now the account of a search. What is being synthesised, the coupler, the three positions as rows that read the same before and after they are filled, what a solution has to satisfy, an explicit Generate, the candidates side by side with what each one can and cannot do, and the one button that puts a solution in the drawing. Every requirement says what it is costing rather than only what it is: switched on it narrows the search, off it widens it, and when nothing is found the panel names the one to relax rather than leaving the reader to guess. The purely-geometric explanation is kept for when no requirement is in the way, because with one switched on it is the wrong answer. Its width goes to 400px, the analysis panel's. Three numbers per position, a gallery read side by side and a transport do not fit in 250, and all three are comparisons that stop working the moment they wrap. The stylesheet ships through the theme mixin nested under the panel's own id: it is twice the component-style budget, and half its class names are words rather than names. Co-Authored-By: Claude Opus 5 --- .../left-tabs/left-tabs.component.html | 2 +- .../left-tabs/left-tabs.component.scss | 8 +- .../synthesis-panel.component.html | 560 +++++-- .../synthesis-panel.component.scss | 1259 ++++++++++++++- .../synthesis-panel.component.ts | 1352 +++++++++-------- 5 files changed, 2387 insertions(+), 794 deletions(-) diff --git a/src/app/component/left-tabs/left-tabs.component.html b/src/app/component/left-tabs/left-tabs.component.html index 2575b6e2..3f6cf65d 100644 --- a/src/app/component/left-tabs/left-tabs.component.html +++ b/src/app/component/left-tabs/left-tabs.component.html @@ -1,6 +1,6 @@
- - - 3 Position Synthesis - - - - End-Effector Link (Coupler) - - Length - - - Fixed Reference - - - - - @for (i of [1, 2, 3]; track i) { -
- -
- -

Pose {{ i }}

-
+
+ + @if (isChooser) { +
+
+
Synthesis
+
+ Build a linkage from the motion you want, then insert it into the drawing. +
+
+
+
What are you synthesising?
+ + + +
+ show_chart + + + Path — points on a curve + Coming soon + + + Sketch a curve and fit a linkage whose coupler point follows it. Not available yet. + + +
+
+
+ } @else { +
+
+ +
+
Motion synthesis
+
{{ headerNote() }}
+
+
+ +
+ +
+
Coupler
+
+ Length + + help_outline + + +
- - @if (synthesisBuilder.isPoseDefined(i)) { -
- + Fixed reference + + help_outline + + +
+ @for (opt of referenceOptions(); track opt.value) { + + } +
+
+
+ + +
+
+
Positions
+ + @if (showAddButton) { + + } + @if (canDuplicate) { + + } +
+ + @for (i of rows; track i) { +
+ {{ i }} + X + + Y + + ⊾ + + + {{ rowStatusIcon(i) }} + + + close + +
+ } +
+ + +
+
+
Requirements
+ + help_outline + + + {{ requirementCount() }} +
+ + @for (rq of requirements(); track rq.key) { +
+ + + @if (rq.hasRegion) { +
+
+ {{ regionSummary() }} + +
+
+ X + + Y + + W + + H + +
+
+ } +
+ } + + @if (requirementsBlocking) { +
+ rule + {{ requirementsBlockingNote() }}
} - - @if (synthesisBuilder.getFirstUndefinedPose() === i) { -
- Create Pose - +
+ + + @if (showGenerate) { +
+
{{ generateNote() }}
+ + @if (solution.generating) { +
+
+ Constructing circle centres, then solving a full crank revolution for each + candidate… +
+ } +
+ } + + + @if (showResults) { +
+
+
{{ candidateHeading() }}
+ + @if (showNoCandidateReason) { +
+ error_outline + {{ noCandidateReason() }} +
+ } + + + + @if (hasMoreCandidates) { + + } +
+ + @if (hasSolution) { +
+
Solution {{ solutionName }}
+ +
+ Assembly branch + + help_outline + + +
+ @for (opt of branchOptions(); track opt.label) { + + } +
+
+ +
+ Driven from + + help_outline + + +
+ @for (opt of pinOptions(); track opt.label) { + + } +
+
+ +
+ + + help_outline + + + +
+ + @if (solution.driverRefusal) { +
{{ solution.driverRefusal }}
+ } + +
+ + @if (solution.dimensionsOpen) { +
+ @for (d of dimensionRows(); track d.label) { +
+ {{ d.label }} + {{ d.value }} +
+ } +
+ } +
+
} + } +
+ + +
+ @if (hasSolution) { +
+
+ {{ solutionName }} + {{ previewNote() }} + {{ angleLabel() }} + + +
+
+
+
+ @for (tk of poseTicks(); track $index) { +
+ } + +
+
+ } +
+ +
- } - - - - - @if (hasLinkage()) { - Drive - - Swap Drive Pin - - - {{ synthesisBuilder.driverWanted ? 'Remove Driver' : 'Add Driver (Six-Bar)' }} - - @if (synthesisBuilder.driverRefusal) { -

{{ synthesisBuilder.driverRefusal }}

+ @if (solution.inserted) { +
+ check_circle + {{ insertedNote() }} + +
} - - Evaluate Poses - - - } - - - - - Remove All Poses - - - +
+
+ }
diff --git a/src/app/component/synthesis-panel/synthesis-panel.component.scss b/src/app/component/synthesis-panel/synthesis-panel.component.scss index 3accc9b8..7ad0fe04 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.scss +++ b/src/app/component/synthesis-panel/synthesis-panel.component.scss @@ -1,105 +1,1204 @@ @use 'sass:map'; @use '@angular/material' as mat; +/* The panel is the app's indigo throughout; these are the same values + mytheme.scss resolves to, written out so a rule can be read on its own. */ +$ink: #2c2c2c; +$indigo: #3f51b5; +$indigo-mid: #5c6bc0; +$indigo-pale: #e8eaf6; +$indigo-tint: #f7f8fd; +$fill: #f0f1f5; +$rule: #eceef5; +$edge: #e6e7ee; +$quiet: rgba(0, 0, 0, 0.55); +$amber: #f5a623; +$amber-ink: #b26a00; +$green: #2e7d32; +$red: #e53935; + +/** + * Shipped through the theme mixin, like every other panel in the app. + * + * Two reasons, and they are the same reason twice. A component stylesheet is + * capped at 6 kB and this one is twice that; and half these class names -- + * `.row`, `.field`, `.card`, `.note` -- are words, not names, so emitting them + * at the top level of the global sheet would restyle the rest of the app. + * Nesting the whole thing under the panel's own id settles both at once. + */ @mixin css($theme) { - // Get the color config from the theme. - $color-config: mat.m2-get-color-config($theme); - // Get the primary color palette from the color-config. - $primary-palette: map.get($color-config, 'primary'); - $accent-palette: map.get($color-config, 'accent'); $typography-config: mat.m2-get-typography-config($theme); - $background: map.get($theme, background); - $foreground: map.get($theme, foreground); -} -#synthesisWrapper { - display: flex; - flex-direction: column; - gap: 10px; -} + #synthesisPanel { + .chooser__title { + @include mat.m2-typography-level($typography-config, 'headline-6'); + } -.poseHeader { - display: flex; - justify-content: space-between; - width: 100%; -} + /* The card, drawn here rather than by panel-section: this panel owns two + scroll regions of its own -- the design above and a transport that has to + stay put below it -- and panel-section's card scrolls as one piece. Same + surface, same shadow, same 5px indigo bar, so it is the same card. */ + & { + display: flex; + flex-direction: column; + min-height: 0; + max-height: 100%; + overflow: hidden; + pointer-events: all; + background-color: var(--card-surface); + border-top: 5px solid $indigo; + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + color: $ink; + font-family: Roboto, 'Helvetica Neue', sans-serif; -.poseEdit { - zoom: 0.7; - justify-content: flex-end; -} + mat-icon { + /* Material's icon box is 24px around an 18px glyph laid out at the top; + every icon here is inline beside text, so the box centres its own + contents rather than the row centring a box with air in it. */ + display: inline-flex; + align-items: center; + justify-content: center; + } + } -// #headline-1 { -// @include mat.typography-level($typography-config, 'headline-1'); -// } + /* --- the chooser --------------------------------------------------------- */ -// #headline-2 { -// @include mat.typography-level($typography-config, 'headline-2'); -// } + .chooser__head { + padding: 10px 15px 8px; + } -// #headline-3 { -// @include mat.typography-level($typography-config, 'headline-3'); -// } + .chooser__title { + white-space: nowrap; + } -// #headline-4 { -// @include mat.typography-level($typography-config, 'headline-4'); -// } + .chooser__sub { + font-size: 14px; + line-height: 18px; + color: rgba(0, 0, 0, 0.6); + } -// #headline-5 { -// @include mat.typography-level($typography-config, 'headline-5'); -// } + .chooser__body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 15px 14px; + border-top: 1px solid $rule; + } -// #headline-6 { -// @include mat.typography-level($typography-config, 'headline-6'); -// } + .chooser__prompt { + font-size: 14px; + font-weight: 500; + line-height: 22px; + } -// #subtitle-1 { -// @include mat.typography-level($typography-config, 'subtitle-1'); -// } + .kindCard { + display: flex; + gap: 12px; + padding: 12px; + border-radius: 6px; + text-align: left; + font: inherit; + color: inherit; -// #body-1 { -// @include mat.typography-level($typography-config, 'body-2'); -// } + &--on { + border: 1.5px solid $indigo; + background: $indigo-tint; + cursor: pointer; -// #body-2 { -// @include mat.typography-level($typography-config, 'body-2'); -// } + &:hover { + background: #eef1fc; + } + } -// #subtitle-2 { -// @include mat.typography-level($typography-config, 'subtitle-2'); -// } + /* Named rather than hidden: what this screen cannot do yet is the first + thing a reader wants to know about it. */ + &--off { + border: 1px solid $edge; + background: #fafafc; + cursor: not-allowed; -// #caption { -// @include mat.typography-level($typography-config, 'caption'); -// } + .kindCard__name, + .kindCard__detail { + color: rgba(0, 0, 0, 0.45); + } -// #overline { -// @include mat.typography-level($typography-config, 'overline'); -// } + .kindCard__icon { + background: $fill; + color: #b0b3c0; + } + } -// #button { -// @include mat.typography-level($typography-config, 'button'); -// } + &__icon { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 6px; + background: $indigo-pale; + color: $indigo; + } -//#input { -// @include mat.typography-level($typography-config, 'input'); -//} + &__text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } -.poseData { - display: flex; - flex-direction: column; - gap: 10px; -} + &__row { + display: flex; + align-items: center; + gap: 8px; + } + + &__name { + font-size: 15px; + font-weight: 500; + } + + &__detail { + font-size: 13px; + line-height: 18px; + color: rgba(0, 0, 0, 0.6); + } + } + + .chip { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 7px; + border-radius: 9px; + background: $fill; + color: #8a8d99; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + + &--on { + background: $indigo-pale; + color: $indigo; + } + } + + /* --- the working screen -------------------------------------------------- */ + + .work { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + } + + .work__head { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + padding: 8px 15px 8px 8px; + border-bottom: 1px solid $rule; + } + + .work__headText { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; + } + + .work__title { + font-size: 17px; + font-weight: 500; + line-height: 24px; + } + + .work__note { + font-size: 12px; + color: $quiet; + } + + .work__scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + } + + .sect { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 15px 10px; + border-bottom: 1px solid $rule; + + &--last { + border-bottom: 0; + } + + &__head { + display: flex; + align-items: center; + gap: 6px; + min-height: 28px; + } + + &__title { + font-size: 14px; + font-weight: 500; + line-height: 22px; + } + + &__count { + font-size: 11.5px; + color: rgba(0, 0, 0, 0.5); + } + + &__prose { + font-size: 12.5px; + line-height: 17px; + color: rgba(0, 0, 0, 0.6); + } + + &__fine { + font-size: 11.5px; + color: $quiet; + } + } + + /* One row: label, help, then the control hard against the right edge, so + every row's control shares a left edge down the panel. */ + .row { + display: flex; + align-items: center; + gap: 5px; + min-height: 30px; + + &__label { + font-size: 14px; + color: rgba(0, 0, 0, 0.72); + + &--btn { + border: 0; + background: none; + padding: 0; + font: inherit; + color: rgba(0, 0, 0, 0.72); + cursor: pointer; + } + } + + &__help { + font-size: 15px; + width: 15px; + height: 15px; + color: rgba(0, 0, 0, 0.3); + cursor: help; + } + + &__spacer { + flex: 1 1 auto; + } + } + + /* The panel's field, as input.component.scss draws it: a filled box with an + underline, and the unit living inside the value as part of the text the + reader selects and types over -- not a separate label beside it. */ + .field { + display: flex; + align-items: center; + height: 30px; + padding: 0 8px 1px; + background: $fill; + border-bottom: 1px solid rgba(0, 0, 0, 0.42); + border-radius: 4px 4px 0 0; + box-sizing: border-box; + + &:focus-within { + padding-bottom: 0; + border-bottom: 2px solid $indigo; + } + + input { + width: 100%; + min-width: 0; + margin: 0; + border: 0; + padding: 0; + background: transparent; + font-family: Roboto, sans-serif; + font-size: 14px; + line-height: 20px; + color: $ink; + + &:focus { + outline: none; + } + + &::placeholder { + color: rgba(0, 0, 0, 0.26); + } + + /* A row that has not been placed yet still shows its shape, greyed. */ + &[disabled] { + -webkit-text-fill-color: rgba(0, 0, 0, 0.3); + color: rgba(0, 0, 0, 0.3); + } + } + + &--wide { + width: 214px; + flex: 0 0 214px; + } + + &--num { + flex: 0 0 76px; + } + + &--angle { + flex: 0 0 64px; + } + + &--tiny { + flex: 1 1 0; + height: 26px; + } + } + + /* A segmented control: one box, one option lit. */ + .seg { + display: flex; + height: 30px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + overflow: hidden; + + &--wide { + width: 214px; + flex: 0 0 214px; + } + + &__opt { + flex: 1 1 0; + display: flex; + align-items: center; + justify-content: center; + border: 0; + background: #fff; + font-family: Roboto, sans-serif; + font-size: 13px; + font-weight: 500; + color: #3c4043; + cursor: pointer; + + & + & { + border-left: 1px solid rgba(0, 0, 0, 0.12); + } + + &--on { + background: $indigo-mid; + color: #fff; + } + + /* The other branch of a pair that has only one: offered, so the reader can + see the choice exists, but there is nothing behind it. */ + &:disabled { + color: #c4c7d0; + cursor: default; + } + } + } + + /* --- positions ----------------------------------------------------------- */ + + .pill { + display: flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border: 1px solid $indigo; + border-radius: 4px; + background: $indigo; + color: #fff; + font-family: Roboto, sans-serif; + font-size: 13px; + font-weight: 500; + cursor: pointer; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } + + /* Armed reads as a mode the reader is *in*, so it inverts rather than + brightening: the button is now the way out of it. */ + &--armed { + background: #eef1fc; + color: $indigo; + } + + &--square { + width: 28px; + padding: 0; + justify-content: center; + background: #fff; + color: $indigo; + } + } + + .poseRow { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 6px; + border: 1.5px solid $edge; + border-radius: 5px; + background: #fff; + cursor: pointer; + + &--sel { + border-color: #ffc107; + background: #fffdf5; + } + + &__n { + flex: 0 0 20px; + display: flex; + align-items: center; + justify-content: center; + height: 20px; + border-radius: 10px; + background: #dfe1e8; + color: rgba(0, 0, 0, 0.45); + font-size: 12px; + font-weight: 500; + + &--placed { + background: $indigo-mid; + color: #fff; + } + } + + &__axis { + flex: 0 0 10px; + font-size: 14px; + color: rgba(0, 0, 0, 0.6); + + &--angle { + flex: 0 0 14px; + font-size: 15px; + text-align: center; + } + } + + &__status { + flex: 0 0 18px; + font-size: 18px; + width: 18px; + height: 18px; + color: #b6bac6; + cursor: help; + + &--ok { + color: $green; + } + + &--defect { + color: $amber-ink; + } + + &--arm { + color: $indigo; + } + } + + &__remove { + flex: 0 0 17px; + font-size: 17px; + width: 17px; + height: 17px; + color: #9aa0ac; + cursor: pointer; + + &--off { + color: #e2e4ea; + cursor: default; + } + } + } + + /* --- requirements -------------------------------------------------------- */ + + .req { + display: flex; + flex-direction: column; + gap: 6px; + padding: 7px 9px; + border: 1px solid $rule; + border-radius: 5px; + background: #fff; + + &--on { + border-color: #d3d8f0; + background: $indigo-tint; + } + + &__line { + display: flex; + align-items: center; + gap: 8px; + border: 0; + padding: 0; + background: none; + font: inherit; + text-align: left; + cursor: pointer; + } + + &__icon { + flex: 0 0 18px; + font-size: 18px; + width: 18px; + height: 18px; + color: #c4c7d0; + } + + &__text { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + } + + &__label { + font-size: 13px; + color: rgba(0, 0, 0, 0.5); + } + + &__detail { + font-size: 11.5px; + line-height: 16px; + color: $quiet; + } + + &--on &__icon { + color: $indigo; + } + + &--on &__label { + color: rgba(0, 0, 0, 0.85); + } + } + + .switch { + position: relative; + flex: 0 0 34px; + width: 34px; + height: 14px; + margin-right: 3px; + border: 0; + padding: 0; + border-radius: 7px; + background: rgba(0, 0, 0, 0.22); + cursor: pointer; + + &__knob { + position: absolute; + left: -3px; + top: -3px; + width: 20px; + height: 20px; + border-radius: 10px; + background: #fafafa; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + transition: left 120ms ease-out; + } + + &--on { + background: rgba(63, 81, 181, 0.5); + } + + &--on .switch__knob { + left: 17px; + background: $indigo; + } + } + + @media (prefers-reduced-motion: reduce) { + .switch__knob { + transition: none; + } + } + + .region { + display: flex; + flex-direction: column; + gap: 6px; + padding-left: 26px; + + &__line { + display: flex; + align-items: center; + gap: 8px; + } + + &__summary { + flex: 1 1 auto; + font-size: 11.5px; + color: rgba(0, 0, 0, 0.6); + } + + &__draw { + border: 0; + padding: 0; + background: none; + font: inherit; + font-size: 12px; + font-weight: 500; + color: #8a6d1b; + cursor: pointer; + + &--on { + color: #c62828; + } + } + + &__fields { + display: flex; + align-items: center; + gap: 5px; + } + + &__tag { + flex: 0 0 11px; + font-size: 13px; + color: rgba(0, 0, 0, 0.6); + } + } + + /* --- notes --------------------------------------------------------------- */ + + .note { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 11px; + border-radius: 5px; + font-size: 12px; + line-height: 17px; + + mat-icon { + flex: 0 0 18px; + font-size: 18px; + width: 18px; + height: 18px; + } + + &--warn { + background: rgba(245, 166, 35, 0.09); + border: 1px solid rgba(245, 166, 35, 0.45); + color: rgba(0, 0, 0, 0.72); + + mat-icon { + color: $amber-ink; + } + } + + &--bad { + background: #fdf6f6; + color: rgba(0, 0, 0, 0.7); + + mat-icon { + color: $red; + } + } + + &--good { + align-items: center; + background: #e6f4ea; + color: #1a4a29; + + mat-icon { + color: #137333; + } + + span { + flex: 1 1 auto; + } + } + + &__undo { + border: 0; + padding: 0; + background: none; + font: inherit; + font-weight: 500; + color: #137333; + cursor: pointer; + } + } + + /* Why no driver could be fitted, under the toggle that tried. Warm rather than + red: the four-bar is still there and still good, so this is an explanation of + what did not happen, not a report of something broken. */ + .refusal { + padding: 8px 10px; + border-left: 3px solid $amber; + background: rgba(245, 166, 35, 0.08); + font-size: 12px; + line-height: 17px; + color: rgba(0, 0, 0, 0.72); + } + + /* --- generating ---------------------------------------------------------- */ + + .cta { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 38px; + border: 0; + border-radius: 4px; + background: $indigo; + color: #fff; + font-family: Roboto, sans-serif; + font-size: 14px; + font-weight: 500; + cursor: pointer; + + &:disabled { + background: #d5d7e0; + color: #8f93a1; + cursor: default; + } + + &--busy:disabled { + background: #9fa8da; + color: #fff; + } + + &--insert { + flex: 1 1 auto; + height: 36px; + } + } + + .sweep { + position: relative; + height: 4px; + border-radius: 2px; + background: #e4e6ef; + overflow: hidden; + + &__bar { + position: absolute; + top: 0; + bottom: 0; + width: 38%; + border-radius: 2px; + background: $indigo; + animation: synthesisSweep 1s linear infinite; + } + } + + @keyframes synthesisSweep { + from { + transform: translateX(-60%); + } + to { + transform: translateX(360%); + } + } + + @media (prefers-reduced-motion: reduce) { + .sweep__bar { + animation: none; + width: 100%; + } + } + + /* --- the gallery --------------------------------------------------------- */ + + .gallery { + display: flex; + gap: 8px; + + /* Three abreast is a comparison; eight abreast is a list. Opened out, they + go two to a row and the section scrolls rather than the panel. */ + &--all { + display: grid; + grid-template-columns: 1fr 1fr; + max-height: 260px; + overflow-y: auto; + } + } + + .card { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + border: 1px solid $edge; + border-radius: 5px; + background: #fff; + text-align: left; + font: inherit; + cursor: pointer; + + &--sel { + border: 1.5px solid $indigo; + background: $indigo-tint; + + .card__name { + color: $indigo; + } + + .card__thumb { + background: #fff; + } + } + + &__head { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } + + &__name { + font-size: 13px; + font-weight: 500; + } + + &__kind { + flex: 1 1 auto; + font-size: 10.5px; + color: $quiet; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__thumb { + background: #fafafc; + border-radius: 3px; + } + + &__reach { + font-size: 10.5px; + color: $amber-ink; + + &--ok { + color: $green; + } + } + + &__metric { + font-size: 10.5px; + color: $quiet; + } + } + + .linkBtn { + display: flex; + align-items: center; + gap: 4px; + border: 0; + padding: 0; + background: none; + font: inherit; + font-size: 12.5px; + color: $indigo; + cursor: pointer; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + } + } + + /* --- link lengths -------------------------------------------------------- */ + + .dims { + display: flex; + flex-direction: column; + border-radius: 5px; + background: #f4f5f9; + overflow: hidden; + + &__head { + display: flex; + align-items: center; + gap: 6px; + border: 0; + padding: 7px 10px; + background: none; + font: inherit; + cursor: pointer; + } + + &__title { + flex: 1 1 auto; + text-align: left; + font-size: 12.5px; + font-weight: 500; + color: rgba(0, 0, 0, 0.7); + } + + &__summary { + font-size: 11.5px; + color: rgba(0, 0, 0, 0.5); + } + + &__body { + display: flex; + flex-direction: column; + gap: 3px; + padding: 0 10px 8px; + } + + &__row { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 12.5px; + line-height: 19px; + color: rgba(0, 0, 0, 0.6); + + span:first-child { + flex: 1 1 auto; + } + } + + &__value { + font-weight: 500; + color: $ink; + } + } + + /* --- the foot ------------------------------------------------------------ */ + + .foot { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + border-top: 1px solid $edge; + background: #fbfbfd; + } + + .iconBtn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 0; + border-radius: 6px; + background: none; + color: #5f6368; + cursor: pointer; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &--sm { + width: 22px; + height: 22px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + } + + .transport { + display: flex; + flex-direction: column; + gap: 3px; + padding: 3px 4px; + border-radius: 6px; + + &--playing { + background: #e9ebf3; + } + + &__line { + display: flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 4px; + overflow: hidden; + } + + &__name { + flex: 0 0 auto; + font-size: 13px; + font-weight: 500; + color: $indigo; + } + + &__note { + font-size: 11px; + color: rgba(0, 0, 0, 0.5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__angle { + margin-left: auto; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + } + } + + /* The channel and its indigo progress are their own elements so the position + marks can sit BETWEEN them and the handle: the input keeps only its thumb, + on top of everything. */ + .track { + position: relative; + height: 16px; + margin: 0 4px; + + &__channel { + position: absolute; + left: 0; + right: 0; + top: 0; + height: 8px; + border-radius: 4px; + background: #d5d7e0; + } + + &__done { + position: absolute; + left: 0; + top: 0; + height: 8px; + border-radius: 4px; + background: $indigo; + } + + &__tick { + position: absolute; + top: 1px; + z-index: 1; + width: 6px; + height: 6px; + margin-left: -3px; + border-radius: 3px; + background: $amber; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85); + pointer-events: none; + + &--ok { + background: #43a047; + } + } + + &__scrub { + -webkit-appearance: none; + appearance: none; + position: absolute; + left: 0; + right: 0; + top: -6px; + z-index: 2; + width: 100%; + height: 20px; + margin: 0; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + + &::-webkit-slider-runnable-track { + height: 8px; + border: none; + background: transparent; + } + + &::-moz-range-track { + height: 8px; + border: none; + background: transparent; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + box-sizing: border-box; + width: 24px; + height: 20px; + margin-top: -6px; + border: 1px solid rgba(0, 0, 0, 0.16); + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); + cursor: inherit; + } + + &::-moz-range-thumb { + box-sizing: border-box; + width: 24px; + height: 20px; + border: 1px solid rgba(0, 0, 0, 0.16); + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); + cursor: inherit; + } + } + } + + .foot__buttons { + display: flex; + gap: 8px; + padding: 0 4px 2px; + } + + .danger { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 36px; + padding: 0 12px; + border: 1px solid $red; + border-radius: 4px; + background: none; + color: $red; + font-family: Roboto, sans-serif; + font-size: 14px; + font-weight: 500; + cursor: pointer; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } -/* Why no driver could be fitted, sitting under the button that tried. Warm - rather than red: the four-bar is still there and still good, so this is an - explanation of what did not happen, not a report of something broken. */ -.driverRefusal { - margin: 6px 2px 10px; - padding: 8px 10px; - border-left: 3px solid #f5a623; - background: rgba(245, 166, 35, 0.08); - font-size: 11.5px; - line-height: 1.4; - color: rgba(0, 0, 0, 0.7); + &:hover { + background: rgba(229, 57, 53, 0.06); + } + } + } } diff --git a/src/app/component/synthesis-panel/synthesis-panel.component.ts b/src/app/component/synthesis-panel/synthesis-panel.component.ts index 43c08516..6c90702b 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.ts +++ b/src/app/component/synthesis-panel/synthesis-panel.component.ts @@ -1,715 +1,861 @@ -import { Component, OnInit, ChangeDetectionStrategy, inject } from '@angular/core'; +import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, inject } from '@angular/core'; import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { Coord } from '../../model/coord'; -import { Joint, RevJoint } from '../../model/joint'; +import { Subscription } from 'rxjs'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; import { MechanismService } from '../../services/mechanism.service'; -import { RealLink } from '../../model/link'; import { SynthesisBuilderService } from 'src/app/services/synthesis/synthesis-builder.service'; +import { SynthesisSolutionService } from 'src/app/services/synthesis/synthesis-solution.service'; import { NumberUnitParserService } from 'src/app/services/number-unit-parser.service'; import { SettingsService } from 'src/app/services/settings.service'; -import { SynthesisStatus } from 'src/app/services/synthesis/synthesis-constants'; -import { driverDyadFor } from 'src/app/services/synthesis/driver-dyad'; +import { COR } from 'src/app/services/synthesis/synthesis-util'; +import { + FourBarCandidate, + describeCouplerPins, + solveFourBar, +} from 'src/app/services/synthesis/synthesis-candidates'; import { MODEL_SCALE } from 'src/app/model/render-scale'; import { SvgGridService } from '../../services/svg-grid.service'; -import { ColorService } from '../../services/color.service'; -import { PanelSectionComponent } from '../BLOCKS/panel-section/panel-section.component'; -import { TitleBlock } from '../BLOCKS/title/title.component'; -import { CollapsibleSubsecitonComponent } from '../BLOCKS/collapsible-subseciton/collapsible-subseciton.component'; -import { SubtitleComponent } from '../BLOCKS/subtitle/subtitle.component'; -import { InputComponent } from '../BLOCKS/input/input.component'; -import { RadioComponent } from '../BLOCKS/radio/radio.component'; -import { MatDivider } from '@angular/material/divider'; -import { DualInputComponent } from '../BLOCKS/dual-input/dual-input.component'; -import { ButtonComponent } from '../BLOCKS/button/button.component'; + +/** One requirement row: what it costs, and what switching it off buys. */ +interface Requirement { + key: string; + on: boolean; + label: string; + detail: string; + toggle: () => void; + hasRegion?: boolean; +} + +/** What one candidate looks like on its card in the gallery. */ +interface CandidateCard { + key: string; + name: string; + kind: string; + thumb: string; + thumbCoupler: string; + selected: boolean; + reachText: string; + defectFree: boolean; + metric: string; +} + +const HELP = { + length: + 'The length of the end-effector link — the part whose three positions you are designing for. ' + + "The four-bar's coupler is pinned to this link, but not necessarily at its ends.", + ref: + 'Which point on the end-effector link the coordinates describe, and the point it turns about: ' + + 'its back end, its middle, or its front end.', + duplicate: + 'Copy the last position and offset it slightly — a quick start for three similar positions.', + branch: + 'A four-bar can be closed two ways through the same pivots. Which way it is closed decides ' + + 'which of the three positions it can pass through without coming apart.', + pin: + 'Which ground pin carries the input. A four-bar that will not turn from one ground pin often ' + + 'turns freely from the other.', + driver: + 'Adds a crank and coupler sized so one full turn walks the linkage through all three ' + + 'positions, making it a six-bar a motor can run.', + requirements: + 'What a solution has to satisfy to be listed. Every one you switch on narrows the search; ' + + 'switching one off widens it.', +}; @Component({ selector: 'app-synthesis-panel', templateUrl: './synthesis-panel.component.html', styleUrls: ['./synthesis-panel.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - imports: [ - PanelSectionComponent, - TitleBlock, - CollapsibleSubsecitonComponent, - SubtitleComponent, - InputComponent, - FormsModule, - ReactiveFormsModule, - RadioComponent, - MatDivider, - DualInputComponent, - ButtonComponent, - ], + imports: [FormsModule, ReactiveFormsModule, MatIcon, MatTooltip], }) -export class SynthesisPanelComponent implements OnInit { +export class SynthesisPanelComponent implements OnInit, OnDestroy { private fb = inject(FormBuilder); mechanismSrv = inject(MechanismService); - synthesisBuilder = inject(SynthesisBuilderService); + design = inject(SynthesisBuilderService); + solution = inject(SynthesisSolutionService); private nup = inject(NumberUnitParserService); private settings = inject(SettingsService); svgGrid = inject(SvgGridService); - private colorService = inject(ColorService); - - private _alreadyHandlingPoseChange: boolean = false; - - ngOnInit() { - //Set initial values - //(The default values are based on the image Pradeep provided but they can be easily changed below) - this.synthesisForm.setValue({ - //a0x: '6', - //a0y: '0', - //b0x: '8.1213', - //b0y: '-2.1213', - //a1x: '8', - //a1y: '-4', - //b1x: '8', - //b1y: '-7', - //a2x: '1', - //a2y: '2', - //b2x: '4', - //b2y: '2', - - //a0x: '-7.96', - //a0y: '-1.34', - //b0x: '-4.42', - //b0y: '2.2', - //a1x: '-0.37', - //a1y: '4.06', - //b1x: '4.63', - //b1y: '4.06', - //a2x: '7.68', - //a2y: '2.30', - //b2x: '11.22', - //b2y: '-1.23', - - a0x: '0', - a0y: '0', - b0x: '12.5', - b0y: '0', - a1x: '20', - a1y: '10', - b1x: '28.8388', - b1y: '18.8388', - a2x: '20', - a2y: '30', - b2x: '26.25', - b2y: '40.8253', - - quality: '0.05', - - position1Match: ' ', - position2Match: ' ', - position3Match: ' ', - }); - // initialize form values from model - this.updateFormFromModel(); + readonly help = HELP; + readonly rows = [1, 2, 3]; - // when model updates, update form values as well - this.synthesisBuilder.valueChanges.subscribe((value) => { - this.updateFormFromModel(); - if (this.synthesisBuilder.isFullyDefined()) { - this.synthesisFunction(); - } - }); + private subs: Subscription[] = []; + private syncing = false; + private frame: number | undefined; - // set up subscriptions to synthesis form changes to update model - this.synthesisPoseForm.valueChanges.subscribe((value) => { - // prevent infinite loop - if (this._alreadyHandlingPoseChange) return; + poseForm = this.fb.group( + { + length: [''], + p1x: [''], + p1y: [''], + p1theta: [''], + p2x: [''], + p2y: [''], + p2theta: [''], + p3x: [''], + p3y: [''], + p3theta: [''], + }, + { updateOn: 'blur' } + ); - this._alreadyHandlingPoseChange = true; + regionForm = this.fb.group({ rx: [''], ry: [''], rw: [''], rh: [''] }, { updateOn: 'blur' }); + + ngOnInit(): void { + // Entering the tab. Whatever a previous visit left on the grid belongs to + // the drawing now, so this visit's Insert makes a new machine rather than + // offering to take back one the reader may have been editing since. + this.solution.forgetInsert(); + this.readFromModel(); + + this.subs.push( + this.design.valueChanges.subscribe((structural) => { + this.readFromModel(); + this.claimWheel(); + // A moved position, a different reference point, a new requirement: the + // candidates on screen were computed for a design that no longer + // exists. Only a re-run may put them back. + if (structural) this.solution.invalidate(); + }) + ); - this.synthesisBuilder.updatePosesFromForm(value); - this.updateFormFromModel(); + this.subs.push( + this.poseForm.valueChanges.subscribe((value) => { + if (this.syncing) return; + this.syncing = true; + this.design.updatePosesFromForm({ ...value, cor: this.corIndex() }); + this.readFromModel(); + this.syncing = false; + this.solution.invalidate(); + this.record(); + }) + ); - if (this.synthesisBuilder.isFullyDefined()) { - this.synthesisFunction(); - } + this.subs.push( + this.regionForm.valueChanges.subscribe(() => { + if (this.syncing) return; + this.readRegionFromForm(); + }) + ); - this._alreadyHandlingPoseChange = false; - }); + this.subs.push( + SettingsService._objectScale.subscribe(() => { + this.design.getAllPoses().forEach((pose) => pose.recompute()); + }) + ); + } - SettingsService._objectScale.subscribe((val) => { - this.synthesisBuilder.getAllPoses().forEach((pose) => { - pose.recompute(); - }); - }); + ngOnDestroy(): void { + this.subs.forEach((s) => s.unsubscribe()); + if (this.frame) cancelAnimationFrame(this.frame); + // Leaving the tab hands the wheel back whatever state placing was left in. + this.svgGrid.setWheelZoomEnabled(true); } - private convertL(value: number): string { - // Pose coordinates and the end-effector length live in internal model - // units; the form speaks the user's unit. - return this.nup.formatModelLength(value, this.settings.lengthUnit.getValue()); + /** + * While a position is waiting to be dropped the wheel turns it, so the canvas + * zoom has to stand down. Reconciled from one place -- every path that arms + * or disarms placing reports through `valueChanges` -- rather than paired by + * hand at each of them, because the failure of a missed pairing is a canvas + * whose wheel is dead until the tab is left. + */ + private claimWheel(): void { + this.svgGrid.setWheelZoomEnabled(!this.design.armed); } - private convertA(value: number): string { - return this.nup.formatValueAndUnit(value, this.settings.angleUnit.getValue()); + // --- units --------------------------------------------------------------- + + private lengthText(model: number): string { + return this.nup.formatModelLength(model, this.settings.lengthUnit.getValue()); } - // given synthesis model, update form values to sync with model - updateFormFromModel() { - this._alreadyHandlingPoseChange = true; + private angleText(degrees: number): string { + return this.nup.formatValueAndUnit(degrees, this.settings.angleUnit.getValue()); + } - let poses = this.synthesisBuilder.poses; - let controls = this.synthesisPoseForm.controls; + /** A model length in the reader's own unit, to two places, without a unit. */ + private plain(model: number): string { + return (model / MODEL_SCALE).toFixed(2); + } - controls.length.setValue(this.convertL(this.synthesisBuilder.length)); + get lengthUnit(): string { + return this.nup.unitLabel(this.settings.lengthUnit.getValue()); + } - if (this.synthesisBuilder.isPoseDefined(1)) { - controls.p1x.setValue(this.convertL(poses[1].position.x)); - controls.p1y.setValue(this.convertL(poses[1].position.y)); - controls.p1theta.setValue(this.convertA(poses[1].thetaDegrees)); - } - if (this.synthesisBuilder.isPoseDefined(2)) { - controls.p2x.setValue(this.convertL(poses[2].position.x)); - controls.p2y.setValue(this.convertL(poses[2].position.y)); - controls.p2theta.setValue(this.convertA(poses[2].thetaDegrees)); + private corIndex(): string { + return this.design.COR === COR.BACK ? '0' : this.design.COR === COR.CENTER ? '1' : '2'; + } + + // --- form <-> model ------------------------------------------------------ + + private readFromModel(): void { + this.syncing = true; + const controls = this.poseForm.controls as unknown as Record< + string, + { setValue(value: string, options?: { emitEvent: boolean }): void } + >; + controls['length'].setValue(this.lengthText(this.design.length), { emitEvent: false }); + for (const i of this.rows) { + const pose = this.design.isPoseDefined(i) ? this.design.getPose(i) : undefined; + controls[`p${i}x`].setValue(pose ? this.lengthText(pose.position.x) : '', { + emitEvent: false, + }); + controls[`p${i}y`].setValue(pose ? this.lengthText(pose.position.y) : '', { + emitEvent: false, + }); + controls[`p${i}theta`].setValue(pose ? this.angleText(pose.thetaDegrees) : '', { + emitEvent: false, + }); + // Through the control rather than the element: a reactive form owns its + // input's disabled state and writes it on every render, so a `disabled` + // attribute set beside `formControlName` is removed again the moment + // Angular looks at it. + [`p${i}x`, `p${i}y`, `p${i}theta`].forEach((name) => { + const control = this.poseForm.get(name)!; + if (pose && control.disabled) control.enable({ emitEvent: false }); + if (!pose && control.enabled) control.disable({ emitEvent: false }); + }); } - if (this.synthesisBuilder.isPoseDefined(3)) { - controls.p3x.setValue(this.convertL(poses[3].position.x)); - controls.p3y.setValue(this.convertL(poses[3].position.y)); - controls.p3theta.setValue(this.convertA(poses[3].thetaDegrees)); + const r = this.design.region; + this.regionForm.setValue( + { + rx: this.lengthText(r.x), + ry: this.lengthText(r.y), + rw: this.lengthText(r.w), + rh: this.lengthText(r.h), + }, + { emitEvent: false } + ); + this.syncing = false; + } + + private readRegionFromForm(): void { + const unit = this.settings.lengthUnit.getValue(); + const parsed = (['rx', 'ry', 'rw', 'rh'] as const).map((key) => + this.nup.parseModelLengthString(this.regionForm.get(key)!.value ?? '', unit) + ); + if (parsed.some(([ok]) => !ok)) { + this.readFromModel(); + return; } + this.design.region = { + x: parsed[0][1], + y: parsed[1][1], + w: Math.max(MODEL_SCALE, parsed[2][1]), + h: Math.max(MODEL_SCALE, parsed[3][1]), + }; + this.readFromModel(); + this.solution.invalidate(); + this.record(); + } - this._alreadyHandlingPoseChange = false; + /** + * One entry in the history for one change to the design. + * + * The design rides in the same URL undo and redo are made of, so a step of it + * has to be written the same way an edit to the drawing is -- once per + * completed change, never per pointer-move. Dragging a position on the grid + * records on release, for the same reason. + */ + private record(): void { + this.mechanismSrv.save(); } - synthesisPoseForm = this.fb.group( - { - cor: ['1'], - length: [''], - p1x: [''], - p1y: [''], - p1theta: [''], - p2x: [''], - p2y: [''], - p2theta: [''], - p3x: [''], - p3y: [''], - p3theta: [''], - }, - { - updateOn: 'blur', + // --- stage --------------------------------------------------------------- + + get isChooser(): boolean { + return this.design.stage === 'chooser'; + } + + startMotionSynthesis(): void { + this.design.stage = 'working'; + this.design.setArmed(false); + } + + backToChooser(): void { + this.design.stage = 'chooser'; + this.design.regionDraw = false; + this.design.setArmed(false); + this.solution.playing = false; + } + + headerNote(): string { + if (!this.design.isFullyDefined()) { + return this.design.getAllPoses().length + ' of 3 positions placed'; } - ); + if (!this.solution.generated) return '3 positions · no solutions yet'; + const count = this.solution.candidates().length; + const kind = this.solution.dyad() ? 'six-bar' : 'four-bar'; + return `${kind} · ${count} ${count === 1 ? 'candidate' : 'candidates'}`; + } + + // --- the coupler --------------------------------------------------------- + + setReference(cor: COR): void { + if (this.design.COR === cor) return; + this.design.updatePosesFromForm({ + ...this.poseForm.value, + cor: cor === COR.BACK ? '0' : cor === COR.CENTER ? '1' : '2', + }); + this.design.valueChanges.next(true); + this.record(); + } + + referenceOptions(): { label: string; value: COR; active: boolean }[] { + return [ + { label: 'Back', value: COR.BACK, active: this.design.COR === COR.BACK }, + { label: 'Center', value: COR.CENTER, active: this.design.COR === COR.CENTER }, + { label: 'Front', value: COR.FRONT, active: this.design.COR === COR.FRONT }, + ]; + } + + // --- positions ----------------------------------------------------------- + + get nextPositionNumber(): number { + return this.design.getAllPoses().length + 1; + } - //Angular form stuff with 12 numbers, a0x, a0y, b0x, b0y, a1x, a1y, b1x, b1y, a2x, a2y, b2x, b2y - synthesisForm = this.fb.group({ - a0x: [''], - a0y: [''], - b0x: [''], - b0y: [''], - a1x: [''], - a1y: [''], - b1x: [''], - b1y: [''], - a2x: [''], - a2y: [''], - b2x: [''], - b2y: [''], - quality: [''], - position1Match: [''], - position2Match: [''], - position3Match: [''], - }); + get showAddButton(): boolean { + return !this.design.isFullyDefined(); + } + + get addLabel(): string { + return this.design.armed ? 'Cancel' : 'Add position ' + this.nextPositionNumber; + } + + toggleArmed(): void { + this.design.setArmed(!this.design.armed); + } + + get canDuplicate(): boolean { + const placed = this.design.getAllPoses().length; + return placed > 0 && placed < 3; + } + + isPlaced(i: number): boolean { + return this.design.isPoseDefined(i); + } - // for html to get current pose as a number - getCurrentPose(): number { - return this.synthesisBuilder.selectedPose; + isSelectedRow(i: number): boolean { + return this.design.selectedPose === i; } - setCurrentPose(pose: number) { - this.synthesisBuilder.selectedPose = pose; + /** Whether this row is the one the pointer is currently about to fill. */ + isPreviewingRow(i: number): boolean { + return !this.isPlaced(i) && this.design.armed && this.nextPositionNumber === i; } - getFormIDPoseX(pose: number): string { - if (pose == 1) return 'p1x'; - else if (pose == 2) return 'p2x'; - else return 'p3x'; + selectRow(i: number): void { + if (this.isPlaced(i)) { + this.design.selectedPose = i; + this.design.setArmed(false); + } else { + // An empty row is the one place a reader looks to fill it in. + this.design.setArmed(true); + } } - getFormIDPoseY(pose: number): string { - if (pose == 1) return 'p1y'; - else if (pose == 2) return 'p2y'; - else return 'p3y'; + removeRow(event: Event, i: number): void { + event.stopPropagation(); + if (!this.isPlaced(i)) return; + this.design.removePose(i); + this.solution.invalidate(); + this.record(); } - getFormIDPoseTheta(pose: number): string { - if (pose == 1) return 'p1theta'; - else if (pose == 2) return 'p2theta'; - else return 'p3theta'; + duplicateLast(): void { + this.design.duplicateLastPose(); + this.record(); } /** - * Take back the linkage this visit to Synthesis last produced. - * - * By id, and only the ids this visit recorded: anything else on the grid was - * drawn by hand or left by an earlier visit and is not this one's to remove. - * Forces attached to a removed link go with it -- a force on a link that no - * longer exists belongs to no mechanism and would sit in the drawing - * unreachable. + * Whether the chosen linkage reaches this position on the assembly it is + * drawn in. Undefined when there is nothing to check it against. */ - private removePreviousSynthesis(): void { - const { joints, links } = this.synthesisBuilder.synthesisedIds; - if (joints.length === 0 && links.length === 0) return; - - const goneLinks = new Set(links); - const goneJoints = new Set(joints); - this.mechanismSrv.forces = this.mechanismSrv.forces.filter( - (force) => !goneLinks.has(force.link?.id ?? '') + reached(i: number): boolean | undefined { + const cand = this.solution.chosen(); + if (!cand || !this.isPlaced(i)) return undefined; + return cand.onBranch[i - 1]; + } + + rowStatusIcon(i: number): string { + if (!this.isPlaced(i)) { + return this.isPreviewingRow(i) ? 'ads_click' : 'radio_button_unchecked'; + } + const ok = this.reached(i); + if (ok === undefined) return 'help_outline'; + return ok ? 'check_circle' : 'link_off'; + } + + rowStatusTip(i: number): string { + if (!this.isPlaced(i)) { + return this.isPreviewingRow(i) ? 'Click the grid to drop this position' : 'Not placed yet'; + } + const ok = this.reached(i); + if (ok === undefined) { + if (!this.design.isFullyDefined()) return 'Waiting for all three positions'; + return this.solution.generated + ? 'No candidate linkage to check this position against yet' + : 'Generate solutions to check this position'; + } + return ok + ? 'The chosen linkage passes through this position on its own assembly' + : 'The chosen linkage reaches this position only on its other assembly — a branch defect'; + } + + // --- requirements -------------------------------------------------------- + + requirements(): Requirement[] { + const length = this.plain(this.design.length); + return [ + { + key: 'coupler', + on: this.design.endsOnly, + label: `Coupler is exactly ${length} ${this.lengthUnit}`, + detail: this.design.endsOnly + ? 'Both pins sit on the ends of the link' + : 'Pins may slide along the link, so the coupler can be any length', + toggle: () => this.toggleRequirement('endsOnly'), + }, + { + key: 'defect', + on: !this.design.allowDefect, + label: 'Reaches all 3 positions on one assembly', + detail: this.design.allowDefect + ? 'Linkages with a branch defect are listed too' + : 'No taking the linkage apart between positions', + toggle: () => this.toggleRequirement('allowDefect'), + }, + { + key: 'region', + on: this.design.constrain, + label: 'Ground pivots inside a region', + detail: this.design.constrain + ? 'Both pivots must land in the box on the grid' + : 'Pivots may land anywhere', + toggle: () => this.toggleRequirement('constrain'), + hasRegion: this.design.constrain, + }, + ]; + } + + private toggleRequirement(which: 'endsOnly' | 'allowDefect' | 'constrain'): void { + if (which === 'endsOnly') this.design.endsOnly = !this.design.endsOnly; + if (which === 'allowDefect') this.design.allowDefect = !this.design.allowDefect; + if (which === 'constrain') { + this.design.constrain = !this.design.constrain; + this.design.regionDraw = false; + this.design.setArmed(false); + if (this.design.constrain) this.frameRegionOnCurrentAnswer(); + } + // Only the defect filter leaves the enumeration standing: it hides members + // of a list rather than changing which list it is. + if (which === 'allowDefect') this.solution.changed.next(); + else this.solution.invalidate(); + this.record(); + } + + /** Open the region around what is already on screen, not around nothing. */ + private frameRegionOnCurrentAnswer(): void { + const cand = this.solution.chosen(); + const points = cand ? [cand.A, cand.D] : this.design.getAllPoses().map((pose) => pose.position); + if (!points.length) return; + const pad = 3 * MODEL_SCALE; + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + const x = Math.min(...xs) - pad; + const y = Math.min(...ys) - pad; + this.design.region = { + x, + y, + w: Math.max(8 * MODEL_SCALE, Math.max(...xs) - x + pad), + h: Math.max(8 * MODEL_SCALE, Math.max(...ys) - y + pad), + }; + this.readFromModel(); + } + + requirementCount(): string { + const n = + (this.design.endsOnly ? 1 : 0) + + (this.design.allowDefect ? 0 : 1) + + (this.design.constrain ? 1 : 0); + return n + ' of 3 required'; + } + + regionSummary(): string { + const r = this.design.region; + return ( + `${this.plain(r.w)} × ${this.plain(r.h)} ${this.lengthUnit} at ` + + `(${this.plain(r.x)}, ${this.plain(r.y)}) — drag the box or its corners` ); - this.mechanismSrv.links = this.mechanismSrv.links.filter((link) => !goneLinks.has(link.id)); - this.mechanismSrv.joints = this.mechanismSrv.joints.filter( - (joint) => !goneJoints.has(joint.id) + } + + toggleRegionDraw(): void { + this.design.regionDraw = !this.design.regionDraw; + this.design.setArmed(false); + } + + /** Named only when the requirements are what stands between reader and answer. */ + get requirementsBlocking(): boolean { + return this.showResults && this.solution.candidates().length === 0; + } + + requirementsBlockingNote(): string { + if (this.design.constrain) { + return ( + 'Nothing satisfies all of these. The region is usually the first to give: widen it, ' + + 'move it, or switch it off.' + ); + } + if (this.design.endsOnly && !this.design.allowDefect) { + return ( + 'Nothing satisfies both. Letting the pins slide along the link is the usual first ' + + 'relaxation — it keeps the motion and changes only where the coupler is pinned.' + ); + } + if (this.design.endsOnly) { + return ( + `No four-bar with a ${this.plain(this.design.length)} ${this.lengthUnit} coupler passes ` + + 'through these three positions. Let the pins slide, or move a position.' + ); + } + if (!this.design.allowDefect) { + return ( + 'Every construction through these three positions needs to be taken apart between them. ' + + 'Accept a branch defect to see them, or turn the middle position further.' + ); + } + return ( + 'Nothing was found even with every requirement relaxed — the three positions are too close ' + + 'to a straight line.' ); - this.synthesisBuilder.synthesisedIds = { joints: [], links: [] }; } - /** As many ids as asked for, none of which anything on the grid is using. */ - private nextLetters(count: number): string[] { - const taken: string[] = []; - for (let i = 0; i < count; i++) { - taken.push(this.mechanismSrv.determineNextLetter(taken)); + // --- generating ---------------------------------------------------------- + + get showGenerate(): boolean { + return this.design.isFullyDefined() && !this.solution.generated; + } + + generateNote(): string { + const parts: string[] = []; + if (this.design.endsOnly) { + parts.push(`a ${this.plain(this.design.length)} ${this.lengthUnit} coupler`); } - return taken; + if (!this.design.allowDefect) parts.push('all three positions on one assembly'); + if (this.design.constrain) parts.push('both ground pivots in the region'); + return parts.length + ? 'Search for four-bars with ' + parts.join(', ') + '.' + : 'Search for any four-bar through these three positions.'; } - /** Whether there is a linkage on the grid for the driver controls to act on. */ - hasLinkage(): boolean { - return this.synthesisBuilder.isFullyDefined(); + generate(): void { + this.solution.generate(); } - /** - * Add a driver to the four-bar, or take it off again. - * - * Both go through a full re-synthesis rather than editing what is on the - * grid, because the drive pin and the driver change how the four-bar itself - * is built -- which of its pins is the input -- and re-running is the only - * path that cannot leave the two disagreeing. - */ - toggleDriver(): void { - this.synthesisBuilder.driverWanted = !this.synthesisBuilder.driverWanted; - if (this.hasLinkage()) this.synthesisFunction(); + // --- results ------------------------------------------------------------- + + get showResults(): boolean { + return this.design.isFullyDefined() && this.solution.generated; } - /** Drive the linkage from its other ground pin. */ - swapDrivePin(): void { - this.synthesisBuilder.driveOnFarPin = !this.synthesisBuilder.driveOnFarPin; - if (this.hasLinkage()) this.synthesisFunction(); + candidateHeading(): string { + const list = this.solution.candidates(); + if (!list.length) return 'No linkage meets the criteria'; + const strict = this.solution.strictCount; + if (strict) { + return `${strict} ${strict === 1 ? 'linkage works' : 'linkages work'} on one assembly`; + } + return `${list.length} candidate${list.length === 1 ? '' : 's'}, all with a branch defect`; } /** - * Score the poses against the linkage as it now stands. - * - * Synthesis scores its own answer as it builds it, so this says nothing new - * about an untouched linkage -- it is for after the drawing has been edited - * by hand, when the marks on the poses are describing a linkage that no - * longer exists. + * The geometric explanation, for when no requirement is standing in the way. + * With one switched on, the Requirements note is the better answer. */ - evaluatePoses(): void { - const built = this.mechanismSrv.joints.find( - (joint) => joint.id === this.synthesisBuilder.synthesisedIds.joints[0] - ); - const solved = built ? this.mechanismSrv.mechanismContaining(built) : undefined; - const poseCoords = [1, 2, 3].flatMap((i) => [ - this.synthesisBuilder.poses[i].posBack, - this.synthesisBuilder.poses[i].posFront, - ]); - this.checkQuality( - solved - ? this.compareTheQualityofSynthesis( - solved.joints, - poseCoords, - Number(this.synthesisForm.value.quality) - ) - : [999, 999, 999, 999, 999, 999, 999, 999, 999] + get showNoCandidateReason(): boolean { + return ( + this.showResults && + this.solution.candidates().length === 0 && + !this.design.endsOnly && + this.design.allowDefect && + !this.design.constrain ); } - synthesisFunction() { - //call synthesis functions - - //populate pose information - - let pose1_coord1 = this.synthesisBuilder.poses[1].posBack; - let pose1_coord2 = this.synthesisBuilder.poses[1].posFront; - let pose2_coord1 = this.synthesisBuilder.poses[2].posBack; - let pose2_coord2 = this.synthesisBuilder.poses[2].posFront; - let pose3_coord1 = this.synthesisBuilder.poses[3].posBack; - let pose3_coord2 = this.synthesisBuilder.poses[3].posFront; - - let qualityfromUser = Number(this.synthesisForm.value.quality); - - //find first itnersection point - - let firstPoint = this.findIntersectionPoint(pose1_coord1, pose2_coord1, pose3_coord1); - let secondPoint = pose1_coord1; - let thirdPoint = pose1_coord2; - let fourthPoint = this.findIntersectionPoint2(pose1_coord2, pose2_coord2, pose3_coord2); - - // Take back what this visit put on the grid last time it ran -- it runs - // again on every change to a pose -- and leave everything else alone. It - // used to empty the whole drawing, which is the wrong answer now that a - // drawing can hold more than one machine. - this.removePreviousSynthesis(); - - //now create joints, links, etc. from the above four coordinates - - // Not A, B, C, D: those letters are taken as soon as there is anything else - // on the grid, and two joints with one id is not a mechanism, it is a bug - // waiting for the codec to find it. - const [idA, idB, idC, idD, idE, idF] = this.nextLetters(6); - - // Which pin the motor sits on is decided here rather than moved afterwards: - // with a driver on the linkage neither ground pin is the input at all, and - // without one it is whichever the drive-pin choice names. - const far = this.synthesisBuilder.driveOnFarPin; - const drivenDirectly = !this.synthesisBuilder.driverWanted; - - let joint1 = new RevJoint(idA, firstPoint.x, firstPoint.y, drivenDirectly && !far, true); - let joint2 = new RevJoint(idB, secondPoint.x, secondPoint.y, false, false); - let joint3 = new RevJoint(idC, thirdPoint.x, thirdPoint.y, false, false); - let joint4 = new RevJoint(idD, fourthPoint.x, fourthPoint.y, drivenDirectly && far, true); - - joint1.connectedJoints.push(joint2); - joint2.connectedJoints.push(joint1, joint3); - joint3.connectedJoints.push(joint2, joint4); - joint4.connectedJoints.push(joint3); - - let link1 = new RealLink(idA + idB, [joint1, joint2]); - link1.fill = this.colorService.getLinkColorFromIndex(0); - let link2 = new RealLink(idB + idC, [joint2, joint3]); - link2.fill = this.colorService.getLinkColorFromIndex(1); - let link3 = new RealLink(idC + idD, [joint3, joint4]); - link3.fill = this.colorService.getLinkColorFromIndex(0); - - joint1.links.push(link1); - joint2.links.push(link1, link2); - joint3.links.push(link2, link3); - joint4.links.push(link3); - - const madeJoints = [joint1, joint2, joint3, joint4]; - const madeLinks = [link1, link2, link3]; - - // Built into the linkage, not added to it afterwards, so that one solve - // sees the finished six-bar and the driver survives the next pose change. - this.synthesisBuilder.driverRefusal = undefined; - if (this.synthesisBuilder.driverWanted) { - const pivot = far ? fourthPoint : firstPoint; - const drivenPin = far ? joint3 : joint2; - const drivenAt = far - ? [pose1_coord2, pose2_coord2, pose3_coord2] - : [pose1_coord1, pose2_coord1, pose3_coord1]; - - const sized = driverDyadFor(pivot, drivenAt); - if ('refusal' in sized) { - // The four-bar still stands, and still passes through the poses — it - // is only the motor that could not be fitted. Left drivable by hand so - // the drawing is not made useless by the refusal. - this.synthesisBuilder.driverRefusal = sized.refusal; - (far ? joint4 : joint1).input = true; - } else { - // The two lengths the sizing solved for are the distances between these - // three points, so placing the pins is all it takes to realise them. - const { ground, elbow } = sized.dyad; - const motor = new RevJoint(idE, ground.x, ground.y, true, true); - const knee = new RevJoint(idF, elbow.x, elbow.y, false, false); - - motor.connectedJoints.push(knee); - knee.connectedJoints.push(motor, drivenPin); - drivenPin.connectedJoints.push(knee); - - const driverCrank = new RealLink(idE + idF, [motor, knee]); - driverCrank.fill = this.colorService.getLinkColorFromIndex(2); - const driverCoupler = new RealLink(idF + drivenPin.id, [knee, drivenPin]); - driverCoupler.fill = this.colorService.getLinkColorFromIndex(3); - - motor.links.push(driverCrank); - knee.links.push(driverCrank, driverCoupler); - drivenPin.links.push(driverCoupler); - - madeJoints.push(motor, knee); - madeLinks.push(driverCrank, driverCoupler); - } + noCandidateReason(): string { + const why = this.solution.rejections(); + if (why.degenerate && !why.tooBig) { + return ( + 'The three positions lie on one line, so no circle passes through the three positions of ' + + 'a coupler point. Turn the middle position, or move it off the line between the other two.' + ); } + if (why.tooBig) { + return ( + `${why.tooBig} of ${why.tried} constructions put a ground pivot further from the ` + + 'positions than the machine could sensibly reach — the three positions are close to a ' + + 'straight line. Turn the middle position further, or move it off the line between the ' + + 'other two.' + ); + } + return 'No four-bar of a buildable size passes through these three positions.'; + } - this.mechanismSrv.mergeToJoints(madeJoints); - this.mechanismSrv.mergeToLinks(madeLinks); - this.synthesisBuilder.synthesisedIds = { - joints: madeJoints.map((joint) => joint.id), - links: madeLinks.map((link) => link.id), + visibleCandidates(): CandidateCard[] { + const list = this.solution.candidates(); + const shown = this.solution.showAll ? list : list.slice(0, 3); + const picked = this.solution.chosen(); + return shown.map((c) => this.toCard(c, picked)); + } + + private toCard(c: FourBarCandidate, picked: FourBarCandidate | null): CandidateCard { + const pts = [c.A, c.B, c.C, c.D]; + const xs = pts.map((p) => p.x); + const ys = pts.map((p) => p.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const k = Math.min(102 / Math.max(1e-6, maxX - minX), 32 / Math.max(1e-6, maxY - minY)); + const tx = (p: { x: number }) => (9 + (p.x - minX) * k).toFixed(1); + const ty = (p: { y: number }) => (40 - (p.y - minY) * k).toFixed(1); + return { + key: c.key, + name: c.name, + kind: c.kind + ' · ' + c.branch.toLowerCase(), + thumb: + `M ${tx(c.A)} ${ty(c.A)} L ${tx(c.B)} ${ty(c.B)} ` + + `M ${tx(c.C)} ${ty(c.C)} L ${tx(c.D)} ${ty(c.D)}`, + thumbCoupler: `M ${tx(c.B)} ${ty(c.B)} L ${tx(c.C)} ${ty(c.C)}`, + selected: !!picked && picked.key === c.key, + defectFree: c.defectFree, + reachText: c.defectFree ? 'all 3, one assembly' : `branch defect · ${c.onBranchCount} of 3`, + metric: + `min angle ${c.minTransmission}° · ` + + (c.range.full ? 'full turn' : `${Math.round(c.range.to - c.range.from)}° swing`), }; + } - this.mechanismSrv.mechanismTimeStep = 0; - this.mechanismSrv.updateMechanism(); + get hasMoreCandidates(): boolean { + return this.solution.candidates().length > 3; + } - // update flag to indicate that mechanism has been modified since last synthesis - this.synthesisBuilder.modifiedMechanism = true; + moreLabel(): string { + return this.solution.showAll ? 'Show fewer' : 'Show all ' + this.solution.candidates().length; + } - let posCoords = [ - pose1_coord1, - pose1_coord2, - pose2_coord1, - pose2_coord2, - pose3_coord1, - pose3_coord2, - ]; + toggleAllCandidates(): void { + this.solution.showAll = !this.solution.showAll; + } - // The machine this synthesis just made, not whichever one sorts first: a - // drawing can hold several now, and the quality being reported is this - // one's. - const solved = this.mechanismSrv.mechanismContaining(joint1); - // Nothing to score if it did not solve. 999 is what the scorer itself uses - // for a pose it could not reach, and every reader here compares against a - // threshold, so this reads as three misses -- which is what happened. - let quality = solved - ? this.compareTheQualityofSynthesis(solved.joints, posCoords, qualityfromUser) - : [999, 999, 999, 999, 999, 999, 999, 999, 999]; - - // let trialCoord = new Coord(this.mechanismSrv.mechanisms[0].joints[0][0].x, this.mechanismSrv.mechanisms[0].joints[0][0].y); - - //now check if there is 999 in the quality. Count 999 and say which position matches - - this.checkQuality(quality); - - // 'Position Matches:' + - // whichPositionMatches[0] + - // ',' + - // whichPositionMatches[1] + - // ',' + - // whichPositionMatches[2] - // ); - } - - checkQuality(quality: number[]) { - // In model units, like the distances it is comparing against. - const POSE_REACHED = 0.09 * MODEL_SCALE; - let positionMatches: string[] = ['Position 1', 'Position 2', 'Position 3']; - if (quality[0] >= POSE_REACHED || quality[1] >= POSE_REACHED) { - positionMatches[0] = 'No Match'; - this.synthesisBuilder.poses[1].status = SynthesisStatus.INVALID; - } else { - this.synthesisBuilder.poses[1].status = SynthesisStatus.VALID; - } - if (quality[3] >= POSE_REACHED || quality[4] >= POSE_REACHED) { - positionMatches[1] = 'No Match'; - this.synthesisBuilder.poses[2].status = SynthesisStatus.INVALID; - } else { - this.synthesisBuilder.poses[2].status = SynthesisStatus.VALID; - } - if (quality[6] >= POSE_REACHED || quality[7] >= POSE_REACHED) { - positionMatches[2] = 'No Match'; - this.synthesisBuilder.poses[3].status = SynthesisStatus.INVALID; - } else { - this.synthesisBuilder.poses[3].status = SynthesisStatus.VALID; - } + pickCandidate(key: string): void { + this.solution.pick(key); + } - return positionMatches; + hoverCandidate(key: string | null): void { + this.solution.setHover(key); } - compareTheQualityofSynthesis(jointValues: Joint[][], posCoords: Coord[], qualityOfSyn: number) { - //get position analysis data - //joint B, Joint C, - //compare that with poses + // --- the chosen solution ------------------------------------------------- - // Both tolerances a person deals with -- the one typed into the panel and - // the 0.09 below -- are lengths in the units the grid is labelled in. Every - // distance measured here is between model coordinates, which are those - // units times MODEL_SCALE. Comparing the two directly meant a pose counted - // as reached only when it was hit to the last decimal place, so all three - // marks read "no match" on linkages that pass straight through the poses. - const tolerance = qualityOfSyn * MODEL_SCALE; + get hasSolution(): boolean { + return this.showResults && this.solution.chosen() !== null; + } - let quality1_b: number = 999; - let quality2_b: number = 999; - let quality3_b: number = 999; + get solutionName(): string { + return this.solution.chosen()?.name ?? '—'; + } - let quality1_c: number = 999; - let quality2_c: number = 999; - let quality3_c: number = 999; + branchOptions(): { label: string; active: boolean; available: boolean; key: string }[] { + const cand = this.solution.chosen(); + const list = this.solution.candidates(); + return (['Open', 'Crossed'] as const).map((label) => { + const sibling = cand + ? list.find((c) => c.pair === cand.pair && c.branch === label) + : undefined; + return { + label, + active: !!cand && cand.branch === label, + available: !!sibling, + key: sibling?.key ?? '', + }; + }); + } - let pos1TimeStep: number = 999; - let pos2TimeStep: number = 999; - let pos3TimeStep: number = 999; + pickBranch(key: string): void { + if (key) this.solution.pick(key); + } - //compare Joint B with pose 1, pose2, and pose3; + pinOptions(): { label: string; far: boolean; active: boolean }[] { + return [ + { label: 'Pin A', far: false, active: !this.solution.driveOnFarPin }, + { label: 'Pin D', far: true, active: this.solution.driveOnFarPin }, + ]; + } - let index: number = 1; + setPin(far: boolean): void { + this.solution.setDriveOnFarPin(far); + } - for (let val in jointValues) { - let pos1Value_b = Math.sqrt( - Math.pow(jointValues[val][1].x - posCoords[0].x, 2) + - Math.pow(jointValues[val][1].y - posCoords[0].y, 2) - ); - let pos2Value_b = Math.sqrt( - Math.pow(jointValues[val][1].x - posCoords[2].x, 2) + - Math.pow(jointValues[val][1].y - posCoords[2].y, 2) - ); - let pos3Value_b = Math.sqrt( - Math.pow(jointValues[val][1].x - posCoords[4].x, 2) + - Math.pow(jointValues[val][1].y - posCoords[4].y, 2) - ); + toggleDriver(): void { + this.solution.toggleDriver(); + } - let pos1Value_c = Math.sqrt( - Math.pow(jointValues[val][2].x - posCoords[1].x, 2) + - Math.pow(jointValues[val][2].y - posCoords[1].y, 2) - ); - let pos2Value_c = Math.sqrt( - Math.pow(jointValues[val][2].x - posCoords[3].x, 2) + - Math.pow(jointValues[val][2].y - posCoords[3].y, 2) - ); - let pos3Value_c = Math.sqrt( - Math.pow(jointValues[val][2].x - posCoords[5].x, 2) + - Math.pow(jointValues[val][2].y - posCoords[5].y, 2) - ); + toggleDimensions(): void { + this.solution.dimensionsOpen = !this.solution.dimensionsOpen; + } + + dimensionsSummary(): string { + const c = this.solution.driven(); + if (!c) return ''; + return [c.r1, c.d, c.r2, c.g].map((v) => this.plain(v)).join(' · ') + ' ' + this.lengthUnit; + } - //need to compare if less than 0.09 - //need to store in quality - //need to check if exact match - //need to extract time step. - - if (pos1Value_b < tolerance && pos1Value_c < tolerance && index == 1) { - quality1_b = pos1Value_b; - quality1_c = pos1Value_c; - pos1TimeStep = index; - } else if (pos1Value_b < tolerance && pos1Value_c < tolerance && index > 1) { - quality1_b = pos1Value_b; - quality1_c = pos1Value_c; - pos1TimeStep = index; - } else if (pos2Value_b < tolerance && pos2Value_c < tolerance && index == 1) { - quality2_b = pos2Value_b; - quality2_c = pos2Value_c; - pos2TimeStep = index; - } else if (pos2Value_b < tolerance && pos2Value_c < tolerance && index > 1) { - quality2_b = pos2Value_b; - quality2_c = pos2Value_c; - pos2TimeStep = index; - } else if (pos3Value_b < tolerance && pos3Value_c < tolerance && index == 1) { - quality3_b = pos3Value_b; - quality3_c = pos3Value_c; - pos3TimeStep = index; - } else if (pos3Value_b < tolerance && pos3Value_c < tolerance && index > 1) { - quality3_b = pos3Value_b; - quality3_c = pos3Value_c; - pos3TimeStep = index; - } else { - //if there is no match, then use the prev index and then with the current and prev, find the midpoint and then evaluate the same - - if (index > 1) { - let jointB_x = (jointValues[val][1].x + jointValues[index - 2][1].x) / 2; - let jointB_y = (jointValues[val][1].y + jointValues[index - 2][1].y) / 2; - let jointC_x = (jointValues[val][2].x + jointValues[index - 2][2].x) / 2; - let jointC_y = (jointValues[val][2].y + jointValues[index - 2][2].y) / 2; - - let pos1Value_b = Math.sqrt( - Math.pow(jointB_x - posCoords[0].x, 2) + Math.pow(jointB_y - posCoords[0].y, 2) - ); - let pos2Value_b = Math.sqrt( - Math.pow(jointB_x - posCoords[2].x, 2) + Math.pow(jointB_y - posCoords[2].y, 2) - ); - let pos3Value_b = Math.sqrt( - Math.pow(jointB_x - posCoords[4].x, 2) + Math.pow(jointB_y - posCoords[4].y, 2) - ); - - let pos1Value_c = Math.sqrt( - Math.pow(jointC_x - posCoords[1].x, 2) + Math.pow(jointC_y - posCoords[1].y, 2) - ); - let pos2Value_c = Math.sqrt( - Math.pow(jointC_x - posCoords[3].x, 2) + Math.pow(jointC_y - posCoords[3].y, 2) - ); - let pos3Value_c = Math.sqrt( - Math.pow(jointC_x - posCoords[5].x, 2) + Math.pow(jointC_y - posCoords[5].y, 2) - ); - - if (pos1Value_b < tolerance && pos1Value_c < tolerance) { - quality1_b = pos1Value_b; - quality1_c = pos1Value_c; - pos1TimeStep = index - 0.5; - } else if (pos2Value_b < tolerance && pos2Value_c < tolerance) { - quality2_b = pos2Value_b; - quality2_c = pos2Value_c; - pos2TimeStep = index - 0.5; - } else if (pos3Value_b < tolerance && pos3Value_c < tolerance) { - quality3_b = pos3Value_b; - quality3_c = pos3Value_c; - pos3TimeStep = index - 0.5; - } - } - } - - index = index + 1; + dimensionRows(): { label: string; value: string }[] { + const c = this.solution.driven(); + if (!c) return []; + const rows = [ + { label: 'Ground link A–D', value: this.lengthText(c.g) }, + { label: 'Input crank', value: this.lengthText(c.r1) }, + { label: 'Coupler B–C', value: this.lengthText(c.d) }, + { label: 'Output rocker', value: this.lengthText(c.r2) }, + { + label: 'Coupler pins', + value: describeCouplerPins(c, this.design.length) + ' ' + this.lengthUnit, + }, + ]; + const dyad = this.solution.dyad(); + if (dyad) { + rows.push({ label: 'Driver crank', value: this.lengthText(dyad.crankLength) }); + rows.push({ label: 'Driver coupler', value: this.lengthText(dyad.couplerLength) }); } + return rows; + } - //now compile quality array and then pass it back + // --- previewing the motion ----------------------------------------------- - let qualityCompilation: number[]; + private direction = 1; - qualityCompilation = [ - quality1_b, - quality1_c, - pos1TimeStep, - quality2_b, - quality2_c, - pos2TimeStep, - quality3_b, - quality3_c, - pos3TimeStep, - ]; + togglePlay(): void { + this.solution.playing = !this.solution.playing; + if (this.solution.playing) this.step(); + } - return qualityCompilation; + flipDirection(): void { + this.solution.clockwise = !this.solution.clockwise; } - findIntersectionPoint(pose1_coord1: Coord, pose2_coord1: Coord, pose3_coord1: Coord) { - //slope of Line 1 - let slope1 = 1 / ((pose2_coord1.y - pose1_coord1.y) / (pose2_coord1.x - pose1_coord1.x)); - //slope of line 2 - let slope2 = 1 / ((pose3_coord1.y - pose2_coord1.y) / (pose3_coord1.x - pose2_coord1.x)); + /** + * Walk the preview forward one frame. + * + * A linkage that turns fully wraps around; one that rocks reverses at the + * ends of its travel, which is what the machine itself would do. + */ + private step = (): void => { + this.frame = undefined; + if (!this.solution.playing) return; + const cand = this.solution.driven(); + if (!cand) return; + const range = cand.range; + const stride = 1.4 * (this.solution.clockwise ? 1 : -1); + let phase = this.solution.currentPhase() + this.direction * stride; + if (range.full) { + if (phase > range.to) phase -= 360; + if (phase < range.from) phase += 360; + } else if (phase > range.to || phase < range.from) { + this.direction = -this.direction; + phase = Math.max(range.from, Math.min(range.to, phase)); + } + this.solution.phase = phase; + this.solution.changed.next(); + this.frame = requestAnimationFrame(this.step); + }; - //midpoints of the above two lines - let midpoint_line1 = new Coord( - (pose1_coord1.x + pose2_coord1.x) / 2, - (pose1_coord1.y + pose2_coord1.y) / 2 - ); - let midpoint_line2 = new Coord( - (pose3_coord1.x + pose2_coord1.x) / 2, - (pose3_coord1.y + pose2_coord1.y) / 2 - ); + scrubMin(): number { + return Math.round(this.solution.driven()?.range.from ?? 0); + } - //intercept - let c1 = midpoint_line1.y + slope1 * midpoint_line1.x; - let c2 = midpoint_line2.y + slope2 * midpoint_line2.x; + scrubMax(): number { + return Math.round(this.solution.driven()?.range.to ?? 360); + } - //intersection point - let x1 = (c1 - c2) / (-slope2 + slope1); - let y1 = -slope1 * x1 + c1; + scrubValue(): number { + return Math.round(this.solution.currentPhase()); + } - return new Coord(x1, y1); + setScrub(event: Event): void { + this.solution.setPhase(Number((event.target as HTMLInputElement).value)); } - findIntersectionPoint2(pose1_coord2: Coord, pose2_coord2: Coord, pose3_coord2: Coord) { - let slope1 = 1 / ((pose2_coord2.y - pose1_coord2.y) / (pose2_coord2.x - pose1_coord2.x)); - //slope of line 2 - let slope2 = 1 / ((pose3_coord2.y - pose2_coord2.y) / (pose3_coord2.x - pose2_coord2.x)); + alongPercent(): string { + const cand = this.solution.driven(); + if (!cand) return '0%'; + const span = Math.max(1e-6, cand.range.to - cand.range.from); + return (((this.solution.currentPhase() - cand.range.from) / span) * 100).toFixed(1) + '%'; + } - //midpoints of the above two lines - let midpoint_line1 = new Coord( - (pose1_coord2.x + pose2_coord2.x) / 2, - (pose1_coord2.y + pose2_coord2.y) / 2 - ); - let midpoint_line2 = new Coord( - (pose3_coord2.x + pose2_coord2.x) / 2, - (pose3_coord2.y + pose2_coord2.y) / 2 - ); + /** Where each position falls along the crank's travel, for the track marks. */ + poseTicks(): { percent: string; reached: boolean }[] { + const cand = this.solution.driven(); + if (!cand) return []; + const range = cand.range; + const span = Math.max(1e-6, range.to - range.from); + return cand.thetas.map((theta, i) => { + let a = theta; + while (a < range.from) a += 360; + while (a > range.to) a -= 360; + const percent = Math.max(0, Math.min(100, ((a - range.from) / span) * 100)); + return { percent: percent.toFixed(1), reached: cand.onBranch[i] }; + }); + } - //intercept - let c1 = midpoint_line1.y + slope1 * midpoint_line1.x; - let c2 = midpoint_line2.y + slope2 * midpoint_line2.x; + angleLabel(): string { + const phase = this.solution.currentPhase(); + return Math.round(((phase % 360) + 360) % 360) + '°'; + } - //intersection point - let x1 = (c1 - c2) / (-slope2 + slope1); - let y1 = -slope1 * x1 + c1; + previewNote(): string { + const cand = this.solution.driven(); + if (!cand) return ''; + return cand.range.full + ? 'full crank rotation' + : `rocks through ${Math.round(cand.range.to - cand.range.from)}°`; + } + + // --- committing ---------------------------------------------------------- + + get canInsert(): boolean { + return this.hasSolution && !this.solution.inserted; + } + + insert(): void { + this.solution.insert(); + } + + undoInsert(): void { + this.solution.undoInsert(); + } + + insertedNote(): string { + const kind = this.solution.dyad() ? 'six-bar' : 'four-bar'; + return `Left on the grid as a ${kind}. Kinematic Analysis can run it now.`; + } + + deleteAll(): void { + this.design.deleteAllPoses(); + this.design.regionDraw = false; + this.design.setArmed(false); + this.solution.reset(); + this.record(); + } - return new Coord(x1, y1); + /** Whether the preview would show anything, for the grid to ask as well. */ + hasPreview(): boolean { + const cand = this.solution.driven(); + return !!cand && solveFourBar(cand, this.solution.currentPhase(), cand.sign) !== null; } } From 010209b1e5d4d7bc9dfd9b1e2ec0ec79e0a24a21 Mon Sep 17 00:00:00 2001 From: Kohmei Kadoya Date: Thu, 20 Aug 2026 17:27:06 -0700 Subject: [PATCH 04/29] Put the design on the grid, and let the pointer take hold of it Placing is opt-in: Add position arms the canvas, the ghost follows the pointer, the wheel turns it, and a click drops it. Synthesis shares the canvas with the drawing, and a mode where every click makes something is a mode where every click meant as "look at this" makes something instead. A placed position wears the dashed box, corner grips and turn knob the tracing underlay wears, because it is the same kind of gesture -- something being placed on the grid rather than built. Its corners pull the one dimension a position has: how long the end-effector is. The chosen candidate is previewed live, tinting each position by what it does with it, and hovering another keeps the picked one on screen faded behind it -- without that, moving along the gallery replaces the linkage with no way to see what it replaced, which is the one comparison the gallery exists to make. Three things had to give way for the gestures to work. The pan guard used to recognise synthesis by what was last clicked, which never stopped being a pose, so the canvas could not be panned again until something else was selected; it now asks whether a gesture is actually in flight. The wheel is handed over through svg-pan-zoom's own API while a position is waiting to be dropped, because swallowing the event depends on which listener was registered first. And it is handed back from one place, since a missed pairing leaves the canvas with a dead wheel until the tab is left. Co-Authored-By: Claude Opus 5 --- .../new-grid/new-grid.component.html | 414 +++++++++----- .../new-grid/new-grid.component.scss | 119 ++++ .../component/new-grid/new-grid.component.ts | 157 ++++-- src/app/services/svg-grid.service.ts | 23 +- .../synthesis/synthesis-canvas.service.ts | 531 ++++++++++++++++++ 5 files changed, 1063 insertions(+), 181 deletions(-) create mode 100644 src/app/services/synthesis/synthesis-canvas.service.ts diff --git a/src/app/component/new-grid/new-grid.component.html b/src/app/component/new-grid/new-grid.component.html index 092cf468..e34802c3 100644 --- a/src/app/component/new-grid/new-grid.component.html +++ b/src/app/component/new-grid/new-grid.component.html @@ -9,6 +9,7 @@ (pointermove)="mouseMove($event)" (pointerup)="mouseUp($event)" (pointerdown)="mouseDown($event)" + (wheel)="onCanvasWheel($event)" > @@ -925,145 +926,286 @@ } - + @if (showSynthesis()) { - - @for (pose of synthesisBuilder.getAllPoses(); track pose) { - - - - - - - @for (nodePos of [pose.posBack, pose.posFront]; track nodePos) { - - } - - + + @if (synthCanvas.regionBox(); as region) { + + + @for (corner of region.corners; track corner.id) { + + } + + } + + + @if (synthesisBuilder.regionDraw) { + + } + + + @if (synthCanvas.hoverGhostLinks(); as ghosts) { + + @for (lk of ghosts; track $index) { + + } + + } + + + @if (mechanismSrv.showPathHolder || synthSolution.generated) { + + } + + + + @for (lk of synthCanvas.previewLinks(); track $index) { + + } + + @for (gd of synthCanvas.previewGrounds(); track gd.id) { + + + + } + @for (jt of synthCanvas.previewJoints(); track jt.id) { + + } + + + + @for (bar of synthCanvas.poseBars(); track bar.id) { + + + + + + + } + + + @if (synthCanvas.ghostBar(); as ghost) { + + + + + } + + + @if (synthCanvas.selectionBox(); as box) { + + + + + @for (corner of box.corners; track corner.id) { + + } + + } + + + + @for (chip of synthCanvas.poseChips(); track chip.id) { + + + + - - - - - - - - - - Pose {{ pose.id }} - - + {{ chip.id }} · {{ chip.text }} + - + } + + + + @if (synthesisHint; as hint) { + + + {{ hint.text }} + + @if (hint.sub) { + + {{ hint.sub }} + + } + } } diff --git a/src/app/component/new-grid/new-grid.component.scss b/src/app/component/new-grid/new-grid.component.scss index eb75dc11..2539d30d 100644 --- a/src/app/component/new-grid/new-grid.component.scss +++ b/src/app/component/new-grid/new-grid.component.scss @@ -187,6 +187,125 @@ pointer-events: none; } + // --- Synthesis on the canvas ------------------------------------------- + // + // The positions being designed for wear the same accent the tracing underlay + // wears, for the same reason: both are things being placed on the grid rather + // than parts of the drawing, and both are edited by the same box-and-grips + // gesture. The candidate linkage under them is drawn in the link palette but + // half-lit, because it is a proposal -- it is not in the drawing until the + // panel's Insert says so. + + .synthPose { + cursor: move; + } + + .synthPosePin { + fill: #ffecb2; + stroke: rgba(0, 0, 0, 0.4); + pointer-events: none; + } + + .synthPreviewJoint { + fill: #ffecb2; + stroke: rgba(0, 0, 0, 0.4); + } + + #synthesisPreview { + opacity: 0.85; + } + + // The candidate that is picked, while a different one is being hovered. + .synthGhostLinkage { + opacity: 0.3; + } + + // Where the middle of the coupler goes over the whole of the travel. + .synthTrace { + fill: none; + stroke: mat.m2-get-color-from-palette($primary-palette, 500); + opacity: 0.35; + pointer-events: none; + } + + // The bar about to be dropped. It pulses so it reads as a promise about the + // next click rather than as a fourth position already placed. + .synthGhost { + animation: synthGhostPulse 1.8s ease-in-out infinite; + } + + @keyframes synthGhostPulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 0.62; + } + } + + @media (prefers-reduced-motion: reduce) { + .synthGhost { + animation: none; + opacity: 0.5; + } + } + + .synthOutline, + .synthRegion { + fill: none; + stroke: mat.m2-get-color-from-palette($accent-palette, 500); + } + + // The region has a body to grab, so unlike the selection outline it is filled + // -- faintly, because what it marks is where pivots may land, not a part. + .synthRegion { + fill: rgba(255, 193, 7, 0.08); + cursor: move; + } + + .synthDrawSurface { + fill: transparent; + cursor: crosshair; + } + + .synthHandle { + fill: mat.m2-get-color-from-palette($accent-palette, 500); + stroke: #fff; + } + + .synthStalk { + stroke: mat.m2-get-color-from-palette($accent-palette, 500); + } + + // What the chosen linkage does with each position, said beside it. + .synthChipDot { + stroke: #fff; + stroke-width: 1; + } + + .synthChipText { + fill: rgba(0, 0, 0, 0.65); + font-family: Roboto, sans-serif; + font-weight: 500; + + &--sel { + fill: rgba(0, 0, 0, 0.87); + } + } + + // What the next click will do, beside the pointer that will do it. + .synthHint { + fill: mat.m2-get-color-from-palette($primary-palette, 500); + font-family: Roboto, sans-serif; + font-weight: 500; + + &--sub { + fill: rgba(0, 0, 0, 0.5); + font-weight: 400; + } + } + // The file dialog's input, kept out of the layout without display:none -- // which stops some browsers opening a picker from a synthetic click. .offscreenFileInput { diff --git a/src/app/component/new-grid/new-grid.component.ts b/src/app/component/new-grid/new-grid.component.ts index a4fa170d..bcf013e8 100644 --- a/src/app/component/new-grid/new-grid.component.ts +++ b/src/app/component/new-grid/new-grid.component.ts @@ -42,10 +42,8 @@ import { SaveHistoryService } from 'src/app/services/save-history.service'; import { SynthesisBuilderService } from 'src/app/services/synthesis/synthesis-builder.service'; import { SelectedTabService, TabID } from 'src/app/selected-tab.service'; import { SynthesisPose } from 'src/app/services/synthesis/synthesis-util'; -import { - SynthesisClickMode, - SynthesisConstants, -} from 'src/app/services/synthesis/synthesis-constants'; +import { SynthesisCanvasService } from 'src/app/services/synthesis/synthesis-canvas.service'; +import { SynthesisSolutionService } from 'src/app/services/synthesis/synthesis-solution.service'; import { ColorService } from '../../services/color.service'; import { NumberUnitParserService } from '../../services/number-unit-parser.service'; import { EditPanelComponent } from '../edit-panel/edit-panel.component'; @@ -110,7 +108,6 @@ export interface SlotStackItem { plate?: WeldPlate; } import introJs from 'intro.js'; -import { SvgArrowComponent } from '../svg-arrow/svg-arrow.component'; import { KeyboardShortcutsService, ShortcutId } from '../../services/keyboard-shortcuts.service'; /** Which corner of the tracing underlay a resize gesture is holding. */ @@ -121,7 +118,7 @@ type BackgroundImageCorner = 'tl' | 'tr' | 'bl' | 'br'; templateUrl: './new-grid.component.html', styleUrls: ['./new-grid.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - imports: [CdkContextMenuTrigger, SvgArrowComponent, ContextMenuComponent], + imports: [CdkContextMenuTrigger, ContextMenuComponent], }) export class NewGridComponent implements OnDestroy { svgGrid = inject(SvgGridService); @@ -132,6 +129,8 @@ export class NewGridComponent implements OnDestroy { activeObjService = inject(ActiveObjService); private tabService = inject(SelectedTabService); synthesisBuilder = inject(SynthesisBuilderService); + synthCanvas = inject(SynthesisCanvasService); + synthSolution = inject(SynthesisSolutionService); notify = inject(NotificationService); private shortcuts = inject(KeyboardShortcutsService); dialog = inject(MatDialog); @@ -217,10 +216,7 @@ export class NewGridComponent implements OnDestroy { private startY!: number; mouseLocation: Coord = new Coord(0, 0); lastMouseLocation: Coord = new Coord(0, 0); - private synthesisClickMode: SynthesisClickMode = SynthesisClickMode.NORMAL; - private synthesisRotateStart: number = 0; - public sConstants = new SynthesisConstants(); mouseLocationRaw: Coord = new Coord(0, 0); /** For template bindings that size things in user units. */ @@ -1053,19 +1049,6 @@ export class NewGridComponent implements OnDestroy { this.updateContextMenuItems(); } - get mode(): typeof SynthesisClickMode { - return SynthesisClickMode; - } - - setSynthesisClickMode(mode: SynthesisClickMode) { - console.log('Setting synthesis click mode to ' + mode); - this.synthesisClickMode = mode; - let pose = this.lastLeftClick as SynthesisPose; - this.synthesisRotateStart = - pose.thetaRadians - - Math.atan2(this.mouseLocation.y - pose.position.y, this.mouseLocation.x - pose.position.x); - } - setLastLeftClick(clickedObj: Joint | Link | string | Force | SynthesisPose, event?: MouseEvent) { // Scenery in the analysis modes takes no clicks: every panel behind a // selection is about a machine that runs, and this geometry is not in one. @@ -1365,6 +1348,12 @@ export class NewGridComponent implements OnDestroy { // snapping, no drop candidates, no mechanism state to keep in step. if (this.dragBackgroundImage(mousePosInSvg)) return; + // Synthesis is the same kind of thing: positions and the preview are a + // question and a proposed answer, not parts of the drawing, so nothing + // here earns a rebuild or an undo entry. The cursor is recorded either + // way -- the ghost about to be dropped follows it. + if (this.showSynthesis() && this.synthCanvas.move(mousePosInSvg)) return; + let deltaMouseX = this.mouseLocation.x - this.lastMouseLocation.x; let deltaMouseY = this.mouseLocation.y - this.lastMouseLocation.y; @@ -1380,27 +1369,6 @@ export class NewGridComponent implements OnDestroy { this.heldGestureNotice = undefined; } - if (this.dragState.isPointerDown && this.lastLeftClickType === 'SynthesisPose') { - if (this.synthesisClickMode === SynthesisClickMode.ROTATE) { - let pose = this.lastLeftClick as SynthesisPose; - let rotate = - Math.atan2( - this.mouseLocation.y - pose.position.y, - this.mouseLocation.x - pose.position.x - ) + this.synthesisRotateStart; - if (!isNaN(rotate)) { - this.gridUtils.setPoseTheta(pose, rotate); - } - } else { - this.gridUtils.dragPose( - this.activeObjService.selectedPose, - deltaMouseX, - deltaMouseY, - this.synthesisClickMode - ); - } - } - switch (this.dragState.joint) { case jointStates.creating: break; @@ -2119,7 +2087,29 @@ export class NewGridComponent implements OnDestroy { mouseUp($event: MouseEvent) { //This is the mouseUp that is called no matter what is clicked on this.bgDrag = undefined; - this.synthesisClickMode = SynthesisClickMode.NORMAL; + if (this.showSynthesis()) { + const wasDragging = this.synthCanvas.release(); + // One gesture, one entry: the design is carried in the same URL the undo + // stack is made of, so a drag that moved a position has to be written -- + // once, here, rather than on every pointer-move that made it. + if (wasDragging) this.mechanismSrv.save(); + // A press that neither took hold of anything nor moved is a click, and + // while placing is armed a click on the canvas drops the next position. + // On the release rather than the press so svg-pan-zoom keeps its own + // gesture: a press it never sees is a canvas that cannot be panned. + if ( + !wasDragging && + !this.synthPressTaken && + this.synthesisBuilder.armed && + !this.pastDragThreshold($event) + ) { + const at = this.svgGrid.screenToSVGfromXY($event.clientX, $event.clientY); + this.synthesisBuilder.placePose(at); + this.dragState.release(); + this.mechanismSrv.save(); + return; + } + } // The alignment guides belong to the drag that made them. this.axisSnapGuides = []; // A press on a held object that never tried to move it is a click — a @@ -2209,9 +2199,88 @@ export class NewGridComponent implements OnDestroy { return true; } + /** + * Whether a synthesis gesture has the pointer. + * + * Read by the pan guard, which cannot inject the canvas service -- that + * service needs the grid's own zoom, so the two would depend on each other. + * The static is how everything else in this file answers that question. + */ + static isSynthesisGestureLive(): boolean { + return this.instance?.synthCanvas.dragging ?? false; + } + + // --- Synthesis on the canvas ------------------------------------------- + // + // Thin plumbing only: every handler turns a screen point into a model point + // and hands it to SynthesisCanvasService, which decides what it means. None + // of it goes through the mechanism's own drag state -- a position is a + // question about a machine, not part of one. + + /** Take hold of a position by its body, its turn knob, or a corner grip. */ + startPoseGesture(event: PointerEvent, id: number, mode: 'move' | 'rotate' | 'length'): void { + event.stopPropagation(); + this.synthPressTaken = true; + const at = this.svgGrid.screenToSVGfromXY(event.clientX, event.clientY); + this.synthCanvas.grabPose(at, id, mode); + if (this.synthesisBuilder.isPoseDefined(id)) { + const pose = this.synthesisBuilder.getPose(id); + this.setLastLeftClick(pose); + this.activeObjService.updateSelectedObj(pose); + } + } + + /** Take hold of the ground-pivot region, or draw a new one. */ + startRegionGesture(event: PointerEvent, mode: 'move' | 'corner' | 'draw', corner?: string): void { + event.stopPropagation(); + this.synthPressTaken = true; + const at = this.svgGrid.screenToSVGfromXY(event.clientX, event.clientY); + this.synthCanvas.grabRegion(at, mode, corner); + } + + /** + * The wheel turns the position about to be dropped. + * + * Only while placing is armed, and only then: the wheel is the canvas zoom + * the rest of the time, and svg-pan-zoom has been asked to stand down for + * exactly as long as this gesture is running. + */ + onCanvasWheel(event: WheelEvent): void { + if (!this.showSynthesis() || !this.synthesisBuilder.armed) return; + if (this.synthesisBuilder.getFirstUndefinedPose() === undefined) return; + event.preventDefault(); + this.synthCanvas.turnGhost(event.deltaY); + } + + /** Where the hint beside the pointer sits while a position is being placed. */ + get synthesisHint(): { x: number; y: number; text: string; sub: string } | undefined { + const cursor = this.synthCanvas.cursor; + if (!this.showSynthesis() || !cursor) return undefined; + if (this.synthesisBuilder.regionDraw) { + return { + x: cursor.x, + y: cursor.y, + text: 'Drag to draw the region', + sub: '', + }; + } + const next = this.synthesisBuilder.getFirstUndefinedPose(); + if (!this.synthesisBuilder.armed || next === undefined) return undefined; + return { + x: cursor.x, + y: cursor.y, + text: 'Click to drop position ' + next, + sub: 'Scroll to turn · ' + this.synthCanvas.ghostAngleLabel(), + }; + } + + /** Whether a synthesis handle claimed the press that is in flight. */ + private synthPressTaken = false; + mouseDown($event: MouseEvent) { // Log the time that the mouse was clicked this.timeMouseDown = new Date().getTime(); + this.synthPressTaken = false; this.dragState.press(); this.startX = $event.pageX; this.startY = $event.pageY; diff --git a/src/app/services/svg-grid.service.ts b/src/app/services/svg-grid.service.ts index 38de4b58..0cec8905 100644 --- a/src/app/services/svg-grid.service.ts +++ b/src/app/services/svg-grid.service.ts @@ -170,6 +170,22 @@ export class SvgGridService { this.scaleToFitLinkage(false); } + /** + * Hand the wheel to whatever gesture wants it, or give it back to the zoom. + * + * Synthesis turns the position it is about to drop with the wheel, and the + * library binds its own wheel listener to the same element -- so asking it to + * stand down is the only way to stop the canvas zooming under the gesture. + * Through the library's own API rather than by swallowing the event, because + * a swallowed event depends on which listener was registered first. + */ + setWheelZoomEnabled(enabled: boolean): void { + if (!this.panZoomObject) return; + if (enabled === this.panZoomObject.isMouseWheelZoomEnabled()) return; + if (enabled) this.panZoomObject.enableMouseWheelZoom(); + else this.panZoomObject.disableMouseWheelZoom(); + } + screenToSVG(screenPos: Coord): Coord { const CTM: SVGMatrix = this.CTM; //Temporary solution. Maybe okay to have... @@ -350,7 +366,12 @@ export class SvgGridService { // added: link dragging panned the canvas underneath itself for exactly as // long as this list did not mention it, which made the drag look inert // because the content moved with the cursor. - if (this.dragState.isDragging || NewGridComponent.getLastLeftClickType() === 'SynthesisPose') { + // Synthesis runs its gestures outside the state machine -- a position is a + // question about a machine, not part of one -- so it is asked separately. + // It used to be recognised by what was last clicked, which never stopped + // being a pose: the canvas could not be panned again until something else + // was selected. + if (this.dragState.isDragging || NewGridComponent.isSynthesisGestureLive()) { return oldPan; } return newPan; diff --git a/src/app/services/synthesis/synthesis-canvas.service.ts b/src/app/services/synthesis/synthesis-canvas.service.ts new file mode 100644 index 00000000..d9831f34 --- /dev/null +++ b/src/app/services/synthesis/synthesis-canvas.service.ts @@ -0,0 +1,531 @@ +import { Injectable, inject } from '@angular/core'; +import { Coord } from 'src/app/model/coord'; +import { SettingsService } from '../settings.service'; +import { SvgGridService } from '../svg-grid.service'; +import { SynthesisBuilderService } from './synthesis-builder.service'; +import { SynthesisSolutionService } from './synthesis-solution.service'; +import { meet, solveFourBar } from './synthesis-candidates'; +import { COR } from './synthesis-util'; + +/** A bar drawn on the grid: two pins, a fill, and what it is called. */ +export interface PoseBar { + id: number; + x1: number; + y1: number; + x2: number; + y2: number; + fill: string; + edge: string; + selected: boolean; +} + +/** A word beside a position, saying what the chosen linkage does with it. */ +export interface PoseChip { + id: number; + x: number; + y: number; + text: string; + dot: string; + selected: boolean; +} + +export interface Handle { + id: string; + x: number; + y: number; + cursor: string; +} + +export interface SelectionBox { + /** Degrees, in the flipped drawing frame the grid renders in. */ + rotate: string; + cx: number; + cy: number; + x: number; + y: number; + w: number; + h: number; + knobY: number; + corners: Handle[]; +} + +export interface PreviewLink { + x1: number; + y1: number; + x2: number; + y2: number; + color: string; + width: number; +} + +export interface PreviewJoint { + id: string; + x: number; + y: number; +} + +const LINK_PALE = '#c5cae9'; +const LINK_DEEP = '#303e9f'; +const DRIVER_CRANK = '#0d125a'; +const DRIVER_COUPLER = '#26a69a'; +const REACH_GREEN = '#bfe0c0'; +const REACH_AMBER = '#f6dcb0'; +const SELECT_AMBER = '#ffc107'; + +/** + * What Synthesis draws on the grid, and what the pointer does to it. + * + * Kept out of NewGridComponent because none of it is about the mechanism: the + * positions are a question, the preview is an answer that is not in the + * drawing yet, and neither earns an undo entry or a rebuild. The grid supplies + * model-space points and this decides what they mean. + */ +@Injectable({ providedIn: 'root' }) +export class SynthesisCanvasService { + private settings = inject(SettingsService); + private svgGrid = inject(SvgGridService); + private design = inject(SynthesisBuilderService); + private solution = inject(SynthesisSolutionService); + + /** Where the pointer last was, in model coordinates. */ + public cursor: Coord | undefined; + + private drag: + | { + kind: 'pose'; + id: number; + mode: 'move' | 'rotate' | 'length'; + dx: number; + dy: number; + grabAngleOffset: number; + } + | { + kind: 'region'; + mode: 'move' | 'corner' | 'draw'; + corner?: string; + dx: number; + dy: number; + originX: number; + originY: number; + } + | undefined; + + get dragging(): boolean { + return this.drag !== undefined; + } + + // --- what is drawn ------------------------------------------------------- + + /** Half the thickness a pose bar is drawn at, in model units. */ + private barHalfWidth(): number { + return 0.25 * this.settings.objectScale; + } + + poseBars(): PoseBar[] { + const cand = this.solution.chosen(); + return this.design.getAllPoses().map((pose) => { + const reached = cand ? cand.onBranch[pose.id - 1] : undefined; + return { + id: pose.id, + x1: pose.posBack.x, + y1: pose.posBack.y, + x2: pose.posFront.x, + y2: pose.posFront.y, + fill: reached === undefined ? LINK_PALE : reached ? REACH_GREEN : REACH_AMBER, + edge: this.design.selectedPose === pose.id ? SELECT_AMBER : 'rgba(0,0,0,0.42)', + selected: this.design.selectedPose === pose.id, + }; + }); + } + + poseChips(): PoseChip[] { + const cand = this.solution.chosen(); + return this.design.getAllPoses().map((pose) => { + const reached = cand ? cand.onBranch[pose.id - 1] : undefined; + const far = pose.posBack.x > pose.posFront.x ? pose.posBack : pose.posFront; + return { + id: pose.id, + x: far.x + 0.5 * this.settings.objectScale, + y: Math.max(pose.posBack.y, pose.posFront.y) + 0.75 * this.settings.objectScale, + text: + reached === undefined ? 'position ' + pose.id : reached ? 'reached' : 'needs reassembly', + dot: reached === undefined ? '#8a90a0' : reached ? '#43a047' : '#f5a623', + selected: this.design.selectedPose === pose.id, + }; + }); + } + + /** + * The handles on the selected position. + * + * The same shape the tracing underlay wears -- a dashed box, four corner + * grips and a knob on a stalk -- because it is the same gesture: something + * on the grid that is being placed rather than built. Its corners pull the + * end-effector length rather than a scale, since that is the one dimension + * a position has. + */ + selectionBox(): SelectionBox | undefined { + const id = this.design.selectedPose; + if (!this.design.isPoseDefined(id)) return undefined; + const pose = this.design.getPose(id); + const grip = this.svgGrid.scaleWithZoom(10); + const pad = this.svgGrid.scaleWithZoom(14); + const length = this.design.length; + const ahead = this.design.COR === COR.CENTER ? length / 2 + pad : length + pad; + const behind = this.design.COR === COR.CENTER ? length / 2 + pad : pad; + const half = this.barHalfWidth() + pad / 2; + // Model coordinates throughout, y up. The grid draws this inside its own + // y-flip, so the flip is already accounted for -- negating here as well put + // every handle on the wrong side of the axis. + const cx = pose.position.x; + const cy = pose.position.y; + const x = cx - (this.design.COR === COR.FRONT ? ahead : behind); + const w = ahead + behind; + const y = cy - half; + const h = half * 2; + // Inside the flip, +y is up, which is the sense a positive angle turns in. + const cursors: Record = { + tl: 'nwse-resize', + tr: 'nesw-resize', + bl: 'nesw-resize', + br: 'nwse-resize', + }; + return { + rotate: `rotate(${pose.thetaDegrees.toFixed(2)} ${cx.toFixed(1)} ${cy.toFixed(1)})`, + cx, + cy, + x, + y, + w, + h, + // Above the link, which in this frame is further along +y. + knobY: y + h + this.svgGrid.scaleWithZoom(34), + corners: [ + { id: 'tl', x, y: y + h }, + { id: 'tr', x: x + w, y: y + h }, + { id: 'bl', x, y }, + { id: 'br', x: x + w, y }, + ].map((c) => ({ ...c, cursor: cursors[c.id], x: c.x - grip / 2, y: c.y - grip / 2 })), + }; + } + + /** + * The bar about to be dropped: same length, same reference point, turned the + * way the wheel has turned it. A promise about what the click will make. + */ + ghostBar(): { x1: number; y1: number; x2: number; y2: number } | undefined { + if (!this.design.armed || !this.cursor || this.design.regionDraw) return undefined; + if (this.design.getFirstUndefinedPose() === undefined) return undefined; + const theta = (this.design.placeAngleDeg * Math.PI) / 180; + const length = this.design.length; + const dx = Math.cos(theta) * length; + const dy = Math.sin(theta) * length; + const anchor = + this.design.COR === COR.BACK + ? { x: this.cursor.x, y: this.cursor.y } + : this.design.COR === COR.FRONT + ? { x: this.cursor.x - dx, y: this.cursor.y - dy } + : { x: this.cursor.x - dx / 2, y: this.cursor.y - dy / 2 }; + return { x1: anchor.x, y1: anchor.y, x2: anchor.x + dx, y2: anchor.y + dy }; + } + + /** The angle the ghost is turned to, for the hint beside the pointer. */ + ghostAngleLabel(): string { + return Math.round(((this.design.placeAngleDeg % 360) + 360) % 360) + '°'; + } + + private linkWidth(): number { + return 0.5 * this.settings.objectScale; + } + + /** + * Whether the proposal is still a proposal. + * + * Once it has been inserted the drawing holds the real thing, and drawing the + * preview over it puts two linkages in the same place -- one of which cannot + * be clicked, which is a worse way to learn that than being told. + */ + private previewing(): boolean { + return !this.solution.inserted; + } + + /** The chosen candidate, drawn where the preview has been scrubbed to. */ + previewLinks(): PreviewLink[] { + const solved = this.previewing() ? this.solution.previewPose() : null; + if (!solved) return []; + const w = this.linkWidth(); + const links: PreviewLink[] = [ + { + x1: solved.A.x, + y1: solved.A.y, + x2: solved.B.x, + y2: solved.B.y, + color: LINK_PALE, + width: w, + }, + { + x1: solved.B.x, + y1: solved.B.y, + x2: solved.C.x, + y2: solved.C.y, + color: LINK_DEEP, + width: w, + }, + { + x1: solved.C.x, + y1: solved.C.y, + x2: solved.D.x, + y2: solved.D.y, + color: LINK_PALE, + width: w, + }, + ]; + const dyad = this.solution.dyad(); + if (dyad) { + const elbow = meet(dyad.ground, dyad.crankLength, solved.B, dyad.couplerLength); + if (elbow) { + links.push({ + x1: dyad.ground.x, + y1: dyad.ground.y, + x2: elbow[0].x, + y2: elbow[0].y, + color: DRIVER_CRANK, + width: w * 0.86, + }); + links.push({ + x1: elbow[0].x, + y1: elbow[0].y, + x2: solved.B.x, + y2: solved.B.y, + color: DRIVER_COUPLER, + width: w * 0.8, + }); + } + } + return links; + } + + previewJoints(): PreviewJoint[] { + const solved = this.previewing() ? this.solution.previewPose() : null; + if (!solved) return []; + const out: PreviewJoint[] = [ + { id: 'A', x: solved.A.x, y: solved.A.y }, + { id: 'B', x: solved.B.x, y: solved.B.y }, + { id: 'C', x: solved.C.x, y: solved.C.y }, + { id: 'D', x: solved.D.x, y: solved.D.y }, + ]; + const dyad = this.solution.dyad(); + if (dyad) { + const elbow = meet(dyad.ground, dyad.crankLength, solved.B, dyad.couplerLength); + if (elbow) { + out.push({ id: 'E', x: dyad.ground.x, y: dyad.ground.y }); + out.push({ id: 'F', x: elbow[0].x, y: elbow[0].y }); + } + } + return out; + } + + /** Which of the preview's pins are bolted to the frame. */ + previewGrounds(): PreviewJoint[] { + const solved = this.previewing() ? this.solution.previewPose() : null; + if (!solved) return []; + const out = [ + { id: 'A', x: solved.A.x, y: solved.A.y }, + { id: 'D', x: solved.D.x, y: solved.D.y }, + ]; + const dyad = this.solution.dyad(); + if (dyad) out.push({ id: 'E', x: dyad.ground.x, y: dyad.ground.y }); + return out; + } + + /** Where the middle of the coupler goes over the whole of the travel. */ + couplerTrace(): string { + const cand = this.previewing() ? this.solution.driven() : null; + if (!cand) return ''; + const span = cand.range.to - cand.range.from; + let d = ''; + for (let k = 0; k <= 60; k++) { + const solved = solveFourBar(cand, cand.range.from + (span * k) / 60, cand.sign); + if (!solved) continue; + const midX = (solved.B.x + solved.C.x) / 2; + const midY = (solved.B.y + solved.C.y) / 2; + d += (d ? ' L ' : 'M ') + midX.toFixed(1) + ' ' + midY.toFixed(1); + } + return d; + } + + /** + * The candidate that is picked, faded, while another is being hovered. + * + * Without it, moving along the gallery replaces the linkage on the grid with + * no way to see what it replaced -- which is the one comparison the gallery + * exists to make. + */ + hoverGhostLinks(): PreviewLink[] { + if (!this.previewing()) return []; + const hovering = this.solution.hoverKey; + const picked = this.solution.picked(); + if (!hovering || !picked || picked.key === hovering) return []; + const base = this.solution.driven(picked); + if (!base) return []; + const solved = solveFourBar(base, base.thetas[0], base.sign); + if (!solved) return []; + const w = this.linkWidth() * 0.8; + return [ + { + x1: solved.A.x, + y1: solved.A.y, + x2: solved.B.x, + y2: solved.B.y, + color: '#9aa0ac', + width: w, + }, + { + x1: solved.B.x, + y1: solved.B.y, + x2: solved.C.x, + y2: solved.C.y, + color: '#9aa0ac', + width: w, + }, + { + x1: solved.C.x, + y1: solved.C.y, + x2: solved.D.x, + y2: solved.D.y, + color: '#9aa0ac', + width: w, + }, + ]; + } + + /** The ground-pivot region, in model coordinates like everything else here. */ + regionBox(): { x: number; y: number; w: number; h: number; corners: Handle[] } | undefined { + if (!this.design.constrain) return undefined; + const r = this.design.region; + const grip = this.svgGrid.scaleWithZoom(10); + const cursors: Record = { + tl: 'nwse-resize', + tr: 'nesw-resize', + bl: 'nesw-resize', + br: 'nwse-resize', + }; + return { + x: r.x, + y: r.y, + w: r.w, + h: r.h, + corners: [ + { id: 'tl', x: r.x, y: r.y + r.h }, + { id: 'tr', x: r.x + r.w, y: r.y + r.h }, + { id: 'bl', x: r.x, y: r.y }, + { id: 'br', x: r.x + r.w, y: r.y }, + ].map((c) => ({ ...c, cursor: cursors[c.id], x: c.x - grip / 2, y: c.y - grip / 2 })), + }; + } + + // --- gestures ------------------------------------------------------------ + + /** Take hold of a position, to slide it, turn it, or stretch the link. */ + grabPose(at: Coord, id: number, mode: 'move' | 'rotate' | 'length'): void { + if (!this.design.isPoseDefined(id)) return; + const pose = this.design.getPose(id); + this.design.selectedPose = id; + this.design.setArmed(false); + this.drag = { + kind: 'pose', + id, + mode, + dx: at.x - pose.position.x, + dy: at.y - pose.position.y, + grabAngleOffset: + (Math.atan2(at.y - pose.position.y, at.x - pose.position.x) * 180) / Math.PI - + pose.thetaDegrees, + }; + } + + grabRegion(at: Coord, mode: 'move' | 'corner' | 'draw', corner?: string): void { + const r = this.design.region; + this.drag = { + kind: 'region', + mode, + corner, + dx: at.x - r.x, + dy: at.y - r.y, + originX: at.x, + originY: at.y, + }; + if (mode === 'draw') this.design.region = { x: at.x, y: at.y, w: 0, h: 0 }; + } + + /** Follow the pointer. Returns whether a gesture consumed the move. */ + move(at: Coord): boolean { + this.cursor = at; + const drag = this.drag; + if (!drag) return false; + + if (drag.kind === 'pose') { + const pose = this.design.getPose(drag.id); + if (drag.mode === 'rotate') { + const angle = + (Math.atan2(at.y - pose.position.y, at.x - pose.position.x) * 180) / Math.PI - + drag.grabAngleOffset; + pose.thetaDegrees = angle; + } else if (drag.mode === 'length') { + // A corner pulls along the link's own axis, not along the screen: the + // one dimension a position has is how long the end-effector is. + const theta = pose.thetaRadians; + const along = Math.abs( + (at.x - pose.position.x) * Math.cos(theta) + (at.y - pose.position.y) * Math.sin(theta) + ); + const factor = this.design.COR === COR.CENTER ? 2 : 1; + this.design.length = Math.max(this.settings.objectScale * 0.5, along * factor); + } else { + pose.position = new Coord(at.x - drag.dx, at.y - drag.dy); + } + this.design.valueChanges.next(true); + return true; + } + + if (drag.mode === 'move') { + this.design.region = { + ...this.design.region, + x: at.x - drag.dx, + y: at.y - drag.dy, + }; + } else if (drag.mode === 'draw') { + this.design.region = { + x: Math.min(drag.originX, at.x), + y: Math.min(drag.originY, at.y), + w: Math.abs(at.x - drag.originX), + h: Math.abs(at.y - drag.originY), + }; + } else { + const r = this.design.region; + // The corner opposite the one being pulled is what stays put. + const fixedX = drag.corner === 'tl' || drag.corner === 'bl' ? r.x + r.w : r.x; + const fixedY = drag.corner === 'bl' || drag.corner === 'br' ? r.y + r.h : r.y; + this.design.region = { + x: Math.min(fixedX, at.x), + y: Math.min(fixedY, at.y), + w: Math.max(this.settings.objectScale, Math.abs(at.x - fixedX)), + h: Math.max(this.settings.objectScale, Math.abs(at.y - fixedY)), + }; + } + this.design.valueChanges.next(true); + return true; + } + + /** Let go. Returns whether anything was in flight. */ + release(): boolean { + const had = this.drag !== undefined; + this.drag = undefined; + if (this.design.regionDraw) this.design.regionDraw = false; + return had; + } + + /** Turn the position that has not been dropped yet. */ + turnGhost(deltaY: number): void { + this.design.placeAngleDeg += deltaY > 0 ? -5 : 5; + } +} From 0aa520e8dbab204d07484e09ab8ccebde2bd8ad7 Mon Sep 17 00:00:00 2001 From: Kohmei Kadoya Date: Thu, 20 Aug 2026 17:27:18 -0700 Subject: [PATCH 05/29] Carry the design in the URL, so it can be undone and shared Undo and redo are a stack of URL strings. A design that is not written into them cannot be stepped back through -- and now that synthesis holds work of its own for as long as it takes to compare seven linkages, that work is exactly the kind a reader expects Undo to reach. A shared link mid-design opened on an empty panel for the same reason. It rides in the trailing section the lock marks opened, tagged 'S', which no lock or centre-of-mass anchor uses. Written only when there is a design to write, so a document with none encodes to the bytes it did before any of this existed -- the same bargain that section was built on, and the existing URL specs still compare the same strings. Unlike a lock or an anchor it names nothing the URL carries, so there is no reference to resolve; what it can be checked against is its own shape. It fails closed on an entry that is short a number or carries a tag nobody wrote, because a design half-read is worse than none: the panel would come up with positions that are not where the reader left them. Co-Authored-By: Claude Opus 5 --- src/app/services/synthesis/synthesis-url.ts | 132 +++++++++++++++ .../services/transcoding/string-transcoder.ts | 36 +++- .../transcoding/transcoder-interface.ts | 18 ++ .../transcoding/url-synthesis-design.spec.ts | 157 ++++++++++++++++++ src/app/services/url-generation.service.ts | 9 + src/app/services/url-processor.service.ts | 8 + src/test-utils/url-encoding.ts | 27 ++- 7 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 src/app/services/synthesis/synthesis-url.ts create mode 100644 src/app/services/transcoding/url-synthesis-design.spec.ts diff --git a/src/app/services/synthesis/synthesis-url.ts b/src/app/services/synthesis/synthesis-url.ts new file mode 100644 index 00000000..23f6f4ab --- /dev/null +++ b/src/app/services/synthesis/synthesis-url.ts @@ -0,0 +1,132 @@ +import { Coord } from 'src/app/model/coord'; +import { MODEL_SCALE } from 'src/app/model/render-scale'; +import { BaseNConverter } from '../transcoding/base64-converter'; +import { FlagPacker } from '../transcoding/flag-packer'; +import { SynthesisBuilderService } from './synthesis-builder.service'; +import { COR, SynthesisPose } from './synthesis-util'; + +/** + * The synthesis design, in and out of the URL. + * + * It rides in the trailing section the lock marks opened, under the tag 'S', + * so a document with no design in progress is byte-identical to one written + * before this existed. Entries: + * + * SD~[length]~[reference]~[flags] the design as a whole + * SP~[x]~[y]~[angle] one position, in the order they were placed + * SR~[x]~[y]~[width]~[height] the ground-pivot region, only when required + * SC~[pinA]~[pinB]~[branch] which candidate was being looked at + * + * Numbers are base-N to three decimals, like every other number in this format, + * and lengths are in the user's own units -- the internal world is MODEL_SCALE + * times those, and the codec is the boundary where that is undone. + */ + +/** The order the flags pack in. Appending is safe; reordering is not. */ +const FLAGS = 6; + +function num(value: number): string { + return BaseNConverter.toUrlSafeBaseN(Math.round(value * 1000)); +} + +function unnum(text: string): number { + return BaseNConverter.fromUrlSafeBaseN(text) / 1000; +} + +function length(model: number): string { + return num(model / MODEL_SCALE); +} + +function unlength(text: string): number { + return unnum(text) * MODEL_SCALE; +} + +const REFERENCES = [COR.BACK, COR.CENTER, COR.FRONT]; + +/** + * What this design is, as trailing entries -- or nothing at all. + * + * A design with no positions and nothing asked of it is not a design, and + * writing one would make every URL in the app longer for a panel most readers + * never open. + */ +export function encodeSynthesisDesign(design: SynthesisBuilderService): string[] { + const poses = design.getAllPoses(); + const untouched = + poses.length === 0 && + design.stage === 'chooser' && + design.endsOnly && + !design.allowDefect && + !design.constrain; + if (untouched) return []; + + const marks = [ + 'SD~' + + length(design.length) + + '~' + + BaseNConverter.toUrlSafeBaseN(REFERENCES.indexOf(design.COR)) + + '~' + + FlagPacker.pack([ + design.endsOnly, + design.allowDefect, + design.constrain, + design.stage === 'working', + false, + false, + ]), + ]; + + poses.forEach((pose: SynthesisPose) => { + marks.push( + 'SP~' + length(pose.position.x) + '~' + length(pose.position.y) + '~' + num(pose.thetaDegrees) + ); + }); + + if (design.constrain) { + const r = design.region; + marks.push('SR~' + length(r.x) + '~' + length(r.y) + '~' + length(r.w) + '~' + length(r.h)); + } + + return marks; +} + +/** + * Put a decoded design back, exactly as it was written. + * + * Everything is replaced rather than merged: the entries are the whole of the + * design, so a URL with fewer positions than the panel currently holds means + * the reader undid one, not that two designs should be combined. + */ +export function applySynthesisDesign(marks: string[], design: SynthesisBuilderService): void { + const header = marks.find((entry) => entry.startsWith('SD~')); + if (!header) { + // No design in this URL. Undo can step back to before there was one, so + // that has to clear the panel rather than leave the last one standing. + design.clearDesign(); + return; + } + + const [lengthText, referenceText, flagsText] = header.substring(3).split('~'); + const [endsOnly, allowDefect, constrain, working] = FlagPacker.unpack(flagsText, FLAGS); + + design.applyDecoded({ + length: unlength(lengthText), + reference: REFERENCES[BaseNConverter.fromUrlSafeBaseN(referenceText, true)] ?? COR.CENTER, + endsOnly, + allowDefect, + constrain, + stage: working ? 'working' : 'chooser', + poses: marks + .filter((entry) => entry.startsWith('SP~')) + .map((entry) => { + const [x, y, theta] = entry.substring(3).split('~'); + return { at: new Coord(unlength(x), unlength(y)), thetaDegrees: unnum(theta) }; + }), + region: (() => { + const entry = marks.find((mark) => mark.startsWith('SR~')); + if (!entry) return undefined; + const [x, y, w, h] = entry.substring(3).split('~'); + return { x: unlength(x), y: unlength(y), w: unlength(w), h: unlength(h) }; + })(), + }); +} diff --git a/src/app/services/transcoding/string-transcoder.ts b/src/app/services/transcoding/string-transcoder.ts index acd6ec72..e3d372d2 100644 --- a/src/app/services/transcoding/string-transcoder.ts +++ b/src/app/services/transcoding/string-transcoder.ts @@ -455,7 +455,11 @@ export class StringTranscoder extends GenericTranscoder { // the two are told apart on the way in and neither can be mistaken for the // other. 'CG' holds the point on the drawing; 'CJ~' // holds it on one pin, '~' being a character no id can contain. - const trailing = [...this.lockedIds, ...this.comAnchors]; + // Synthesis joins them for a third time, tagged 'S'. Its entries describe a + // design rather than an object the URL carries, so unlike a lock or an + // anchor there is nothing for them to resolve against -- which is exactly + // why they can be validated on their own numbers alone. + const trailing = [...this.lockedIds, ...this.comAnchors, ...this.synthesisMarks]; if (trailing.length > 0) { fullString += '.' + trailing.join(','); } @@ -575,6 +579,7 @@ export class StringTranscoder extends GenericTranscoder { let entry = sd.nextToken(','); if (entry === '') continue; if (entry.charAt(0) === 'C') this.comAnchors.push(entry); + else if (entry.charAt(0) === 'S') this.synthesisMarks.push(entry); else this.lockedIds.push(entry); } @@ -684,6 +689,35 @@ export class StringTranscoder extends GenericTranscoder { } }); this.validateDecodedComAnchors(linkIDs); + this.validateDecodedSynthesis(); + } + + /** + * A synthesis design must be numbers, and the right count of them. + * + * It names nothing in the drawing, so there is no reference to resolve -- + * but a design half-read is worse than no design, because the panel would + * open on positions that are not where the reader left them. Fails closed, + * like every other trailing section. + */ + private validateDecodedSynthesis(): void { + const expected: { [tag: string]: number } = { SD: 3, SP: 3, SR: 4, SC: 3 }; + let positions = 0; + this.synthesisMarks.forEach((entry) => { + const tag = entry.substring(0, 2); + const count = expected[tag]; + if (count === undefined) { + throw new Error('URL contains an unknown synthesis entry'); + } + const parts = entry.substring(2).split('~').slice(1); + if (parts.length !== count || parts.some((part) => part === '')) { + throw new Error('URL contains an incomplete synthesis entry'); + } + if (tag === 'SP') positions++; + }); + if (positions > 3) { + throw new Error('URL contains more than three synthesis positions'); + } } /** diff --git a/src/app/services/transcoding/transcoder-interface.ts b/src/app/services/transcoding/transcoder-interface.ts index 34f913ca..fd05b6ee 100644 --- a/src/app/services/transcoding/transcoder-interface.ts +++ b/src/app/services/transcoding/transcoder-interface.ts @@ -37,6 +37,16 @@ export abstract class GenericTranscoder { * StringTranscoder for the spelling. */ protected comAnchors: string[] = []; + /** + * The synthesis design -- three positions of an end-effector link and what a + * solution has to satisfy -- as tagged entries sharing the same trailing + * section. See StringTranscoder for the spelling. + * + * It is in the URL for the same reason the mechanism is: undo and redo are a + * stack of these strings, so a design that was not written here could not be + * undone, and a link shared mid-design would open on an empty panel. + */ + protected synthesisMarks: string[] = []; // Initialize data dictionaries based on settings enums constructor() { @@ -120,6 +130,14 @@ export abstract class GenericTranscoder { return this.comAnchors; } + setSynthesisMarks(marks: string[]): void { + this.synthesisMarks = marks; + } + + getSynthesisMarks(): string[] { + return this.synthesisMarks; + } + abstract decodeURL(url: string): void; getJoints(): JointData[] { diff --git a/src/app/services/transcoding/url-synthesis-design.spec.ts b/src/app/services/transcoding/url-synthesis-design.spec.ts new file mode 100644 index 00000000..1203a294 --- /dev/null +++ b/src/app/services/transcoding/url-synthesis-design.spec.ts @@ -0,0 +1,157 @@ +import { Coord } from '../../model/coord'; +import { Force } from '../../model/force'; +import { RevJoint } from '../../model/joint'; +import { RealLink } from '../../model/link'; +import { MODEL_SCALE } from '../../model/render-scale'; +import { MechanismService } from '../mechanism.service'; +import { SettingsService } from '../settings.service'; +import { SynthesisBuilderService } from '../synthesis/synthesis-builder.service'; +import { applySynthesisDesign } from '../synthesis/synthesis-url'; +import { COR } from '../synthesis/synthesis-util'; +import { designFor, urlGeneratorFor } from '../../../test-utils/url-encoding'; +import { StringTranscoder } from './string-transcoder'; + +/** + * A synthesis design in the URL. + * + * It rides in the trailing section the lock marks opened, under a tag of its + * own, for the reason the mechanism is there: undo and redo are a stack of + * these strings, so a design left out of them could not be undone, and a link + * shared mid-design would open on an empty panel. + * + * The bargain the section was built on holds here too -- written only when + * there is something to say, so a document with no design in progress encodes + * to exactly the bytes it did before any of this existed. + */ + +const S = MODEL_SCALE; + +/** A four-bar to hang the design off, so the URL is a real one. */ +function drawing() { + const a = new RevJoint('A', 0, 0, true, true); + const b = new RevJoint('B', 2 * S, 0); + const bar = new RealLink('AB', [a, b], 1, 1); + [a, b].forEach((joint) => joint.links.push(bar)); + a.connectedJoints.push(b); + b.connectedJoints.push(a); + return { + joints: [a, b], + links: [bar], + forces: [] as Force[], + mechanismTimeStep: 0, + } as unknown as MechanismService; +} + +function encode(design?: SynthesisBuilderService): string { + return urlGeneratorFor(drawing(), new SettingsService(), design).generateUrlQuery(); +} + +/** Decode a URL and hand its design to a fresh, empty one. */ +function decodeInto(url: string): SynthesisBuilderService { + const decoder = new StringTranscoder(); + decoder.decodeURL(url); + const restored = designFor(new SettingsService()); + applySynthesisDesign(decoder.getSynthesisMarks(), restored); + return restored; +} + +/** A design with three positions placed and every requirement moved off default. */ +function worked(): SynthesisBuilderService { + const design = designFor(new SettingsService()); + design.stage = 'working'; + design.length = 6.25 * S; + design.applyDecoded({ + length: 6.25 * S, + reference: COR.FRONT, + endsOnly: false, + allowDefect: true, + constrain: true, + stage: 'working', + poses: [ + { at: new Coord(-2.5 * S, 1.25 * S), thetaDegrees: 12.5 }, + { at: new Coord(4 * S, 2 * S), thetaDegrees: -37 }, + { at: new Coord(7 * S, 7.5 * S), thetaDegrees: 61.25 }, + ], + region: { x: -3 * S, y: -8 * S, w: 14 * S, h: 9 * S }, + }); + return design; +} + +describe('a synthesis design in the URL', () => { + it('adds nothing at all when no design has been started', () => { + expect(encode(designFor(new SettingsService()))).toBe(encode()); + }); + + it('brings back the three positions exactly as they were placed', () => { + const restored = decodeInto(encode(worked())); + const poses = restored.getAllPoses(); + expect(poses.length).toBe(3); + expect(poses[0].position.x).toBeCloseTo(-2.5 * S, 3); + expect(poses[0].position.y).toBeCloseTo(1.25 * S, 3); + expect(poses[0].thetaDegrees).toBeCloseTo(12.5, 3); + expect(poses[1].thetaDegrees).toBeCloseTo(-37, 3); + expect(poses[2].position.x).toBeCloseTo(7 * S, 3); + expect(poses[2].thetaDegrees).toBeCloseTo(61.25, 3); + }); + + it('brings back the coupler, the reference point and the screen', () => { + const restored = decodeInto(encode(worked())); + expect(restored.length).toBeCloseTo(6.25 * S, 3); + expect(restored.COR).toBe(COR.FRONT); + expect(restored.stage).toBe('working'); + }); + + it('brings back what a solution has to satisfy', () => { + const restored = decodeInto(encode(worked())); + expect(restored.endsOnly).toBe(false); + expect(restored.allowDefect).toBe(true); + expect(restored.constrain).toBe(true); + }); + + it('brings back the region, but only because it is required', () => { + const restored = decodeInto(encode(worked())); + expect(restored.region.x).toBeCloseTo(-3 * S, 3); + expect(restored.region.y).toBeCloseTo(-8 * S, 3); + expect(restored.region.w).toBeCloseTo(14 * S, 3); + expect(restored.region.h).toBeCloseTo(9 * S, 3); + + const unconstrained = worked(); + unconstrained.constrain = false; + expect(encode(unconstrained)).not.toContain('SR~'); + }); + + it('carries fewer than three positions while one is still being placed', () => { + const partial = designFor(new SettingsService()); + partial.stage = 'working'; + partial.placeAngleDeg = 30; + partial.placePose(new Coord(1 * S, 2 * S)); + const restored = decodeInto(encode(partial)); + expect(restored.getAllPoses().length).toBe(1); + expect(restored.getAllPoses()[0].position.y).toBeCloseTo(2 * S, 3); + }); + + it('clears a design that the URL being read does not have', () => { + const restored = worked(); + applySynthesisDesign([], restored); + expect(restored.getAllPoses().length).toBe(0); + expect(restored.stage).toBe('chooser'); + expect(restored.endsOnly).toBe(true); + }); + + it('refuses a URL whose design is incomplete rather than half-reading it', () => { + const url = encode(worked()); + // Run a position's last two numbers together, so it carries two fields + // where it should carry three. The edit is one character for one character + // -- the checksum is over the length, and a URL that fails that would fail + // for a reason that says nothing about this section. + const broken = url.replace(/(SP~[^~,]+~[^~,]+)~/, '$1-'); + expect(broken).not.toBe(url); + expect(broken.length).toBe(url.length); + expect(() => decodeInto(broken)).toThrowError(/incomplete synthesis entry/); + }); + + it('refuses an entry it does not recognise', () => { + const url = encode(worked()); + expect(() => decodeInto(url.replace('SD~', 'SZ~'))).toThrowError(/unknown synthesis entry/); + }); +}); diff --git a/src/app/services/url-generation.service.ts b/src/app/services/url-generation.service.ts index 7c3188d4..cad653a1 100644 --- a/src/app/services/url-generation.service.ts +++ b/src/app/services/url-generation.service.ts @@ -22,6 +22,8 @@ import { } from './transcoding/transcoder-data'; import { SettingsService } from './settings.service'; import { MODEL_SCALE } from '../model/render-scale'; +import { SynthesisBuilderService } from './synthesis/synthesis-builder.service'; +import { encodeSynthesisDesign } from './synthesis/synthesis-url'; /* * This service is responsible for generating the URL from the current mechanism. @@ -39,6 +41,7 @@ import { MODEL_SCALE } from '../model/render-scale'; export class UrlGenerationService { private mechanism = inject(MechanismService); private settings = inject(SettingsService); + private synthesis = inject(SynthesisBuilderService); _addJointToEncoder(encoder: StringTranscoder, joint: Joint) { if (joint instanceof RevJoint) { @@ -201,6 +204,12 @@ export class UrlGenerationService { ) ); + // The synthesis design, if one is being worked on. It is not part of the + // mechanism -- nothing here is on the grid yet -- but undo and redo are a + // stack of these strings, so a design left out of them could not be + // undone, and a link shared mid-design would open on an empty panel. + encoder.setSynthesisMarks(encodeSynthesisDesign(this.synthesis)); + // Encode global settings encoder.addEnumSetting( EnumSetting.LENGTH_UNIT, diff --git a/src/app/services/url-processor.service.ts b/src/app/services/url-processor.service.ts index 337fac07..f6749983 100644 --- a/src/app/services/url-processor.service.ts +++ b/src/app/services/url-processor.service.ts @@ -7,6 +7,8 @@ import { SvgGridService } from './svg-grid.service'; import { ActiveObjService } from './active-obj.service'; import { NotificationService } from './notification.service'; import { SelectedTabService, TabID } from '../selected-tab.service'; +import { SynthesisBuilderService } from './synthesis/synthesis-builder.service'; +import { applySynthesisDesign } from './synthesis/synthesis-url'; @Injectable({ providedIn: 'root', @@ -17,6 +19,7 @@ export class UrlProcessorService { private svgGrid = inject(SvgGridService); private activeObj = inject(ActiveObjService); private notify = inject(NotificationService); + private synthesis = inject(SynthesisBuilderService); constructor() { // the content part of the url (the part after the ?) @@ -108,6 +111,11 @@ export class UrlProcessorService { this.activeObj ); builder.build(updateSettings); + // After the mechanism, because a design is about a machine that is not + // on the grid yet and so has nothing in the build to wait for -- but + // before the rebuild below, so the panel and the canvas come up + // describing the same state. + applySynthesisDesign(decoder.getSynthesisMarks(), this.synthesis); } catch (error) { console.error('Unable to load mechanism URL', error); // Deferred because this can run inside the service's own constructor, diff --git a/src/test-utils/url-encoding.ts b/src/test-utils/url-encoding.ts index 936248ee..0ac47118 100644 --- a/src/test-utils/url-encoding.ts +++ b/src/test-utils/url-encoding.ts @@ -2,22 +2,33 @@ import { Injector, runInInjectionContext } from '@angular/core'; import { MechanismService } from '../app/services/mechanism.service'; import { SettingsService } from '../app/services/settings.service'; import { UrlGenerationService } from '../app/services/url-generation.service'; +import { SynthesisBuilderService } from '../app/services/synthesis/synthesis-builder.service'; +import { NumberUnitParserService } from '../app/services/number-unit-parser.service'; /** * A UrlGenerationService wired to the given mechanism and settings. * - * The service resolves both with inject(), so a spec cannot pass them + * The service resolves them with inject(), so a spec cannot pass them * positionally any more; this is the one place that builds the injection * context for it. + * + * A synthesis design can be handed in the same way. Left out, a real but empty + * one stands in -- which encodes to nothing, so every spec that predates the + * design being in the URL still compares the bytes it always did. */ export function urlGeneratorFor( mechanism: MechanismService, - settings: SettingsService + settings: SettingsService, + design?: SynthesisBuilderService ): UrlGenerationService { const injector = Injector.create({ providers: [ { provide: MechanismService, useValue: mechanism }, { provide: SettingsService, useValue: settings }, + { provide: NumberUnitParserService, deps: [] }, + design + ? { provide: SynthesisBuilderService, useValue: design } + : { provide: SynthesisBuilderService, deps: [] }, { provide: UrlGenerationService, deps: [] }, ], }); @@ -29,4 +40,16 @@ export function encodeUrlOf(mechanism: MechanismService, settings: SettingsServi return urlGeneratorFor(mechanism, settings).generateUrlQuery(); } +/** A synthesis design built outside Angular, for the specs that need one. */ +export function designFor(settings: SettingsService): SynthesisBuilderService { + const injector = Injector.create({ + providers: [ + { provide: SettingsService, useValue: settings }, + { provide: NumberUnitParserService, deps: [] }, + { provide: SynthesisBuilderService, deps: [] }, + ], + }); + return injector.get(SynthesisBuilderService); +} + export { runInInjectionContext }; From b723f786889cc435489e373a8422bf65b43b45c8 Mon Sep 17 00:00:00 2001 From: Kohmei Kadoya Date: Thu, 20 Aug 2026 17:27:31 -0700 Subject: [PATCH 06/29] Walk the whole of Synthesis in a browser, from empty grid to inserted six-bar The suite it replaces checked that synthesis added a mechanism rather than replacing the drawing -- a promise about a build that no longer happens on its own. What is worth checking now is mostly the machinery around the new promise: that the canvas gestures do not fight svg-pan-zoom, that nothing reaches the drawing before Insert, that the preview stops being drawn once the real thing is there, and that the design survives undo and redo. Selectors are scoped to the panel's own id. Half these class names are words rather than names -- card, row, note -- and the app has its own elements wearing them: an unscoped `.card` matched six elements and quietly clicked the wrong one, which read as two features being broken. Co-Authored-By: Claude Opus 5 --- e2e/README.md | 1 + e2e/synthesis-adds-mechanism.mjs | 128 ------------ e2e/synthesis-redesign.mjs | 332 +++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+), 128 deletions(-) delete mode 100644 e2e/synthesis-adds-mechanism.mjs create mode 100644 e2e/synthesis-redesign.mjs diff --git a/e2e/README.md b/e2e/README.md index 6ee5a3ac..54b9227f 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -33,6 +33,7 @@ their source. Everything browser-driven lives here. - `template-thumbnails.mjs` — regenerates the library cards' images in `src/assets/gifs/` by opening each generated template payload and clipping the canvas. Not a check: it writes assets, so run it after `npm run template-payloads` changes a payload - `playback-timing.mjs` — real-time playback: a revolution takes 60/RPM wall-clock seconds, the reported cycle period scales with input speed, and simulation time is held (not the sample index) across a speed change - `input-settings-and-playback.mjs` — the input joint's Input Settings section (direction, unit-free speed field, RPM / deg/s / rad/s picker), its removal from global Settings, the time field's width, and that playback interpolates between samples at a slow input speed +- `synthesis-redesign.mjs` — Synthesis end to end: the chooser, arming and dropping the three positions (wheel turns the one about to land, and does not zoom), dragging one without panning the canvas, Generate, the candidate gallery and its hover comparison, the six-bar driver, the preview transport, Insert and its Undo, and the design surviving undo and redo ## Running diff --git a/e2e/synthesis-adds-mechanism.mjs b/e2e/synthesis-adds-mechanism.mjs deleted file mode 100644 index 9676d6ea..00000000 --- a/e2e/synthesis-adds-mechanism.mjs +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Synthesis adds a machine rather than replacing the drawing. - * - * It used to empty the joint and link arrays before writing its four-bar, which - * was the only sensible thing to do when a drawing held one mechanism. It holds - * as many as are drawn now, so the linkage it produces joins them. - * - * PMKS_BASE_URL= node e2e/synthesis-adds-mechanism.mjs - */ - -import { readFileSync } from 'node:fs'; - -const { chromium } = await import( - (process.env.PMKS_PLAYWRIGHT_DIR ?? '/tmp/pmks-playwright') + '/node_modules/playwright/index.mjs' -); -import { waitForReady } from './app-ready.mjs'; - -const BASE = process.env.PMKS_BASE_URL ?? 'http://127.0.0.1:4200'; -const source = readFileSync('src/app/component/MODALS/templates/template-linkages.ts', 'utf8'); -const payloads = Object.fromEntries( - [...source.matchAll(/^ {2}'?([\w-]+)'?:\n {4}'([^']+)',$/gm)].map(([, id, p]) => [id, p]) -); - -const browser = await chromium.launch(); -const page = await browser.newPage({ viewport: { width: 1500, height: 950 } }); -const errors = []; -page.on('pageerror', (error) => errors.push(String(error))); - -const results = []; -const record = (what, ok, detail) => { - results.push([what, ok]); - console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); -}; - -const model = () => - page.evaluate(() => { - const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; - return { - joints: srv.joints.map((j) => j.id), - links: srv.links.map((l) => l.id), - mechanisms: srv.partitions.map((p) => p.id), - valid: srv.mechanisms.map((m) => m.isMechanismValid()), - }; - }); - -/** Place the three poses the panel needs, then let it synthesise. */ -const drawPoses = (spread) => - page.evaluate((spread) => { - const panel = ng.getComponent(document.querySelector('app-synthesis-panel')); - const builder = panel.synthesisBuilder; - const at = [ - [-4, 3, 0.2], - [0, 4, 0.5], - [4, 3, 0.9], - ]; - at.forEach(([x, y, theta], i) => { - const id = i + 1; - if (!builder.isPoseDefined(id)) builder.createPose(id); - const pose = builder.getPose(id); - // Through the pose's own setter, so the front and back points it derives - // are recomputed the way a drag on the canvas would. - const Coord = Object.getPrototypeOf(pose.position).constructor; - pose.position = new Coord(x * spread, y * spread); - builder.setPoseTheta(pose, theta); - }); - }, spread); - -await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); -await waitForReady(page); -const before = await model(); -record('a four-bar is on the grid to start with', before.mechanisms.length === 1, before); - -await page.locator('.tabButton', { hasText: 'Synthesis' }).click(); -await page.waitForTimeout(900); -await drawPoses(60); -await page.waitForTimeout(1500); -const after = await model(); - -record( - 'the drawing the user already had is still there', - before.joints.every((id) => after.joints.includes(id)), - { before, after } -); -record('and the synthesised linkage is a second mechanism', after.mechanisms.length === 2, after); -record( - 'with no two joints sharing an id', - new Set(after.joints).size === after.joints.length, - after -); -record('nor two links', new Set(after.links).size === after.links.length, after); -record('and both machines solve', after.valid.length === 2 && after.valid.every(Boolean), after); - -// Re-running while still in the tab replaces this visit's answer, not adds to it. -await drawPoses(80); -await page.waitForTimeout(1500); -const again = await model(); -record( - 'moving a pose revises the linkage rather than drawing another', - again.mechanisms.length === 2 && again.joints.length === after.joints.length, - { after, again } -); -record('still with no repeated ids', new Set(again.joints).size === again.joints.length, again); - -// Leaving and coming back starts a new one. -await page.locator('.tabButton', { hasText: 'Edit' }).click(); -await page.waitForTimeout(900); -await page.locator('.tabButton', { hasText: 'Synthesis' }).click(); -await page.waitForTimeout(900); -await drawPoses(45); -await page.waitForTimeout(1500); -const third = await model(); -record( - 'a fresh visit leaves the last visit alone and adds another', - third.mechanisms.length === 3, - { again, third } -); -record( - 'and all three ids are still distinct', - new Set(third.joints).size === third.joints.length, - third -); - -record('nothing threw', errors.length === 0, errors.slice(0, 3)); -await browser.close(); - -const failed = results.filter(([, ok]) => !ok); -console.log(`\n${results.length - failed.length}/${results.length} checks passed`); -process.exit(failed.length ? 1 : 0); diff --git a/e2e/synthesis-redesign.mjs b/e2e/synthesis-redesign.mjs new file mode 100644 index 00000000..d521afef --- /dev/null +++ b/e2e/synthesis-redesign.mjs @@ -0,0 +1,332 @@ +/** + * Synthesis, end to end: place, generate, browse, preview, insert. + * + * The redesign changed what the mode *is*. It used to build a four-bar onto the + * grid on every nudge of a coordinate, which made comparing two solutions + * impossible -- looking at the second destroyed the first. Now three positions + * are placed, an explicit search offers every four-bar that passes through + * them, and exactly one of them reaches the drawing, when Insert says so. + * + * Most of what is checked here is the machinery around that promise: that the + * canvas gestures do not fight svg-pan-zoom, that the preview stops being drawn + * once it is real, and that the design survives a shared link. + * + * PMKS_BASE_URL= node e2e/synthesis-redesign.mjs + */ + +const { chromium } = await import( + (process.env.PMKS_PLAYWRIGHT_DIR ?? '/tmp/pmks-playwright') + '/node_modules/playwright/index.mjs' +); +import { waitForReady } from './app-ready.mjs'; + +const BASE = process.env.PMKS_BASE_URL ?? 'http://127.0.0.1:4200'; + +const checks = []; +const check = (what, ok, detail) => { + checks.push([what, ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); +}; + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1500, height: 950 } }); +const errors = []; +page.on('pageerror', (error) => errors.push(String(error))); + +const panel = (fn, arg) => + page.evaluate( + ([body, value]) => + new Function('panel', 'arg', body)( + ng.getComponent(document.querySelector('app-synthesis-panel')), + value + ), + [`return (${fn})(panel, arg);`, arg ?? null] + ); + +const grid = (fn) => + page.evaluate( + (body) => new Function('grid', body)(ng.getComponent(document.querySelector('app-new-grid'))), + `return (${fn})(grid);` + ); + +await page.goto(BASE, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +const skip = page.locator('.introjs-skipbutton').first(); +if (await skip.isVisible().catch(() => false)) await skip.click({ force: true }); +await page.locator('.tabButton', { hasText: 'Synthesis' }).click(); +await page.waitForTimeout(700); + +// --- the chooser -------------------------------------------------------- +check( + 'Synthesis opens on the question of what is being synthesised', + await page.locator('#synthesisPanel .kindCard--on').isVisible() +); +check( + 'and says what it cannot do yet rather than hiding it', + (await page.locator('#synthesisPanel .kindCard--off').innerText()).includes('Coming soon') +); +check( + 'the panel is as wide as the analysis panel, not as wide as Edit', + (await page.evaluate(() => + Math.round(document.querySelector('#synthesisPanel').getBoundingClientRect().width) + )) === 400 +); + +await page.locator('#synthesisPanel .kindCard--on').click(); +await page.waitForTimeout(400); + +// --- placing ------------------------------------------------------------ +check( + 'all three positions have a row before any is placed', + (await page.locator('#synthesisPanel .poseRow').count()) === 3 +); +check( + 'and none of them can be typed into yet', + await page.locator('.poseRow input').first().isDisabled() +); + +await page.locator('#synthesisPanel .pill', { hasText: 'Add position' }).click(); +await page.waitForTimeout(250); +await page.mouse.move(900, 560); +await page.waitForTimeout(200); +const angleBefore = await panel('(p) => p.design.placeAngleDeg'); +await page.mouse.wheel(0, -120); +await page.waitForTimeout(200); +const angleAfter = await panel('(p) => p.design.placeAngleDeg'); +check('the wheel turns the position that is about to be dropped', angleAfter !== angleBefore, { + angleBefore, + angleAfter, +}); +const zoomWhileArmed = await grid('(g) => g.svgGrid.panZoomObject.getZoom()'); +await page.mouse.wheel(0, -120); +await page.waitForTimeout(200); +check( + 'and does not zoom the canvas while it is doing so', + (await grid('(g) => g.svgGrid.panZoomObject.getZoom()')) === zoomWhileArmed +); + +await page.mouse.down(); +await page.mouse.up(); +await page.waitForTimeout(350); +check('a click on the grid drops it', (await panel('(p) => p.design.getAllPoses().length')) === 1); +check('and placing stays armed for the next one', await panel('(p) => p.design.armed')); + +// The remaining two, so there is a design to search. +for (const [x, y] of [ + [1000, 470], + [1120, 330], +]) { + await page.mouse.move(x, y); + await page.waitForTimeout(150); + await page.mouse.down(); + await page.mouse.up(); + await page.waitForTimeout(300); +} +check( + 'three placed, and placing disarms itself', + (await panel('(p) => p.design.getAllPoses().length')) === 3 +); +check('placing disarmed', !(await panel('(p) => p.design.armed'))); +check( + 'the wheel is the canvas zoom again', + await grid('(g) => g.svgGrid.panZoomObject.isMouseWheelZoomEnabled()') +); + +// --- dragging a position on the grid ------------------------------------ +const panBefore = await grid('(g) => JSON.stringify(g.svgGrid.panZoomObject.getPan())'); +const posBefore = await panel('(p) => p.design.getPose(1).position.x'); +const bar = await page.evaluate(() => { + const box = document.querySelector('.synthPose').getBoundingClientRect(); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +}); +await page.mouse.move(bar.x, bar.y); +await page.mouse.down(); +await page.mouse.move(bar.x + 60, bar.y + 20, { steps: 6 }); +await page.mouse.up(); +await page.waitForTimeout(300); +check( + 'a position can be dragged on the grid', + (await panel('(p) => p.design.getPose(1).position.x')) !== posBefore +); +check( + 'and the canvas does not pan under the drag', + (await grid('(g) => JSON.stringify(g.svgGrid.panZoomObject.getPan())')) === panBefore +); + +// --- a design that actually has solutions ------------------------------- +await panel(`(p) => { + p.design.applyDecoded({ + length: 1000, reference: 'CENTER', endsOnly: true, allowDefect: false, + constrain: false, stage: 'working', + poses: [ + { at: { x: 0, y: 0 }, thetaDegrees: 0 }, + { at: { x: 800, y: 400 }, thetaDegrees: 25 }, + { at: { x: 1400, y: 1400 }, thetaDegrees: 50 }, + ], + }); +}`); +await page.waitForTimeout(400); + +check( + 'nothing is on the grid before Generate', + (await grid('(g) => g.mechanismSrv.joints.length')) === 0 +); +check( + 'and no candidates are offered', + (await panel('(p) => p.solution.candidates().length')) === 0 +); + +await page.locator('#synthesisPanel .cta', { hasText: 'Generate solutions' }).click(); +await page.waitForTimeout(900); +const strict = await panel('(p) => p.solution.candidates().length'); +check('Generate finds four-bars through the three positions', strict > 0, strict); +check( + 'every one of them is drawn as a card', + (await page.locator('#synthesisPanel .card').count()) > 0 +); +check( + 'the positions are marked as reached', + (await panel('(p) => JSON.stringify([1,2,3].map(i => p.reached(i)))')) === '[true,true,true]' +); +check( + 'and the linkage is previewed on the grid', + (await grid('(g) => g.synthCanvas.previewLinks().length')) > 0 +); +check( + 'but still nothing has been added to the drawing', + (await grid('(g) => g.mechanismSrv.joints.length')) === 0 +); + +// Letting the pins slide finds more machines through the same positions. +await page + .locator('#synthesisPanel .req', { hasText: 'Coupler is exactly' }) + .locator('.req__line') + .click(); +await page.waitForTimeout(300); +await page.locator('#synthesisPanel .cta', { hasText: 'Generate solutions' }).click(); +await page.waitForTimeout(900); +const loose = await panel('(p) => p.solution.candidates().length'); +check('letting the pins slide finds more of them', loose > strict, { strict, loose }); + +// --- comparing -------------------------------------------------------- +if ((await page.locator('#synthesisPanel .card').count()) > 1) { + await page.locator('#synthesisPanel .card').nth(1).hover(); + await page.waitForTimeout(300); + check( + 'hovering another candidate keeps the chosen one on screen to compare against', + (await grid('(g) => g.synthCanvas.hoverGhostLinks().length')) === 3 + ); + await page.locator('#synthesisPanel .card').nth(1).click(); + await page.waitForTimeout(300); + check('and clicking it takes it', (await panel('(p) => p.solutionName')) === 'B'); + await page.locator('#synthesisPanel .card').first().click(); + await page.waitForTimeout(300); +} + +// --- the driver ------------------------------------------------------- +await page.locator('#synthesisPanel .row', { hasText: 'Add driver' }).locator('.switch').click(); +await page.waitForTimeout(500); +const withDriver = await panel( + '(p) => JSON.stringify({ dyad: !!p.solution.dyad(), refusal: p.solution.driverRefusal ?? null, rows: p.dimensionRows().length })' +); +const driver = JSON.parse(withDriver); +check( + 'a driver is either fitted or refused in words', + driver.dyad || typeof driver.refusal === 'string', + driver +); +if (driver.dyad) { + check('and its two lengths are listed with the rest', driver.rows === 7, driver); +} + +// --- the transport ---------------------------------------------------- +const phaseBefore = await panel('(p) => p.solution.currentPhase()'); +await page.locator('#synthesisPanel .iconBtn--sm').first().click(); +await page.waitForTimeout(600); +const phaseAfter = await panel('(p) => p.solution.currentPhase()'); +check('the preview can be played', phaseAfter !== phaseBefore, { phaseBefore, phaseAfter }); +await page.locator('#synthesisPanel .iconBtn--sm').first().click(); +await page.waitForTimeout(200); +check('and paused', !(await panel('(p) => p.solution.playing'))); +check( + 'the three positions are marked along its travel', + (await page.locator('#synthesisPanel .track__tick').count()) === 3 +); + +// --- inserting --------------------------------------------------------- +await page.locator('#synthesisPanel .cta--insert').click(); +await page.waitForTimeout(900); +const inserted = JSON.parse( + await grid( + '(g) => JSON.stringify({ joints: g.mechanismSrv.joints.map(j => j.id), links: g.mechanismSrv.links.map(l => l.id), valid: g.mechanismSrv.mechanisms.map(m => m.isMechanismValid()) })' + ) +); +check( + 'Insert puts the solution on the grid', + inserted.joints.length === (driver.dyad ? 6 : 4), + inserted +); +check( + 'with no two joints sharing an id', + new Set(inserted.joints).size === inserted.joints.length, + inserted +); +check('and it solves', inserted.valid.length > 0 && inserted.valid.every(Boolean), inserted); +check( + 'the preview stops being drawn once the real thing is there', + (await grid('(g) => g.synthCanvas.previewLinks().length')) === 0 +); +check( + 'the positions stay for reference', + (await panel('(p) => p.design.getAllPoses().length')) === 3 +); + +await page.locator('#synthesisPanel .note__undo').click(); +await page.waitForTimeout(700); +check('and Undo takes exactly it back', (await grid('(g) => g.mechanismSrv.joints.length')) === 0); +check('leaving the design alone', (await panel('(p) => p.design.getAllPoses().length')) === 3); + +// --- the design survives undo ------------------------------------------- +// +// The real test of the design being in the URL: undo and redo are a stack of +// those strings, so a design that is not written into them cannot survive one. +const beforeUndo = await panel( + '(p) => JSON.stringify(p.design.getAllPoses().map(q => [Math.round(q.position.x), Math.round(q.position.y)]))' +); +await page + .locator('#synthesisPanel .pill--square') + .count() + .catch(() => 0); +await page.locator('#synthesisPanel .poseRow').nth(2).locator('.poseRow__remove').click(); +await page.waitForTimeout(600); +check('a position can be removed', (await panel('(p) => p.design.getAllPoses().length')) === 2); + +await page.evaluate(() => { + const bar = ng.getComponent(document.querySelector('app-top-bar')); + bar.undo(); +}); +await page.waitForTimeout(900); +check( + 'and Undo brings it back, in the place it was', + (await panel( + '(p) => JSON.stringify(p.design.getAllPoses().map(q => [Math.round(q.position.x), Math.round(q.position.y)]))' + )) === beforeUndo, + { + beforeUndo, + now: await panel( + '(p) => JSON.stringify(p.design.getAllPoses().map(q => [Math.round(q.position.x), Math.round(q.position.y)]))' + ), + } +); + +await page.evaluate(() => { + ng.getComponent(document.querySelector('app-top-bar')).redo(); +}); +await page.waitForTimeout(900); +check('and Redo takes it away again', (await panel('(p) => p.design.getAllPoses().length')) === 2); + +check('nothing threw', errors.length === 0, errors.slice(0, 3)); +await browser.close(); + +const failed = checks.filter(([, ok]) => !ok); +console.log(`\n${checks.length - failed.length}/${checks.length} checks passed`); +process.exit(failed.length ? 1 : 0); From 9ae5a23786ddc761640028fba32cf6269c4d1ade Mon Sep 17 00:00:00 2001 From: Kohmei Kadoya Date: Thu, 20 Aug 2026 17:30:26 -0700 Subject: [PATCH 07/29] Say what Synthesis is waiting for on the strip that says what everything else is The status strip reports on the drawing, and in Synthesis the drawing is not what the reader is working on: a design in progress is not on it at all, so "Nothing to analyse yet" was true and useless for the whole of the work. It now says where in the search they are -- which position is about to be dropped, that three are placed and ready, which solution is being looked at and what it does with the positions -- and after Insert it says what was left behind. Co-Authored-By: Claude Opus 5 --- e2e/synthesis-redesign.mjs | 12 +++++ .../bottombar/bottombar.component.ts | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/e2e/synthesis-redesign.mjs b/e2e/synthesis-redesign.mjs index d521afef..51a2e3ef 100644 --- a/e2e/synthesis-redesign.mjs +++ b/e2e/synthesis-redesign.mjs @@ -55,6 +55,8 @@ if (await skip.isVisible().catch(() => false)) await skip.click({ force: true }) await page.locator('.tabButton', { hasText: 'Synthesis' }).click(); await page.waitForTimeout(700); +const status = () => page.locator('#bottomBar .status').innerText(); + // --- the chooser -------------------------------------------------------- check( 'Synthesis opens on the question of what is being synthesised', @@ -126,6 +128,11 @@ check( (await panel('(p) => p.design.getAllPoses().length')) === 3 ); check('placing disarmed', !(await panel('(p) => p.design.armed'))); +check( + 'the status strip follows the design rather than the empty drawing', + (await status()).includes('positions placed'), + await status() +); check( 'the wheel is the canvas zoom again', await grid('(g) => g.svgGrid.panZoomObject.isMouseWheelZoomEnabled()') @@ -279,6 +286,11 @@ check( 'the positions stay for reference', (await panel('(p) => p.design.getAllPoses().length')) === 3 ); +check( + 'and the strip says what was left on the grid', + (await status()).startsWith('Inserted as a'), + await status() +); await page.locator('#synthesisPanel .note__undo').click(); await page.waitForTimeout(700); diff --git a/src/app/component/bottombar/bottombar.component.ts b/src/app/component/bottombar/bottombar.component.ts index 9afd87da..59b19f83 100644 --- a/src/app/component/bottombar/bottombar.component.ts +++ b/src/app/component/bottombar/bottombar.component.ts @@ -7,6 +7,8 @@ import { SettingsService } from '../../services/settings.service'; import { MechanismService } from '../../services/mechanism.service'; import { environment } from '../../../environments/environment'; import { SelectedTabService, TabID } from '../../selected-tab.service'; +import { SynthesisBuilderService } from '../../services/synthesis/synthesis-builder.service'; +import { SynthesisSolutionService } from '../../services/synthesis/synthesis-solution.service'; @Component({ selector: 'app-bottombar', @@ -20,6 +22,8 @@ export class BottombarComponent { private tabs = inject(SelectedTabService); private svgGrid = inject(SvgGridService); private nup = inject(NumberUnitParserService); + private design = inject(SynthesisBuilderService); + private solution = inject(SynthesisSolutionService); /** * Which mode the app is in, spelled the way the tabs spell it. @@ -51,6 +55,9 @@ export class BottombarComponent { if (this.tabs.isAnalysisMode()) { return 'Geometry locked'; } + if (this.tabs.getCurrentTab() === TabID.SYNTHESIZE) { + return this.synthesisStatus(); + } const blockers = this.mechanismSrv.blockerCount(); if (this.mechanismSrv.mechanisms.length === 0) { return 'Nothing to analyse yet'; @@ -61,6 +68,45 @@ export class BottombarComponent { return `${blockers} ${blockers === 1 ? 'fix' : 'fixes'} before analysis`; } + /** + * What Synthesis is waiting for, or what it has found. + * + * The rest of this strip reports on the drawing, and in Synthesis the drawing + * is not what the reader is working on -- a design in progress is not on it + * at all, so "Nothing to analyse yet" was true and useless. This says where + * in the search they are, and after Insert it says what was left behind. + */ + private synthesisStatus(): string { + if (this.design.stage === 'chooser') return 'Pick a synthesis type to begin'; + if (this.design.regionDraw) { + return 'Drag on the grid to draw the region the ground pivots must sit in'; + } + const placed = this.design.getAllPoses().length; + const next = this.design.getFirstUndefinedPose(); + if (this.design.armed && next !== undefined) { + return `Click the grid to place position ${next} of 3 · scroll to turn it`; + } + if (placed < 3) return `${placed} of 3 positions placed`; + if (this.solution.generating) { + return 'Searching for four-bars through these three positions…'; + } + if (!this.solution.generated) { + return 'Three positions placed · ready to generate solutions'; + } + const kind = this.solution.dyad() ? 'six-bar' : 'four-bar'; + if (this.solution.inserted) { + return `Inserted as a ${kind} · positions kept for reference`; + } + const chosen = this.solution.chosen(); + if (!chosen) return 'No linkage meets the current criteria'; + const missed = 3 - chosen.onBranchCount; + const how = chosen.defectFree + ? 'walks all 3 on one assembly' + : `branch defect at ${missed} position${missed === 1 ? '' : 's'}`; + const count = this.solution.candidates().length; + return `Solution ${chosen.name} of ${count} · ${how}`; + } + /** * The mobility, or a dash where there is no such number. * From d1f05f02db91091cbb82fc76dc9d7b243ce53a29 Mon Sep 17 00:00:00 2001 From: Kohmei Kadoya Date: Thu, 20 Aug 2026 18:42:04 -0700 Subject: [PATCH 08/29] Fix six things that made the panel harder to use than it needed to be The one that mattered: a position was dropped only on a press released within a tenth of a second. `pastDragThreshold` calls a slow press a drag, which is right for a part already on the grid -- holding still is how you take hold of something -- and wrong for aiming at an empty spot, where a deliberate click is the normal gesture. Every slower click was thrown away, which is why placing a position seemed to need several. Distance is the only thing that tells the two apart here. The rest: - Generate reports itself for at least a second. The search is real work but on a small design it finishes inside one frame, and a button that answers that fast reads as though nothing happened. The floor is under the progress state, not the work: a slower search simply takes longer. - No position row is selected until one is asked for. A highlighted row before anything is placed reads as "your first position went here", which is exactly what it is not. - The chips on the grid get the pill the design gives them. They are read against whatever the drawing puts behind them, and grey words over a link are words nobody can read. - "2 of 3 required" named an obligation the reader does not have -- it looked like two had to be switched on before anything would happen. It says what the number reports instead: how much is being asked of a solution. - The three values on a position row share the space rather than each holding a fixed width. Fixed, every one had to be sized for the longest value it might ever hold, and they could not all be -- so whichever lost had its unit cut off. That was "301 deg" arriving as "301 de". Co-Authored-By: Claude Opus 5 --- e2e/synthesis-redesign.mjs | 40 +++++++++++++-- .../new-grid/new-grid.component.html | 31 +++++++++-- .../new-grid/new-grid.component.scss | 21 +++++++- .../component/new-grid/new-grid.component.ts | 18 ++++++- .../synthesis-panel.component.scss | 28 +++++++--- .../synthesis-panel.component.ts | 10 +++- .../synthesis/synthesis-builder.service.ts | 20 ++++++-- .../synthesis/synthesis-canvas.service.ts | 12 ++++- .../synthesis/synthesis-solution.service.ts | 51 +++++++++++++++---- 9 files changed, 194 insertions(+), 37 deletions(-) diff --git a/e2e/synthesis-redesign.mjs b/e2e/synthesis-redesign.mjs index 51a2e3ef..64fb14cc 100644 --- a/e2e/synthesis-redesign.mjs +++ b/e2e/synthesis-redesign.mjs @@ -57,6 +57,19 @@ await page.waitForTimeout(700); const status = () => page.locator('#bottomBar .status').innerText(); +/** + * Wait for the search rather than for a clock: its progress state has a floor + * under how briefly it may flash past, so a fixed sleep would race it. + */ +const settled = async () => { + await page.waitForFunction( + () => !ng.getComponent(document.querySelector('app-synthesis-panel')).solution.generating, + null, + { timeout: 15000 } + ); + await page.waitForTimeout(200); +}; + // --- the chooser -------------------------------------------------------- check( 'Synthesis opens on the question of what is being synthesised', @@ -81,6 +94,10 @@ check( 'all three positions have a row before any is placed', (await page.locator('#synthesisPanel .poseRow').count()) === 3 ); +check( + 'and none of them is selected until one is asked for', + (await page.locator('#synthesisPanel .poseRow--sel').count()) === 0 +); check( 'and none of them can be typed into yet', await page.locator('.poseRow input').first().isDisabled() @@ -107,11 +124,28 @@ check( ); await page.mouse.down(); +// Held well past the tenth of a second the old gate allowed: aiming at a spot +// takes as long as it takes, and every slower click used to be thrown away. +await page.waitForTimeout(500); await page.mouse.up(); await page.waitForTimeout(350); -check('a click on the grid drops it', (await panel('(p) => p.design.getAllPoses().length')) === 1); +check( + 'a click on the grid drops it, however long it is held', + (await panel('(p) => p.design.getAllPoses().length')) === 1 +); check('and placing stays armed for the next one', await panel('(p) => p.design.armed')); +// A press that travels is a drag, and must not drop anything. +await page.mouse.move(1180, 300); +await page.mouse.down(); +await page.mouse.move(1260, 250, { steps: 6 }); +await page.mouse.up(); +await page.waitForTimeout(300); +check( + 'but a press that travels is a drag, and drops nothing', + (await panel('(p) => p.design.getAllPoses().length')) === 1 +); + // The remaining two, so there is a design to search. for (const [x, y] of [ [1000, 470], @@ -183,7 +217,7 @@ check( ); await page.locator('#synthesisPanel .cta', { hasText: 'Generate solutions' }).click(); -await page.waitForTimeout(900); +await settled(); const strict = await panel('(p) => p.solution.candidates().length'); check('Generate finds four-bars through the three positions', strict > 0, strict); check( @@ -210,7 +244,7 @@ await page .click(); await page.waitForTimeout(300); await page.locator('#synthesisPanel .cta', { hasText: 'Generate solutions' }).click(); -await page.waitForTimeout(900); +await settled(); const loose = await panel('(p) => p.solution.candidates().length'); check('letting the pins slide finds more of them', loose > strict, { strict, loose }); diff --git a/src/app/component/new-grid/new-grid.component.html b/src/app/component/new-grid/new-grid.component.html index e34802c3..b4cdc57e 100644 --- a/src/app/component/new-grid/new-grid.component.html +++ b/src/app/component/new-grid/new-grid.component.html @@ -1159,23 +1159,44 @@ @for (chip of synthCanvas.poseChips(); track chip.id) { + the right way up inside the grid's own y-flip. A filled pill + rather than bare text: it is read against whatever the drawing + puts behind it, and grey words over a link are words nobody can + read. --> + + + {{ chip.id }} + - {{ chip.id }} · {{ chip.text }} + {{ chip.text }} } diff --git a/src/app/component/new-grid/new-grid.component.scss b/src/app/component/new-grid/new-grid.component.scss index 2539d30d..e3ca00c5 100644 --- a/src/app/component/new-grid/new-grid.component.scss +++ b/src/app/component/new-grid/new-grid.component.scss @@ -278,12 +278,31 @@ stroke: mat.m2-get-color-from-palette($accent-palette, 500); } - // What the chosen linkage does with each position, said beside it. + // What the chosen linkage does with each position, said beside it. A filled + // pill, because it is read against whatever the drawing happens to put behind + // it -- grey words over a link are words nobody can read. + .synthChip { + fill: #fff; + stroke: rgba(0, 0, 0, 0.12); + + &--sel { + fill: mat.m2-get-color-from-palette($accent-palette, 500); + stroke: rgba(0, 0, 0, 0.2); + } + } + .synthChipDot { stroke: #fff; stroke-width: 1; } + .synthChipNumber { + fill: #fff; + font-family: Roboto, sans-serif; + font-weight: 500; + text-anchor: middle; + } + .synthChipText { fill: rgba(0, 0, 0, 0.65); font-family: Roboto, sans-serif; diff --git a/src/app/component/new-grid/new-grid.component.ts b/src/app/component/new-grid/new-grid.component.ts index bcf013e8..29d8d854 100644 --- a/src/app/component/new-grid/new-grid.component.ts +++ b/src/app/component/new-grid/new-grid.component.ts @@ -2101,7 +2101,7 @@ export class NewGridComponent implements OnDestroy { !wasDragging && !this.synthPressTaken && this.synthesisBuilder.armed && - !this.pastDragThreshold($event) + this.pressDidNotTravel($event) ) { const at = this.svgGrid.screenToSVGfromXY($event.clientX, $event.clientY); this.synthesisBuilder.placePose(at); @@ -2199,6 +2199,22 @@ export class NewGridComponent implements OnDestroy { return true; } + /** + * Whether this press stayed put -- a click rather than a drag. + * + * Deliberately not `pastDragThreshold`, which also calls a press held for a + * tenth of a second a drag. That is right for a part already on the grid, + * where holding still is how you take hold of something, but wrong for + * dropping a position: aiming at a spot takes as long as it takes, and every + * click slower than 100ms was being thrown away -- which is why a position + * seemed to need several clicks to place. Distance is the only thing that + * tells the two gestures apart here. + */ + private pressDidNotTravel($event: MouseEvent): boolean { + const from = new Coord(this.startX, this.startY); + return getDistance(from, new Coord($event.pageX, $event.pageY)) <= 10; + } + /** * Whether a synthesis gesture has the pointer. * diff --git a/src/app/component/synthesis-panel/synthesis-panel.component.scss b/src/app/component/synthesis-panel/synthesis-panel.component.scss index 7ad0fe04..cf2fdbb2 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.scss +++ b/src/app/component/synthesis-panel/synthesis-panel.component.scss @@ -354,12 +354,22 @@ $red: #e53935; flex: 0 0 214px; } + /* The three values on a position row share what is left of the row after + its number, the axis captions and the two icons, rather than each + holding a width of its own. Fixed widths meant every value had to be + sized for the longest one it might ever hold -- and they could not all + be, so whichever lost had its unit cut off. Sharing gives each of them + as much as there is. The angle takes a slightly larger share: "deg" is + a character longer than "cm", and an angle can want a sign and three + digits in front of it. */ &--num { - flex: 0 0 76px; + flex: 1 1 0; + min-width: 0; } &--angle { - flex: 0 0 64px; + flex: 1.12 1 0; + min-width: 0; } &--tiny { @@ -454,8 +464,10 @@ $red: #e53935; .poseRow { display: flex; align-items: center; - gap: 5px; - padding: 5px 6px; + /* Tighter than the panel's usual 5px: the row carries nine things and + the space belongs to the fields. */ + gap: 4px; + padding: 5px 4px; border: 1.5px solid $edge; border-radius: 5px; background: #fff; @@ -467,11 +479,11 @@ $red: #e53935; } &__n { - flex: 0 0 20px; + flex: 0 0 18px; display: flex; align-items: center; justify-content: center; - height: 20px; + height: 18px; border-radius: 10px; background: #dfe1e8; color: rgba(0, 0, 0, 0.45); @@ -485,12 +497,12 @@ $red: #e53935; } &__axis { - flex: 0 0 10px; + flex: 0 0 9px; font-size: 14px; color: rgba(0, 0, 0, 0.6); &--angle { - flex: 0 0 14px; + flex: 0 0 12px; font-size: 15px; text-align: center; } diff --git a/src/app/component/synthesis-panel/synthesis-panel.component.ts b/src/app/component/synthesis-panel/synthesis-panel.component.ts index 6c90702b..ebdc72a3 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.ts +++ b/src/app/component/synthesis-panel/synthesis-panel.component.ts @@ -477,12 +477,20 @@ export class SynthesisPanelComponent implements OnInit, OnDestroy { this.readFromModel(); } + /** + * How strict the search currently is. + * + * It used to read "2 of 3 required", which names an obligation the reader + * does not have: it looked like two of the three had to be switched on before + * anything would happen. What the number reports is how much is being asked + * of a solution, so it says that instead. + */ requirementCount(): string { const n = (this.design.endsOnly ? 1 : 0) + (this.design.allowDefect ? 0 : 1) + (this.design.constrain ? 1 : 0); - return n + ' of 3 required'; + return n === 0 ? 'nothing narrowing the search' : `${n} of 3 narrowing the search`; } regionSummary(): string { diff --git a/src/app/services/synthesis/synthesis-builder.service.ts b/src/app/services/synthesis/synthesis-builder.service.ts index 4763b475..fc1c231d 100644 --- a/src/app/services/synthesis/synthesis-builder.service.ts +++ b/src/app/services/synthesis/synthesis-builder.service.ts @@ -78,7 +78,15 @@ export class SynthesisBuilderService { _COR: COR; _length: number; // length of the end-effector link - _selectedPose: number; // currently selected pose (1-3) + /** + * Which position row is selected, or 0 for none. + * + * Starts at none. A row highlighted before anything has been placed reads as + * "this is where your first position went", which is exactly what it is not: + * the first position does not exist until Add position has armed the canvas + * and a click has dropped it. + */ + _selectedPose: number; poses: { [key: number]: SynthesisPose }; // a dictionary of poses, but including each pose is optional @@ -88,7 +96,7 @@ export class SynthesisBuilderService { // start with a length of 5 user units, held in model units this._COR = COR.CENTER; this._length = 5 * MODEL_SCALE; - this._selectedPose = 1; + this._selectedPose = 0; // start with no defined poses this.poses = {}; @@ -352,7 +360,7 @@ export class SynthesisBuilderService { ); }); this.armed = false; - this.selectedPose = Math.min(this.selectedPose, Math.max(1, kept.length)); + this.selectedPose = kept.length ? Math.min(this.selectedPose, kept.length) : 0; this.valueChanges.next(true); } @@ -361,7 +369,7 @@ export class SynthesisBuilderService { this.poses = {}; this._COR = COR.CENTER; this._length = 5 * MODEL_SCALE; - this._selectedPose = 1; + this._selectedPose = 0; this.stage = 'chooser'; this.armed = false; this.regionDraw = false; @@ -408,7 +416,9 @@ export class SynthesisBuilderService { () => this.length ); }); - this._selectedPose = Math.min(this._selectedPose, Math.max(1, decoded.poses.length)); + // A decode is not a click, so it selects nothing: the design comes back as + // it was written, not as though a row had just been picked. + this._selectedPose = 0; this.valueChanges.next(true); } } diff --git a/src/app/services/synthesis/synthesis-canvas.service.ts b/src/app/services/synthesis/synthesis-canvas.service.ts index d9831f34..c6197cc1 100644 --- a/src/app/services/synthesis/synthesis-canvas.service.ts +++ b/src/app/services/synthesis/synthesis-canvas.service.ts @@ -27,6 +27,8 @@ export interface PoseChip { text: string; dot: string; selected: boolean; + /** How wide the pill behind the words has to be, in screen pixels. */ + width: number; } export interface Handle { @@ -143,14 +145,20 @@ export class SynthesisCanvasService { return this.design.getAllPoses().map((pose) => { const reached = cand ? cand.onBranch[pose.id - 1] : undefined; const far = pose.posBack.x > pose.posFront.x ? pose.posBack : pose.posFront; + const text = + reached === undefined ? 'position ' + pose.id : reached ? 'reached' : 'needs reassembly'; return { id: pose.id, x: far.x + 0.5 * this.settings.objectScale, y: Math.max(pose.posBack.y, pose.posFront.y) + 0.75 * this.settings.objectScale, - text: - reached === undefined ? 'position ' + pose.id : reached ? 'reached' : 'needs reassembly', + text, dot: reached === undefined ? '#8a90a0' : reached ? '#43a047' : '#f5a623', selected: this.design.selectedPose === pose.id, + // Estimated rather than measured: SVG cannot report a text width before + // it is laid out, and this only has to be wide enough that the pill + // does not clip the words. Roboto Medium at 13px runs a little over + // half its size per character, plus the numbered dot and its padding. + width: 30 + text.length * 6.9, }; }); } diff --git a/src/app/services/synthesis/synthesis-solution.service.ts b/src/app/services/synthesis/synthesis-solution.service.ts index ef61dc01..c2fb6572 100644 --- a/src/app/services/synthesis/synthesis-solution.service.ts +++ b/src/app/services/synthesis/synthesis-solution.service.ts @@ -31,6 +31,15 @@ import { * nudge of a coordinate, which made comparing two solutions impossible -- * looking at the second one destroyed the first. */ +/** + * How long the search reports itself for, at the least. + * + * Not a delay on the work -- the work starts at once -- but a floor under how + * briefly the progress state may flash past. Under about a second the reader + * sees a button flicker rather than a search happen. + */ +const MIN_SEARCH_VISIBLE_MS = 1100; + @Injectable({ providedIn: 'root' }) export class SynthesisSolutionService { private design = inject(SynthesisBuilderService); @@ -112,24 +121,44 @@ export class SynthesisSolutionService { /** * Run the search. * - * The first search is the one worth showing as work: it is the moment three - * positions become a set of machines. It is deliberately deferred a beat so - * the button can say so, and because the enumeration walks a full crank - * revolution for every candidate and would otherwise freeze the frame it was - * clicked on. + * The enumeration is real work -- it constructs a circle centre for every + * pair of pin positions and then walks a full crank revolution for each + * candidate -- but on a small design it finishes in well under a tenth of a + * second, and a button labelled "Generate solutions" that produces its answer + * in one frame reads as though nothing happened. So the progress state has a + * floor rather than a fake delay: the search starts immediately, and the bar + * stays up until it has been visible long enough to be read. A slower search + * simply takes longer, and the bar tells the truth about it. */ generate(): void { if (this.generating || !this.design.isFullyDefined()) return; this.generating = true; this.changed.next(); if (this.timer) clearTimeout(this.timer); + const started = Date.now(); + // Off the frame the click landed on, so the bar is painted before the + // enumeration blocks the thread. this.timer = setTimeout(() => { - this.timer = undefined; - this.generating = false; - this.generated = true; - this.candidateKey = null; - this.changed.next(); - }, 60); + this.warmCandidates(); + const remaining = Math.max(0, MIN_SEARCH_VISIBLE_MS - (Date.now() - started)); + this.timer = setTimeout(() => { + this.timer = undefined; + this.generating = false; + this.generated = true; + this.candidateKey = null; + this.changed.next(); + }, remaining); + }, 30); + } + + /** Do the search now, so the wait is spent on it rather than after it. */ + private warmCandidates(): void { + const key = this.design.searchKey(); + if (key === this.cacheKey) return; + const result = enumerateCandidates(this.design.search()); + this.cacheKey = key; + this.cached = result.candidates; + this.cachedRejections = result.rejections; } /** Every candidate the current design admits, best first, at most eight. */ From ffc65339ad3d63284d098e3f37265100bbe6a749 Mon Sep 17 00:00:00 2001 From: Kohmei Kadoya Date: Thu, 20 Aug 2026 19:08:39 -0700 Subject: [PATCH 09/29] Let Synthesis keep hold of the linkage it made, and keep the positions beside it Two things were missing, and they turn out to be one thing. Insert used to be a one-way door: it added a machine, switched itself off, and forgot which machine it had added. So the loop the mode exists for -- try a solution, look at it, change a position, try the next -- was not available. And the positions vanished the moment the reader left Synthesis, taking with them the only record of what the linkage on the grid was *for*. Now the design owns the joints it inserted, by id, and the ownership rides in the URL with the rest of it. Inserting again revises that machine in place. Nothing else in the drawing is ever touched, which is what makes this safe in a project that already holds work: another machine can sit beside it and Insert will not reach it. Ownership answers one of four things, and each calls for something different: none nothing of ours is there -- first insert, or Undo stepped back past it, or the reader deleted it. Insert simply inserts. ours exactly what we wrote. Insert replaces it without asking; it is our own previous answer. edited still ours, still separable, but moved by hand. Insert stops and says what would be lost, offering the two things the reader could mean: replace it, or keep it and insert a new one beside it. entangled pinned to another machine, or half deleted. We can no longer take it back cleanly, so we stop claiming it. A warning on entering Edit was the other candidate for this, and the multi-mechanism case is what ruled it out. With a second machine in the drawing, going to Edit to work on *that* is an ordinary, innocent act -- a warning that fires on it is both wrong and quickly trained away, and one that severed the link would cost the reader their synthesis loop for doing something unrelated. Warning at the moment the work would actually be lost is later but exact, and it can say what is at stake rather than what might one day be. Which leaves nothing needing to be said at the boundary, because the positions say it themselves: they stay on the grid in every mode, faint and out of the way of every click, with the canvas menu to clear them -- one at a time from Synthesis, or all of them from anywhere. Clearing them is not an edit to the mechanism, so unlike everything else on that menu it does not wait for the start pose. Two smaller things fell out of it: a right-click while placing was armed dropped a position, and the analysis modes cleared the whole context menu rather than only the half of it that edits the drawing. Co-Authored-By: Claude Opus 5 --- e2e/synthesis-redesign.mjs | 102 ++++++++++ .../context-menu/context-menu.component.ts | 21 +- .../new-grid/new-grid.component.html | 33 ++- .../new-grid/new-grid.component.scss | 14 ++ .../component/new-grid/new-grid.component.ts | 116 ++++++++--- .../synthesis-panel.component.html | 4 +- .../synthesis-panel.component.ts | 60 +++++- .../synthesis/synthesis-builder.service.ts | 19 ++ .../synthesis/synthesis-solution.service.ts | 189 +++++++++++++----- src/app/services/synthesis/synthesis-url.ts | 12 +- .../services/transcoding/string-transcoder.ts | 9 +- .../transcoding/url-synthesis-design.spec.ts | 13 ++ 12 files changed, 491 insertions(+), 101 deletions(-) diff --git a/e2e/synthesis-redesign.mjs b/e2e/synthesis-redesign.mjs index 64fb14cc..d6d881b0 100644 --- a/e2e/synthesis-redesign.mjs +++ b/e2e/synthesis-redesign.mjs @@ -326,6 +326,108 @@ check( await status() ); +// --- the positions outlive the mode ------------------------------------- +await page.locator('.tabButton', { hasText: 'Kinematic Analysis' }).click(); +await page.waitForTimeout(900); +check( + 'the positions are still drawn once the reader goes to look at the motion', + (await page.locator('#synthesis .synthPose').count()) === 3, + await page.locator('#synthesis .synthPose').count() +); +check( + 'as a shadow rather than as controls', + (await page.locator('#synthesis.shadow').count()) === 1, + await page.evaluate( + () => document.querySelector('#synthesis')?.getAttribute('class') ?? 'no #synthesis' + ) +); +check( + 'and the verdict on each is not repeated there', + (await page.locator('#synthesisChips').count()) === 0 +); +// Back to Synthesis for the rest of the suite. +await page.locator('.tabButton', { hasText: 'Synthesis' }).click(); +await page.waitForTimeout(800); +await page.mouse.click(700, 820, { button: 'right' }); +await page.waitForTimeout(500); +check( + 'the canvas menu can clear them away from any mode', + (await page.locator('#contextMenu #menu-item').allInnerTexts()).some((t) => + t.includes('Delete Synthesis Positions') + ), + await page.locator('#contextMenu #menu-item').allInnerTexts() +); +await page.keyboard.press('Escape'); +const poseBar = await page.evaluate(() => { + const box = document.querySelector('.synthPose').getBoundingClientRect(); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +}); +await page.mouse.click(poseBar.x, poseBar.y, { button: 'right' }); +await page.waitForTimeout(500); +check( + 'and one position can be taken away on its own', + (await page.locator('#contextMenu #menu-item').allInnerTexts()).some((t) => + /Delete Position \d/.test(t) + ), + await page.locator('#contextMenu #menu-item').allInnerTexts() +); +await page.keyboard.press('Escape'); + +// --- inserting again revises, rather than accumulating ------------------ +// +// The loop the mode is for: try a solution, look at it, try the next. Insert +// replaces the machine this design put there, and never anything else. +const ownedFirst = await panel('(p) => JSON.stringify(p.design.ownedJointIds)'); +check( + 'the design knows which joints it put on the grid', + JSON.parse(ownedFirst).length > 0, + ownedFirst +); +if ((await page.locator('#synthesisPanel .card').count()) > 1) { + await page.locator('#synthesisPanel .card').nth(1).click(); + await page.waitForTimeout(400); + check( + 'a different solution offers to replace what is there, not to add to it', + (await panel('(p) => p.insertLabel')) === 'Replace on grid', + await panel('(p) => p.insertLabel') + ); + await page.locator('#synthesisPanel .cta--insert').click(); + await page.waitForTimeout(900); + const again = JSON.parse( + await grid('(g) => JSON.stringify(g.mechanismSrv.joints.map(j => j.id))') + ); + check('and inserting it leaves one machine, not two', again.length === inserted.joints.length, { + first: inserted.joints, + again, + }); +} + +// A joint moved by hand is work the reader may still want. Insert says so and +// changes nothing until they answer. +await panel(`(p) => { + const id = p.design.ownedJointIds[1]; + const joint = p.mechanismSrv.joints.find((j) => j.id === id); + joint.x += 600; + joint.y += 600; +}`); +await page.waitForTimeout(200); +check('a hand-moved joint is noticed', (await panel('(p) => p.solution.ownership()')) === 'edited'); +const beforeAsking = await grid('(g) => g.mechanismSrv.joints.length'); +await page.locator('#synthesisPanel .cta--insert').click(); +await page.waitForTimeout(500); +check( + 'and Insert asks instead of overwriting it', + (await grid('(g) => g.mechanismSrv.joints.length')) === beforeAsking +); +check( + 'offering both of the things the reader could mean', + (await page.locator('button', { hasText: 'Replace it' }).count()) === 1 && + (await page.locator('button', { hasText: 'Keep it, insert a new one' }).count()) === 1 +); +await page.locator('button', { hasText: 'Replace it' }).first().click(); +await page.waitForTimeout(900); +check('and replaces it when told to', (await panel('(p) => p.solution.ownership()')) === 'ours'); + await page.locator('#synthesisPanel .note__undo').click(); await page.waitForTimeout(700); check('and Undo takes exactly it back', (await grid('(g) => g.mechanismSrv.joints.length')) === 0); diff --git a/src/app/component/context-menu/context-menu.component.ts b/src/app/component/context-menu/context-menu.component.ts index aa766092..9e4f417d 100644 --- a/src/app/component/context-menu/context-menu.component.ts +++ b/src/app/component/context-menu/context-menu.component.ts @@ -11,12 +11,29 @@ export class cMenuItem { }; public icon: string = 'none'; public disabled: boolean = false; + /** + * Whether this item works away from the start pose. + * + * Almost nothing does: editing a mechanism that is parked mid-cycle would + * write the pose it is standing in back into the drawing. The exceptions are + * items that do not touch the mechanism at all -- the synthesis positions are + * a note about what it was designed for, not a part of it, and there is no + * reason a reader watching the motion cannot clear them away. + */ + public alwaysAllowed: boolean = false; - constructor(_label: string, _action: Function, _icon: string, _disabled: boolean = false) { + constructor( + _label: string, + _action: Function, + _icon: string, + _disabled: boolean = false, + _alwaysAllowed: boolean = false + ) { this.label = _label; this.action = _action; this.icon = _icon; this.disabled = _disabled; + this.alwaysAllowed = _alwaysAllowed; } actionWrapper() { @@ -24,7 +41,7 @@ export class cMenuItem { // transport says where the mechanism is parked, and it said the wrong thing // anyway -- the test here is the timestep, and the message it showed was // the one about the animation running, which it need not be. - if (NewGridComponent.instance.mechanismSrv.mechanismTimeStep !== 0) { + if (!this.alwaysAllowed && NewGridComponent.instance.mechanismSrv.mechanismTimeStep !== 0) { return; } this.action(); diff --git a/src/app/component/new-grid/new-grid.component.html b/src/app/component/new-grid/new-grid.component.html index b4cdc57e..ee850a16 100644 --- a/src/app/component/new-grid/new-grid.component.html +++ b/src/app/component/new-grid/new-grid.component.html @@ -931,11 +931,11 @@ of it is a question and a proposed answer rather than part of the drawing, which is why none of it goes through the mechanism's holders and none of it survives a tab change without being inserted first. --> - @if (showSynthesis()) { - + @if (showSynthesisPositions()) { + - @if (synthCanvas.regionBox(); as region) { + @if (showSynthesis() && synthCanvas.regionBox(); as region) { - @if (synthesisBuilder.regionDraw) { + @if (showSynthesis() && synthesisBuilder.regionDraw) { - @if (synthCanvas.hoverGhostLinks(); as ghosts) { + @if (showSynthesis() && synthCanvas.hoverGhostLinks(); as ghosts) { @for (lk of ghosts; track $index) { - @if (mechanismSrv.showPathHolder || synthSolution.generated) { + @if (showSynthesis() && synthSolution.generated) { + @if (showSynthesis()) { @for (lk of synthCanvas.previewLinks(); track $index) { } + } @for (bar of synthCanvas.poseBars(); track bar.id) { - + - @if (synthCanvas.ghostBar(); as ghost) { + @if (showSynthesis() && synthCanvas.ghostBar(); as ghost) { - @if (synthCanvas.selectionBox(); as box) { + @if (showSynthesis() && synthCanvas.selectionBox(); as box) { + the answer can be read off the grid rather than off the panel. Only + in Synthesis: the verdict belongs to the candidate being compared, + and the shadow left in the other modes is a record of where the + positions were, not of which solution won. --> + @if (showSynthesis()) { @for (chip of synthCanvas.poseChips(); track chip.id) { - @if (synthesisHint; as hint) { + @if (showSynthesis() && synthesisHint; as hint) { 0; + } + + /** Positions drawn, but as a record rather than as controls. */ + synthesisShadowOnly(): boolean { + return !this.showSynthesis(); + } + + /** + * What can be done to the synthesis positions from here. + * + * The only part of this menu offered in every mode, because the positions + * themselves are drawn in every mode. They are a note about what the linkage + * was designed to do rather than a part of it, so clearing them is not an + * edit to the mechanism and does not have to wait for the start pose. + */ + private synthesisMenuItems(): cMenuItem[] { + if (this.synthesisBuilder.getAllPoses().length === 0) return []; + const items: cMenuItem[] = []; + if (this.objectKind(this.lastRightClick) === 'SynthesisPose') { + const pose = this.lastRightClick as SynthesisPose; + items.push( + new cMenuItem( + `Delete Position ${pose.id}`, + () => this.deleteSynthesisPosition(pose.id), + 'trash', + false, + true + ) + ); + } + items.push( + new cMenuItem( + 'Delete Synthesis Positions', + () => this.deleteAllSynthesisPositions(), + 'remove', + false, + true + ) + ); + return items; + } + + /** Take one position away, or all of them, and record it as one step. */ + deleteSynthesisPosition(id: number): void { + this.synthesisBuilder.removePose(id); + this.synthSolution.invalidate(); + this.mechanismSrv.save(); + } + + deleteAllSynthesisPositions(): void { + this.synthesisBuilder.deleteAllPoses(); + this.synthSolution.invalidate(); + this.mechanismSrv.save(); + } + enableGridAnimationForThisAction() { this.svgGridElement.setAttribute('class', 'animated'); //Disable after 0.5 seconds @@ -502,7 +568,7 @@ export class NewGridComponent implements OnDestroy { break; } case 'RevJoint': { - let jointIsSlider = this.gridUtils.isAttachedToSlider(this.lastRightClick); + let jointIsSlider = this.gridUtils.isAttachedToSlider(this.lastRightClick as RealJoint); let jointIsGround = (this.lastRightClick as RealJoint).ground; let canToggleInput = this.gridUtils.canToggleInput(this.lastRightClick as RealJoint); const jointIsInput = this.gridUtils.isVisuallyInput(this.lastRightClick as RealJoint); @@ -633,10 +699,14 @@ export class NewGridComponent implements OnDestroy { // allowed -- that direction takes a body away. this.cMenuItems.push( new cMenuItem( - this.gridUtils.isAttachedToSlider(this.lastRightClick) ? 'Remove Slider' : 'Add Slider', + this.gridUtils.isAttachedToSlider(this.lastRightClick as RealJoint) + ? 'Remove Slider' + : 'Add Slider', this.mechanismSrv.toggleSlider.bind(this.mechanismSrv), - this.gridUtils.isAttachedToSlider(this.lastRightClick) ? 'remove_slider' : 'add_slider', - jointIsInput && !this.gridUtils.isAttachedToSlider(this.lastRightClick) + this.gridUtils.isAttachedToSlider(this.lastRightClick as RealJoint) + ? 'remove_slider' + : 'add_slider', + jointIsInput && !this.gridUtils.isAttachedToSlider(this.lastRightClick as RealJoint) ) ); //Rev Joint - Always @@ -656,7 +726,13 @@ export class NewGridComponent implements OnDestroy { break; } + case 'SynthesisPose': + this.cMenuItems.push(...this.synthesisMenuItems()); + break; case 'String': //This means grid + // The positions stay on the grid after the linkage is inserted, in + // every mode, so the way to be rid of them is reachable from every mode. + this.cMenuItems.push(...this.synthesisMenuItems()); this.cMenuItems.push( new cMenuItem('Add Link', this.startCreatingLink.bind(this), 'new_link') ); @@ -1024,7 +1100,7 @@ export class NewGridComponent implements OnDestroy { this.mechanismSrv.createCylinderFrom(start, end, mountOn, mountAt); } - setLastRightClick(clickedObj: Joint | Link | string | Force, event?: MouseEvent) { + setLastRightClick(clickedObj: Joint | Link | string | Force | SynthesisPose, event?: MouseEvent) { this.lastRightClick = clickedObj; // The edit context menu acts on the selected object, so in Edit mode a // right-click selects what it will target. In Analyze/Synthesis mode a @@ -2057,23 +2133,14 @@ export class NewGridComponent implements OnDestroy { } onContextMenu($event: MouseEvent) { - if (this.tabService.getCurrentTab() === TabID.SYNTHESIZE) { - this.cMenuItems = []; - return; - } - - if (this.tabService.isAnalysisMode()) { - // Both analysis modes are read-only. Show no edit menu; setLastRightClick - // has already declined to change the selection. - this.cMenuItems = []; - return; - } - - if (this.mechanismSrv.isPlaying == true) { - this.cMenuItems = []; - return; - } - if (this.mechanismSrv.mechanismTimeStep !== 0) { + // Everything on this menu except the synthesis positions edits the drawing, + // and only Edit does that -- setLastRightClick has already declined to move + // the selection in the other modes. The positions are drawn in every mode + // though, so the way to clear them away comes with them. + if (this.tabService.getCurrentTab() !== TabID.EDIT) { + this.cMenuItems = this.synthesisMenuItems(); + if (this.cMenuItems.length === 0) return; + } else if (this.mechanismSrv.isPlaying || this.mechanismSrv.mechanismTimeStep !== 0) { this.cMenuItems = []; //Close the MatContextMenu return; @@ -2098,6 +2165,7 @@ export class NewGridComponent implements OnDestroy { // On the release rather than the press so svg-pan-zoom keeps its own // gesture: a press it never sees is a canvas that cannot be panned. if ( + $event.button === 0 && !wasDragging && !this.synthPressTaken && this.synthesisBuilder.armed && diff --git a/src/app/component/synthesis-panel/synthesis-panel.component.html b/src/app/component/synthesis-panel/synthesis-panel.component.html index 79cc8b77..85887a81 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.html +++ b/src/app/component/synthesis-panel/synthesis-panel.component.html @@ -442,8 +442,8 @@ }
} - + -
-
-
Requirements
- - help_outline - - - {{ requirementCount() }} -
+ @for (rq of requirements(); track rq.key) {
@@ -237,7 +227,7 @@ {{ requirementsBlockingNote() }}
} -
+ @if (showGenerate) { @@ -277,6 +267,7 @@
} + @if (showGallery) {