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..5e771a63 --- /dev/null +++ b/e2e/synthesis-redesign.mjs @@ -0,0 +1,1701 @@ +/** + * 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); + +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', + 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( + "the Positions buttons live in that section's heading, not in a row of their own", + (await page.locator('#synthesisPanel .panel-header__actions .pill').count()) === 1 +); +check( + 'one button at the foot carries whatever the next step is', + (await page.locator('#synthesisPanel .cta').count()) === 1 +); +check( + 'and it names the search before there is anything to search', + (await page.locator('#synthesisPanel .cta').innerText()).includes('Generate') && + (await page.locator('#synthesisPanel .cta').isDisabled()) +); +check( + 'section dividers are a single rule, as they are everywhere else in the app', + await page.evaluate(() => + [...document.querySelectorAll('#synthesisPanel collapsible-subseciton')].every((section) => { + // The header draws the rule; the section must not draw a second one + // against it, or every boundary comes out at twice the weight. + const own = getComputedStyle(section).borderBottomWidth; + const header = getComputedStyle(section.querySelector('.panel-header')).borderTopWidth; + return own === '0px' && header === '1px'; + }) + ) +); +check( + "a section's header, and its hit area, run the full width of the panel", + await page.evaluate(() => { + const panel = document.querySelector('#synthesisPanel').getBoundingClientRect(); + const section = [...document.querySelectorAll('#synthesisPanel collapsible-subseciton')].find( + (s) => s.querySelector('.panel-header')?.textContent.includes('Requirements') + ); + const toggle = section.querySelector('.panel-header__toggle').getBoundingClientRect(); + // And the far edge is the toggle, not a dead strip beside it. + const atEdge = document.elementFromPoint(panel.right - 4, toggle.top + toggle.height / 2); + return ( + Math.abs(toggle.left - panel.left) < 1 && + Math.abs(toggle.right - panel.right) < 1 && + atEdge.classList.contains('panel-header__toggle') + ); + }) +); +check( + 'the strictest requirement is the one offered first', + (await page.locator('#synthesisPanel .req__label').first().innerText()).includes( + 'Reaches all 3 positions' + ) +); +check( + 'the design is laid out as sections that can be folded away', + (await page.locator('#synthesisPanel collapsible-subseciton').count()) === 3 +); + +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() +); + +// Arming from an empty row, before the suite arms from the button: both are +// advertised as ways to place a position, and only the button used to prepare +// the scale, so a ghost armed from the row was drawn at the old one. Left +// disarmed, which is the state the next step expects. +check( + 'an empty position row arms placing, and fits the scale as the button does', + await (async () => { + await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.settings.objectScale = 140; + }); + await page.waitForTimeout(200); + await page.locator('#synthesisPanel .poseRow__n').first().click(); + await page.waitForTimeout(300); + const armed = await panel('(p) => p.design.armed'); + const fitted = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + return Math.abs(grid.settings.objectScale - 60 / grid.svgGrid.getZoom()) < 0.02; + }); + await panel('(p) => p.design.setArmed(false)'); + await page.waitForTimeout(200); + return armed && fitted; + })() +); + +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 ghost is drawn at the size the position will be, not resized by the click', + await page.evaluate(() => { + // Object scale decides how big parts are drawn, and it used to be fitted on + // the first click -- so the ghost was drawn small and the position it + // turned into was drawn large, which looked like clicking had grown it. + const ghost = document.querySelector('.synthGhost path'); + return !!ghost && ghost.getBoundingClientRect().width > 20; + }) +); +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(); +// 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, 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], + [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'))); +// With all three placed there is no button left to put in the Positions +// header, and the empty row it left behind was a gap under the heading. +check( + 'the first position sits under its heading, with no empty row between', + (await page.evaluate(() => { + const section = [...document.querySelectorAll('#synthesisPanel collapsible-subseciton')].find( + (s) => s.querySelector('.panel-header')?.textContent.includes('Positions') + ); + const head = section.querySelector('.panel-header').getBoundingClientRect(); + const row = section.querySelector('.poseRow').getBoundingClientRect(); + return Math.round(row.top - head.bottom); + })) <= 8 +); +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()') +); + +// --- 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 +); + +// Where the panel is looking before the search, so the scroll can be measured. +await page.evaluate(() => document.querySelector('#synthesisPanel .work__scroll').scrollTo(0, 0)); +await page.waitForTimeout(200); +const scrollBefore = await page.evaluate(() => + Math.round(document.querySelector('#synthesisPanel .work__scroll').scrollTop) +); +await page.locator('#synthesisPanel .cta', { hasText: 'Generate solutions' }).click(); +await page.waitForTimeout(400); +check( + 'a search in progress is a bar, with no prose nobody has time to read', + await page.evaluate(() => { + const box = document.querySelector('#synthesisPanel .foot__progress'); + return !!box && box.innerText.trim() === '' && box.querySelectorAll('.sweep').length === 1; + }) +); +await settled(); +await page.waitForTimeout(1200); +check( + 'and when it finishes the panel goes to meet its answer', + await page.evaluate((was) => { + const box = document.querySelector('#synthesisPanel .work__scroll'); + const bottom = Math.round(box.scrollHeight - box.clientHeight); + return bottom === 0 || (Math.round(box.scrollTop) >= bottom - 4 && box.scrollTop > was); + }, scrollBefore) +); +const strict = await panel('(p) => p.solution.candidates().length'); +check('Generate finds four-bars through the three positions', strict > 0, strict); +check( + 'a lone candidate is not given a gallery of one to be compared against', + (await page.locator('#synthesisPanel .card').count()) === (strict > 1 ? strict : 0), + { strict, cards: await page.locator('#synthesisPanel .card').count() } +); +check( + 'nor a heading counting it', + (await page.locator('#synthesisPanel .sect__head .sect__title').count()) === (strict > 1 ? 1 : 0) +); +check( + 'and is called simply the solution, with no letter to go looking past', + (await panel('(p) => p.solutionHeading')) === (strict > 1 ? 'Solution A' : 'Solution'), + await panel('(p) => p.solutionHeading') +); +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( + 'drawn broken, because it is still only an offer', + await page.evaluate(() => { + const bars = [...document.querySelectorAll('#synthesisPreview path.synthBar')]; + // Rendered at all, first. Asking only whether every bar is dashed is a + // question an empty canvas answers yes to, so losing the class -- or the + // paths -- would have read as a pass. + return ( + bars.length >= 3 && + bars.every( + (bar) => + bar.classList.contains('synthBar--proposed') && bar.getAttribute('stroke-dasharray') + ) + ); + }) +); +check( + 'but still nothing has been added to the drawing', + (await grid('(g) => g.mechanismSrv.joints.length')) === 0 +); + +// Unpinning the coupler from the link's ends finds more machines through the +// same three positions. +await page + .locator('#synthesisPanel .req', { hasText: "Coupler pinned at the link's ends" }) + .locator('.req__line') + .click(); +await page.waitForTimeout(300); +await page.locator('#synthesisPanel .cta', { hasText: 'Generate solutions' }).click(); +await settled(); +const loose = await panel('(p) => p.solution.candidates().length'); +check('letting the pins slide finds more of them', loose > strict, { strict, loose }); +check( + 'and now there is a gallery to compare them in', + (await page.locator('#synthesisPanel .card').count()) > 1 +); + +// --- a nudge is a different answer, not a different question ------------- +await panel(`(p) => { + const pose = p.design.getPose(2); + pose.position = { x: pose.position.x + 40, y: pose.position.y + 40, applyMatrix() {} }; + p.design.valueChanges.next(true); +}`); +await page.waitForTimeout(500); +check( + 'nudging a position does not send the reader back to Generate', + await panel('(p) => p.solution.generated') +); +check( + 'and the search keeps up with it by itself', + (await panel('(p) => p.solution.candidates().length')) > 0 +); + +// --- "Driven from" has to change something the reader can see ------------- +const onPinA = await grid('(g) => JSON.stringify(g.synthCanvas.previewGrounds())'); +await page.locator('#synthesisPanel .seg__opt', { hasText: 'Pin D' }).click(); +await page.waitForTimeout(600); +const onPinD = await grid('(g) => JSON.stringify(g.synthCanvas.previewGrounds())'); +check('changing the drive pin moves the input mark on the grid', onPinA !== onPinD); +await page.locator('#synthesisPanel .seg__opt', { hasText: 'Pin A' }).click(); +await page.waitForTimeout(500); + +// --- 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); +// A six-bar is driven by its own crank, so its preview must turn through a +// whole revolution without the driver's links dropping out. +if (driver.dyad) { + const steady = await page.evaluate(() => { + const panel = ng.getComponent(document.querySelector('app-synthesis-panel')); + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const range = panel.solution.drivenRange(); + const held = panel.solution.phase; + const counts = new Set(); + for (let k = 0; k <= 120; k++) { + panel.solution.phase = range.from + ((range.to - range.from) * k) / 120; + counts.add(grid.synthCanvas.previewLinks().length); + } + panel.solution.phase = held; + return { counts: [...counts], span: Math.round(range.to - range.from) }; + }); + check( + 'the six-bar preview holds together across the whole of its travel', + steady.counts.length === 1 && steady.counts[0] === 5, + steady + ); + check('and that travel is a revolution, not a sliver of one', steady.span >= 180, steady); +} + +check( + 'every pin the panel names by letter is lettered on the drawing', + await page.evaluate(() => { + const drawn = new Set( + [...document.querySelectorAll('#synthesisPreviewTags .synthPreviewTag')].map((t) => + t.textContent.trim() + ) + ); + const panel = ng.getComponent(document.querySelector('app-synthesis-panel')); + // Every letter the panel uses -- "Pin A", "Ground A–D", "Coupler B–C" -- + // has to name something the reader can find on the grid. + const named = new Set(); + panel.pinOptions().forEach((o) => (o.label.match(/[A-F]/g) ?? []).forEach((l) => named.add(l))); + panel + .dimensionRows() + .forEach((r) => (r.label.match(/\b[A-F]\b/g) ?? []).forEach((l) => named.add(l))); + return [...named].every((letter) => drawn.has(letter)); + }) +); +check( + 'and a length given in words still puts its unit beside the number', + await panel(`(p) => { + const rows = p.dimensionRows(); + // Every length carries a unit, and none of them carries it at the end of a + // phrase. Rejecting only the phrase let "no unit anywhere" through, which + // is the more obvious way for this to be wrong. + const lengths = rows.filter((r) => /\\d/.test(r.value) && r.label !== 'Coupler pinned'); + return ( + lengths.length > 0 && + lengths.every((r) => / (cm|m|in)$/.test(r.value)) && + !rows.some((r) => /[a-z]{3,} (cm|m|in)$/.test(r.value)) + ); + }`) +); +check( + 'Space activates a focused button, as Space does', + await (async () => { + // A real key press on a real button, judged by whether the button did its + // job. The old check dispatched a synthetic event and asked only whether + // anything called preventDefault on it -- which a synthetic event proves + // nothing about, since it cannot trigger the native activation the global + // shortcut was suppressing in the first place. + const toggle = page.locator('#synthesisPanel .panel-header__toggle').first(); + if (!(await toggle.count())) return false; + const openState = () => + page.evaluate( + () => + !!document + .querySelector('#synthesisPanel .panel-header__toggle mat-icon') + ?.classList.contains('rotate180') + ); + const before = await openState(); + await toggle.focus(); + await page.keyboard.press(' '); + await page.waitForTimeout(350); + const after = await openState(); + // Put the section back the way it was found. + await page.keyboard.press(' '); + await page.waitForTimeout(350); + const restored = await openState(); + return after !== before && restored === before; + })() +); +check( + 'driving from the far pin is no slower to draw than driving from the near one', + await page.evaluate(() => { + const panel = ng.getComponent(document.querySelector('app-synthesis-panel')); + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const held = panel.solution.driveOnFarPin; + const timeIt = () => { + const range = panel.solution.drivenRange(); + const started = performance.now(); + for (let k = 0; k < 20; k++) { + panel.solution.phase = range.from + ((range.to - range.from) * k) / 20; + grid.synthCanvas.previewLinks(); + grid.synthCanvas.couplerTrace(); + } + panel.solution.phase = null; + return performance.now() - started; + }; + panel.solution.setDriveOnFarPin(false); + const near = timeIt(); + panel.solution.setDriveOnFarPin(true); + const far = timeIt(); + panel.solution.setDriveOnFarPin(held); + // Reading the linkage from the far pin re-assesses it, which walks a whole + // revolution. Done per call, that was hundreds of thousands of solves a + // frame and the preview crawled. + return far < Math.max(60, near * 4); + }) +); +check( + 'a driver is only offered when it can turn a whole revolution', + await page.evaluate(() => { + const panel = ng.getComponent(document.querySelector('app-synthesis-panel')); + const held = panel.solution.driverWanted; + const bad = []; + for (const candidate of panel.solution.candidates()) { + panel.solution.pick(candidate.key); + const refused = !!panel.driverRefusal; + panel.solution.driverWanted = true; + const range = panel.solution.drivenRange(); + const fitted = !!panel.solution.dyad(); + panel.solution.driverWanted = held; + // Offered, sized, and yet unable to complete a turn is the combination + // that breaks the "one full turn" promise the panel makes. So is offered + // and not sized at all: the panel raised no objection and then produced + // no driver, which leaves the switch on over a linkage that has none. + if (!refused && fitted && !range.full) bad.push(candidate.key + ' jams'); + if (!refused && !fitted) bad.push(candidate.key + ' offered but unsized'); + } + return panel.solution.candidates().length > 0 && bad.length === 0; + }) +); +check( + 'a drag released off the canvas still ends the gesture', + await (async () => { + const bar = await page.evaluate(() => { + const box = document.querySelector('.synthPose')?.getBoundingClientRect(); + return box + ? { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) } + : null; + }); + // No bar to drag is a broken check, not a passing one -- and the gesture + // has to be seen to start, or "it ended" is true of a drag that never was. + if (!bar) return false; + // Remember where it was: this drag deliberately ends over the panel, and a + // position parked under the panel is one nothing later can right-click. + const before = await panel( + '(p) => JSON.stringify(p.design.getAllPoses().map((q) => [q.position.x, q.position.y, q.thetaDegrees]))' + ); + await page.mouse.move(bar.x, bar.y); + await page.mouse.down(); + await page.mouse.move(bar.x - 40, bar.y + 40, { steps: 4 }); + const began = await grid('(g) => !!g.synthCanvas.dragging'); + await page.mouse.move(200, 500, { steps: 8 }); + await page.mouse.up(); + await page.waitForTimeout(400); + const ended = + began && (await grid('(g) => !g.synthCanvas.dragging && !g.synthSolution.interactive')); + await page.evaluate((was) => { + const design = ng.getComponent(document.querySelector('app-synthesis-panel')).design; + JSON.parse(was).forEach(([x, y, theta], index) => { + const pose = design.getPose(index + 1); + pose.position = { x, y, applyMatrix() {} }; + pose.thetaDegrees = theta; + }); + design.valueChanges.next(true); + }, before); + await page.waitForTimeout(400); + return ended; + })() +); +check( + 'the driver switch agrees with the panel on every candidate and both drive ends', + await (async () => { + /* + Named for what it establishes, which is agreement across this design -- + not the greying, because this design never refuses a driver and so never + turns the switch off. It was called "a driver that cannot be fitted is + greyed" while comparing two values that were both false on every sample. + A design that does refuse is checked at the end of this file, where one + is built for the purpose. + */ + const seen = await page.evaluate(() => { + const panel = ng.getComponent(document.querySelector('app-synthesis-panel')); + const row = [...document.querySelectorAll('#synthesisPanel .row')].find((r) => + r.textContent.includes('Add driver') + ); + if (!row) return null; + const button = row.querySelector('.switch'); + if (!button) return null; + const held = panel.solution.driveOnFarPin; + const out = { agreed: true, enabled: 0, disabled: 0 }; + for (const candidate of panel.solution.candidates()) { + panel.solution.pick(candidate.key); + for (const far of [false, true]) { + panel.solution.setDriveOnFarPin(far); + const refused = !!panel.driverRefusal; + if (button.disabled !== refused) out.agreed = false; + refused ? out.disabled++ : out.enabled++; + } + } + panel.solution.setDriveOnFarPin(held); + return out; + }); + return !!seen && seen.agreed && seen.enabled > 0; + })() +); +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 +); +check( + 'the transport buttons are big enough for the glyphs in them', + await page.evaluate(() => + [...document.querySelectorAll('#synthesisPanel .iconBtn--sm')].every((button) => { + const outer = button.getBoundingClientRect(); + const glyph = button.querySelector('mat-icon').getBoundingClientRect(); + return glyph.height <= outer.height + 0.5 && glyph.width <= outer.width + 0.5; + }) + ) +); + +// --- 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 +); +check( + 'and the strip says what was left on the grid', + (await status()).startsWith('Inserted as a'), + 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( + 'Open and Crossed are one solution with a switch, not two solutions', + await page.evaluate(() => { + const panel = ng.getComponent(document.querySelector('app-synthesis-panel')); + const shown = panel.solution.candidates(); + const constructions = new Set(panel.solution.allAssemblies().map((c) => c.pair)); + // One card per construction, and never two cards for the same one. + return ( + shown.length === new Set(shown.map((c) => c.pair)).size && shown.length <= constructions.size + ); + }) +); +check( + 'a position says which way round it is without changing its silhouette', + await page.evaluate(() => { + const bar = document.querySelector('.synthPose path.synthBar'); + const arrows = document.querySelectorAll('.synthBarArrow').length; + // Two round caps, as every other link on this canvas has, plus a chevron. + return (bar.getAttribute('d').match(/A /g) || []).length === 2 && arrows > 0; + }) +); +check( + 'the gallery keeps its three columns when it is opened out', + await (async () => { + // Opened out, which is the only state the claim is about. The old check + // never opened it, asked whether the closed gallery's columns were "not + // two", and passed because a flex row has no columns at all -- so the one + // thing it was named for was the one thing it did not look at. + const more = page.locator('#synthesisPanel .linkBtn').first(); + if (!(await more.count())) return false; + await more.click(); + await page.waitForTimeout(250); + const opened = await page.evaluate(() => { + const gallery = document.querySelector('#synthesisPanel .gallery--all'); + if (!gallery) return null; + return getComputedStyle(gallery).gridTemplateColumns.split(/\s+/).filter(Boolean).length; + }); + await more.click(); + await page.waitForTimeout(250); + return opened === 3; + })() +); +check( + 'a flurry of presses on Insert commits once, and one Undo takes it back', + await (async () => { + await panel(`(p) => { + const range = p.solution.drivenRange(); + p.solution.setPhase(range.from + (range.to - range.from) * 0.6); + window.__inserts = 0; + const real = p.solution.insert.bind(p.solution); + p.solution.insert = (...args) => { window.__inserts += 1; return real(...args); }; + }`); + const button = page.locator('#synthesisPanel .cta--insert'); + await button.click(); + await button.click({ force: true }); + await button.click({ force: true }); + await page.waitForTimeout(1200); + const committed = await page.evaluate(() => window.__inserts); + // And measured where it actually shows: one press must be one step of + // history. Counting calls missed that inserting saved twice -- once through + // the rebuild and once again afterwards -- so a single Undo stepped back + // over the second save and left the linkage on the grid. + const joints = await grid('(g) => g.mechanismSrv.joints.length'); + await page.evaluate(() => ng.getComponent(document.querySelector('app-top-bar')).undo()); + await page.waitForTimeout(1200); + const afterOneUndo = await grid('(g) => g.mechanismSrv.joints.length'); + await panel('(p) => { p.solution.releaseOwnership(); }'); + await page.waitForTimeout(300); + return committed === 1 && joints > 0 && afterOneUndo === 0; + })() +); +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); +check( + 'and that, unlike a nudge, does send the reader back to Generate', + !(await panel('(p) => p.solution.generated')) +); + +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); + +/* + The four things the fifth review found, each on its own page. + + They run last and in isolation because every one of them is about state -- + what is on the grid, what has been chosen, what is mid-flight -- and a check + that leaves any of that behind is a check that breaks the next one. That has + happened twice in this file already. +*/ + +/** The tour's overlay swallows pointer events until it is sent away. */ +async function dismissTour(p) { + const skip = p.locator('.introjs-skipbutton').first(); + if (await skip.isVisible().catch(() => false)) await skip.click({ force: true }); + await p.evaluate(() => + document.querySelectorAll('.introjs-overlay, .introjs-tooltip').forEach((n) => n.remove()) + ); +} + +const SOLVED = + BASE + '/?2P.VC,1E8.5,0.1011....N_.SD~1uT~1~8,SP~01lk~g_~1z0,SP~DT~1e5~087a,SP~59p~0I0~0OBHJ'; + +/** A page showing that design with its solutions already worked out. */ +async function solvedPage() { + const p = await browser.newPage({ viewport: { width: 1500, height: 950 } }); + p.on('pageerror', (error) => errors.push(String(error))); + await p.goto(SOLVED, { waitUntil: 'domcontentloaded' }); + await waitForReady(p); + // The app does not open on Synthesis, and a design in the URL does not send + // it there either. The tour's overlay eats the click if it is still up. + await dismissTour(p); + await p.locator('.tabButton', { hasText: 'Synthesis' }).click(); + await p.waitForTimeout(700); + await p.locator('#synthesisPanel .cta', { hasText: 'Generate solutions' }).click(); + await p.waitForFunction( + () => !ng.getComponent(document.querySelector('app-synthesis-panel')).solution.generating, + null, + { timeout: 20000 } + ); + return p; +} + +/** Asks the grid, which outlives the panel when the reader leaves Synthesis. */ +const askGrid = (p, fn) => + p.evaluate( + (body) => new Function('grid', body)(ng.getComponent(document.querySelector('app-new-grid'))), + `return (${fn})(grid);` + ); + +const ask = (p, fn) => + p.evaluate( + (body) => + new Function('panel', 'grid', body)( + ng.getComponent(document.querySelector('app-synthesis-panel')), + ng.getComponent(document.querySelector('app-new-grid')) + ), + `return (${fn})(panel, grid);` + ); + +{ + // A pin keeps its name when you change which end drives it. The ground pins + // are the ones to ask: they are the two the control names, and unlike the + // coupler pins they do not move when the linkage is re-posed. + const p = await solvedPage(); + const groundsAt = () => + ask( + p, + `(panel, grid) => + JSON.stringify( + grid.synthCanvas + .previewGrounds() + .filter((j) => j.id === 'A' || j.id === 'D') + .sort((a, b) => (a.id < b.id ? -1 : 1)) + .map((j) => [j.id, Math.round(j.x), Math.round(j.y)]) + )` + ); + await ask(p, '(panel) => panel.solution.setDriveOnFarPin(false)'); + await p.waitForTimeout(250); + const near = await groundsAt(); + await ask(p, '(panel) => panel.solution.setDriveOnFarPin(true)'); + await p.waitForTimeout(400); + const far = await groundsAt(); + check('the letter on a pin does not move when the other end drives', near === far, { + near, + far, + }); + check( + 'and the motor moves to the pin the control names', + await ask( + p, + `(panel, grid) => { + const input = grid.synthCanvas.previewGrounds().find((j) => j.input); + return !!input && input.id === 'D'; + }` + ) + ); + await p.close(); +} + +{ + // Insert one, then look at another: the panel offers to replace, so the one + // being offered has to be the one on screen. + const p = await solvedPage(); + const outcome = await ask( + p, + `(panel, grid) => { + const all = panel.solution.candidates(); + if (all.length < 2) return { few: all.length }; + panel.solution.pick(all[0].key); + panel.insert(); + const afterInsert = grid.synthCanvas.previewLinks().length; + panel.solution.pick(all[1].key); + return { + few: 0, + afterInsert, + afterChoosingAnother: grid.synthCanvas.previewLinks().length, + offersToReplace: panel.primaryLabel === 'Replace on grid', + }; + }` + ); + check( + 'the one on the grid stops being previewed, and a different choice starts being', + outcome.few === 0 && + outcome.afterInsert === 0 && + outcome.afterChoosingAnother > 0 && + outcome.offersToReplace, + outcome + ); + await p.close(); +} + +{ + // Insert waits 220ms to wind the preview home. Choosing something else + // during that time is a change of mind, not a redirection of the press. + const p = await solvedPage(); + const setup = await ask( + p, + `(panel) => { + const all = panel.solution.candidates(); + if (all.length < 2) return { few: all.length }; + panel.solution.pick(all[0].key); + const range = panel.solution.drivenRange(); + panel.solution.setPhase(range.from + (range.to - range.from) * 0.6); + panel.insert(); + return { few: 0, second: all[1].key, joints: panel.mechanismSrv.joints.length }; + }` + ); + if (setup.few === 0) { + await p.waitForTimeout(40); + await ask(p, `(panel) => panel.solution.pick(${JSON.stringify(setup.second)})`); + await p.waitForTimeout(600); + } + const after = await ask(p, '(panel) => panel.mechanismSrv.joints.length'); + check( + 'changing the choice mid-press cancels it rather than building the new one', + setup.few === 0 && setup.joints === 0 && after === 0, + { setup, after } + ); + await p.close(); +} + +{ + // And the letters go onto the grid with the pins. Naming the preview + // honestly is only half of it: what gets built has to agree with what was + // shown, or the fix has moved the mismatch rather than removed it. + const p = await solvedPage(); + const outcome = await ask( + p, + `(panel, grid) => { + panel.solution.setDriveOnFarPin(true); + const shown = grid.synthCanvas + .previewGrounds() + .map((j) => [j.id, Math.round(j.x), Math.round(j.y), !!j.input]); + panel.solution.insert(); + const built = panel.mechanismSrv.joints.map((j) => [ + j.id.toUpperCase(), + Math.round(j.x), + Math.round(j.y), + ]); + const at = (x, y) => + built.find((b) => Math.abs(b[1] - x) < 2 && Math.abs(b[2] - y) < 2)?.[0] ?? null; + return { + // An empty list satisfies every(), and satisfying it is what this + // check was reporting as agreement. + agrees: shown.length >= 2 && shown.every(([id, x, y]) => at(x, y) === id), + shown, + built, + links: panel.mechanismSrv.links.map((l) => l.id), + }; + }` + ); + check('the letters the preview showed are the letters that get built', outcome.agrees, outcome); + check( + 'and every link is still named by its ends in order', + outcome.links.length > 0 && + outcome.links.every((id) => [...id].join('') === [...id].sort().join('')), + outcome.links + ); + await p.close(); +} + +{ + // And beside work that is already there, where A-D are taken and the pins + // have to be drawn under whatever letters are actually free. Labelling the + // preview A-D regardless meant it promised D/C/B/A over pins that arrived + // as E/D/C/B -- every one of the four renamed between being shown and being + // built. + const p = await solvedPage(); + const outcome = await ask( + p, + `(panel, grid) => { + grid.mechanismSrv.mergeToJoints([grid.mechanismSrv.createRevJoint('0', '0')]); + grid.mechanismSrv.updateMechanism(); + panel.solution.setDriveOnFarPin(true); + const shown = grid.synthCanvas + .previewJoints() + .map((j) => [j.id, Math.round(j.x), Math.round(j.y)]); + panel.solution.insert(); + const built = panel.mechanismSrv.joints.map((j) => [j.id, Math.round(j.x), Math.round(j.y)]); + const at = (x, y) => + built.find((b) => Math.abs(b[1] - x) < 2 && Math.abs(b[2] - y) < 2)?.[0] ?? null; + return { + shown, + built, + agrees: shown.length >= 4 && shown.every(([id, x, y]) => at(x, y) === id), + usedLaterLetters: shown.some(([id]) => id > 'D'), + }; + }` + ); + check( + 'and they still agree when the letters have to start after existing work', + outcome.agrees && outcome.usedLaterLetters, + outcome + ); + await p.close(); +} + +{ + // Loose joints are geometry too. Fitting the scale to the zoom resizes + // whatever is already drawn, so "empty" has to mean empty. + const p = await browser.newPage({ viewport: { width: 1500, height: 950 } }); + p.on('pageerror', (error) => errors.push(String(error))); + await p.goto(BASE, { waitUntil: 'domcontentloaded' }); + await waitForReady(p); + await dismissTour(p); + await p.locator('.tabButton', { hasText: 'Synthesis' }).click(); + await p.waitForTimeout(700); + await p.locator('#synthesisPanel .kindCard--on').click(); + await p.waitForTimeout(200); + const before = await ask( + p, + `(panel, grid) => { + grid.mechanismSrv.mergeToJoints([grid.mechanismSrv.createRevJoint('0', '0')]); + grid.mechanismSrv.updateMechanism(); + grid.settings.objectScale = 140; + return { scale: grid.settings.objectScale, joints: grid.mechanismSrv.joints.length }; + }` + ); + await p.waitForTimeout(200); + await p.locator('#synthesisPanel .poseRow__n').first().click(); + await p.waitForTimeout(300); + const after = await ask(p, '(panel, grid) => grid.settings.objectScale'); + check( + 'arming leaves the scale alone when the drawing holds a loose joint', + before.joints === 1 && after === 140, + { before, after } + ); + await p.close(); +} + +{ + // Fitting a driver changes what would be built, so the drawing no longer + // holds what is being looked at -- and the preview has to come back to show + // the difference. + const p = await solvedPage(); + const outcome = await ask( + p, + `(panel, grid) => { + panel.solution.driverWanted = false; + panel.solution.insert(); + const asFourBar = { + joints: panel.mechanismSrv.joints.length, + stale: panel.solution.needsReinsert(), + preview: grid.synthCanvas.previewLinks().length, + }; + panel.solution.toggleDriver(); + const withDriver = { + dyad: !!panel.solution.dyad(), + stale: panel.solution.needsReinsert(), + preview: grid.synthCanvas.previewLinks().length, + label: panel.primaryLabel, + }; + return { asFourBar, withDriver }; + }` + ); + check( + 'adding a driver to an inserted four-bar is a change the panel notices', + outcome.asFourBar.joints === 4 && + outcome.asFourBar.stale === false && + outcome.asFourBar.preview === 0 && + outcome.withDriver.dyad === true && + outcome.withDriver.stale === true && + outcome.withDriver.preview > 0 && + outcome.withDriver.label === 'Replace on grid', + outcome + ); + await p.close(); +} + +{ + // Inserting saves once. Leaving for Edit used to save again, identically, + // so the first Undo stepped onto the state it was already in. + const p = await solvedPage(); + await ask(p, '(panel) => panel.solution.insert()'); + await p.waitForTimeout(500); + const afterInsert = await askGrid(p, '(g) => g.mechanismSrv.joints.length'); + await p.locator('.tabButton', { hasText: 'Edit' }).click(); + await p.waitForTimeout(600); + await p.evaluate(() => ng.getComponent(document.querySelector('app-top-bar')).undo()); + await p.waitForTimeout(900); + // Asked of the grid: the panel is gone, which is the whole point of the check. + const afterUndo = await askGrid(p, '(g) => g.mechanismSrv.joints.length'); + check( + 'one Undo takes the linkage back even after leaving Synthesis', + afterInsert > 0 && afterUndo === 0, + { afterInsert, afterUndo } + ); + await p.close(); +} + +{ + // A design that put four joints on the grid and has since lost one has been + // cut into, and a reload must not forget that: the joints that survived are + // the reader's now, and replacing them without asking loses their work. + const p = await solvedPage(); + const before = await ask( + p, + `(panel) => { + panel.solution.insert(); + const ids = panel.design.ownedJointIds.slice(); + // Through the app's own delete, which works on the selection. + const victim = panel.mechanismSrv.joints.find((j) => j.id === ids[1]); + panel.mechanismSrv.activeObjService.updateSelectedObj(victim); + panel.mechanismSrv.deleteJoint(true); + return { ids, ownership: panel.solution.ownership() }; + }` + ); + const link = await p.evaluate(() => + ng.getComponent(document.querySelector('app-top-bar')).urlGeneration.generateUrlQuery() + ); + const reloaded = await browser.newPage({ viewport: { width: 1500, height: 950 } }); + reloaded.on('pageerror', (error) => errors.push(String(error))); + await reloaded.goto(BASE + '/?' + link, { waitUntil: 'domcontentloaded' }); + await waitForReady(reloaded); + await dismissTour(reloaded); + await reloaded.locator('.tabButton', { hasText: 'Synthesis' }).click(); + await reloaded.waitForTimeout(700); + const after = await ask(reloaded, '(panel) => panel.solution.ownership()'); + check( + 'a linkage cut into before the link was written is still cut into after it is opened', + before.ownership === 'entangled' && after === 'entangled', + { before, after } + ); + await reloaded.close(); + await p.close(); +} + +{ + // Replacing, which is where the letters went wrong last time. Insert takes + // the old linkage away before it builds, so the ids it was holding come back + // -- and counting them as taken made the preview promise E-J over pins that + // arrived as A-F. Also checked straight after an insert, where the same + // arithmetic used to rename the labels off the linkage they describe. + const p = await solvedPage(); + const outcome = await ask( + p, + `(panel, grid) => { + panel.solution.driverWanted = false; + panel.solution.insert(); + const afterFirst = grid.synthCanvas.previewJoints().map((j) => j.id); + const onGrid = panel.design.ownedJointIds.slice(); + const labelledAfterInsert = panel.dimensionRows().map((r) => r.label); + // Now ask for a driver, which makes this a replacement. + panel.solution.toggleDriver(); + const promised = grid.synthCanvas.previewJoints().map((j) => j.id); + panel.solution.insert(); + const built = panel.design.ownedJointIds.slice(); + return { onGrid, afterFirst, labelledAfterInsert, promised, built }; + }` + ); + check( + 'a replacement is built under the letters it was shown under', + outcome.promised.length === 6 && + outcome.built.length === 6 && + outcome.promised.join(',') === outcome.built.join(','), + outcome + ); + check( + 'and the labels do not rename themselves the moment a linkage is inserted', + outcome.onGrid.length === 4 && + outcome.labelledAfterInsert.some((l) => l.includes(outcome.onGrid[0])) && + outcome.labelledAfterInsert.some((l) => l.includes(outcome.onGrid[3])), + { onGrid: outcome.onGrid, labels: outcome.labelledAfterInsert } + ); + await p.close(); +} + +{ + // Ids come round again. Delete one of ours and draw a joint, and the new + // joint takes the letter we just lost -- so the count comes back up, every + // id is present, and the linkage would read as wholly ours with somebody + // else's joint standing in it. A later replace would take that joint away + // without asking, so being cut into has to stick. + const p = await solvedPage(); + const outcome = await ask( + p, + `(panel, grid) => { + panel.solution.driverWanted = false; + panel.solution.insert(); + const ids = panel.design.ownedJointIds.slice(); + const victim = panel.mechanismSrv.joints.find((j) => j.id === ids[3]); + panel.mechanismSrv.activeObjService.updateSelectedObj(victim); + panel.mechanismSrv.deleteJoint(true); + const cutInto = panel.solution.ownership(); + // A joint of the reader's own, which takes the freed letter back. + const replacement = grid.mechanismSrv.createRevJoint('3', '3'); + grid.mechanismSrv.mergeToJoints([replacement]); + grid.mechanismSrv.updateMechanism(true); + const afterwards = panel.solution.ownership(); + const was = { id: replacement.id, x: replacement.x, y: replacement.y }; + // And then actually replace, which is the moment the reader's joint + // would be taken away. Reporting "still entangled" and stopping there + // left the deletion itself untested. + panel.solution.insert(true); + // By where it is, not by what it is called. Removing it frees its letter + // and the very next insert hands that letter straight back out, so a + // joint called D exists either way -- somewhere else, belonging to + // somebody else. Asking only for the name reported the deletion as a + // survival. + const survivor = panel.mechanismSrv.joints.find( + (j) => Math.hypot(j.x - was.x, j.y - was.y) < 1 + ); + return { + ids, + cutInto, + reusedTheLetter: replacement.id === ids[3], + afterwards, + was, + survivedTheReplace: !!survivor, + survivorId: survivor ? survivor.id : null, + }; + }` + ); + check( + 'a joint that takes back a deleted id does not become ours to delete', + outcome.cutInto === 'entangled' && + outcome.reusedTheLetter && + outcome.afterwards === 'entangled' && + outcome.survivedTheReplace, + outcome + ); + await p.close(); +} + +{ + /* + A design that really does refuse a driver, so the greyed switch is tested + against both answers. + + The sweep earlier in this file only ever meets designs that accept one, so + it can say the switch agrees with the panel without ever seeing the switch + turned off -- removing the binding would not have failed it. These three + positions need the input to swing more than half a turn between them, which + is the refusal `driverDyadFor` exists to give. + */ + const p = await browser.newPage({ viewport: { width: 1500, height: 950 } }); + p.on('pageerror', (error) => errors.push(String(error))); + await p.goto(BASE, { waitUntil: 'domcontentloaded' }); + await waitForReady(p); + await dismissTour(p); + await p.locator('.tabButton', { hasText: 'Synthesis' }).click(); + await p.waitForTimeout(600); + await p.locator('#synthesisPanel .kindCard--on').click(); + await p.waitForTimeout(400); + await ask( + p, + `(panel) => { + const S = 200; + [[0, 0, 0], [1, 2, 5], [2, 4, 60]].forEach(([x, y]) => + panel.design.placePose({ x: x * S, y: y * S, applyMatrix() {} }) + ); + [0, 5, 60].forEach((t, i) => (panel.design.getPose(i + 1).thetaDegrees = t)); + panel.design.valueChanges.next(true); + }` + ); + await p.waitForTimeout(300); + await p.locator('#synthesisPanel .cta', { hasText: 'Generate solutions' }).click(); + await p.waitForFunction( + () => !ng.getComponent(document.querySelector('app-synthesis-panel')).solution.generating, + null, + { timeout: 20000 } + ); + const present = await ask( + p, + `(panel) => { + const wanted = panel.solution.candidates().find((c) => c.key === '0:1:-1'); + if (!wanted) return { missing: panel.solution.candidates().map((c) => c.key) }; + panel.solution.pick(wanted.key); + return {}; + }` + ); + // Read after Angular has drawn it: asking in the same turn as the change + // reports the switch as it was before, which is how this first "found" a + // binding that was never broken. + const read = () => + ask( + p, + `(panel) => { + const row = [...document.querySelectorAll('#synthesisPanel .row')].find((r) => + r.textContent.includes('Add driver') + ); + const button = row && row.querySelector('.switch'); + return { refused: !!panel.driverRefusal, disabled: !!(button && button.disabled) }; + }` + ); + await ask(p, '(panel) => panel.solution.setDriveOnFarPin(true)'); + await p.waitForTimeout(400); + const far = await read(); + await ask(p, '(panel) => panel.solution.setDriveOnFarPin(false)'); + await p.waitForTimeout(400); + const near = await read(); + const outcome = { ...present, far, near }; + check( + 'the switch is actually greyed on a design whose driver is refused', + !outcome.missing && + outcome.far.refused && + outcome.far.disabled && + !outcome.near.refused && + !outcome.near.disabled, + outcome + ); + await p.close(); +} + +{ + /* + A joint moved by hand stays moved, across a shared link. + + The record of where insert put each joint was held in memory, so opening a + link produced a design that believed nothing had been touched -- and + Replace put the moved joint back where synthesis had wanted it, silently, + because "untouched" is the one state that needs no warning. Nothing in the + session that made the link is available to check this: it has to survive + the URL. + */ + const p = await solvedPage(); + const moved = await ask( + p, + `(panel) => { + panel.solution.driverWanted = false; + panel.solution.insert(); + const ids = panel.design.ownedJointIds.slice(); + const joint = panel.mechanismSrv.joints.find((j) => j.id === ids[1]); + joint.x += 900; + joint.y -= 700; + panel.mechanismSrv.updateMechanism(true); + return { ids, at: [Math.round(joint.x), Math.round(joint.y)], says: panel.solution.ownership() }; + }` + ); + const link = await p.evaluate(() => + ng.getComponent(document.querySelector('app-top-bar')).urlGeneration.generateUrlQuery() + ); + const opened = await browser.newPage({ viewport: { width: 1500, height: 950 } }); + opened.on('pageerror', (error) => errors.push(String(error))); + await opened.goto(BASE + '/?' + link, { waitUntil: 'domcontentloaded' }); + await waitForReady(opened); + await dismissTour(opened); + await opened.locator('.tabButton', { hasText: 'Synthesis' }).click(); + await opened.waitForTimeout(700); + // A reopened link has the design but not the search, and Insert with nothing + // chosen refuses for that reason rather than the one being tested. + await opened.locator('#synthesisPanel .cta', { hasText: 'Generate solutions' }).click(); + await opened.waitForFunction( + () => !ng.getComponent(document.querySelector('app-synthesis-panel')).solution.generating, + null, + { timeout: 20000 } + ); + const after = await ask( + opened, + `(panel) => { + const ids = panel.design.ownedJointIds.slice(); + const joint = panel.mechanismSrv.joints.find((j) => j.id === ids[1]); + return { + says: panel.solution.ownership(), + at: joint ? [Math.round(joint.x), Math.round(joint.y)] : null, + // 'edited' is the answer that makes Insert ask first rather than act. + wouldAsk: panel.solution.insert() === 'edited', + stillThere: (() => { + const now = panel.mechanismSrv.joints.find((j) => j.id === ids[1]); + return now ? [Math.round(now.x), Math.round(now.y)] : null; + })(), + }; + }` + ); + check( + 'a joint moved by hand is still known to have been moved after a reload', + moved.says === 'edited' && + after.says === 'edited' && + after.wouldAsk && + JSON.stringify(after.at) === JSON.stringify(moved.at) && + JSON.stringify(after.stillThere) === JSON.stringify(moved.at), + { moved, after } + ); + await opened.close(); + await p.close(); +} + +{ + /* + The other half of the same fact, and the half that tells the two apart. + + Without a baseline the safest answer to "has this been moved" is "ask" -- + which is what an unmoved linkage would also get, so a design that has + forgotten everything looks exactly like one that remembers a move. This is + the case that separates them: nothing was touched, so nothing should be + asked. + */ + const p = await solvedPage(); + await ask(p, '(panel) => { panel.solution.driverWanted = false; panel.solution.insert(); }'); + await p.waitForTimeout(400); + const link = await p.evaluate(() => + ng.getComponent(document.querySelector('app-top-bar')).urlGeneration.generateUrlQuery() + ); + await p.close(); + const opened = await browser.newPage({ viewport: { width: 1500, height: 950 } }); + opened.on('pageerror', (error) => errors.push(String(error))); + await opened.goto(BASE + '/?' + link, { waitUntil: 'domcontentloaded' }); + await waitForReady(opened); + await dismissTour(opened); + await opened.locator('.tabButton', { hasText: 'Synthesis' }).click(); + await opened.waitForTimeout(700); + await opened.locator('#synthesisPanel .cta', { hasText: 'Generate solutions' }).click(); + await opened.waitForFunction( + () => !ng.getComponent(document.querySelector('app-synthesis-panel')).solution.generating, + null, + { timeout: 20000 } + ); + const untouched = await ask( + opened, + `(panel) => ({ + says: panel.solution.ownership(), + replacedWithoutAsking: panel.solution.insert() === 'done', + })` + ); + check( + 'a linkage nobody touched is still known to be untouched after a reload', + untouched.says === 'ours' && untouched.replacedWithoutAsking, + untouched + ); + await opened.close(); +} + +{ + // Insert one, replace it with another, undo. What comes back is the first + // linkage exactly as it was written, so it is ours and not an edit -- which + // it could not be while the baseline described whatever had been inserted + // most recently rather than what is actually on the grid. + const p = await solvedPage(); + const outcome = await ask( + p, + `(panel) => { + panel.solution.driverWanted = false; + panel.solution.insert(); + const first = panel.design.ownedJointIds.slice(); + panel.solution.toggleDriver(); + panel.solution.insert(); + return { first, second: panel.design.ownedJointIds.slice() }; + }` + ); + await p.evaluate(() => ng.getComponent(document.querySelector('app-top-bar')).undo()); + await p.waitForTimeout(900); + const restored = await ask(p, '(panel) => panel.solution.ownership()'); + check( + 'undoing a replacement gives back a linkage that is ours, not one that looks edited', + outcome.first.length === 4 && outcome.second.length === 6 && restored === 'ours', + { ...outcome, restored } + ); + await p.close(); +} + +{ + /* + The switch offered instead of a second card has to do the second card's job. + + Collapsing Open and Crossed into one solution is only right if the control + that replaced the extra card actually reaches the other assembly -- and the + check that they are not two cards says nothing about that. Run on a + construction that really has both: on one where the second assembly cannot + be built, the switch is correctly stuck, which would prove nothing either + way. + */ + const p = await solvedPage(); + const picked = await ask( + p, + `(panel) => { + // With defects allowed, which is a setting the panel offers. Held to the + // strict list, this design's constructions each have one assembly that + // can be built, so the switch is correctly stuck and proves nothing. + panel.design.allowDefect = true; + panel.solution.changed.next(); + const both = panel.solution + .candidates() + .find((c) => panel.solution.allAssemblies().filter((a) => a.pair === c.pair).length === 2); + if (!both) return null; + panel.solution.pick(both.key); + return both.key; + }` + ); + await p.waitForTimeout(400); + const row = p + .locator('#synthesisPanel .row', { hasText: 'Assembly branch' }) + .locator('.seg__opt'); + const labels = () => + ask(p, '(panel) => JSON.stringify(panel.branchOptions().map((o) => [o.label, o.active]))'); + const before = picked ? await labels() : null; + let moved = null; + let restored = null; + if (picked && (await row.count()) === 2) { + const off = (await row.nth(0).getAttribute('class')).includes('--on') ? 1 : 0; + await row.nth(off).click(); + await p.waitForTimeout(400); + moved = await labels(); + await row.nth(off === 1 ? 0 : 1).click(); + await p.waitForTimeout(400); + restored = await labels(); + } + check( + 'the Open/Crossed switch reaches the assembly it replaced a card for', + !!picked && (await row.count()) === 2 && moved !== before && restored === before, + { picked, before, moved, restored } + ); + await p.close(); +} + +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); diff --git a/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.html b/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.html index 6519a50e..8a396032 100644 --- a/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.html +++ b/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.html @@ -1,9 +1,15 @@
@if (!hideHeader()) { - + +
+ + +
}
diff --git a/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.scss b/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.scss index f47cf899..285c1e36 100644 --- a/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.scss +++ b/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.scss @@ -12,19 +12,46 @@ $foreground: map.get($theme, foreground); .collapsibleSubsection { + // The header is a row: the toggle takes all of it unless a section has put + // something beside it, and then that something takes what it needs. + /* Edge to edge. The inset used to be here, which meant the toggle's own + background -- and so its hover, and its hit area -- stopped short of both + sides of the panel, leaving a strip beside the header that looked part of + it and did nothing. The inset belongs to the text, so it now sits on the + button's padding instead. */ .panel-header { - padding: 0 15px; - border-style: none; + display: flex; + align-items: center; height: 40px; - width: 100%; + box-sizing: border-box; + border-top: 1px solid #eceef5; + } + + .panel-header__actions { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + padding-right: 15px; + + // Empty unless a section fills it, and an empty box must take no room. + &:empty { + display: none; + } + } + + .panel-header__toggle { + padding: 0 15px; + border-style: none; + height: 100%; + flex: 1 1 auto; + min-width: 0; //border-color: mat.get-color-from-palette($primary-palette, 500); //Hide left and right borders //border-left: 0; //border-right: 0; background-color: transparent; - border-top: 1px solid #eceef5; - //border-width: 1px; display: flex; justify-content: space-between; align-items: center; diff --git a/src/app/component/bottombar/bottombar.component.ts b/src/app/component/bottombar/bottombar.component.ts index 9afd87da..f6c5e5fe 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 pins 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 solution meets the current requirements'; + 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. * 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/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 @@
@@ -936,145 +937,374 @@ } - - @if (showSynthesis()) { - - @for (pose of synthesisBuilder.getAllPoses(); track pose) { - - - + + @if (showSynthesis() && synthCanvas.regionBox(); as region) { + + + @for (corner of region.corners; track corner.id) { + + } + + } + + + @if (showSynthesis() && synthesisBuilder.regionDraw) { + + } + + + @if (showSynthesis() && synthCanvas.hoverGhostLinks(); as ghosts) { + + @for (lk of ghosts; track $index) { + + } + + } + + + @if (showSynthesis() && synthSolution.generated) { + + } + + + @if (showSynthesis()) { + + + @for (lk of synthCanvas.previewLinks(); track $index) { + + } + + @for (gd of synthCanvas.previewGrounds(); track gd.id) { + + @if (!gd.input) { + + } + + @if (gd.input) { + + } + + } + @for (jt of synthCanvas.previewJoints(); track jt.id) { + + } + + + @for (jt of synthCanvas.previewJoints(); track jt.id) { + + [attr.x]="svgGrid.scaleWithZoom(11)" + [attr.y]="svgGrid.scaleWithZoom(-9)" + [style.font-size.px]="svgGrid.scaleWithZoom(13)" + > + {{ jt.id }} + + } + + + } + + + @for (bar of synthCanvas.poseBars(); track bar.id) { + + + + @if (bar.arrow) { + + } + + + + + + + - - + } + + + @if (showSynthesis() && synthCanvas.ghostBar(); as ghost) { + + + @if (ghost.arrow) { + + } + + } + + + @if (showSynthesis() && synthCanvas.selectionBox(); as box) { + + + + + + + + + } + + + @if (showSynthesis()) { + + @for (chip of synthCanvas.poseChips(); track chip.id) { + + + + + - @for (nodePos of [pose.posBack, pose.posFront]; track nodePos) { - - } - - + - - - - - - - - - - Pose {{ pose.id }} - - + {{ chip.text }} + - + } + + } + + + @if (showSynthesis() && 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 8c8a417b..d69f68b3 100644 --- a/src/app/component/new-grid/new-grid.component.scss +++ b/src/app/component/new-grid/new-grid.component.scss @@ -232,6 +232,218 @@ 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; + } + + // Positions, previewed solutions and ghosts are all drawn the way the drawing + // draws a link: a filled outline at a quarter of the object scale, the same + // 0.7 fill opacity, and a stroke of its own colour. They used to be strokes on + // a line, which came out the same width by arithmetic and subtly unlike every + // other bar on the canvas. + .synthBar { + fill-opacity: 0.7; + + // A solution that is only proposed. Its fill is the colour it will be + // built in, so the reader can see what they are choosing; its outline is + // broken, so an inserted linkage -- drawn solid, by the drawing's own + // renderer -- cannot be mistaken for one that is still an offer. + &--proposed { + fill-opacity: 0.45; + } + + // The one that is being edited takes the accent, like everything else that + // is being edited on this canvas. + &--sel { + stroke: mat.m2-get-color-from-palette($accent-palette, 500); + } + } + + // Outside Synthesis the positions are a record rather than a control: what + // the linkage on the grid was designed to do, kept beside it. Faint, and out + // of the way of every click -- a bar this size left hit-testable would sit + // over whatever is beneath it and swallow presses meant for a real part. The + // canvas menu is how they are taken away from here. + #synthesis.shadow { + opacity: 0.4; + pointer-events: none; + + .synthPose { + cursor: default; + } + } + + // The datum each position's coordinates are measured from. Drawn as a + // crosshair rather than another circle, so it is not read as a third pin. + .synthRef { + stroke: rgba(0, 0, 0, 0.55); + + line { + stroke: inherit; + } + } + + .synthRefRing { + fill: #fff; + stroke: inherit; + } + + // The chevron inside a position, pointing at its front end. + .synthBarArrow { + fill: none; + stroke: rgba(0, 0, 0, 0.42); + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; + } + + .synthPosePin { + fill: #ffecb2; + stroke: rgba(0, 0, 0, 0.4); + pointer-events: none; + } + + // The pins' letters, which the panel refers to by name. Haloed rather than + // boxed: they sit over whatever the drawing has behind them, and a box that + // size beside every pin would be more chrome than linkage. + .synthPreviewTag { + fill: rgba(0, 0, 0, 0.72); + stroke: #fff; + stroke-width: 3; + paint-order: stroke; + font-family: Roboto, sans-serif; + font-weight: 500; + 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. 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; + 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 dccdc040..64426449 100644 --- a/src/app/component/new-grid/new-grid.component.ts +++ b/src/app/component/new-grid/new-grid.component.ts @@ -43,10 +43,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'; @@ -121,7 +119,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); @@ -133,6 +131,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); @@ -157,7 +157,7 @@ export class NewGridComponent implements OnDestroy { private svgGridElement!: HTMLElement; public cMenuItems: cMenuItem[] = []; - public lastRightClick: Joint | Link | Force | string = ''; + public lastRightClick: Joint | Link | Force | string | SynthesisPose = ''; public lastRightClickCoord: Coord = new Coord(0, 0); public lastLeftClick: Joint | Link | Force | string | SynthesisPose = ''; @@ -218,10 +218,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. */ @@ -326,11 +323,77 @@ export class NewGridComponent implements OnDestroy { //This is for debug purposes, do not make anything else static! } - // whether to show the synthesis poses + /** Whether Synthesis owns the canvas: its handles, ghost and preview. */ showSynthesis(): boolean { return this.tabService.getCurrentTab() === TabID.SYNTHESIZE; } + /** + * Whether the positions are on the grid at all. + * + * They outlive the mode. A design that has produced a linkage is the record + * of what that linkage was *for*, and hiding it the moment the reader goes to + * look at the motion leaves them with a machine and no account of it. Outside + * Synthesis they are a shadow -- faint, and not in the way of anything -- and + * the canvas menu is where they are taken away. + */ + showSynthesisPositions(): boolean { + return this.showSynthesis() || this.synthesisBuilder.getAllPoses().length > 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 @@ -472,7 +535,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); @@ -603,10 +666,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 @@ -626,7 +693,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') ); @@ -994,7 +1067,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 @@ -1019,19 +1092,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. @@ -1331,6 +1391,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; @@ -1346,27 +1412,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; @@ -2055,23 +2100,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; @@ -2085,7 +2121,46 @@ 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 ( + $event.button === 0 && + !wasDragging && + !this.synthPressTaken && + this.synthesisBuilder.armed && + this.pressDidNotTravel($event) + ) { + const at = this.svgGrid.screenToSVGfromXY($event.clientX, $event.clientY); + this.synthesisBuilder.placePose(at); + // A position added is a different question, not a different answer. + this.synthSolution.invalidate(); + this.dragState.release(); + this.mechanismSrv.save(); + return; + } + // A click on the canvas that took hold of nothing lets go of whatever + // position was selected -- the same way clicking empty grid clears the + // selection everywhere else in the app. Only when placing is not armed: + // armed, the click has already been spent dropping a position. + if ( + !wasDragging && + !this.synthPressTaken && + !this.synthesisBuilder.armed && + this.synthesisBuilder.selectedPose !== 0 && + this.pressDidNotTravel($event) && + this.objectKind(this.lastLeftClick) === 'String' + ) { + this.synthesisBuilder.selectedPose = 0; + } + } // 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 @@ -2175,9 +2250,146 @@ 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; + } + + /** + * Let go of any canvas gesture still in flight. + * + * Called from the window-level release in SvgGridService, for the presses + * that come up somewhere the canvas cannot hear. Safe to call when nothing + * is happening -- it is the same tidying `mouseUp` does. + */ + releaseCanvasGestures(): void { + this.bgDrag = undefined; + if (this.synthCanvas.dragging) { + this.synthCanvas.release(); + this.mechanismSrv.save(); + } + } + + /** + * 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. */ + /** + * Hold the pointer for the rest of the gesture. + * + * The canvas hears `pointerup` only on itself, so a drag released anywhere + * else -- over the panel, off the window -- was never told it had ended: the + * gesture stayed live, which left the canvas unpannable and the search frozen + * until something else was clicked. Capture makes the release come back here + * whatever it happens over. + */ + private holdPointer(event: PointerEvent): void { + const target = event.target; + if (target instanceof Element && target.hasPointerCapture !== undefined) { + try { + target.setPointerCapture(event.pointerId); + } catch { + // Some pointers cannot be captured; the window-level release below is + // what covers those. + } + } + } + + startPoseGesture(event: PointerEvent, id: number, mode: 'move' | 'rotate' | 'length'): void { + // The left button only. A right-press is asking for the menu, and taking + // the pointer for a drag took the menu with it. + if (event.button !== 0) return; + event.stopPropagation(); + this.synthPressTaken = true; + this.holdPointer(event); + 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 { + if (event.button !== 0) return; + event.stopPropagation(); + this.synthPressTaken = true; + this.holdPointer(event); + 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/component/synthesis-panel/synthesis-panel.component.html b/src/app/component/synthesis-panel/synthesis-panel.component.html index e0d130f1..924fd5ae 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.html +++ b/src/app/component/synthesis-panel/synthesis-panel.component.html @@ -1,118 +1,561 @@ -
- - - 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() }}
+
+
+ +
+ + + +
+ Length + + help_outline + + +
- - @if (synthesisBuilder.isPoseDefined(i)) { -
- + Fixed reference + + help_outline + + +
+ @for (opt of referenceOptions(); track opt.value) { + + } +
+
+
+ + + + + + @if (showAddButton) { + + } + @if (canDuplicate) { + + } + + + @for (i of rows; track i) { + +
+ + X + + Y + + ⊾ + + + {{ rowStatusIcon(i) }} + +
} - - @if (synthesisBuilder.getFirstUndefinedPose() === i) { -
- Create Pose - + + + + + @for (rq of requirements(); track rq.key) { +
+ + + @if (rq.hasRegion) { +
+
+ {{ regionSummary() }} + +
+ +
+ X + + Y + + W + + H + + {{ lengthUnit }} +
+
+ }
} +
+ + + @if (requirementsBlocking) { +
+
+ rule + {{ requirementsBlockingNote() }} +
+
+ } + + + + @if (showResults) { + + @if (showGallery || !hasSolution || showNoCandidateReason) { +
+ + @if (showGallery || !hasSolution) { +
+
{{ candidateHeading() }}
+
+ } + + @if (showNoCandidateReason) { +
+ error_outline + {{ noCandidateReason() }} +
+ } + + @if (showGallery) { + + + @if (hasMoreCandidates) { + + } + } +
+ } + + @if (hasSolution) { +
+
{{ solutionHeading }}
+ +
+ 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.dimensionsOpen) { +
+ @for (d of dimensionRows(); track d.label) { +
+ {{ d.label }} + {{ d.value }} +
+ } +
+ } +
+
+ } + } +
+ + +
+ @if (hasSolution) { +
+
+ @if (showGallery) { + {{ solutionName }} + } + {{ previewNote() }} + {{ angleLabel() }} + + +
+
+
+
+ @for (tk of poseTicks(); track $index) { +
+ } + +
+
+ } + + + @if (solution.generating) { +
+
+
+ } +
+ +
- } - - - - - @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..f99db59a 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.scss +++ b/src/app/component/synthesis-panel/synthesis-panel.component.scss @@ -1,105 +1,1332 @@ @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; + /* No rule of its own: the first section's header draws one directly + under this, and two together read as a heavy line. */ + } + + .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; + } + + /* The collapsible sections carry their own header, so this styles the box + around whichever kind a section is -- a plain
for the results, + a collapsible for the design above them. */ + collapsible-subseciton.sect { + display: block; + padding: 0; + /* Its header already draws the rule above it. Carrying `.sect`'s own rule + as well put two 1px lines against each other at every boundary, which + is why the dividers in this panel came out twice the weight of the ones + everywhere else in the app. */ + border-bottom: 0; + + /* The band runs the whole width of the panel. The inset used to be on + this row, which meant the toggle's own background -- and so its hover, + and its hit area -- stopped short of both sides, leaving a strip beside + the header that looked part of it and did nothing. The inset belongs to + the text, so it sits on the button's padding instead. */ + .panel-header { + width: 100%; + box-sizing: border-box; + padding: 0; + font-size: 14px; + font-weight: 500; + } + + .panel-header__toggle { + padding: 10px 15px; + height: auto; + } + + .panel-content { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 15px 10px; + } + } + + /* A note that belongs to the section above it rather than to one of its + own: no heading, and a little more air over it than under. */ + .sect--note { + padding-top: 10px; + } + + .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); + + /* A button, so it needs the label's size said again: `font: inherit` + reaches the row, which sets none, and the three rows in the solution + section came out at two different sizes. */ + &--btn { + border: 0; + background: none; + padding: 0; + font-family: inherit; + font-size: 14px; + color: rgba(0, 0, 0, 0.72); + cursor: pointer; + + /* Greyed with the switch beside it: a driver that will not fit + these positions is not something to press. */ + &:disabled { + color: rgba(0, 0, 0, 0.35); + cursor: default; + } + } + } + + &__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; + } + + /* 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: 1 1 0; + min-width: 0; + } + + &--angle { + flex: 1.12 1 0; + min-width: 0; + } + + &--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; + + /* 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; + cursor: pointer; + + &--sel { + border-color: #ffc107; + background: #fffdf5; + } + + &__n { + flex: 0 0 18px; + display: flex; + align-items: center; + justify-content: center; + height: 18px; + border: 0; + padding: 0; + font-family: inherit; + cursor: pointer; + + &:focus-visible { + outline: 2px solid $indigo; + outline-offset: 2px; + } + + 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 9px; + font-size: 14px; + color: rgba(0, 0, 0, 0.6); + + &--angle { + flex: 0 0 12px; + 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; + display: flex; + align-items: center; + justify-content: center; + border: 0; + padding: 0; + background: none; + font-size: 17px; + width: 17px; + height: 17px; + color: #9aa0ac; + cursor: pointer; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } + + &--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; + + /* Offered but not available -- a driver that will not fit these + positions. The reason is on the switch itself, on hover; this is the + part that says not to bother pressing it. */ + &:disabled { + cursor: default; + background: rgba(0, 0, 0, 0.09); + } + + &:disabled .switch__knob { + background: #f2f2f2; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + } + + &__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); + } + + /* Said once, for all four. */ + &__unit { + flex: 0 0 auto; + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + } + } + + /* --- 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; + + /* Opened out, the same three columns rather than two: the cards are there + to be compared with each other, and a set that re-flows as it grows + makes the reader find their place again for no reason. */ + &--all { + display: grid; + grid-template-columns: repeat(3, 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; + + /* A dead point is the one number on this card that decides whether the + linkage is a machine or an ornament. */ + &--binds { + color: $amber-ink; + } + } + } + + .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; + } + + /* The search reporting itself, directly over the button that started it. + A bar and nothing else: the sentence that used to sit under it described + the search accurately and was gone before anybody finished reading it. */ + .foot__progress { + padding: 0 4px; + } + + .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); + } + + /* The box Material gives an icon is 24px; in a 22px button its glyph was + clipped, which took the top off the direction arrow. The button is the + size of the thing inside it. */ + &--sm { + width: 24px; + height: 24px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + overflow: visible; + } + } + } + + .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; + 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..b7769655 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.ts +++ b/src/app/component/synthesis-panel/synthesis-panel.component.ts @@ -1,715 +1,1243 @@ -import { Component, OnInit, ChangeDetectionStrategy, inject } from '@angular/core'; +import { + Component, + OnInit, + OnDestroy, + ChangeDetectionStrategy, + ElementRef, + inject, + viewChild, +} 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 { CollapsibleSubsecitonComponent } from '../BLOCKS/collapsible-subseciton/collapsible-subseciton.component'; import { MechanismService } from '../../services/mechanism.service'; -import { RealLink } from '../../model/link'; +import { NotificationService } from '../../services/notification.service'; 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; + /** Whether it stalls at a dead point on the way between positions. */ + binds: boolean; + metric: string; +} + +/** The one message whose answers act on this panel after it has been raised. */ +const REPLACE_WARNING = 'synthesis.replace-edited'; + +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 ground pins. 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, CollapsibleSubsecitonComponent], }) -export class SynthesisPanelComponent implements OnInit { +export class SynthesisPanelComponent implements OnInit, OnDestroy { private fb = inject(FormBuilder); mechanismSrv = inject(MechanismService); - synthesisBuilder = inject(SynthesisBuilderService); + private notify = inject(NotificationService); + 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(); - } - }); + /** The panel's scrolling half, so a finished search can be scrolled to. */ + private readonly workScroll = viewChild>('workScroll'); + + private subs: Subscription[] = []; + private syncing = false; + private frame: number | undefined; + + poseForm = this.fb.group( + { + length: [''], + p1x: [''], + p1y: [''], + p1theta: [''], + p2x: [''], + p2y: [''], + p2theta: [''], + p3x: [''], + p3y: [''], + p3theta: [''], + }, + { updateOn: 'blur' } + ); + + regionForm = this.fb.group({ rx: [''], ry: [''], rw: [''], rh: [''] }, { updateOn: 'blur' }); + + ngOnInit(): void { + this.readFromModel(); + + this.subs.push( + this.design.valueChanges.subscribe(() => { + this.readFromModel(); + this.claimWheel(); + // Deliberately not invalidating here. Moving a position changes the + // answer, not the question, and the search keeps up with it on its own + // -- being sent back to Generate for a one-millimetre nudge made the + // button the thing the reader spent the session pressing. The sites + // that really do change the question say so themselves. + this.solution.changed.next(); + }) + ); - // set up subscriptions to synthesis form changes to update model - this.synthesisPoseForm.valueChanges.subscribe((value) => { - // prevent infinite loop - if (this._alreadyHandlingPoseChange) return; + 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.record(); + }) + ); - this._alreadyHandlingPoseChange = true; + this.subs.push( + this.regionForm.valueChanges.subscribe(() => { + if (this.syncing) return; + this.readRegionFromForm(); + }) + ); - this.synthesisBuilder.updatePosesFromForm(value); - this.updateFormFromModel(); + this.subs.push( + SettingsService._objectScale.subscribe(() => { + this.design.getAllPoses().forEach((pose) => pose.recompute()); + }) + ); - if (this.synthesisBuilder.isFullyDefined()) { - this.synthesisFunction(); - } + // A finished search puts its answer at the bottom of a panel the reader is + // looking at the top of. Nothing about the design above it has changed, so + // there is no cue that anything happened down there -- and the button that + // was just pressed is in the foot, which does not move. So the panel goes + // to meet it. + this.subs.push( + this.solution.changed.subscribe(() => { + const searching = this.solution.generating; + const finished = this.wasSearching && !searching && this.solution.generated; + this.wasSearching = searching; + if (finished) this.revealTheAnswer(); + }) + ); + } + + /** Whether the last thing this panel heard about was a search in progress. */ + private wasSearching = false; - this._alreadyHandlingPoseChange = false; + /** + * Scroll the design out of the way and the solution into view. + * + * Deferred a frame: the results are rendered by the change detection this + * notification is part of, so the box is still its old height until that has + * run and there is nothing yet to scroll to. + */ + private revealTheAnswer(): void { + setTimeout(() => { + const box = this.workScroll()?.nativeElement; + if (!box) return; + const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + box.scrollTo({ top: box.scrollHeight, behavior: still ? 'auto' : 'smooth' }); }); + } + + ngOnDestroy(): void { + this.gone = true; + // Take the replace warning with us: its buttons act on this panel, and left + // on screen they were still clickable after it was gone. + this.notify.live + .filter((one) => one.id === REPLACE_WARNING) + .forEach((one) => this.notify.dismiss(one.key)); + this.subs.forEach((s) => s.unsubscribe()); + if (this.frame) cancelAnimationFrame(this.frame); + // Stop any wind-back, and stop what it was going to do afterwards: a commit + // that lands after the panel is gone acts on a drawing nobody is looking at. + this.windingBack = false; + if (this.windBackFrame) cancelAnimationFrame(this.windBackFrame); + // Leaving the tab hands the wheel back whatever state placing was left in. + this.svgGrid.setWheelZoomEnabled(true); + } + + /** + * 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); + } + + // --- units --------------------------------------------------------------- - SettingsService._objectScale.subscribe((val) => { - this.synthesisBuilder.getAllPoses().forEach((pose) => { - pose.recompute(); + private lengthText(model: number): string { + return this.nup.formatModelLength(model, this.settings.lengthUnit.getValue()); + } + + private angleText(degrees: number): string { + return this.nup.formatValueAndUnit(degrees, this.settings.angleUnit.getValue()); + } + + /** 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); + } + + get lengthUnit(): string { + return this.nup.unitLabel(this.settings.lengthUnit.getValue()); + } + + 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 }); + }); + } + const r = this.design.region; + this.regionForm.setValue( + { + rx: this.plain(r.x), + ry: this.plain(r.y), + rw: this.plain(r.w), + rh: this.plain(r.h), + }, + { emitEvent: false } + ); + this.syncing = false; + } + + private readRegionFromForm(): void { + const unit = this.settings.lengthUnit.getValue(); + // The four boxes hold bare numbers -- the unit is said once, on the row -- + // so a value is read as being in whatever unit the drawing is in. A reader + // who types one anyway is still understood. + const parsed = (['rx', 'ry', 'rw', 'rh'] as const).map((key) => { + const typed = (this.regionForm.get(key)!.value ?? '').trim(); + return this.nup.parseModelLengthString( + /[a-z]/i.test(typed) ? typed : `${typed} ${this.lengthUnit}`, + 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.record(); } - 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()); + /** + * 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(); } - private convertA(value: number): string { - return this.nup.formatValueAndUnit(value, this.settings.angleUnit.getValue()); + // --- stage --------------------------------------------------------------- + + get isChooser(): boolean { + return this.design.stage === 'chooser'; } - // given synthesis model, update form values to sync with model - updateFormFromModel() { - this._alreadyHandlingPoseChange = true; + startMotionSynthesis(): void { + this.design.stage = 'working'; + this.design.setArmed(false); + } - let poses = this.synthesisBuilder.poses; - let controls = this.synthesisPoseForm.controls; + backToChooser(): void { + this.design.stage = 'chooser'; + this.design.regionDraw = false; + this.design.setArmed(false); + this.solution.playing = false; + } - controls.length.setValue(this.convertL(this.synthesisBuilder.length)); + 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 ? 'solution' : 'solutions'}`; + } + + // --- 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; + } + + get showAddButton(): boolean { + return !this.design.isFullyDefined(); + } + + get addLabel(): string { + return this.design.armed ? 'Cancel' : 'Add position ' + this.nextPositionNumber; + } + + /** + * Arm or disarm placing, fitting the scale on the way in. + * + * Every route to arming comes through here. Object scale is what parts are + * drawn at, and it was being fitted on the first click -- after the ghost had + * already been drawn at the old one, so the click appeared to grow the + * position. Fitting it here happens before the ghost first appears; putting + * it in the button's own handler missed the other way in, which is clicking + * an empty position row. + * + * Only on a drawing with nothing in it: the scale is global, and resizing + * someone's work because a position is about to be placed is a change nobody + * asked for. + */ + private arm(armed: boolean): void { + // Joints as well as links. A joint on its own belongs to no link, so a + // drawing holding nothing but loose joints counted as empty -- and fitting + // the scale to the zoom resized them under the reader, which is the one + // thing this was supposed to avoid doing to existing work. + const drawingIsEmpty = + this.mechanismSrv.links.length === 0 && this.mechanismSrv.joints.length === 0; + if (armed && drawingIsEmpty && !this.design.getAllPoses().length) { + this.svgGrid.updateObjectScale(); + } + this.design.setArmed(armed); + } + + toggleArmed(): void { + this.arm(!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); + } + + isSelectedRow(i: number): boolean { + return this.design.selectedPose === i; + } + + /** 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; + } + + 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 -- so it is + // a way of arming, and has to prepare the same way the button does. + this.arm(true); + } + } + + removeRow(event: Event, i: number): void { + event.stopPropagation(); + if (!this.isPlaced(i)) return; + this.design.removePose(i); + this.solution.invalidate(); + this.record(); + } + + duplicateLast(): void { + this.design.duplicateLastPose(); + this.solution.invalidate(); + this.record(); + } + + /** + * Whether the chosen linkage reaches this position on the assembly it is + * drawn in. Undefined when there is nothing to check it against. + */ + reached(i: number): boolean | undefined { + const cand = this.solution.chosen(); + if (!cand || !this.isPlaced(i)) return undefined; + return cand.onBranch[i - 1]; + } - 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)); + rowStatusIcon(i: number): string { + if (!this.isPlaced(i)) { + return this.isPreviewingRow(i) ? 'ads_click' : 'radio_button_unchecked'; } - 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)); + 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'; } - 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 ok = this.reached(i); + if (ok === undefined) { + if (!this.design.isFullyDefined()) return 'Waiting for all three positions'; + return this.solution.generated + ? 'No solution to check this position against yet' + : 'Generate solutions to check this position'; } + return ok + ? 'The chosen solution passes through this position on its own assembly' + : 'The chosen solution reaches this position only on its other assembly — a branch defect'; + } - this._alreadyHandlingPoseChange = false; + // --- requirements -------------------------------------------------------- + + requirements(): Requirement[] { + const length = this.plain(this.design.length); + return [ + { + key: 'defect', + on: !this.design.allowDefect, + label: 'Reaches all 3 positions on one assembly', + detail: this.design.allowDefect + ? 'Solutions that have to be taken apart between positions are listed too' + : 'The linkage never has to be taken apart', + toggle: () => this.toggleRequirement('allowDefect'), + }, + { + // Named for where the coupler is pinned rather than for how long it + // comes out, because the length is a consequence and the pinning is + // the choice -- and because the panel already has a Length field, for + // the end-effector link, which is a different bar. + key: 'coupler', + on: this.design.endsOnly, + label: "Coupler pinned at the link's ends", + detail: this.design.endsOnly + ? `The coupler is the whole ${length} ${this.lengthUnit} of the end-effector link` + : 'Pins are tried at a range of places along the link and past its ends', + toggle: () => this.toggleRequirement('endsOnly'), + }, + { + key: 'region', + on: this.design.constrain, + label: 'Ground pins inside a region', + detail: this.design.constrain + ? 'Both ground pins must land in the box on the grid' + : 'Ground pins may land anywhere', + toggle: () => this.toggleRequirement('constrain'), + hasRegion: this.design.constrain, + }, + ]; } - synthesisPoseForm = this.fb.group( - { - cor: ['1'], - length: [''], - p1x: [''], - p1y: [''], - p1theta: [''], - p2x: [''], - p2y: [''], - p2theta: [''], - p3x: [''], - p3y: [''], - p3theta: [''], - }, - { - updateOn: 'blur', + 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(); + } + + /** + * 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 === 0 ? 'nothing narrowing the search' : `${n} of 3 narrowing the search`; + } - //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: [''], - }); + regionSummary(): string { + return 'Drag the box on the grid, or its corners.'; + } + + toggleRegionDraw(): void { + this.design.regionDraw = !this.design.regionDraw; + this.design.setArmed(false); + } - // for html to get current pose as a number - getCurrentPose(): number { - return this.synthesisBuilder.selectedPose; + /** Named only when the requirements are what stands between reader and answer. */ + get requirementsBlocking(): boolean { + return this.showResults && this.solution.candidates().length === 0; } - setCurrentPose(pose: number) { - this.synthesisBuilder.selectedPose = pose; + requirementsBlockingNote(): string { + // Every one of these ends by offering to move a position, because that is + // the way out that does not cost a requirement -- and when nothing fits at + // all, it is usually because the three are close to a straight line. + const moveOne = + 'Moving the middle position further off the line between the other two also opens it up.'; + if (this.design.constrain) { + return ( + 'No solution keeps both ground pins inside the region. Widen it, move it, or switch it ' + + `off. ${moveOne}` + ); + } + if (this.design.endsOnly && !this.design.allowDefect) { + return ( + 'No solution satisfies both. Unpinning the coupler from the ends of the link is the ' + + 'usual first one to give: the three positions stay exactly where they are, and only ' + + `where the coupler is attached to the link changes. ${moveOne}` + ); + } + if (this.design.endsOnly) { + return ( + "No four-bar whose coupler is pinned at the link's ends passes through these three " + + `positions. Unpin it to let the coupler be any length. ${moveOne}` + ); + } + if (!this.design.allowDefect) { + return ( + 'Every four-bar through these three positions has to be taken apart between them. ' + + `Accept a branch defect to see them. ${moveOne}` + ); + } + return ( + 'Nothing was found even with every requirement relaxed. The three positions are too close ' + + `to a straight line. ${moveOne}` + ); } - getFormIDPoseX(pose: number): string { - if (pose == 1) return 'p1x'; - else if (pose == 2) return 'p2x'; - else return 'p3x'; + // --- generating ---------------------------------------------------------- + + get showGenerate(): boolean { + return this.design.isFullyDefined() && !this.solution.generated; } - getFormIDPoseY(pose: number): string { - if (pose == 1) return 'p1y'; - else if (pose == 2) return 'p2y'; - else return 'p3y'; + generateNote(): string { + const parts: string[] = []; + if (!this.design.allowDefect) parts.push('all three positions on one assembly'); + if (this.design.endsOnly) parts.push("the coupler pinned at the link's ends"); + if (this.design.constrain) parts.push('both ground pins in the region'); + return parts.length + ? 'Search for four-bars with ' + parts.join(', ') + '.' + : 'Search for four-bars through these three positions.'; } - getFormIDPoseTheta(pose: number): string { - if (pose == 1) return 'p1theta'; - else if (pose == 2) return 'p2theta'; - else return 'p3theta'; + generate(): void { + this.solution.generate(); } /** - * Take back the linkage this visit to Synthesis last produced. + * The one button at the foot of the panel. * - * 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. + * Generate and Insert are the same button at two moments: they are the step + * the reader takes next, and only one of them is ever the step. Two buttons + * in two places meant hunting for whichever one was live, and the one in the + * scroll area could be scrolled off the screen at the moment it mattered. */ - 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 ?? '') - ); - 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.synthesisBuilder.synthesisedIds = { joints: [], links: [] }; + get primaryIsGenerate(): boolean { + // Named for the step that is coming even before it can be taken. With two + // positions placed the button used to read "Replace on grid", greyed -- + // which is true and useless: what is actually next is the search, and the + // reader is one position away from it. + return !this.solution.generated; + } + + get primaryLabel(): string { + if (this.primaryIsGenerate) { + return this.solution.generating ? 'Searching…' : 'Generate solutions'; + } + return this.insertLabel; } - /** 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)); + get primaryIcon(): string { + if (this.primaryIsGenerate) { + return this.solution.generating ? 'hourglass_top' : 'auto_awesome'; } - return taken; + return this.solutionIsOnGrid ? 'check' : 'add_circle_outline'; } - /** Whether there is a linkage on the grid for the driver controls to act on. */ - hasLinkage(): boolean { - return this.synthesisBuilder.isFullyDefined(); + get primaryDisabled(): boolean { + if (!this.primaryIsGenerate) return !this.canInsert; + return this.solution.generating || !this.design.isFullyDefined(); + } + + primaryAction(): void { + if (this.primaryIsGenerate) this.generate(); + else this.insert(); + } + + // --- results ------------------------------------------------------------- + + get showResults(): boolean { + return this.design.isFullyDefined() && this.solution.generated; } /** - * Add a driver to the four-bar, or take it off again. + * Whether the gallery is worth drawing. * - * 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. + * With one candidate there is nothing to compare it against, and a row of + * one card asks the reader to choose between a thing and nothing. The + * solution below says everything the card would have. */ - toggleDriver(): void { - this.synthesisBuilder.driverWanted = !this.synthesisBuilder.driverWanted; - if (this.hasLinkage()) this.synthesisFunction(); + get showGallery(): boolean { + return this.solution.candidates().length > 1; + } + + /** + * What to call the solution being looked at. + * + * The letters exist to tell candidates apart in the gallery. With only one, + * there is nothing to tell it apart from, and "Solution A" invites the reader + * to go looking for B. + */ + get solutionHeading(): string { + return this.showGallery ? `Solution ${this.solutionName}` : 'Solution'; } - /** 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 solution meets the requirements'; + const strict = this.solution.strictCount; + // Counted as what can be browsed, not as what was found. The gallery shows + // the best few; a heading naming ten when eight is the most anybody can + // open is a heading describing something else. + const shown = list.length; + const capped = strict > shown ? ` (best ${shown} shown)` : ''; + if (strict) { + const label = strict === 1 ? 'solution reaches' : 'solutions reach'; + return `${strict} ${label} all 3 positions${capped}`; + } + return `${shown} solution${shown === 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.'; + } + + 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)); + } - this.mechanismSrv.mergeToJoints(madeJoints); - this.mechanismSrv.mergeToLinks(madeLinks); - this.synthesisBuilder.synthesisedIds = { - joints: madeJoints.map((joint) => joint.id), - links: madeLinks.map((link) => link.id), + 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)}`, + // The same construction, whichever way it is closed: a card is the + // solution, and the assembly is a switch inside it. + selected: !!picked && picked.pair === c.pair, + defectFree: c.defectFree, + binds: c.binds, + reachText: c.defectFree + ? 'all 3, one assembly' + : c.binds && c.onBranchCount === 3 + ? 'all 3, but stalls between them' + : `branch defect · ${c.onBranchCount} of 3`, + metric: + (c.binds ? `stalls at ${c.minTransmission}° · ` : `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.allAssemblies(); + 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 }[] { + // Named by the letters those two pins are drawn under. `chosen()` rather + // than `driven()`: this asks which end to read the linkage from, so it has + // to name the ends of the unswapped one, and the far pin is the one that + // is called D whichever end is currently driving. + const e = this.solution.previewLetters(this.solution.chosen()); + return [ + { label: `Pin ${e.A}`, far: false, active: !this.solution.driveOnFarPin }, + { label: `Pin ${e.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) - ); + /** + * Why a driver cannot be fitted to this solution, if it cannot. + * + * Asked whether or not one is wanted, so the switch can be turned off before + * it is pressed rather than after. A refusal used to arrive as a paragraph + * under a switch that had just been flipped -- a large piece of text + * explaining that the thing the reader had asked for had not happened. + */ + get driverRefusal(): string | undefined { + return this.solution.driverAvailability(); + } - 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) - ); + /** Whether the driver is both wanted and possible. */ + get driverOn(): boolean { + return this.solution.driverWanted && !this.driverRefusal; + } - //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; - } - } - } + toggleDriver(): void { + if (this.driverRefusal) return; + this.solution.toggleDriver(); + } + + 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; + } - index = index + 1; + dimensionRows(): { label: string; value: string }[] { + const c = this.solution.driven(); + if (!c) return []; + // Every bar named by the pins at its ends, and every one of those letters + // drawn on the linkage beside it. Two of these named their pins and two + // did not, so half the list pointed at something on the grid and half + // asked the reader to work out which bar was meant. + // Named by the letters actually drawn beside those pins. Driving from the + // far pin reads the same linkage from the other end, and naming the bars + // after the fields rather than the pins renamed all four of them. + const e = this.solution.previewLetters(c); + const rows = [ + { label: `Crank ${e.A}–${e.B}`, value: this.lengthText(c.r1) }, + { label: `Coupler ${e.B}–${e.C}`, value: this.lengthText(c.d) }, + { label: `Rocker ${e.C}–${e.D}`, value: this.lengthText(c.r2) }, + { label: `Ground ${e.A}–${e.D}`, value: this.lengthText(c.g) }, + { + label: 'Coupler pinned', + value: describeCouplerPins(c, this.design.length, this.lengthUnit), + }, + ]; + const dyad = this.solution.dyad(); + if (dyad) { + rows.push({ label: `Driver crank ${e.E}–${e.F}`, value: this.lengthText(dyad.crankLength) }); + rows.push({ + label: `Driver coupler ${e.F}–${e.B}`, + 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 = this.solution.drivenRange(); + 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.drivenRange().from); + } - //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.drivenRange().to); + } - //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 range = this.solution.drivenRange(); + const span = Math.max(1e-6, range.to - range.from); + return (((this.solution.currentPhase() - 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 = this.solution.drivenRange(); + const span = Math.max(1e-6, range.to - range.from); + // Along whatever is being turned: with a driver fitted the track is the + // driver crank's own revolution, so the marks have to be where the + // positions fall on *that*, not on the four-bar's angle. + return this.solution.positionPhases().map((phase, i) => { + let a = phase ?? range.from; + 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] }; + }); + } + + angleLabel(): string { + const phase = this.solution.currentPhase(); + return Math.round(((phase % 360) + 360) % 360) + '°'; + } + + previewNote(): string { + const cand = this.solution.driven(); + if (!cand) return ''; + const range = this.solution.drivenRange(); + // Which crank is turning, because with a driver fitted it is not the + // four-bar's: naming it "crank rotation" beside a six-bar left the reader + // to guess which of the two the transport was scrubbing. + const crank = this.solution.dyad() ? 'driver crank' : 'crank'; + return range.full + ? `full ${crank} rotation` + : `${crank} rocks through ${Math.round(range.to - range.from)}°`; + } + + // --- committing ---------------------------------------------------------- + + /** + * Insert is offered whenever there is a solution to insert. + * + * It used to switch off once something had been inserted, which made the + * mode a one-shot: the whole point of comparing seven linkages is to try one, + * look at it, and try the next. Inserting again revises the machine this + * design already put on the grid rather than adding another. + */ + get canInsert(): boolean { + return this.hasSolution; + } + + get insertLabel(): string { + if (!this.solution.inserted) return 'Insert into grid'; + return this.solutionIsOnGrid ? 'Inserted into grid' : 'Replace on grid'; + } + + /** Whether what is on the grid is the solution now being looked at. */ + get solutionIsOnGrid(): boolean { + return this.solution.inserted && !this.solution.needsReinsert(); + } + + /** + * Wind the preview back to where the linkage starts, then do something. + * + * The preview can be parked anywhere in its cycle, and what gets built is + * always the start pose -- so committing from halfway round replaced the + * linkage on screen with a differently-posed one between two frames, which + * reads as a jump rather than as the thing being put down. It goes home + * first, at the same 220ms the app eases everything else home at. + */ + private windBackThen(then: () => void): void { + const cand = this.solution.driven(); + const home = this.solution.startPhase(); + if (!cand || this.solution.phase === null || Math.abs(this.solution.phase - home) < 0.5) { + this.solution.phase = null; + then(); + return; + } + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + this.solution.phase = null; + then(); + return; + } + this.solution.playing = false; + const from = this.solution.phase; + /* + The short way round. + + On a crank that turns fully, home can be a degree ahead and three hundred + and fifty-nine behind, and interpolating the raw numbers took the long + way: pressing Insert near the end of the cycle spun the linkage almost a + whole revolution backwards to get somewhere it was nearly at. The app's + own easeToStart picks the shorter direction for the same reason. + */ + let delta = home - from; + if (this.solution.drivenRange().full) { + // Into (-180, 180]: at exactly half a turn both ways are the same length, + // and forwards is the one that matches which way the crank was going. + while (delta > 180) delta -= 360; + while (delta <= -180) delta += 360; + } + const started = performance.now(); + const DURATION = 220; + const step = () => { + // The panel can be left while this is running -- and was: a press, a + // switch to Edit, and the commit landed afterwards, onto a drawing the + // reader had moved on from. + if (!this.windingBack) return; + const t = Math.min(1, (performance.now() - started) / DURATION); + // Ease out, so it settles rather than stopping dead. + const eased = 1 - (1 - t) * (1 - t); + this.solution.phase = from + delta * eased; + this.solution.changed.next(); + if (t < 1) { + this.windBackFrame = requestAnimationFrame(step); + return; + } + this.windingBack = false; + this.windBackFrame = undefined; + this.solution.phase = null; + then(); + }; + this.windingBack = true; + this.windBackFrame = requestAnimationFrame(step); + } + + /** Whether this panel has been left, so nothing deferred acts on it. */ + private gone = false; - //intercept - let c1 = midpoint_line1.y + slope1 * midpoint_line1.x; - let c2 = midpoint_line2.y + slope2 * midpoint_line2.x; + /** Whether a wind-back is running, so a second press cannot start another. */ + private windingBack = false; + private windBackFrame: number | undefined; - //intersection point - let x1 = (c1 - c2) / (-slope2 + slope1); - let y1 = -slope1 * x1 + c1; + /** + * What is about to be built, as one string. + * + * Insert defers by 220ms to wind the preview home, and used to work out what + * to build only once it got there -- so choosing a different card during + * those 220ms built that one instead, from a press that was aimed at the one + * before it. Nothing else on the panel takes that long to act, so there is + * no reason for the reader to expect the press to still be in flight. + */ + private commitKey(): string { + return [ + this.solution.chosen()?.key ?? '', + this.solution.driveOnFarPin, + !!this.solution.dyad(), + this.design.searchKey(), + ].join('|'); + } + + insert(force = false): void { + // One commit per press. Each press used to start its own wind-back, so a + // double-press committed twice -- rebuilding the linkage, and writing two + // entries into the history for one intention. + if (this.windingBack) return; + // Only the first press winds back; the retries from the warning below are + // already home. + if (!force && this.solution.phase !== null) { + const pressedOn = this.commitKey(); + this.windBackThen(() => { + // Changing the choice mid-flight cancels the press rather than + // redirecting it: the reader has just said they want to look at + // something else, and building either one from here would be building + // something they did not ask for. + if (this.commitKey() !== pressedOn) return; + this.insert(force); + }); + return; + } + const outcome = this.solution.insert(force); + if (outcome === 'edited') { + // Not a refusal and not a silent overwrite. The reader moved those joints + // by hand, and only they know whether that work still matters -- so the + // two things they could mean are on the message. + this.notify.warning( + REPLACE_WARNING, + `${this.solutionName} would replace the linkage on the grid, and it has been moved by ` + + `hand since Synthesis put it there. Those changes would be lost.`, + { + // Guarded as well as dismissed on the way out. The message outlives + // the press that raised it by design -- it waits to be answered -- + // but its answers act on this panel, and a panel that has been left + // is not one to act on. + actions: [ + { label: 'Replace it', run: () => !this.gone && this.insert(true) }, + { + label: 'Keep it, insert a new one', + run: () => { + if (this.gone) return; + this.solution.releaseOwnership(); + this.insert(); + }, + }, + ], + } + ); + return; + } + // No `record()` here. Inserting rebuilds the mechanism through + // `updateMechanism(true)`, and the `true` is a save -- so recording again + // wrote two entries for one press, and one Undo left the linkage on the + // grid because it only stepped back over the second of them. + } + + undoInsert(): void { + this.solution.undoInsert(); + } + + insertedNote(): string { + const kind = this.solution.dyad() ? 'six-bar' : 'four-bar'; + return `Left on the grid as a ${kind}. Change a position and insert again to revise it.`; + } + + 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; } } diff --git a/src/app/selected-tab.service.ts b/src/app/selected-tab.service.ts index be1afbcb..7a8ad9db 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 @@ -91,20 +103,18 @@ export class SelectedTabService { this.settings.animating.next(false); } - 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(); - // reset flag - this.synthesis.modifiedMechanism = false; - } + /* + Leaving Synthesis no longer saves. + + It used to, because the old mode built onto the grid as the reader typed + and nothing else was going to write that down. The redesign only ever + touches the drawing through Insert, Undo-insert and Delete, and each of + those saves for itself -- so this wrote a second, identical entry on the + way out, and the first Undo after inserting appeared to do nothing at all + because it stepped back onto the same state. + + The flag that was supposed to gate it had not been set by anything for as + long as the redesign has existed, and is gone with it. + */ } } 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/keyboard-shortcuts.service.ts b/src/app/services/keyboard-shortcuts.service.ts index d640b459..89b0d265 100644 --- a/src/app/services/keyboard-shortcuts.service.ts +++ b/src/app/services/keyboard-shortcuts.service.ts @@ -233,6 +233,8 @@ export class KeyboardShortcutsService { // A key pressed into a text field belongs to that field: Delete means // delete a character there, and Undo means undo the typing. if (this.typingInAField(event)) return; + // And Space or Enter on something that answers them itself belongs to it. + if (this.targetAnswersKey(event)) return; // And a key pressed while something stands over the canvas belongs to that // thing, or to nothing. These are the canvas's keys: with the Templates // dialog open, Delete was removing the selected joint behind it, out of @@ -276,4 +278,27 @@ export class KeyboardShortcutsService { // the digits that pick a mode here are letters to it. return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable; } + + /** + * Whether the focused thing answers this key by being what it is. + * + * A button is activated by Space and by Enter -- that is not a shortcut + * anybody assigned, it is what a button is. This service answered Space + * wherever it was pressed and called `preventDefault` on the way, which took + * that activation away from every button in the app: reachable by keyboard, + * focusable, outlined, and inert when pressed. Nothing noticed for as long as + * nobody tried to drive the app without a mouse. + * + * Only these two keys, and only for the things that natively consume them, so + * every other shortcut still works with a button focused. + */ + private targetAnswersKey(event: KeyboardEvent): boolean { + if (event.key !== ' ' && event.key !== 'Spacebar' && event.key !== 'Enter') return false; + const target = event.target as HTMLElement | null; + if (!target) return false; + const tag = target.tagName; + if (tag === 'BUTTON' || tag === 'SUMMARY') return true; + if (tag === 'A' && target.hasAttribute('href')) return true; + return target.getAttribute('role') === 'button'; + } } diff --git a/src/app/services/mechanism.service.ts b/src/app/services/mechanism.service.ts index 959ef96b..2916ac7e 100644 --- a/src/app/services/mechanism.service.ts +++ b/src/app/services/mechanism.service.ts @@ -860,8 +860,18 @@ export class MechanismService { * joints are filled, and past those a two-letter name is used; a drawing with * fifty-three live joints has run out of single letters honestly. */ - determineNextLetter(additionalLetters?: string[]) { + /** + * The next free joint id. + * + * `additionalLetters` are treated as already used, for callers handing out + * several at once. `freedLetters` are treated as free although the joints + * holding them are still on the grid: a caller that is about to take those + * joints away wants the ids they will release, not the ids that would be + * left if they stayed. + */ + determineNextLetter(additionalLetters?: string[], freedLetters?: string[]) { const taken = new Set(this.joints.map((joint) => joint.id)); + freedLetters?.forEach((letter) => taken.delete(letter)); additionalLetters?.forEach((letter) => taken.add(letter)); let highest = -1; diff --git a/src/app/services/svg-grid.service.ts b/src/app/services/svg-grid.service.ts index 0b15d59e..98a3f365 100644 --- a/src/app/services/svg-grid.service.ts +++ b/src/app/services/svg-grid.service.ts @@ -167,9 +167,44 @@ export class SvgGridService { }); this.guardAgainstStuckPan(root); this.restoreMissingPointerDown(root); + this.releaseGesturesOnLostPointer(); this.scaleToFitLinkage(false); } + /** + * End a canvas gesture the canvas never saw end. + * + * A press that goes down on the drawing can come up anywhere -- over a panel, + * outside the window, or not at all if the browser cancels it. The canvas + * hears `pointerup` only on itself, so those releases went unheard and the + * gesture stayed live: the pan guard kept refusing to pan and the search + * stayed frozen mid-drag until something else was clicked. + * + * On the window, and in the capture phase, so it runs wherever the release + * lands and whatever else claims it. + */ + private releaseGesturesOnLostPointer(): void { + const release = () => NewGridComponent.instance?.releaseCanvasGestures(); + window.addEventListener('pointerup', release, true); + window.addEventListener('pointercancel', release, true); + } + + /** + * 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 +385,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/driver-dyad.ts b/src/app/services/synthesis/driver-dyad.ts index cede682a..f6587fc2 100644 --- a/src/app/services/synthesis/driver-dyad.ts +++ b/src/app/services/synthesis/driver-dyad.ts @@ -190,7 +190,14 @@ function distance(a: Coord, b: Coord): number { * The shortest arc holding every angle: found as the complement of the widest * empty gap between neighbours, which is the one stretch nothing has to cross. */ -function smallestArcContaining(angles: number[]): { start: number; span: number } { +/** + * The shortest arc that contains every one of the given directions. + * + * Found by looking for the widest gap between them and taking what is left: + * the answer wraps, so it cannot be had from the smallest and largest values. + * Angles in radians; the arc runs from `start` for `span`. + */ +export function smallestArcContaining(angles: number[]): { start: number; span: number } { const sorted = angles.map((angle) => ((angle % TAU) + TAU) % TAU).sort((a, b) => a - b); let widest = -1; let after = 0; diff --git a/src/app/services/synthesis/synthesis-builder.service.ts b/src/app/services/synthesis/synthesis-builder.service.ts index 3fdf1d24..75a4e54f 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,53 +21,121 @@ 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. + * + * 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 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 coupler must be pinned to the end-effector link's own ends. + * + * 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 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; + + /** The region, in model units, as a box with its origin at bottom-left. */ + /** + * Which joints on the grid this design put there. + * + * Synthesis keeps hold of what it inserted so that the loop the mode is for + * -- tweak a position, search again, insert -- revises one machine rather + * than leaving a trail of them. By id, so it can never reach anything else + * in a drawing that holds several. + * + * It rides in the URL with the rest of the design, for two reasons. Undo + * steps the drawing back past an insert, and ownership has to step back with + * it or the panel claims a machine that is no longer there. And a reload has + * to come back holding what it held, or the next Insert quietly makes a + * duplicate of the linkage already on the grid. + */ + public ownedJointIds: string[] = []; + + /** + * Whether some of what this design put on the grid has since gone. * - * 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. + * A missing owned joint is what tells `ownership` the linkage has been cut + * into, and the ids alone stop being able to say so the moment they are + * written down: a reload drops the ids of joints that are not there, and a + * shortened list is indistinguishable from a smaller linkage. Worse, keeping + * the missing ids instead would claim whatever new joint next takes that + * letter. So the fact is carried on its own, and rides in the URL with the + * rest of the design. */ - public synthesisedIds: { joints: string[]; links: string[] } = { joints: [], links: [] }; + public ownershipPartial = false; /** - * Whether the linkage should be built with a driver on it, and which of its - * two ground pins the drive belongs to. + * Where each of those joints was put, in the order the ids are held. * - * 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. + * The baseline for "has this been moved by hand", and it has to be written + * down for the same reason the ids are. Kept in memory only, it vanished on + * reload and every owned linkage read as untouched -- so a joint the reader + * had dragged somewhere was quietly dragged back by the next Replace, and + * the warning that exists to stop exactly that never appeared. */ - public driverWanted: boolean = false; - public driveOnFarPin: boolean = false; + public ownedAt: { x: number; y: number }[] = []; - /** Why the last build could not fit a driver, for the panel to show. */ - public driverRefusal: string | undefined; + 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 - _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 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; this._length = 5 * MODEL_SCALE; - this._selectedPose = 1; + this._selectedPose = 0; // start with no defined poses this.poses = {}; @@ -129,26 +197,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 +274,199 @@ 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 = kept.length ? Math.min(this.selectedPose, kept.length) : 0; + 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 = 0; + this.stage = 'chooser'; + this.armed = false; + this.regionDraw = false; + this.endsOnly = true; + this.allowDefect = false; + this.constrain = false; + this.ownedJointIds = []; + this.ownedAt = []; + this.ownershipPartial = 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 }; + ownedJointIds: string[]; + ownedAt?: { x: number; y: number }[]; + ownershipPartial?: boolean; + }): 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; + this.ownedJointIds = decoded.ownedJointIds; + this.ownedAt = decoded.ownedAt ?? []; + this.ownershipPartial = !!decoded.ownershipPartial; + 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 + ); + }); + // 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-candidates.spec.ts b/src/app/services/synthesis/synthesis-candidates.spec.ts new file mode 100644 index 00000000..3364beef --- /dev/null +++ b/src/app/services/synthesis/synthesis-candidates.spec.ts @@ -0,0 +1,257 @@ +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)]; + +/** + * A motion whose four-bars close at all three positions and seize doing it. + * + * Every candidate this produces reaches all three, and every one of them binds + * -- which makes it the corpus for the half of `defectFree` that MOTION cannot + * exercise, because nothing in MOTION binds at all. + */ +const BINDING_MOTION: PosePoint[] = [pose(0, 0, 0), pose(1, 3, 55), pose(-2, 5, 110)]; + +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 when all three are on one assembly and it does not bind', () => { + const { candidates } = enumerateCandidates({ + poses: MOTION, + length: LENGTH, + endsOnly: false, + }); + candidates.forEach((c) => { + // Both halves of the rule. Stating it as `onBranchCount === 3` alone was + // true of this corpus only because nothing in it bound, so the half that + // rejects a linkage for seizing up was never being checked. + expect(c.defectFree).toBe(c.onBranchCount === 3 && !c.binds); + expect(c.onBranchCount).toBe(c.onBranch.filter(Boolean).length); + }); + }); + + it('and turns down one that reaches all three but seizes on the way', () => { + const { candidates } = enumerateCandidates({ + poses: BINDING_MOTION, + length: LENGTH, + endsOnly: false, + }); + // Stating the rule against MOTION alone left half of it untested: nothing + // there binds, so `onBranchCount === 3` and the real rule agreed on every + // candidate and the `!binds` term could have been deleted without a test + // noticing. Here all three are reached and every one of them binds. + const reachesAll = candidates.filter((c) => c.onBranchCount === 3); + expect(reachesAll.length).toBeGreaterThan(0); + expect(reachesAll.every((c) => c.binds)).toBe(true); + expect(reachesAll.some((c) => c.defectFree)).toBe(false); + }); + + 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); + // The whole sequence. Asking only whether the first is A is satisfied by + // every one of them being A, which is not an order. + expect(ranked.map((c) => c.name).join('')).toBe('ABCDEFGH'.slice(0, ranked.length)); + 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..1d9276ec --- /dev/null +++ b/src/app/services/synthesis/synthesis-candidates.ts @@ -0,0 +1,663 @@ +import { Coord } from 'src/app/model/coord'; +import { MODEL_SCALE } from 'src/app/model/render-scale'; +import { smallestArcContaining } from './driver-dyad'; + +/** + * 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; + +/** + * The worst transmission angle a solution may have and still be offered. + * + * The transmission angle is how squarely the coupler pushes the rocker. As it + * goes to zero the linkage approaches a dead point: the force needed to keep + * it moving goes to infinity, and the pin it drives races across the drawing + * for a fraction of a degree of crank. Such a four-bar passes through its + * positions on paper and stalls at them in metal -- which is exactly what + * "it claims all three and only gets to the first" looks like. + * + * Fifteen degrees is deliberately permissive. Machine design usually wants + * forty-five and treats thirty as the floor for a working linkage; this only + * rules out the ones that are stuck. + */ +export const BINDING_ANGLE = 15; + +/** + * 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; + /** + * Whether this is the far-pin reading, in which the fields hold the opposite + * physical pins. The letters drawn on the grid follow the pins, not the + * fields, so anything that labels a pin must ask `endLetters`. + */ + swappedEnds?: boolean; + /** 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; + /** Whether that angle is so tight the linkage stalls rather than turns. */ + binds: boolean; + /** The span of crank travel the angle above was measured over. */ + stroke: { from: number; to: 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); + // The one honest failure: the coupler and the rocker cannot reach each other, + // so the loop does not close at all. That is where travel ends. + if (!pair) return null; + const want = sign === undefined ? cand.sign : sign; + /* + Choose between the two intersections, always. + + This used to look for the one on the wanted side and give up if it found + neither -- which sounds equivalent and is not. Near a dead point the two + intersections converge, `cross` goes to zero, and its sign is whatever the + rounding says; both can come back on the same side, and the search then + reported the loop as unclosable at an angle it closes at perfectly well. + Downstream that read as travel ending early, or as the linkage jumping to + its other assembly for a frame -- a solution that promised three positions + and stopped at the first. + + Picking by which side the first intersection is on always yields one of the + two, and yields the same one the sign test did wherever the sign test meant + anything. Where it did not -- the two points within rounding of each other + -- either is right. + */ + const C = Math.sign(cross(B, cand.D, pair[0])) === want ? pair[0] : pair[1]; + 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. + */ +/** + * The stretch of crank travel the three positions occupy, with a little margin. + * + * Which stretch this is decides everything the transmission angle then says, + * and it is not simply the smallest and largest of the three angles. + * + * On a crank that turns fully the angles live on a circle, so the arc holding + * them wraps: taking their smallest and largest can name the long way round. + * Three positions clustered near the top of the circle came out as a stroke of + * three hundred and thirty degrees rather than eighty, and the linkage was then + * judged on travel it never makes between them -- which rejected candidates + * that are perfectly good, and the other way about accepted ones that bind + * where they actually work. So the arc is the shortest one containing all + * three, found by looking for the widest gap between them and taking the rest. + * + * On a crank that only rocks there is no wrap to worry about: the travel has + * ends, and the positions lie between them. + */ +function poseStroke(cand: FourBarCandidate): [number, number] { + const MARGIN = 5; + if (cand.range.full) { + const arc = smallestArcContaining(cand.thetas.map((theta) => (theta * Math.PI) / 180)); + const start = (arc.start * 180) / Math.PI; + const span = (arc.span * 180) / Math.PI; + return [start - MARGIN, start + span + MARGIN]; + } + const placed = cand.thetas + .map((theta) => intoTravel(theta, cand.range)) + .filter((theta): theta is number => theta !== null); + if (!placed.length) return [cand.range.from, cand.range.to]; + return [ + Math.max(cand.range.from, Math.min(...placed) - MARGIN), + Math.min(cand.range.to, Math.max(...placed) + MARGIN), + ]; +} + +/** + * The worst transmission angle over a stretch of crank travel, exactly. + * + * This was sampled every two degrees, and the answer rounded, which is not + * good enough for the one number that decides whether a linkage is a machine + * or an ornament: near a travel limit the angle falls away steeply, and a + * candidate reported at sixteen degrees was measured independently at four and + * a half between two of the samples. + * + * It does not need sampling. With the ground link fixed, the distance between + * the crank pin and the far ground pin is + * + * s(theta)^2 = g^2 + r1^2 - 2*g*r1*cos(theta - theta_AD) + * + * and the angle at the coupler-rocker joint follows from that distance alone + * by the cosine rule. Folded into the first quadrant, the angle is worst where + * |cos| is largest, which is where s is at an extreme -- and s is extreme only + * at the ends of the interval or where the crank points directly at, or + * directly away from, the far ground pin. Four angles to check, not a hundred. + */ +export function worstTransmission(cand: FourBarCandidate, from: number, to: number): number { + const towardsD = (Math.atan2(cand.D.y - cand.A.y, cand.D.x - cand.A.x) * 180) / Math.PI; + const candidates = [from, to]; + // The two interior extremes, brought into the interval a turn at a time. + [towardsD, towardsD + 180].forEach((critical) => { + for (let turn = -2; turn <= 2; turn++) { + const at = critical + turn * 360; + if (at > from && at < to) candidates.push(at); + } + }); + + let worst = 90; + candidates.forEach((deg) => { + const t = (deg * Math.PI) / 180; + const bx = cand.A.x + cand.r1 * Math.cos(t); + const by = cand.A.y + cand.r1 * Math.sin(t); + const span = Math.hypot(bx - cand.D.x, by - cand.D.y); + const cosine = (cand.d * cand.d + cand.r2 * cand.r2 - span * span) / (2 * cand.d * cand.r2); + let mu = (Math.acos(Math.max(-1, Math.min(1, cosine))) * 180) / Math.PI; + if (mu > 90) mu = 180 - mu; + worst = Math.min(worst, mu); + }); + return worst; +} + +/** + * Whether a crank angle lies inside a stretch of continuous travel. + * + * Angles come out of `atan2` in (-180, 180] while a walked range can run + * anywhere, so the same direction has to be tried a turn either way before it + * can be called out of reach. + */ +function withinTravel(theta: number, range: { from: number; to: number; full: boolean }): boolean { + return intoTravel(theta, range) !== null; +} + +/** + * The same crank direction, expressed inside a stretch of travel. + * + * Angles come out of `atan2` in (-180, 180] while a walked range runs wherever + * the walk took it, so the same direction has to be tried a turn either way + * before it can be placed -- or called out of reach. Returns null when it is + * genuinely outside. + */ +function intoTravel( + theta: number, + range: { from: number; to: number; full: boolean } +): number | null { + const slack = 1e-6; + for (let turn = -2; turn <= 2; turn++) { + const at = theta + turn * 360; + if (at >= range.from - slack && at <= range.to + slack) return at; + } + return range.full ? theta : null; +} + +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); + + // 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 position 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 }; + + /* + Reached means driveable to, not merely solvable at. + + These two came apart badly. The loop can close at a crank angle the crank + cannot actually turn to: the circles intersect again on a stretch of the + curve the linkage can only get onto by being taken apart, which is the very + thing a branch defect is. Asking only whether the loop closes therefore + called such a candidate defect-free, and the reader got a linkage that + promised three positions and stopped at the first. + + So a position counts when the loop closes there on this assembly AND its + crank angle lies inside the travel walked above -- one continuous run, + starting from the position the linkage is drawn in. + */ + cand.errors = cand.ptsB.map((target, i) => { + if (!withinTravel(cand.thetas[i], cand.range)) return Infinity; + 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; + + // 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 positions + // at five degrees will stall there in real life. + const [strokeFrom, strokeTo] = poseStroke(cand); + const worst = worstTransmission(cand, strokeFrom, strokeTo); + cand.stroke = { from: strokeFrom, to: strokeTo }; + cand.minTransmission = Math.round(worst); + // Against the exact figure, not the rounded one: a linkage that stalls at + // 14.6 degrees is not saved by being displayed as 15. + cand.binds = worst < BINDING_ANGLE; + // Reaching all three is not enough on its own: a linkage that has to pass + // through a dead point to get between them arrives at the first position and + // stops there, which is not what "reaches all 3" promises anybody. + cand.defectFree = cand.onBranchCount === 3 && !cand.binds; + 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, + swappedEnds: !cand.swappedEnds, + }; + assess(swapped); + return swapped; +} + +/** + * Which letter belongs to each field of a candidate. + * + * A pin keeps its name when you change which end drives. Reading the linkage + * from the far pin puts pin D in the field called `A`, and labelling by the + * field meant choosing "Driven from Pin D" drew the motor beside a pin marked + * A and renamed every bar in the dimensions list -- so the one control whose + * whole job is to say which pin drives was the control that made the letters + * stop meaning anything. + */ +export function endLetters(cand: FourBarCandidate | null): { + A: string; + B: string; + C: string; + D: string; +} { + return cand?.swappedEnds + ? { A: 'D', B: 'C', C: 'B', D: 'A' } + : { A: 'A', B: 'B', C: 'C', D: 'D' }; +} + +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, unit: string): 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; + // The unit goes with its number. Appended to the whole phrase by the + // caller, it produced "3.0 past the back cm" -- and, when the pins sit on + // the ends and there is no number at all, "at both ends cm". + return ( + (away / MODEL_SCALE).toFixed(1) + + ' ' + + unit + + ' ' + + (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, + binds: false, + stroke: { from: 0, to: 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. */ +function betterFirst(a: FourBarCandidate, b: FourBarCandidate): number { + 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; +} + +/** + * Best first, and one entry per construction rather than per assembly. + * + * Open and Crossed are the same four bars closed two different ways -- the same + * pins in the same places, the same lengths -- so listing them as two solutions + * asks the reader to compare a thing with itself. They are one solution with a + * switch on it, and the switch is Assembly branch. + * + * The letter is assigned to the construction, so flipping the switch does not + * rename the solution under the reader. + */ +export function rankCandidates(list: FourBarCandidate[], limit = 8): FourBarCandidate[] { + const byPair = new Map(); + list.forEach((c) => { + const siblings = byPair.get(c.pair); + if (siblings) siblings.push(c); + else byPair.set(c.pair, [c]); + }); + + const best = [...byPair.values()] + .map((siblings) => siblings.slice().sort(betterFirst)[0]) + .sort(betterFirst) + .slice(0, limit); + + best.forEach((c, i) => { + const name = 'ABCDEFGH'[i] ?? '?'; + // Both assemblies of one construction wear it, so the name survives the + // switch. + (byPair.get(c.pair) ?? []).forEach((sibling) => (sibling.name = name)); + }); + return best; +} + +/** Where the two coupler pins sit on the link, in the reader's own words. */ +export function describeCouplerPins(cand: FourBarCandidate, length: number, unit: string): string { + return describePins(cand.uA, cand.uB, length, unit); +} 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..3f7a1766 --- /dev/null +++ b/src/app/services/synthesis/synthesis-canvas.service.ts @@ -0,0 +1,626 @@ +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 { ColorService } from '../color.service'; +import { SynthesisBuilderService } from './synthesis-builder.service'; +import { SynthesisSolutionService } from './synthesis-solution.service'; +import { solveFourBar, endLetters } 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; + /** The bar's outline -- the same capsule every link on this canvas wears. */ + d: string; + /** A chevron inside the bar, pointing from its back end to its front. */ + arrow: string; + /** The point the coordinates describe, and the point it turns about. */ + refX: number; + refY: number; + x1: number; + y1: number; + x2: number; + y2: number; + fill: 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; + /** How wide the pill behind the words has to be, in screen pixels. */ + width: number; +} + +export interface Handle { + id: string; + x: number; + y: number; + cursor: string; +} + +export interface SelectionBox { + /** Degrees, applied about the position's own point. */ + rotate: string; + cx: number; + cy: number; + x: number; + y: number; + w: number; + h: number; + /** Where the turn knob sits, on its stalk above the bar. */ + knobX: number; + knobY: number; + /** Where the length handle sits, on the bar's own axis at its front end. */ + lengthX: number; + lengthY: number; + grip: number; +} + +export interface PreviewLink { + /** The bar's outline, drawn exactly as the drawing draws a link. */ + d: string; + color: string; +} + +export interface PreviewJoint { + id: string; + x: number; + y: number; + /** Whether this pin carries the input, so it wears the motor's mark. */ + input?: boolean; +} + +const REACH_GREEN = '#bfe0c0'; +const REACH_AMBER = '#f6dcb0'; +const SELECT_AMBER = '#ffc107'; +const NEUTRAL_BAR = '#c5cae9'; + +/** + * A bar between two pins, as a filled outline. + * + * The same shape `RealLink` computes for a two-joint link, at the same radius + * -- a quarter of the object scale -- so a position and a previewed solution + * are drawn to the dimensions the drawing itself uses. They were strokes on a + * line before, which matched by arithmetic rather than by construction and + * looked subtly unlike every other bar on the canvas. + */ +/** + * Which way round a position is, drawn inside it. + * + * A capsule is unchanged by turning it half a revolution, so a position that + * had been flipped end for end looked exactly like one that had not -- and a + * design that solves perfectly once a position is turned 180 degrees is + * impossible to spot when the wrong drawing and the right one are the same + * picture. Shaping the outline solved that and cost more than it was worth: + * the bars stopped looking like the links they are. So the silhouette is a + * plain capsule again and the direction is said inside it, with a chevron + * pointing from the back end to the front -- the way a drawing normally says + * which way round something goes. + */ +function directionMark(x1: number, y1: number, x2: number, y2: number, r: number): string { + const theta = Math.atan2(y2 - y1, x2 - x1); + const length = Math.hypot(x2 - x1, y2 - y1); + // A short bar has no room for a chevron inside it, and one drawn anyway + // would be bigger than the link it is meant to annotate. + if (length < r * 3) return ''; + const ux = Math.cos(theta); + const uy = Math.sin(theta); + const nx = -uy; + const ny = ux; + // Two thirds of the way along, so it reads as pointing at the front end + // rather than sitting in the middle of the bar. + // Three quarters of the way along, clear of the datum that marks the middle + // when the reference is Center, and plainly nearer the end it points at. + const cx = x1 + ux * length * 0.75; + const cy = y1 + uy * length * 0.75; + const reach = r * 0.6; + const wing = r * 0.66; + const tipX = cx + ux * reach; + const tipY = cy + uy * reach; + const backX = cx - ux * reach; + const backY = cy - uy * reach; + return ( + `M ${backX + nx * wing} ${backY + ny * wing} ` + + `L ${tipX} ${tipY} ` + + `L ${backX - nx * wing} ${backY - ny * wing}` + ); +} + +function capsulePath(x1: number, y1: number, x2: number, y2: number, r: number): string { + const theta = Math.atan2(y2 - y1, x2 - x1); + const nx = r * Math.sin(theta); + const ny = r * Math.cos(theta); + return ( + `M ${x1 - nx} ${y1 + ny} ` + + `A ${r} ${r} 0 1 1 ${x1 + nx} ${y1 - ny} ` + + `L ${x2 + nx} ${y2 - ny} ` + + `A ${r} ${r} 0 1 1 ${x2 - nx} ${y2 + ny} Z` + ); +} + +/** + * 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); + private colors = inject(ColorService); + + /** 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, + d: capsulePath( + pose.posBack.x, + pose.posBack.y, + pose.posFront.x, + pose.posFront.y, + this.settings.objectScale / 4 + ), + arrow: directionMark( + pose.posBack.x, + pose.posBack.y, + pose.posFront.x, + pose.posFront.y, + this.settings.objectScale / 4 + ), + refX: pose.position.x, + refY: pose.position.y, + x1: pose.posBack.x, + y1: pose.posBack.y, + x2: pose.posFront.x, + y2: pose.posFront.y, + fill: reached === undefined ? NEUTRAL_BAR : reached ? REACH_GREEN : REACH_AMBER, + 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; + 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, + 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, + }; + }); + } + + /** + * 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(9); + const pad = this.svgGrid.scaleWithZoom(12); + 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.settings.objectScale / 4 + pad / 2; + // Model coordinates throughout, y up. The grid draws this inside its own + // y-flip, so the flip is already accounted for. + 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; + return { + // Inside the flip, +y is up, which is the sense a positive angle turns in. + rotate: `rotate(${pose.thetaDegrees.toFixed(2)} ${cx.toFixed(1)} ${cy.toFixed(1)})`, + cx, + cy, + x, + y, + w, + h, + // One handle per thing that can be changed, and each one where the change + // happens. Four corners said "scale me in two directions and maybe turn + // me", which is three promises this gesture does not keep: a position has + // a place, a heading, and one length. So the body is the place, a knob + // above it is the heading, and a single grip off the front end -- on the + // bar's own axis, which is the direction it actually pulls -- is the + // length. + knobX: x + w / 2, + knobY: y + h + this.svgGrid.scaleWithZoom(30), + lengthX: x + w, + lengthY: cy, + grip, + }; + } + + /** + * 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(): { d: string; arrow: string } | 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 { + d: capsulePath( + anchor.x, + anchor.y, + anchor.x + dx, + anchor.y + dy, + this.settings.objectScale / 4 + ), + arrow: directionMark( + anchor.x, + anchor.y, + anchor.x + dx, + anchor.y + dy, + this.settings.objectScale / 4 + ), + }; + } + + /** The angle the ghost is turned to, for the hint beside the pointer. */ + ghostAngleLabel(): string { + return Math.round(((this.design.placeAngleDeg % 360) + 360) % 360) + '°'; + } + + /** + * 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 { + // Or the drawing holds a different answer than the one being looked at. + // Suppressing on `inserted` alone meant that after inserting A, choosing B + // left A standing solid on the grid while the panel said B and offered to + // replace it -- so B went in having never been shown. + return !this.solution.inserted || this.solution.needsReinsert(); + } + + /** 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 r = this.settings.objectScale / 4; + const bar = (a: Coord, b: Coord, colorIndex: number): PreviewLink => ({ + d: capsulePath(a.x, a.y, b.x, b.y, r), + // The colours the linkage will actually be built in, asked of the same + // service `insert` asks, so the preview cannot promise one thing and the + // drawing deliver another. + color: this.colors.getLinkColorFromIndex(colorIndex), + }); + const links = [ + bar(solved.A, solved.B, 0), + bar(solved.B, solved.C, 1), + bar(solved.C, solved.D, 0), + ]; + const dyad = this.solution.dyad(); + // The elbow the six-bar solve already found, rather than one worked out + // again from the pin: solving the same joint twice is what let the driver's + // two links vanish for a frame whenever the second answer disagreed. + if (dyad && solved.elbow) { + links.push(bar(dyad.ground, solved.elbow, 2)); + links.push(bar(solved.elbow, solved.B, 3)); + } + return links; + } + + previewJoints(): PreviewJoint[] { + const solved = this.previewing() ? this.solution.previewPose() : null; + if (!solved) return []; + // The letters these pins will be built under, which follow the pins rather + // than the fields: reading from the far pin puts pin D in the field called + // A, and labelling by the field renamed half the linkage every time the + // drive end changed. + const letter = this.solution.previewLetters(); + const out: PreviewJoint[] = [ + { id: letter.A, x: solved.A.x, y: solved.A.y }, + { id: letter.B, x: solved.B.x, y: solved.B.y }, + { id: letter.C, x: solved.C.x, y: solved.C.y }, + { id: letter.D, x: solved.D.x, y: solved.D.y }, + ]; + const dyad = this.solution.dyad(); + if (dyad && solved.elbow) { + out.push({ id: letter.E, x: dyad.ground.x, y: dyad.ground.y }); + out.push({ id: letter.F, x: solved.elbow.x, y: solved.elbow.y }); + } + return out; + } + + /** Which of the preview's pins are bolted to the frame. */ + /** + * Which of the preview's pins are bolted to the frame, and which one turns. + * + * The input mark is the whole visible answer to "Driven from": without it, + * swapping the drive pin rearranged nothing a reader could see and the + * control looked broken. With a driver fitted neither ground pin is the + * input at all -- the motor sits on the driver's own ground -- which is + * itself worth being able to see. + */ + previewGrounds(): PreviewJoint[] { + const solved = this.previewing() ? this.solution.previewPose() : null; + if (!solved) return []; + const dyad = this.solution.dyad(); + const letter = this.solution.previewLetters(); + const out: PreviewJoint[] = [ + { id: letter.A, x: solved.A.x, y: solved.A.y, input: !dyad }, + { id: letter.D, x: solved.D.x, y: solved.D.y, input: false }, + ]; + if (dyad) out.push({ id: letter.E, x: dyad.ground.x, y: dyad.ground.y, input: true }); + 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 ''; + /* + Sampled through the same solve the preview itself uses. + + This asked the *four-bar* where it would be at each angle, while the range + it was stepping belonged to the *driver's* crank -- two different cranks, + so the angles meant nothing to the solver they were handed to. Where they + happened not to close it skipped the sample and carried on with a line, + ruling a straight edge across a region the linkage never visits. That is + the flat side on an otherwise curved path. + */ + const range = this.solution.drivenRange(); + const span = range.to - range.from; + // Fine enough that a stretch the coupler crosses quickly is drawn as the + // path it takes rather than as one long chord across it. + const STEPS = 240; + let d = ''; + let lifted = true; + for (let k = 0; k <= STEPS; k++) { + const solved = this.solution.poseAtPhase(range.from + (span * k) / STEPS); + // A phase that will not close is a hole in the travel, not a shortcut + // over it: the pen lifts rather than drawing across. + if (!solved) { + lifted = true; + continue; + } + const midX = (solved.B.x + solved.C.x) / 2; + const midY = (solved.B.y + solved.C.y) / 2; + d += (lifted ? (d ? ' M ' : 'M ') : ' L ') + midX.toFixed(1) + ' ' + midY.toFixed(1); + lifted = false; + } + 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 r = this.settings.objectScale / 4; + const ghost = (a: Coord, b: Coord): PreviewLink => ({ + d: capsulePath(a.x, a.y, b.x, b.y, r), + color: '#9aa0ac', + }); + return [ghost(solved.A, solved.B), ghost(solved.B, solved.C), ghost(solved.C, solved.D)]; + } + + /** 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.solution.interactive = true; + 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 }; + this.solution.interactive = true; + } + + /** 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; + // The search was held still through the gesture; let it catch up now. + this.solution.interactive = false; + 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; + } +} 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-driveable.spec.ts b/src/app/services/synthesis/synthesis-driveable.spec.ts new file mode 100644 index 00000000..00dde02b --- /dev/null +++ b/src/app/services/synthesis/synthesis-driveable.spec.ts @@ -0,0 +1,352 @@ +import { Coord } from '../../model/coord'; +import { MODEL_SCALE } from '../../model/render-scale'; +import { + BINDING_ANGLE, + POSE_TOLERANCE, + PosePoint, + enumerateCandidates, + solveFourBar, +} from './synthesis-candidates'; + +/** + * The promise a solution makes, kept the way a machine would keep it. + * + * "Reaches all 3 positions on one assembly" is not "the loop closes at three + * crank angles". It is: start the linkage where it is drawn, turn the crank + * without ever taking it apart, and pass through all three on the way. Asking + * the first question and reporting the second is how a solution came to be + * offered that stopped at the first position. + * + * Two things had come apart from each other, and both are checked here by + * driving rather than asking: + * + * - The loop can close at a crank angle the crank cannot turn to. The circles + * intersect again on a stretch of the curve reachable only by taking the + * linkage apart -- which is the very thing a branch defect is. + * - The loop can close at an angle the crank can reach and still stall there. + * At a dead point the transmission angle goes to zero, the coupler pin races + * hundreds of units per degree, and no force turns it. On paper it passes + * through the position; in metal it stops at it. + * + * The sweep at the end is deliberately random and deliberately large: both + * faults were found by one, and neither showed up on a design anybody would + * think to write by hand. + */ + +const S = MODEL_SCALE; +const LENGTH = 5 * S; + +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)), + }; +} + +const distance = (a: Coord, b: Coord) => Math.hypot(b.x - a.x, b.y - a.y); + +/** A deterministic source, so a failure names a design that can be rebuilt. */ +function rng(seed: number) { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 4294967296; + }; +} + +type Candidate = ReturnType['candidates'][number]; + +/** + * Drive the linkage across its travel and report how near it came to each + * position, and whether it came apart on the way. + * + * The closest sample is refined afterwards. A stiff linkage moves its coupler + * pin hundreds of units per degree, so a fixed step straddles the position and + * would report a miss where the linkage passes cleanly through it. + */ +function drive(cand: Candidate): { closest: number[]; brokeAt: number | null } { + const STEP = 0.5; + const closest = cand.ptsB.map(() => Infinity); + const closestAt = cand.ptsB.map(() => cand.range.from); + let brokeAt: number | null = null; + + for (let deg = cand.range.from; deg <= cand.range.to; deg += STEP) { + const solved = solveFourBar(cand, deg, cand.sign); + if (!solved) { + brokeAt = deg; + break; + } + cand.ptsB.forEach((target, i) => { + const d = distance(solved.C, target); + if (d < closest[i]) { + closest[i] = d; + closestAt[i] = deg; + } + }); + } + + cand.ptsB.forEach((target, i) => { + let lo = closestAt[i] - STEP; + let hi = closestAt[i] + STEP; + for (let k = 0; k < 40; k++) { + const mid = (lo + hi) / 2; + const before = solveFourBar(cand, mid - 1e-3, cand.sign); + const after = solveFourBar(cand, mid + 1e-3, cand.sign); + if (!before || !after) break; + const dBefore = distance(before.C, target); + const dAfter = distance(after.C, target); + closest[i] = Math.min(closest[i], dBefore, dAfter); + if (dBefore < dAfter) hi = mid; + else lo = mid; + } + }); + + return { closest, brokeAt }; +} + +describe('every solution offered can be driven through what it claims', () => { + const designs: { name: string; poses: PosePoint[] }[] = [ + { name: 'a gentle sweep', poses: [pose(0, 0, 0), pose(4, 2, 25), pose(7, 7, 50)] }, + { name: 'a tight turn', poses: [pose(0, 0, 0), pose(1, 3, 55), pose(-2, 5, 110)] }, + { name: 'a long reach', poses: [pose(-6, -2, -20), pose(2, 1, 15), pose(11, 6, 40)] }, + { name: 'doubling back', poses: [pose(0, 0, 10), pose(5, 4, -30), pose(1, 7, 60)] }, + { name: 'a small angle change', poses: [pose(0, 0, 0), pose(3, 1, 6), pose(6, 3, 13)] }, + { name: 'a near reversal', poses: [pose(0, 0, 0), pose(4, 5, 85), pose(-1, 6, 165)] }, + ]; + + for (const design of designs) { + it(`walks all three, for ${design.name}`, () => { + const { candidates } = enumerateCandidates({ + poses: design.poses, + length: LENGTH, + endsOnly: false, + }); + const offered = candidates.filter((c) => c.defectFree); + walked.set(design.name, offered.length); + offered.forEach((cand) => { + const { closest, brokeAt } = drive(cand); + expect(brokeAt).toBeNull(); + closest.forEach((d) => expect(d).toBeLessThan(POSE_TOLERANCE)); + }); + }); + } + + /** + * What each named design actually offered, so that walking nothing cannot + * read as walking everything. + * + * Three of the six offer nothing, and each for its own reason: a tight turn + * reaches all three on twenty-eight candidates and every one of them binds, + * a long reach admits no construction at all, and doubling back stays on one + * assembly for none. Those are answers, not gaps -- but a test that walks an + * empty list passes without doing anything, so the ones that do offer + * something have to be seen to. + */ + const walked = new Map(); + + it('and the named designs between them gave it something to walk', () => { + expect(walked.size).toBe(designs.length); + // Each of the three that has an answer, not just the total and not just + // one of them: any of the others regressing to zero would turn its own + // walk into a test of nothing while the total stayed comfortably positive. + ['a gentle sweep', 'a small angle change', 'a near reversal'].forEach((name) => + expect(walked.get(name)).toBeGreaterThan(0) + ); + }); + + it('never claims a position the crank cannot turn to', () => { + const { candidates } = enumerateCandidates({ + poses: designs[3].poses, + length: LENGTH, + endsOnly: false, + }); + candidates.forEach((cand) => { + cand.onBranch.forEach((claims, i) => { + if (!claims) return; + const { from, to, full } = cand.range; + const inside = + full || + [-2, -1, 0, 1, 2].some((turn) => { + const at = cand.thetas[i] + turn * 360; + return at >= from - 1e-6 && at <= to + 1e-6; + }); + expect(inside).toBe(true); + }); + }); + }); + + /** + * The transmission angle, worked out from the bar lengths alone. + * + * Deliberately not the code's own routine, and deliberately not by asking + * the solver where the joints are: this measures the same physical quantity + * by an independent route, so agreeing with it means something. The angle at + * the coupler-rocker joint follows from the distance between the crank pin + * and the far ground pin by the cosine rule. + */ + function transmissionAt(cand: Candidate, deg: number): number | null { + const t = (deg * Math.PI) / 180; + const bx = cand.A.x + cand.r1 * Math.cos(t); + const by = cand.A.y + cand.r1 * Math.sin(t); + const span = Math.hypot(bx - cand.D.x, by - cand.D.y); + if (span > cand.d + cand.r2 || span < Math.abs(cand.d - cand.r2)) return null; + const cosine = (cand.d * cand.d + cand.r2 * cand.r2 - span * span) / (2 * cand.d * cand.r2); + let mu = (Math.acos(Math.max(-1, Math.min(1, cosine))) * 180) / Math.PI; + if (mu > 90) mu = 180 - mu; + return mu; + } + + /** + * The worst it gets over the stroke, swept densely rather than claimed. + * + * The stroke is the code's, because which span to measure is a definition + * rather than a measurement -- but the number is arrived at independently, + * from the bar lengths by the cosine rule, without asking the solver where + * any joint is. Stepped so that both ends are always sampled: the angle + * collapses at a travel limit, so an endpoint missed by a rounding error is + * exactly the sample that matters. + */ + function measuredWorst(cand: Candidate): number { + const { from, to } = cand.stroke; + const steps = Math.max(1, Math.ceil((to - from) / 0.02)); + let worst = 90; + for (let k = 0; k <= steps; k++) { + const mu = transmissionAt(cand, from + ((to - from) * k) / steps); + if (mu !== null) worst = Math.min(worst, mu); + } + return worst; + } + + /** + * The shortest arc containing three directions, worked out independently. + * + * Sorted, then the widest gap between neighbours removed: what is left is the + * arc, and it is not in general the span between the smallest and the largest. + */ + function shortestArc(anglesDeg: number[]): { from: number; to: number } { + const sorted = anglesDeg.map((a) => ((a % 360) + 360) % 360).sort((a, b) => a - b); + let widest = -1; + let after = 0; + sorted.forEach((angle, i) => { + const next = sorted[(i + 1) % sorted.length]; + const gap = (((next - angle) % 360) + 360) % 360; + if (gap > widest) { + widest = gap; + after = (i + 1) % sorted.length; + } + }); + return { from: sorted[after], to: sorted[after] + (360 - widest) }; + } + + it('measures over the shortest travel between the positions, not the long way round', () => { + const next = rng(20260821); + const wrong: string[] = []; + for (let n = 0; n < 300; n++) { + const poses = [0, 1, 2].map(() => + pose(next() * 24 - 12, next() * 24 - 12, next() * 360 - 180) + ); + const { candidates } = enumerateCandidates({ + poses, + length: LENGTH, + endsOnly: next() < 0.5, + }); + candidates + .filter((c) => c.range.full) + .forEach((c) => { + // On a crank that turns fully the arc wraps, so the smallest and + // largest of the three angles can name the long way round -- which is + // travel the linkage never makes between the positions, and judging + // it there rejects good candidates and accepts binding ones. + const arc = shortestArc(c.thetas); + const span = c.stroke.to - c.stroke.from; + const expected = arc.to - arc.from + 10; + if (Math.abs(span - expected) > 0.5) { + wrong.push( + `design ${n} ${c.key}: measured over ${span.toFixed(1)}°, shortest is ${expected.toFixed(1)}°` + ); + } + }); + } + expect(wrong.slice(0, 5)).toEqual([]); + }); + + it('reports a transmission angle that a dense independent measure agrees with', () => { + const next = rng(20260821); + const disagreements: string[] = []; + for (let n = 0; n < 300; n++) { + const poses = [0, 1, 2].map(() => + pose(next() * 24 - 12, next() * 24 - 12, next() * 360 - 180) + ); + const { candidates } = enumerateCandidates({ + poses, + length: LENGTH, + endsOnly: next() < 0.5, + }); + candidates.forEach((cand) => { + const measured = measuredWorst(cand); + // Half a degree of slack for the dense sweep's own step. + if (Math.abs(measured - cand.minTransmission) > 1.5) { + disagreements.push( + `design ${n} ${cand.key}: reported ${cand.minTransmission}°, measured ${measured.toFixed(2)}°` + ); + } + }); + } + expect(disagreements.slice(0, 5)).toEqual([]); + }); + + it('never offers one that a dense measure says stalls', () => { + const next = rng(20260821); + const stalling: string[] = []; + for (let n = 0; n < 300; n++) { + const poses = [0, 1, 2].map(() => + pose(next() * 24 - 12, next() * 24 - 12, next() * 360 - 180) + ); + const { candidates } = enumerateCandidates({ + poses, + length: LENGTH, + endsOnly: next() < 0.5, + }); + candidates + .filter((c) => c.defectFree) + // Measured independently, so this is not the code agreeing with itself. + // The previous version of this test asked whether any defect-free + // candidate had `minTransmission < BINDING_ANGLE`, which `defectFree` + // is defined to make impossible: it could not have failed. + .filter((c) => measuredWorst(c) < BINDING_ANGLE - 0.5) + .forEach((c) => stalling.push(`design ${n} ${c.key} at ${measuredWorst(c).toFixed(2)}°`)); + } + expect(stalling.slice(0, 5)).toEqual([]); + }); + + it('over four hundred designs, every claim survives being driven', () => { + const next = rng(20260821); + const broken: string[] = []; + let claims = 0; + for (let n = 0; n < 400; n++) { + const poses = [0, 1, 2].map(() => + pose(next() * 24 - 12, next() * 24 - 12, next() * 360 - 180) + ); + const { candidates } = enumerateCandidates({ + poses, + length: LENGTH, + endsOnly: next() < 0.5, + }); + candidates + .filter((c) => c.defectFree) + .forEach((cand) => { + claims++; + const { closest, brokeAt } = drive(cand); + if (brokeAt !== null || closest.some((d) => d >= POSE_TOLERANCE)) { + broken.push( + `design ${n} ${cand.key}: closest ${closest.map((d) => d.toFixed(1)).join(', ')}` + + (brokeAt === null ? '' : ` and came apart at ${brokeAt.toFixed(1)}°`) + ); + } + }); + } + expect(claims).toBeGreaterThan(100); + expect(broken.slice(0, 5)).toEqual([]); + }); +}); 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..1a73d723 --- /dev/null +++ b/src/app/services/synthesis/synthesis-solution.service.ts @@ -0,0 +1,880 @@ +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 { RealJoint, RevJoint } from 'src/app/model/joint'; +import { MechanismService } from '../mechanism.service'; +import { ColorService } from '../color.service'; +import { MODEL_SCALE } from 'src/app/model/render-scale'; +import { SynthesisBuilderService } from './synthesis-builder.service'; +import { driverDyadFor, DriverDyad } from './driver-dyad'; +import { + CandidateRejections, + FourBarCandidate, + drivenFromFarPin, + enumerateCandidates, + meet, + rankCandidates, + solveFourBar, + endLetters, +} 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. + */ +/** + * 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; + +/** + * How far a joint has to have moved to count as moved by hand. + * + * A tenth of a user unit. Solving is deterministic, so an untouched linkage + * comes back at exactly the coordinates it was written at; this is only here so + * that floating-point drift through a rebuild cannot read as an edit. + */ +const MOVED_BY_HAND = 0.1 * MODEL_SCALE; + +@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; + /** Where the preview stands, in crank degrees, and whether it is running. */ + public phase: number | null = null; + public playing = false; + public clockwise = true; + + /** + * Where each joint stood when synthesis last wrote it. + * + * The one thing that cannot be derived from the drawing: whether the linkage + * on the grid is still the one synthesis produced, or one the reader has + * since moved by hand. + * + * It used to be held in memory only, on the reasoning that after a reload + * there was nothing to compare against and the honest default was to assume + * nothing had been touched. That default was not honest, it was expensive: + * assuming nothing had been touched meant Replace deleted whatever had been, + * without the warning that exists for it. Move a joint, share the link, open + * it, press Replace, and the joint went back where synthesis had put it. So + * it is written down with the ids, and read back off the design. + */ + private get writtenAt(): Map { + const at = this.design.ownedAt; + return new Map( + this.design.ownedJointIds + .map((id, index) => [id, at[index]] as const) + .filter((pair): pair is readonly [string, { x: number; y: number }] => !!pair[1]) + .map(([id, place]) => [id, place]) + ); + } + + 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; + + /** + * Whether a gesture is in flight, so the search should hold still. + * + * Set by the canvas while a position is being dragged. The enumeration walks + * a full crank revolution for every candidate; running it on each pointermove + * would make the drag stutter for an answer nobody can read mid-gesture. The + * positions themselves still follow the pointer -- they are drawn from the + * design, not from the search -- and the answer catches up on release. + */ + public interactive = false; + + /** + * Throw away the answer because the *question* changed. + * + * Only for changes that alter what is being searched for: a position added + * or removed, or a requirement switched. Those change which linkages are + * even candidates, so the reader is sent back to Generate. + * + * Moving a position does not land here. It changes the answer, not the + * question, and the search keeps up with it by itself: `candidates()` is + * keyed on the design, so a nudge simply recomputes, and the chosen + * candidate -- identified by where its pins sit on the link -- survives. + */ + 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.changed.next(); + } + + /** + * Run the search. + * + * 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.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. */ + candidates(): FourBarCandidate[] { + if (!this.generated || !this.design.isFullyDefined()) return []; + const key = this.design.searchKey(); + // Held still through a drag: the last answer stays on screen while the + // position moves under it, and the search runs once when the hand lets go. + if (key !== this.cacheKey && !this.interactive) { + const result = enumerateCandidates(this.design.search()); + this.cacheKey = key; + this.cached = result.candidates; + this.cachedRejections = result.rejections; + } + let list = this.cached; + this.strictCount = new Set(list.filter((c) => c.defectFree).map((c) => c.pair)).size; + if (!this.design.allowDefect) list = list.filter((c) => c.defectFree); + return rankCandidates(list); + } + + /** + * Every assembly of every solution, including the ones the gallery folds + * away. What Assembly branch reaches for, and where a picked one is found. + */ + allAssemblies(): FourBarCandidate[] { + if (!this.generated || !this.design.isFullyDefined()) return []; + this.candidates(); + return this.design.allowDefect ? this.cached : this.cached.filter((c) => c.defectFree); + } + + 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; + // Looked up among every assembly, not only the ones on show: the crossed + // half of a construction is picked by the branch switch and never appears + // in the gallery. + const all = this.allAssemblies(); + const hovered = this.hoverKey ? all.find((c) => c.key === this.hoverKey) : undefined; + if (hovered) return hovered; + return all.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 this.allAssemblies().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; + /* + Read from the far pin, and remembered. + + `drivenFromFarPin` re-assesses the swapped linkage, which walks a whole + revolution a degree at a time -- seven hundred solves. That was done on + every call, and the calls are not few: drawing the coupler's path asks + for this once per sample, two hundred and forty times, on every animation + frame. Driving from Pin D therefore cost something like a hundred and + seventy thousand solves a frame, which is as slow as it sounds. Driving + from Pin A never noticed, because that path returns the candidate + untouched. + */ + const key = cand.key + ':' + this.design.searchKey(); + const remembered = this.swapped.get(key); + if (remembered) return remembered; + const swapped = drivenFromFarPin(cand); + swapped.name = cand.name; + swapped.branch = cand.branch; + swapped.key = cand.key; + swapped.pair = cand.pair; + // Keyed by candidate rather than holding only the last one. Hovering a + // card asks for that candidate while the picked one is still being drawn, + // and a single slot let the two evict each other on every pass -- the same + // seven hundred solves the cache exists to avoid, just less often. + if (this.swapped.size > 64) this.swapped.clear(); + this.swapped.set(key, swapped); + return swapped; + } + + private swapped = new Map(); + + /** + * Why a driver cannot be fitted to the current solution, or nothing. + * + * Asked independently of whether one is wanted: the panel needs to know + * before the switch is pressed, not after. + */ + driverAvailability(): string | undefined { + const cand = this.driven(); + if (!cand) return undefined; + const sized = driverDyadFor(cand.A, cand.ptsA); + if ('refusal' in sized) return sized.refusal; + // Sized is not the same as workable. `driverDyadFor` solves for a crank and + // coupler that carry the input across the arc the three positions need, + // and stops there -- it never asks whether the four-bar can be closed + // everywhere in between, and sometimes it cannot. The result is a six-bar + // whose motor jams partway round, which is worse than no driver at all + // because the panel promises "one full turn". + return this.driverTravel(cand, sized.dyad).full + ? undefined + : 'A driver sized for these positions cannot turn a whole revolution — the ' + + 'four-bar jams partway round. Moving a position, or driving from the ' + + 'other pin, usually frees it.'; + } + + /** The driver dyad for the current solution, if one is wanted and works. */ + dyad(): DriverDyad | undefined { + const cand = this.driven(); + if (!cand || !this.driverWanted) return undefined; + const sized = driverDyadFor(cand.A, cand.ptsA); + if ('refusal' in sized) return undefined; + return this.driverTravel(cand, sized.dyad).full ? sized.dyad : undefined; + } + + /** + * Where the driver's elbow sits when the pin it drives is at a given place. + * + * The same choice `insert` makes when it builds the joint, so the preview and + * the linkage that comes out of it are assembled the same way round. + */ + private elbowFor(dyad: DriverDyad, drivenPin: Coord): Coord | null { + const pair = meet(dyad.ground, dyad.crankLength, drivenPin, dyad.couplerLength); + return pair ? pair[0] : null; + } + + /** + * The driver crank angle that puts the linkage at a given four-bar angle. + * + * Reading the train backwards: the four-bar angle fixes the pin, the pin + * fixes the elbow, and the elbow fixes the crank. Used to find where the + * three positions fall along the driver's own revolution. + */ + private driverAngleAt( + cand: FourBarCandidate, + dyad: DriverDyad, + fourBarDeg: number + ): number | null { + const solved = solveFourBar(cand, fourBarDeg, cand.sign); + if (!solved) return null; + const elbow = this.elbowFor(dyad, solved.B); + if (!elbow) return null; + return (Math.atan2(elbow.y - dyad.ground.y, elbow.x - dyad.ground.x) * 180) / Math.PI; + } + + /** + * The six-bar, solved forwards from the crank a motor would actually turn. + * + * This is the whole of the fix for a six-bar preview that would not move. It + * used to be run the other way about: the four-bar's own crank was stepped + * and a driver drawn onto whatever came out. But with a driver fitted the + * four-bar's crank is not an input at all -- it is an output, rocking back + * and forth as the driver goes round -- so stepping it covered half a stroke + * once and then ran out of angles where the dyad could close. On one design + * that left four degrees of travel to animate, and the linkage sat still. + * + * Forwards: the driver crank turns, which places the elbow, which places the + * pin it drives, which sets the four-bar. Exactly the train the inserted + * mechanism solves -- which is why that one always moved correctly. + */ + private solveFromDriver( + cand: FourBarCandidate, + dyad: DriverDyad, + driverDeg: number + ): { A: Coord; B: Coord; C: Coord; D: Coord; elbow: Coord } | null { + const phi = (driverDeg * Math.PI) / 180; + const elbow = new Coord( + dyad.ground.x + dyad.crankLength * Math.cos(phi), + dyad.ground.y + dyad.crankLength * Math.sin(phi) + ); + // Where the coupler can put the driven pin: on the four-bar's crank circle + // and a coupler's length from the elbow. + const pair = meet(cand.A, cand.r1, elbow, dyad.couplerLength); + if (!pair) return null; + const sign = this.driverAssemblySign(cand, dyad); + const B = + Math.sign( + (elbow.x - cand.A.x) * (pair[0].y - cand.A.y) - + (elbow.y - cand.A.y) * (pair[0].x - cand.A.x) + ) === sign + ? pair[0] + : pair[1]; + const solved = solveFourBar( + cand, + (Math.atan2(B.y - cand.A.y, B.x - cand.A.x) * 180) / Math.PI, + cand.sign + ); + return solved ? { ...solved, elbow } : null; + } + + /** + * Which side of the crank the driver's coupler sits on, fixed at position 1. + * + * The dyad, like the four-bar, can be put together two ways; it has to stay + * on the one it was built in or the preview flips through itself. + */ + private driverAssemblySign(cand: FourBarCandidate, dyad: DriverDyad): number { + const B = cand.ptsA[0]; + const elbow = this.elbowFor(dyad, B); + if (!elbow) return 1; + return ( + Math.sign( + (elbow.x - cand.A.x) * (B.y - cand.A.y) - (elbow.y - cand.A.y) * (B.x - cand.A.x) + ) || 1 + ); + } + + /** + * How far the preview may be driven, and in what. + * + * Without a driver, the four-bar's own crank across its travel. With one, + * the driver crank across as much of a revolution as it can turn -- which + * for a dyad sized for these positions is normally the whole of it. + */ + drivenRange(): { from: number; to: number; full: boolean } { + const cand = this.driven(); + if (!cand) return { from: 0, to: 360, full: false }; + const dyad = this.dyad(); + if (!dyad) return cand.range; + return this.driverTravel(cand, dyad); + } + + /** + * How far the driver's crank can turn before the train stops closing. + * + * Walked forwards from the position the linkage is drawn in. Its answer is + * both what the transport offers and what decides whether a driver may be + * fitted at all, so the two cannot disagree about what the machine does. + */ + private driverTravel( + cand: FourBarCandidate, + dyad: DriverDyad + ): { from: number; to: number; full: boolean } { + const key = cand.key + ':' + this.driveOnFarPin + ':' + this.design.searchKey(); + if (this.driverRangeKey === key) return this.driverRange; + + const startPhi = this.driverAngleAt(cand, dyad, cand.thetas[0]) ?? 0; + const STEP = 1; + let from = startPhi; + let to = startPhi; + for (let k = STEP; k <= 360; k += STEP) { + if (!this.solveFromDriver(cand, dyad, startPhi + k)) break; + to = startPhi + k; + } + for (let k = STEP; k <= 360; k += STEP) { + if (!this.solveFromDriver(cand, dyad, startPhi - k)) break; + from = startPhi - k; + } + const full = to - from >= 359; + this.driverRangeKey = key; + this.driverRange = full + ? { from: startPhi, to: startPhi + 360, full: true } + : { from, to, full: false }; + return this.driverRange; + } + + private driverRangeKey = ''; + private driverRange = { from: 0, to: 360, full: false }; + + /** + * Where the preview stands now -- in driver crank degrees when a driver is + * fitted, in the four-bar's own crank degrees otherwise. + */ + currentPhase(): number { + const cand = this.driven(); + if (!cand) return 0; + if (this.phase !== null) return this.phase; + const dyad = this.dyad(); + if (dyad) return this.driverAngleAt(cand, dyad, cand.thetas[0]) ?? 0; + return cand.thetas[0]; + } + + /** The linkage at an arbitrary phase, in whatever is being turned. */ + poseAtPhase(phase: number): { A: Coord; B: Coord; C: Coord; D: Coord; elbow?: Coord } | null { + const cand = this.driven(); + if (!cand) return null; + const dyad = this.dyad(); + if (dyad) return this.solveFromDriver(cand, dyad, phase); + return solveFourBar(cand, phase, cand.sign); + } + + /** Where the linkage sits when it is drawn: position 1, in whatever turns. */ + startPhase(): number { + const cand = this.driven(); + if (!cand) return 0; + const dyad = this.dyad(); + if (dyad) return this.driverAngleAt(cand, dyad, cand.thetas[0]) ?? 0; + return cand.thetas[0]; + } + + /** Where each position falls along whatever is being turned. */ + positionPhases(): (number | null)[] { + const cand = this.driven(); + if (!cand) return []; + const dyad = this.dyad(); + if (!dyad) return cand.thetas; + return cand.thetas.map((theta) => this.driverAngleAt(cand, dyad, theta)); + } + + /** The four pin positions of the preview at the current phase. */ + previewPose(): { A: Coord; B: Coord; C: Coord; D: Coord; elbow?: Coord } | null { + const cand = this.driven(); + if (!cand) return null; + const dyad = this.dyad(); + if (dyad) return this.solveFromDriver(cand, dyad, this.currentPhase()); + 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(); + } + + /** Take back everything: the answer and the question both. */ + reset(): void { + this.invalidate(); + this.driverWanted = false; + this.driveOnFarPin = false; + this.showAll = false; + this.releaseOwnership(); + } + + // --- committing to the drawing ----------------------------------------- + + /** + * The letters this design's pins will be built under. + * + * Asked by the preview as well as by insert, so that what is drawn beside a + * pin is what that pin ends up called. Labelling the preview A-D and letting + * insert take the next free letters agreed only on an empty grid: beside one + * loose joint the preview said D-C-B-A over pins that arrived as E-D-C-B. + */ + previewLetters(cand: FourBarCandidate | null = this.driven()): { + A: string; + B: string; + C: string; + D: string; + E: string; + F: string; + } { + /* + Counted against the grid as insert will find it, not as it stands. + + Insert takes a replaceable linkage of ours away before it builds, so its + four or six ids come free -- and counting them as taken meant a + replacement preview promised E-J over pins that arrived as A-F. The same + arithmetic renamed the labels out from under a linkage the moment it was + inserted, because its own new ids were suddenly occupied. Survivors of a + linkage that has been cut into are a different matter: those stay, so + their ids stay taken. + */ + const held = this.ownership(); + const freed = held === 'ours' || held === 'edited' ? this.design.ownedJointIds : []; + const key = this.lettersKey(freed); + if (this.lettersAt !== key) { + this.lettersAt = key; + this.letters = this.nextLetters(6, freed); + } + const letters = this.letters; + const slot: Record = { A: 0, B: 1, C: 2, D: 3 }; + const mark = endLetters(cand); + return { + A: letters[slot[mark.A]], + B: letters[slot[mark.B]], + C: letters[slot[mark.C]], + D: letters[slot[mark.D]], + E: letters[4], + F: letters[5], + }; + } + + /** + * What the six letters depend on: who is on the grid, and what is coming off + * it. Cheap enough to build every frame, which handing out six ids -- six + * scans of the joint list -- is not. + */ + private lettersKey(freed: string[]): string { + return this.mechanismSrv.joints.map((joint) => joint.id).join(',') + '|' + freed.join(','); + } + private lettersAt = '\u0000'; + private letters: string[] = []; + + /** As many ids as asked for, none of which anything on the grid is using. */ + private nextLetters(count: number, freed: string[] = []): string[] { + const taken: string[] = []; + for (let i = 0; i < count; i++) { + taken.push(this.mechanismSrv.determineNextLetter(taken, freed)); + } + return taken; + } + + /** + * What the design owns on the grid right now. + * + * Three states worth telling apart, because each calls for something + * different: + * + * 'none' nothing of ours is there -- the first insert, or the + * reader deleted it, or Undo stepped back past it. + * 'ours' exactly what we wrote, untouched. Insert may replace it + * without asking: it is our own previous answer. + * 'edited' still ours, still separable, but moved by hand. Replacing + * it would throw that work away, so the reader chooses. + * 'entangled' joined to something else in the drawing, or half deleted. + * We can no longer take it back cleanly, so we stop claiming + * it and the next insert makes a new machine. + */ + ownership(): 'none' | 'ours' | 'edited' | 'entangled' { + const ids = new Set(this.design.ownedJointIds); + if (ids.size === 0) return 'none'; + const owned = this.mechanismSrv.joints.filter((joint) => ids.has(joint.id)); + if (owned.length === 0) return 'none'; + if (owned.length !== ids.size) { + /* + Latched the moment it is noticed, not only when a URL is read back. + + Ids are handed out again after a deletion. Delete one of ours and draw + a joint, and that joint takes the letter we just lost -- so the count + comes back up, every id is present again, and the linkage reads as + wholly ours with somebody else's joint standing in it. A later replace + would take that joint away without asking. Once cut into, cut into. + */ + this.design.ownershipPartial = true; + return 'entangled'; + } + // Or it was cut into earlier: the ids that survived look complete on their + // own, and only the flag remembers that they are not all of them. + if (this.design.ownershipPartial) return 'entangled'; + // A joint of ours pinned to a joint that is not ours means the two machines + // have been joined. Taking ours back would either leave a link hanging off + // nothing or cut into a machine that was never ours to touch. + const joinedOutward = owned.some((joint) => + (joint as RealJoint).connectedJoints?.some((other) => !ids.has(other.id)) + ); + if (joinedOutward) return 'entangled'; + /* + No baseline is "ask", not "help yourself". + + Every insert writes one, so the only way to be without it is a URL from + before this was written down -- and the answer that costs nothing is to + ask before replacing. The answer that used to be given, that the linkage + must be untouched, cost the reader whatever they had moved. + */ + const baseline = this.writtenAt; + if (baseline.size === 0) return 'edited'; + const moved = owned.some((joint) => { + const was = baseline.get(joint.id); + return !was || Math.hypot(joint.x - was.x, joint.y - was.y) > MOVED_BY_HAND; + }); + return moved ? 'edited' : 'ours'; + } + + /** Whether the design's own linkage is on the grid, in any condition. */ + get inserted(): boolean { + const state = this.ownership(); + return state === 'ours' || state === 'edited'; + } + + /** + * Whether the linkage on the grid is a different solution from the one on + * screen -- so Insert would change the drawing rather than confirm it. + */ + needsReinsert(): boolean { + const cand = this.driven(); + if (!cand || this.ownership() === 'none') return true; + const solved = solveFourBar(cand, cand.thetas[0], cand.sign); + if (!solved) return true; + const owned = new Map( + this.mechanismSrv.joints + .filter((joint) => this.design.ownedJointIds.includes(joint.id)) + .map((joint) => [joint.id, joint]) + ); + // The four pins the four-bar would be built from, in the order insert + // writes them. Anything else on the grid under our ids means a different + // answer is standing there. + const wanted = [solved.A, solved.B, solved.C, solved.D]; + // The driver's two pins as well, when there is one. Comparing the four-bar + // alone meant fitting a driver to an inserted four-bar -- or taking one off + // an inserted six-bar -- left the panel saying "Inserted into grid" over a + // drawing that no longer held what was being looked at, and the preview + // stayed hidden because it agreed. + const dyad = this.dyad(); + if (dyad) { + const elbow = meet(dyad.ground, dyad.crankLength, solved.B, dyad.couplerLength); + if (elbow) wanted.push(dyad.ground, elbow[0]); + } + const ids = this.design.ownedJointIds; + if (owned.size !== wanted.length || ids.length !== wanted.length) return true; + return wanted.some((point, i) => { + const joint = owned.get(ids[i]); + return !joint || Math.hypot(joint.x - point.x, joint.y - point.y) > MOVED_BY_HAND; + }); + } + + /** Stop claiming the linkage on the grid, without removing it. */ + releaseOwnership(): void { + this.design.ownedJointIds = []; + this.design.ownedAt = []; + this.design.ownershipPartial = false; + this.changed.next(); + } + + /** + * Put the chosen solution on the grid, replacing the one this design put + * there last time. + * + * 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. + * + * Returns 'edited' without writing anything when replacing would throw away + * work done by hand; the caller asks, and calls again with `force` if the + * answer is yes. + */ + insert(force = false): 'done' | 'edited' | 'nothing' { + const cand = this.driven(); + if (!cand) return 'nothing'; + const solution = solveFourBar(cand, cand.thetas[0], cand.sign); + if (!solution) return 'nothing'; + + const held = this.ownership(); + if (held === 'edited' && !force) return 'edited'; + if (held === 'entangled') this.releaseOwnership(); + else if (held !== 'none') this.removeOwned(); + + const dyad = this.dyad(); + // The same letters the preview has been showing beside these pins. + const { A: idA, B: idB, C: idC, D: idD, E: idE, F: idF } = this.previewLetters(cand); + /** A link is named by its ends in alphabetical order, as everywhere else. */ + const linkId = (one: string, two: string): string => [one, two].sort().join(''); + // 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(linkId(idA, idB), [jointA, jointB]); + crank.fill = this.colors.getLinkColorFromIndex(0); + const coupler = new RealLink(linkId(idB, idC), [jointB, jointC]); + coupler.fill = this.colors.getLinkColorFromIndex(1); + const rocker = new RealLink(linkId(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(linkId(idE, idF), [motor, knee]); + driverCrank.fill = this.colors.getLinkColorFromIndex(2); + const driverCoupler = new RealLink(linkId(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.design.ownedJointIds = joints.map((joint) => joint.id); + this.design.ownedAt = joints.map((joint) => ({ x: joint.x, y: joint.y })); + this.design.ownershipPartial = false; + this.playing = false; + // Eased rather than snapped: the drawing may be parked anywhere in its + // cycle, and dropping it onto its start pose between one frame and the next + // reads as the linkage jumping rather than as it going home. The same + // easing leaving an analysis mode uses. + this.mechanismSrv.easeToStart(); + this.mechanismSrv.updateMechanism(true); + this.changed.next(); + return 'done'; + } + + /** + * Take the design's own linkage off the grid. + * + * By id, and only links every one of whose joints is ours -- a link that + * reaches outward belongs half to something else, and `ownership()` has + * already refused to call that ours. Forces on a removed link go with it: a + * force on a link that no longer exists belongs to no mechanism. + */ + private removeOwned(): void { + const ids = new Set(this.design.ownedJointIds); + const goneLinks = new Set( + this.mechanismSrv.links + .filter((link) => link.joints.every((joint) => ids.has(joint.id))) + .map((link) => link.id) + ); + 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) => !ids.has(joint.id)); + this.design.ownedJointIds = []; + this.design.ownedAt = []; + this.design.ownershipPartial = false; + } + + /** + * 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 { + if (this.ownership() === 'none') { + this.releaseOwnership(); + return; + } + this.removeOwned(); + this.mechanismSrv.updateMechanism(true); + this.changed.next(); + } +} diff --git a/src/app/services/synthesis/synthesis-url.ts b/src/app/services/synthesis/synthesis-url.ts new file mode 100644 index 00000000..626ff7c8 --- /dev/null +++ b/src/app/services/synthesis/synthesis-url.ts @@ -0,0 +1,166 @@ +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 + * SO~[id]~[id]... the joints this design put on the grid + * SW~[x]~[y]~[x]~[y]... where it put each of them, in that order + * + * 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 && + design.ownedJointIds.length === 0 && + !design.ownershipPartial; + 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', + design.ownershipPartial, + 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)); + } + + // What this design owns on the grid, and where it put each of them, so undo + // and a reload both come back holding it and knowing whether it has been + // moved since. Ids are letters, which nothing here needs to encode. + if (design.ownedJointIds.length) { + marks.push('SO~' + design.ownedJointIds.join('~')); + // The baseline rides in its own entry rather than beside the ids: an id is + // a letter and a number is base-N over an alphabet that includes letters, + // so one entry holding both cannot be read back without guessing. + if (design.ownedAt.length === design.ownedJointIds.length) { + marks.push('SW~' + design.ownedAt.map((at) => length(at.x) + '~' + length(at.y)).join('~')); + } + } + + 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, ownershipPartial] = 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) }; + })(), + ownedJointIds: (marks.find((mark) => mark.startsWith('SO~')) ?? '').split('~').slice(1), + ownedAt: (() => { + const entry = marks.find((mark) => mark.startsWith('SW~')); + if (!entry) return []; + const parts = entry.substring(3).split('~'); + const out: { x: number; y: number }[] = []; + for (let i = 0; i + 1 < parts.length; i += 2) { + out.push({ x: unlength(parts[i]), y: unlength(parts[i + 1]) }); + } + return out; + })(), + // Whether some of what this design put on the grid has since been taken + // away. The ids alone cannot say so after a reload -- the missing ones get + // dropped, and a shortened list looks exactly like a shorter linkage. + ownershipPartial, + }); +} 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 - ) {} -} diff --git a/src/app/services/transcoding/string-transcoder.ts b/src/app/services/transcoding/string-transcoder.ts index acd6ec72..6762359e 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,72 @@ export class StringTranscoder extends GenericTranscoder { } }); this.validateDecodedComAnchors(linkIDs); + this.validateDecodedSynthesis(); + } + + /** + * A synthesis design must be the right entries, with the right count of + * readable numbers in each. + * + * Most of it names nothing in the drawing -- the exception is `SO`, which + * lists the joints the design put there -- so there is little to resolve + * against. What there is to check is shape, and a design half-read is worse + * than no design: the panel would open on positions that are not where the + * reader left them. Fails closed, like every other trailing section. + */ + private validateDecodedSynthesis(): void { + // Field counts per entry. `SO` is the one that varies: it lists the joints + // the design owns, and how many there are depends on the linkage. + const expected: { [tag: string]: number } = { SD: 3, SP: 3, SR: 4 }; + /* + Every character the number encoder can emit, and nothing else. + + Without this the check stopped at counting fields, and an unreadable + character decoded to -1 rather than failing -- so one corrupt digit in a + length silently produced a different number, and the design came back + with its three positions intact around a coupler that was not the one + that had been shared. That is precisely the half-load this validator + exists to prevent. + */ + const numeric = /^[0-9A-Za-z_-]+$/; + const seen = new Map(); + this.synthesisMarks.forEach((entry) => { + const tag = entry.substring(0, 2); + const count = expected[tag]; + // `SO` and `SW` both vary in length with the size of the linkage: the + // ids the design owns, and the place each of them was put. + const varies = tag === 'SO' || tag === 'SW'; + if (count === undefined && !varies) { + throw new Error('URL contains an unknown synthesis entry'); + } + seen.set(tag, (seen.get(tag) ?? 0) + 1); + const parts = entry.substring(2).split('~').slice(1); + const enough = varies ? parts.length >= 1 : parts.length === count; + // Two numbers to a joint, so an odd count is a truncated list and the + // baseline would silently belong to the wrong joints. + if (tag === 'SW' && parts.length % 2 !== 0) { + throw new Error('URL contains an incomplete synthesis entry'); + } + if (!enough || parts.some((part) => part === '')) { + throw new Error('URL contains an incomplete synthesis entry'); + } + // Ownership carries object ids, which are letters; every other entry is + // numbers, and has to look like numbers. + if (tag !== 'SO' && parts.some((part) => !numeric.test(part))) { + throw new Error('URL contains an unreadable synthesis number'); + } + }); + if ((seen.get('SP') ?? 0) > 3) { + throw new Error('URL contains more than three synthesis positions'); + } + // One design per URL. Two headers, or two of anything that describes the + // design as a whole, means the section was assembled by something other + // than this app, and there is no sensible way to choose between them. + (['SD', 'SR', 'SO', 'SW'] as const).forEach((tag) => { + if ((seen.get(tag) ?? 0) > 1) { + throw new Error('URL repeats a synthesis entry'); + } + }); } /** 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..fa39e0d7 --- /dev/null +++ b/src/app/services/transcoding/url-synthesis-design.spec.ts @@ -0,0 +1,194 @@ +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 { Checksum } from './checksum'; +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 }, + ownedJointIds: ['E', 'F', 'G', 'H'], + }); + 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('brings back the joints the design owns on the grid', () => { + const restored = decodeInto(encode(worked())); + expect(restored.ownedJointIds).toEqual(['E', 'F', 'G', 'H']); + }); + + it('says nothing about ownership when the design has inserted nothing', () => { + const nothingInserted = worked(); + nothingInserted.ownedJointIds = []; + expect(encode(nothingInserted)).not.toContain('SO~'); + }); + + 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); + expect(restored.ownedJointIds).toEqual([]); + }); + + 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 a number it cannot read, rather than substituting a default', () => { + const url = encode(worked()); + // One character of the coupler length replaced by something outside the + // number alphabet. It used to decode as -1 and be absorbed silently: the + // three positions came back intact around a coupler that was not the one + // that had been shared, which is a half-load wearing the face of a success. + const broken = url.replace(/SD~([^~,])/, 'SD~$'); + expect(broken).not.toBe(url); + expect(broken.length).toBe(url.length); + expect(() => decodeInto(broken)).toThrowError(/unreadable synthesis number/); + }); + + it('refuses a design that describes itself twice', () => { + const url = encode(worked()); + // Doubled, and then given the checksum that length deserves. The checksum + // is the first thing decode looks at, so without this the URL was rejected + // for being the wrong length and `toThrow()` passed on an error that would + // still be raised with the duplicate rule deleted. + const body = url.slice(0, -1).replace(/(SD~[^,]+)/, '$1,$1'); + const doubled = body + new Checksum().generateChecksum(body.length); + expect(() => decodeInto(doubled)).toThrowError(/repeats a 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 f945b93b..35a28946 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..be39fdca 100644 --- a/src/app/services/url-processor.service.ts +++ b/src/app/services/url-processor.service.ts @@ -7,6 +7,9 @@ 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'; +import { SynthesisSolutionService } from './synthesis/synthesis-solution.service'; @Injectable({ providedIn: 'root', @@ -17,6 +20,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 +112,44 @@ 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); + // Ownership names joints this URL is supposed to carry. Anything it + // names that is not here was removed by hand at some point, and a + // claim on an object that does not exist is not a claim worth keeping. + const present = new Set(mechanismSrv.joints.map((joint) => joint.id)); + // Ids and their baselines are one list in two arrays, so they are + // filtered together or not at all -- dropping an id on its own would + // slide every baseline after it onto the wrong joint. + const survives = this.synthesis.ownedJointIds.map((id) => present.has(id)); + const kept = this.synthesis.ownedJointIds.filter((_, index) => survives[index]); + const keptAt = this.synthesis.ownedAt.filter((_, index) => survives[index]); + // Dropping the ids of joints that are gone is right -- a claim on + // something that does not exist would be inherited by whatever new + // joint next took that letter. But dropping them silently threw away + // the one fact that made the linkage entangled rather than ours, so a + // reload turned "you cut into this, I will leave it alone" into "this + // is mine, I will delete it": the reader's own edits, removed without + // the warning that exists to prevent exactly that. + // Only while something of ours is still standing. If every joint we + // wrote has been taken away there is nothing left to be entangled + // with -- the design owns nothing, and saying otherwise would keep a + // section in every URL it writes from then on. + if (kept.length > 0 && kept.length !== this.synthesis.ownedJointIds.length) { + this.synthesis.ownershipPartial = true; + } else if (kept.length === 0) { + this.synthesis.ownershipPartial = false; + } + this.synthesis.ownedJointIds = kept; + this.synthesis.ownedAt = keptAt; + // A decode replaces the design wholesale, so whatever was found for the + // last one says nothing about this one. Resolved late, like the other + // services this one reaches: asking for it at construction would build + // MechanismService before this service is finished being built. + this.injector.get(SynthesisSolutionService).invalidate(); } 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 };