diff --git a/AGENTS.md b/AGENTS.md index 3944d822..a3a90923 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ Animation (`animate()`) steps through the precomputed timesteps on a ~16 ms setT ### Solvers (`src/app/model/mechanism/`) -Pure computation, mostly static classes: `loop-solver` (finds kinematic loops), `position-solver`, `kinematic-solver` (velocity/acceleration), `force-solver`, `ic-solver` (instant centers). `app.component.spec.ts` numerically verifies these against MATLAB results (`SixBarVerification.m`) for a sixbar linkage — treat it as the regression test for solver changes. +Pure computation, mostly static classes: `loop-solver` (finds kinematic loops), `position-solver`, `kinematic-solver` (velocity/acceleration), `force-solver`. `app.component.spec.ts` numerically verifies these against MATLAB results (`SixBarVerification.m`) for a sixbar linkage — treat it as the regression test for solver changes. ### Model classes (`src/app/model/`) diff --git a/CLAUDE.md b/CLAUDE.md index 72ea12ad..360e70a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,9 @@ The same command is how to get a second opinion on a hard non-UI problem. `Model metadata for 'gpt-5.6' not found. Defaulting to fallback metadata` warning that reads like it worked. Verified against codex-cli 0.146.0. -Tests are Vitest but written in Jasmine style (globals via `vitest/globals`). `tsconfig.spec.json` deliberately includes `src/app/app.module.ts` — without it, NgModule-declared components lose their template scope under the per-file test compile and fail with NG8001. Vitest errors on spec files containing no tests. +Tests are Vitest but written in Jasmine style (globals via `vitest/globals`). Vitest errors on spec files containing no tests. + +The app is **fully standalone** — `src/main.ts` calls `bootstrapApplication`, there is no `AppModule` and no `NgModule` anywhere, and components declare their own `imports`. A component spec must therefore import the component itself rather than a declaring module. (`tsconfig.spec.json` used to include `src/app/app.module.ts` to keep NgModule-declared components' template scope under the per-file test compile; that file and that workaround are both gone.) Unit specs stay co-located in `src/**/*.spec.ts`; browser-driven E2E tests are Playwright scripts in `e2e/*.mjs` (run directly — see above), with outputs in gitignored `artifacts/`. Details in `e2e/README.md`. @@ -79,31 +81,53 @@ Changing the encoding format breaks previously shared URLs — the transcoder fo ### MechanismService is the central hub -`services/mechanism.service.ts` (root singleton) owns the **editable** mechanism: `joints`, `links`, `forces` arrays representing the state at timestep 0. All create/delete/weld/ground/slider/input mutations live here. `updateMechanism()` rebuilds `this.mechanisms[0]` — a `Mechanism` (`model/mechanism/mechanism.ts`) that deep-copies the current state and, if the linkage is valid (DOF = 1 with an input joint), precomputes joint/link/force positions for **all** timesteps up front. The `mechanisms` array only ever uses index 0 (multi-mechanism support was planned, never built). +`services/mechanism.service.ts` (root singleton) owns the **editable** drawing: `joints`, `links`, `forces` arrays representing the state at timestep 0. All create/delete/weld/ground/slider/input mutations live here. + +`updateMechanism()` first **partitions** the drawing into independently solvable machines (`model/mechanism/mechanism-partition.ts`), then builds one `Mechanism` (`model/mechanism/mechanism.ts`) per partition — each deep-copies its own part of the state and, if that machine is valid (DOF = 1 with an input joint), precomputes joint/link/force positions for **all** its timesteps up front. `partitions[i]` and `mechanisms[i]` are the same machine seen two ways, and stay in the same order. One drawing can therefore hold several machines (M1, M2…), each with its own input, speed, direction and playback row — so **do not assume index 0**; ask `partitions`. Note `partition.joints` is everything the solver must be handed (shared frame pieces included) while `partition.ownJoints` is what that machine is actually made of — the one to ask "is this mine?". `onMechUpdateState` (BehaviorSubject) broadcasts mechanism state: 0 = normal, 1 = being dragged (graphs disabled), 2 = pending graph redraw, 3 = pending analysis after add/remove. -Animation (`animate()`) steps through the precomputed timesteps on a ~16 ms setTimeout loop, mutating the editable joints/links/forces in place. Editing is only allowed when the animation is paused at timestep 0. +Animation ticks on a ~16 ms setTimeout (`FRAME_INTERVAL_MS`) but advances by **elapsed real time**, not one precomputed timestep per tick: samples are spaced a fixed amount of input travel apart (1 degree of crank rotation for a pin), so how much simulated time one sample covers depends on the input speed. That is what makes a faster input speed animate faster and one revolution take 60/RPM seconds regardless of frame rate. `animate(progress, playing)` mutates the editable joints/links/forces in place; any external call is treated as a seek. Machines run on one shared clock by default, and can be unsynced to run on their own. Editing is only allowed when the animation is paused at timestep 0 (`isPlaying || mechanismTimeStep !== 0` gates the Edit panel). ### Solvers (`src/app/model/mechanism/`) -Pure computation, mostly static classes: `loop-solver` (finds kinematic loops), `position-solver`, `kinematic-solver` (velocity/acceleration), `force-solver`, `ic-solver` (instant centers). `app.component.spec.ts` numerically verifies these against MATLAB results (`SixBarVerification.m`) for a sixbar linkage — treat it as the regression test for solver changes. +Pure computation, mostly static classes: `loop-solver` (finds kinematic loops), `position-solver`, `kinematic-solver` (velocity/acceleration), `force-solver`. `app.component.spec.ts` numerically verifies these against MATLAB results (`SixBarVerification.m`) for a sixbar linkage — treat it as the regression test for solver changes. ### Model classes (`src/app/model/`) - Joints: `Joint` → `RealJoint` → `RevJoint` (revolute) / `PrisJoint` (prismatic). Code frequently narrows with `instanceof RealJoint` guards. -- Links: `Link` → `RealLink` / `Piston`. A **welded** compound link is a `RealLink` whose `subset` holds its constituent sub-links; weld/unweld logic in MechanismService restructures joints' `links`/`connectedJoints` arrays and link IDs (link IDs are the concatenated, sorted joint letters). +- Links: `Link` → `RealLink` / `SliderBlock` (the block that rides a slot; there is no `Piston` class any more). A **welded** compound link is a `RealLink` whose `subset` holds its constituent sub-links; weld/unweld logic in MechanismService restructures joints' `links`/`connectedJoints` arrays and link IDs (link IDs are the concatenated, sorted joint letters). +- A driven joint carries its own speed: `Joint.driveSpeed`, signed for direction, in rpm for a pin and length/second for a slider. Zero means "use the document-wide default" — which is what every URL written before this existed says. A drawing with several machines needs one speed per machine, so it lives on the joint rather than in settings. +- `mechanism/readiness.ts` produces the per-machine blocker/warning list the mode chips and setup drawers show; `mechanism/actuator.ts` decides what can be driven. - Joint IDs are single letters assigned alphabetically (`determineNextLetter`). - `utils.ts` is a large grab-bag: interaction state enums (`gridStates`, `jointStates`, `linkStates`, `forceStates`), unit enums (`LengthUnit`, `GlobalUnit`, ...), and geometry helpers. ### UI layer -- `AppComponent` is just a shell that registers SVG icons; `component/new-grid/new-grid.component.ts` is the real center — the SVG canvas handling the mouse/touch interaction state machine and context menus, with pan/zoom via `SvgGridService` (svg-pan-zoom + hammerjs). -- Left panel tabs (Synthesis / Edit / Analyze) are coordinated by `SelectedTabService` (`TabID` enum); the Edit and Analyze panels operate on whatever `ActiveObjService` says is selected (joint, link, force, or synthesis pose). -- `SettingsService` exposes global settings as RxJS BehaviorSubjects (units, input speed, gravity, grid visibility). -- `component/BLOCKS/` holds the reusable form primitives (input, toggle, radio, dual-input, panel-section, ...) that the panels are composed from; `component/MODALS/` holds dialogs. -- Components also communicate through static members (e.g. `NewGridComponent.sendNotification()`, `AnimationBarComponent.animate`) — grep for the static before assuming a service is the only channel. -- Four-bar synthesis (generating a linkage from desired end-effector poses) lives in `services/synthesis/`. +**Where things are on screen.** `app.component.html` is the whole layout, and it is worth reading before describing the UI — the arrangement below replaced an earlier one with a horizontal file toolbar and a *vertical mode rail down the left*, and stale descriptions of that older layout have outlived it in more than one place. + +| Region | Component | Holds | +| --- | --- | --- | +| Top strip | `app-top-bar` | project menu + logo · the four **mode tabs**, each with a readiness chip · a corner card that is Undo/Redo in Synthesis/Edit and swaps to **Export Data** in the analysis modes | +| Left card (below the strip, `left: 0`) | `app-left-tabs` | the current mode's panel — Synthesis form, Edit properties, or Analysis graphs. 250px, widening to 400px in analysis | +| Canvas (full bleed, behind everything) | `app-new-grid` | grid, right-click context menus, drag | +| Bottom centre | `app-playback-bar` | transport card (speed · play/pause · stop-to-start) and a scrub card with **one row per machine** | +| Bottom right | `app-view-controls` | centre of mass · joint IDs · traced paths ‖ zoom out · zoom in · reset view | +| Bottom strip | `app-bottombar` | mode name · status · cursor coords · units | +| Right drawer | `app-right-panel` | Settings, Help/Feedback, the two analysis setups, Export Data | + +The **modes are tabs in the top strip, not a left rail**, and there are four of them — Synthesis, Edit, Kinematic Analysis, Force Analysis (`TabID`) — not three. The left card is that mode's panel. + +- `AppComponent` is just a shell that registers SVG icons; `component/new-grid/new-grid.component.ts` is the real center — the SVG canvas handling the mouse/touch interaction state machine, with pan/zoom via `SvgGridService` (svg-pan-zoom + hammerjs). +- The right-click menu is **built in a service, not in the canvas**: `services/context-menu-builder.service.ts` turns whatever was right-clicked, plus the current mode, into a `ContextMenuModel` (`component/context-menu/menu-model.ts`), and `component/context-menu/` renders it. Every greyed row quotes the model that enforces it — `describeActuatorRefusal` in `model/actuator.ts`, `weldRefusal` in `grid-utils`, `locksHolding` in `model/lock-set.ts` — rather than restating the rule, so the menu, the panel and the drag ring cannot disagree. New rows belong in the builder; the canvas only supplies the gesture handlers (`MenuHandlers`). +- `SelectedTabService` (`TabID` enum) coordinates the four modes; the Edit and analysis panels operate on whatever `ActiveObjService` says is selected (joint, link, force, mechanism, background image, or synthesis pose). +- The right drawer is addressed by number through statics on `RightPanelComponent`: 1 Settings, 3 Help, 4 Debug (dev only), 5 `KINEMATIC_SETUP_TAB`, 6 `FORCE_SETUP_TAB`, 7 `EXPORT_TAB`. **Tab 2 (`app-equation-panel`) is unreachable** — nothing calls `tabClicked(2)` and its content is placeholder images. It is unfinished work, not a feature. +- `SettingsService` exposes document-wide settings as RxJS BehaviorSubjects (units, gravity, grid and snap visibility, object scale). Input **speed and direction are not global** — they belong to the driven joint (`Joint.driveSpeed`), because a drawing can hold several machines; the SettingsService values are only the default a joint falls back to. +- `component/BLOCKS/` holds the reusable form primitives (input, toggle, radio, dual-input, panel-section, ...) that the panels are composed from; `component/MODALS/` holds the two dialogs (Templates, touchscreen warning). +- Messages to the user go through `NotificationService`, which replaced the old `NewGridComponent.sendNotification()` static. Some components still talk through statics (e.g. `RightPanelComponent.openTab` / `insistOn`) — grep for the static before assuming a service is the only channel. +- Four-bar synthesis (generating a linkage from three desired coupler poses) lives in `services/synthesis/`. +- Onboarding is the **tutorial**: `services/tutorial.service.ts` with `model/tutorial-steps.ts`, shown by `component/tutorial-panel/` as a card *pinned* in the right drawer above whatever page is open (it is not one of the numbered pages). Its step is derived from the drawing by `progressFor`, never counted, which is what lets it start on a half-built mechanism and follow an undo backwards. It is offered from the Edit panel's empty state, reopened from the project menu, and remembers in `localStorage` (`tutorialSeen`) that it has been finished, dismissed or walked out of. The `intro.js` overlay tour it replaced is gone, dependency and all. +- The tutorial card asks the drawing for its step from `ngDoCheck`, not a subscription: every edit ends in `updateMechanism`, which publishes on nothing that could be listened to — `onMechUpdateState` carries the *analysis* state, which is why caches elsewhere key on `poseRevision` instead. ### Misc diff --git a/README.md b/README.md index 6ff61551..bd19f604 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ We encourage high code quality and strive for clean, readable, and maintainable - Selectors for your components should always be dash-delimited, like files, and contain the appropriate app prefix. For example: `app-hero-list` - The name of a component class should end with `Component`. For example: `HeroListComponent` - The name of a directive class should end with `Directive`. For example: `HighlightDirective` -- The name of a module class should end with `Module`. For example: `AppModule2` +- The name of a module class should end with `Module`. For example: `AppModule2`. (In practice the app is fully standalone and no longer defines NgModules — prefer a standalone component with its own `imports`.) - The name of a pipe class should be in `PascalCase` and end with `Pipe`. For example: `CurrencyConverterPipe2` - For more details, check out [Angular's offical coding style guide](https://angular.io/guide/styleguide). @@ -84,19 +84,23 @@ Landing Page: https://pmksplus.com -This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 13.2.5. +This project was generated with [Angular CLI](https://github.com/angular/angular-cli), and now runs on Angular 22. It needs Node 22.22 or newer (24.x works). ## Development server -Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. +Run `npm start` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. + +## Tests + +Run `npm test -- --watch=false` for the Vitest suite; drop the flag for watch mode. ## Code scaffolding -Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. +Run `ng generate component component-name` to generate a new component. The app is fully standalone — there is no `AppModule`, and a new component declares its own `imports` rather than being added to an NgModule. ## Build -Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. +Run `npm run build` to build the project. The build artifacts will be stored in `dist/pmksweb`. ## Additional Help Resoruces @@ -108,29 +112,37 @@ PMKS+ features an intuitive interface that allows you to create, edit, and analy ### Application Interface -The PMKS+ interface consists of a full-screen grid with a toolbar at the top and an animation bar at the bottom. +PMKS+ is a full-screen grid canvas with a strip of controls across the top, the current mode's panel in the top-left corner, and playback and view controls along the bottom. + +- **Top strip**: On the left, a menu button (New Project, Open, Templates, Save, Share Project, Settings, Help / Feedback) beside the PMKS+ logo. In the middle, the four **mode tabs** — Synthesis, Edit, Kinematic Analysis, and Force Analysis. Each analysis tab carries a small status chip saying whether that analysis can run; clicking the chip opens a setup list explaining exactly what is missing and offering to jump to the part at fault. On the right, a card holding Undo and Redo while you are building, which becomes **Export Data** once you are in an analysis mode. -- **Toolbar**: The toolbar provides quick access to common tasks. It includes a PMKS+ logo, buttons for creating a new project, loading a project, accessing the Linkage Library, downloading the current project, and generating a shareable URL for the current project. +- **Mode panel (top-left)**: Whichever mode is selected shows its panel here — the synthesis form, the properties of the selected joint or link, or that part's analysis graphs. With nothing selected it shows a short hint about what to do next. -- **Animation Bar**: The animation bar allows users to control the simulation of the linkage animation. It consists of buttons for controlling the playback speed, time control buttons, and buttons for controlling the grid visibility. +- **Playback (bottom)**: A transport card with a playback-speed button, play/pause, and a stop button that eases the mechanism back to the pose it was drawn in. Beside it, a scrubber card with **one row per mechanism** — a drawing can hold several independent machines, and each has its own input direction, angle and time readout, and scrub handle. When there is more than one, a toggle switches between running them all on a shared clock and controlling each separately. -- **Side Panels**: On the left, there are three tabs for editing, analyzing, and synthesizing linkages. On the right, there are buttons that open panels for settings, help, and displaying equations. +- **View controls (bottom-right)**: Show or hide centres of mass, joint IDs, and traced paths; zoom in, zoom out, and reset the view. + +- **Status strip (bottom)**: The current mode, what the mechanism is or is not ready for, the cursor position, and the units in use. + +- **Right drawer**: Slides in over the canvas for Settings, Help / Feedback, either analysis's setup list, and the Export Data flow. ### Using the Tool -1. **Synthesis**: Use the synthesis tab to automatically generate a linkage based on a desired position for one of the links. +1. **Build**: Right-click empty grid to add a link or a cylinder. Right-click a joint to attach another link, a cylinder, a tracer point or a force to it, to ground it, to make it the input, to give it a slider, or to weld it. Drag joints to move them. + +2. **Edit**: With the Edit tab open, select a joint or link to change its position, length, mass, centre of mass, input speed and direction, or appearance. The linkage can only be modified while the animation is paused at the start pose. -2. **Edit**: When the edit tab is open, you can modify the properties of the selected object (highlighted in amber color). +3. **Kinematic Analysis**: Select a joint for its position, velocity and acceleration; select a link for its angle, angular velocity and acceleration, and the same three for its centre of mass. -3. **Analysis**: The analysis panel allows you to view kinematics and force analysis graphs for the selected object. +4. **Force Analysis**: Choose Static (Equilibrium) or In-motion (Dynamic) to see the reaction forces each link carries at a joint and the torque or force the input has to supply. Gravity can be turned off in Settings. -4. **Settings**: The settings panel enables you to modify overall mechanism settings, such as units, input speed, enabling gravity, as well as visual settings like mechanism size and grid line visibility. +5. **Synthesis**: Generate a four-bar linkage from three desired poses of the coupler. -5. **Animation Control**: Use the animation bar to control the playback of the linkage simulation. You can set the playback speed, play or pause the animation, reset the animation to T=0, and seek through the simulation using the scrubber. +6. **Export**: In either analysis mode, Export Data walks you through choosing parts, choosing columns, and choosing a format — CSV, Excel, graph images, or a printable report. -6. **File Management**: Use the toolbar buttons to create a new project, load a project, access the Linkage Library, download the current project, and generate a shareable URL for the current project. +7. **Share**: The entire project is encoded in the URL, so Share Project produces a link that reopens exactly what you are looking at. This is also what Undo and Redo are built on. -Remember, only one tab can be open at a time for each group (left and right), and the linkage can only be modified when the animation is paused and reset to T=0. +Only one drawer can be open at a time, and the linkage can only be modified when the animation is paused and reset to the start pose. ## Licensing diff --git a/angular.json b/angular.json index 82b3e8e5..342520ad 100644 --- a/angular.json +++ b/angular.json @@ -72,8 +72,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "2kb", - "maximumError": "4kb" + "maximumWarning": "4kb", + "maximumError": "6kb" } ], "fileReplacements": [ diff --git a/docs/fixture-urls.md b/docs/fixture-urls.md index 02ec788f..3ebeb232 100644 --- a/docs/fixture-urls.md +++ b/docs/fixture-urls.md @@ -19,40 +19,41 @@ PMKS_FIXTURE_BASE_URL=https://deploy-preview-227--pmksprod.netlify.app npm run f | Mechanism | What it is for | Floating slot | Slide | Asserted in | | --- | --- | --- | --- | --- | -| [Punch press](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,0,Im,0.0C,C,0,0r8,0.5P,P,0,0r8,OZ..YRAB,Crank,VG,o,0,9O,c5cae9,A,B,,.YRBC,Connecting rod,ku,6G,0,0HC,303e9f,B,C,,.YPCP,CP,1Tm,0,0,0,,C,P,,..2F1,BC,F1,0,0r8,0,0bW,1Xg0..N_J) | A load on the ram: the crank torque spikes where the rod comes into line with the slide | — | — | `force-templates.spec.ts` | -| [Derrick crane](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4O,O,0,0,0.0C,C,Qv,cP,0.0T,T,rn,1Cp,0.6G,G,YO,09O,0.0K,K,Mp,18,0..YRGK,Luffing crank,VG,o,Sc,048,c5cae9,G,K,,.YRCK,Luffing link,ku,6G,Os,Jn,303e9f,C,K,,.YROCT,Boom,2xW,ku,Qv,cP,0d125a,O,C,T,,..2F1,OCT,F1,rn,1Cp,rn,zB,z2G..N_v) | A weight far out on a boom held close in: the link carries several times the load | — | — | `force-templates.spec.ts` | -| [Toggle clamp](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6H,H,0ee,Ti,0.0E,E,09O,NS,0.0P,P,P0,5U,0.4N,N,0Lu,09O,0..YRHE,Handle,NS,o,0P0,Qa,c5cae9,H,E,,.YREP,Toggle link,Fe,1G,7q,ET,303e9f,E,P,,.YRNP,Clamp bar,VG,38,1a,01z,0d125a,N,P,,..2F1,NP,F1,P0,5U,P0,L6,hyW..N_e) | Where mechanical advantage comes from: the clamping force runs away as the links line up | — | — | `force-templates.spec.ts` | -| [Rocker with an offset load](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,HC,7q,0.0C,C,104,bW,0.4D,D,1E8,0,0.0L,L,1SC,jK,0..YRAB,Crank,VG,o,8c,3w,c5cae9,A,B,,.YRBC,Coupler,_W,7q,ee,Mg,303e9f,B,C,,.YRCDL,Rocker,1E8,E4,1E8,Rd,0d125a,C,D,L,,..2F1,CDL,F1,1SC,jK,1Fi,Zy,adm..N_l) | A load off the line of its link is a moment — the term a free-body sketch leaves out | — | — | `force-templates.spec.ts` | -| [Hydraulic cylinder](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4A,A,0_W,0,0.0B,B,07E,0,0.8C,C,0OE,0,0.0D,D,V4,0,0.6E,E,V4,ku,0.XP,P,0OE,0,0,AB,A,B..YRAB,AB,Fe,Fe,0Yt,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,3R,0,303e9f,C,D,,.YRDE,DE,Fe,Fe,V4,NS,0d125a,D,E,,.YPCP,CP,Fe,0,0,0,,C,P,,...N_i) | Cylinder skin (§2.7): a rod welded to a block sliding in a barrel, all on one line | yes | yes | `cylinder.spec.ts` | -| [Cylinder-driven boom](https://app.pmksplus.com/?2P.Ay,Fe.K,0.1011.4O,O,0,0,0.0C,C,0,_W,0.4G,G,ku,0,0.0N,N,Ju,Z-,0.8P,P,R0,QX,0.ZS,S,R0,QX,0,GN,G,N..YROC,OC,Fe,Fe,0,VG,c5cae9,O,C,,.YRGN,GN,Fe,Fe,XO,I0,303e9f,G,N,,.YRPC,PC,Fe,Fe,DW,iW,0d125a,P,C,,.YPPS,PS,Fe,0,0,0,,P,S,,...N_e) | Gate 5: the cylinder is the drive, and the boom follows the law of cosines | yes | yes | `driven-cylinder.spec.ts` | -| [Cylinder-driven gripper](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4A,A,019C,Bh,0.0B,B,01H,Bg,0.8C,C,0Qb,Bg,0.0D,D,jM,Bf,0.0G,G,13N,ky,0.0H,H,20s,ky,0.0I,I,13N,0Fp,0.0J,J,20s,0Fp,0.4K,K,0,2NW,0.4L,L,1Q,01pB,0.0M,M,K,1SF,0.4O,O,1bt,2Q6,0.4P,P,1eQ,02Tv,0.0Q,Q,1cO,1QQ,0.0S,S,3e3,13W,0.0T,T,16,0tr,0.0V,V,1dc,0w4,0.0X,X,3e3,0Yl,0.ZE,E,0Qb,Bg,0,AB,A,B.1N,N,K,1SF,0,KL,K,L.1R,R,1cO,1QQ,0,OP,O,P.1U,U,16,0tr,0,KL,K,L.1W,W,1dc,0w4,0,OP,O,P..YRAB,AB,Fe,Fe,0bE,Bg,c5cae9,A,B,,.YRCD,CD,Fe,Fe,9O,Bg,303e9f,C,D,,.YRDGHIJ,DGHIJ,Fe,Fe,1NZ,Eo,0d125a,D,G,H,I,J,,.YRKL,KL,Fe,Fe,j,IA,B2DFDB,K,L,,.YRGM,GM,Fe,Fe,Xr,15c,26A69A,G,M,,.YROP,OP,Fe,Fe,1d9,01w,00695C,O,P,,.YRHQ,HQ,Fe,Fe,1pd,14h,c5cae9,H,Q,,.YRMQS,MQS,Fe,Fe,1lb,1JO,303e9f,M,Q,S,,.YRIT,IT,Fe,Fe,YF,0Zq,0d125a,I,T,,.YRJV,JV,Fe,Fe,1qE,0ax,B2DFDB,J,V,,.YRTVX,TVX,Fe,Fe,1mG,0nZ,26A69A,T,V,X,,.YPCE,CE,Fe,0,0,0,,C,E,,.YPMN,MN,Fe,0,0,0,,M,N,,.YPQR,QR,Fe,0,0,0,,Q,R,,.YPTU,TU,Fe,0,0,0,,T,U,,.YPVW,VW,Fe,0,0,0,,V,W,,...N_c) | §2.7a: no chain of dyads solves this — the plate and both arms settle together | yes | yes | `gripper.spec.ts, anchored-bar-mobility.spec.ts` | -| [Gripper the cylinder closes](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4A,A,02SG,ku,0.0B,B,0185,uK,0.8C,C,019F,uC,0.0D,D,Ay,11e,0.4G,G,_W,ku,0.0H,H,1HG,S8,0.0I,I,2hu,VG,0.4J,J,_W,0ku,0.0K,K,hm,0S8,0.0L,L,2hu,0VG,0.ZE,E,019F,uC,0,AB,A,B..YRAB,AB,Fe,Fe,01oB,pc,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0V9,yw,303e9f,C,D,,.YRDGHI,DGHI,Fe,Fe,1Hf,g_,0d125a,D,G,H,I,,.YRHK,HK,Fe,Fe,_W,0,B2DFDB,H,K,,.YRJKL,JKL,Fe,Fe,1Sj,0ZR,26A69A,J,K,L,,.YPCE,CE,Fe,0,0,0,,C,E,,...N_N) | Counter-rotating jaw levers: extending the cylinder pinches them shut | yes | yes | `pinching-gripper.spec.ts` | -| [Backhoe bucket](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4A,A,01jO,0,0.0B,B,0sb,Py,0.8C,C,018N,HX,0.0D,D,0Ha,hT,0.4G,G,0,0,0.0H,H,VG,Im,0.4J,J,o0,0P0,0.0K,K,1BI,2i,0.0T,T,1NW,0ee,0.ZE,E,018N,HX,0,AB,A,B..YRAB,AB,Fe,Fe,01H-,C_,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0iz,UV,303e9f,C,D,,.YRDGH,DGH,Fe,Fe,4a,Kl,0d125a,D,G,H,,.YRHK,HK,Fe,Fe,rH,Ak,B2DFDB,H,K,,.YRJKT,JKT,Fe,Fe,16x,0K-,26A69A,J,K,T,,.YPCE,CE,Fe,0,0,0,,C,E,,...N_0) | A driven ram feeding an ordinary four-bar: bell crank, link, and the bucket curls | yes | yes | `excavator-bucket.spec.ts` | -| [Toggle press](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4A,A,01z0,0,0.0B,B,017k,0Uc,0.8C,C,01P8,0Kc,0.0D,D,0Zs,0pD,0.4G,G,0,0,0.0R,R,0,01cP,0.ZE,E,01P8,0Kc,0,AB,A,B.5F,F,0,01cP,OZ..YRAB,AB,Fe,Fe,01YN,0FJ,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0_V,0Zv,303e9f,C,D,,.YRGD,GD,Fe,Fe,0Hx,0Pc,0d125a,G,D,,.YRDR,DR,Fe,Fe,0Hx,01Cp,B2DFDB,D,R,,.YPCE,CE,Fe,0,0,0,,C,E,,.YPRF,RF,Fe,0,0,0,,R,F,,...N_S) | A ram closing a toggle onto a block: travel traded for force as it approaches straight | yes | yes | `toggle-press.spec.ts` | -| [Scissor lift](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4A,A,2SG,0,0.0B,B,1fl,a3,0.8C,C,1wL,OD,0.0D,D,17q,yH,0.4G,G,0,0,0.0M,M,1Vm,1GM,0.0K,K,2-X,2Wj,0.0S,S,2-X,0,0.0T,T,0,2Wj,0.0U,U,3_1,2Wj,0.ZE,E,1wL,OD,0,AB,A,B.5N,N,2-X,0,0.1P,P,2-X,2Wj,0,TU,T,U..YRAB,AB,Fe,Fe,230,I2,c5cae9,A,B,,.YRCD,CD,Fe,Fe,1X5,gF,303e9f,C,D,,.YRGDMK,GDMK,Fe,Fe,1Pn,1BL,0d125a,G,D,M,K,,.YRSMT,SMT,Fe,Fe,1Vm,1GM,B2DFDB,S,M,T,,.YRTU,TU,Fe,Fe,1-0,2Wj,26A69A,T,U,,.YPCE,CE,Fe,0,0,0,,C,E,,.YPSN,SN,Fe,0,0,0,,S,N,,.YPKP,KP,Fe,0,0,0,,K,P,,...N_e) | Ram, supporting block and a slot in the moving platform — all three parts, three jobs | yes | yes | `scissor-lift.spec.ts` | -| [Shaper's quick-return drive](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.4C,C,0,0ku,0.0D,D,Oj,RF,0.0R,R,qW,si,0.1P,P,Fe,0,0,CD,C,D.5Q,Q,qW,si,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,CN,09q,303e9f,C,D,,.YRDR,DR,Fe,Fe,cd,e_,0d125a,D,R,,.YPBP,BP,Fe,0,0,0,,B,P,,.YPRQ,RQ,Fe,0,0,0,,R,Q,,...N_H) | A floating slot handing off to a grounded one: the ram cuts slow and returns fast | yes | — | `shaper-quick-return.spec.ts` | -| [Slider-crank whose rod comes square to the guide](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,VG,0.0B,B,Fe,VG,0.0C,C,oa,0,0.5P,P,oa,0,0..YRAB,AB,Fe,Fe,7q,VG,c5cae9,A,B,,.YRBC,BC,Fe,Fe,X6,Fe,303e9f,B,C,,.YPCP,CP,Fe,0,0,0,,C,P,,...N_d) | The slot tangent to the rod circle: the two roots meet and trade places | — | — | `square-rod-tangency.spec.ts` | -| [MotionGen gripper](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.0A,A,0U5,01,0.0B,B,0Hj,Fj,0.0C,C,0Hj,0FZ,0.0D,D,DZ,Fj,0.0E,E,DZ,0FZ,0.0F,F,0V6,X9,0.0G,G,A,X9,0.0H,H,0V6,0X0,0.0I,I,A,0X0,0.0J,J,_d,0Bn,0.0K,K,_o,GS,0.7P,P,0U5,01,0.5Q,Q,0V6,X9,OZ.5R,R,A,X9,OZ.5S,S,0V6,0X0,OZ.5T,T,A,0X0,OZ..YRABCDE,ABCDE,Fe,Fe,07i,4,c5cae9,A,B,C,D,E,,.YRBF,BF,Fe,Fe,0OQ,OR,303e9f,B,F,,.YRDG,DG,Fe,Fe,6s,OR,0d125a,D,G,,.YRCH,CH,Fe,Fe,0OQ,0OH,B2DFDB,C,H,,.YREI,EI,Fe,Fe,6s,0OH,26A69A,E,I,,.YRHIJ,HIJ,Fe,Fe,Aa,0Px,00695C,H,I,J,,.YRFGK,FGK,Fe,Fe,Ae,Rb,c5cae9,F,G,K,,.YPAP,AP,Fe,0,0,0,,A,P,,.YPFQ,FQ,Fe,0,0,0,,F,Q,,.YPGR,GR,Fe,0,0,0,,G,R,,.YPHS,HS,Fe,0,0,0,,H,S,,.YPIT,IT,Fe,0,0,0,,I,T,,...N_3) | A second engine's mechanism, rebuilt: over-constrained, so PMKS+ reports DOF 0 and refuses it | — | — | `motiongen-gripper.spec.ts` | -| [Gripper with the redundancy removed](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.0A,A,0U5,0,0.0M,M,DZ,0,0.0B,B,0Hj,Fj,0.0C,C,0Hj,0FZ,0.4F,F,0V6,X9,0.0G,G,A,X9,0.0K,K,_o,GS,0.4H,H,0V6,0X0,0.0I,I,A,0X0,0.0J,J,_d,0Bn,0.7P,P,0U5,0,0.5N,N,DZ,0,0..YRAMBC,AMBC,Fe,Fe,0C-,2,c5cae9,A,M,B,C,,.YRBG,BG,Fe,Fe,08o,OR,303e9f,B,G,,.YRCI,CI,Fe,Fe,08o,0OH,0d125a,C,I,,.YRFGK,FGK,Fe,Fe,Ae,Rb,B2DFDB,F,G,K,,.YRHIJ,HIJ,Fe,Fe,Aa,0Px,26A69A,H,I,J,,.YPAP,AP,Fe,0,0,0,,A,P,,.YPMN,MN,Fe,0,0,0,,M,N,,...N_j) | The same gripper, jaws pivoting instead of railed: DOF 1, and it runs | — | — | `pivoting-gripper.spec.ts` | -| [Radial engine, five cylinders](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6O,O,0,0,0.0A,A,Fe,0,0.0B,B,0,iC,0.0C,C,0UD,9q,0.0D,D,0L9,0T6,0.0E,E,Vy,0hz,0.0F,F,wV,J0,0.5P,P,0,iC,OZ.5Q,Q,0UD,9q,iB.5R,R,0L9,0T6,-q.5S,S,Vy,0hz,1JT.5T,T,wV,J0,1d5..YROA,OA,Fe,Fe,7q,0,c5cae9,O,A,,.YRAB,AB,Fe,Fe,7q,M6,303e9f,A,B,,.YRAC,AC,Fe,Fe,07J,4w,0d125a,A,C,,.YRAD,AD,Fe,Fe,02m,0EZ,B2DFDB,A,D,,.YRAE,AE,Fe,Fe,No,0L_,26A69A,A,E,,.YRAF,AF,Fe,Fe,b3,9W,00695C,A,F,,.YPBP,BP,Fe,0,0,0,,B,P,,.YPCQ,CQ,Fe,0,0,0,,C,Q,,.YPDR,DR,Fe,0,0,0,,D,R,,.YPES,ES,Fe,0,0,0,,E,S,,.YPFT,FT,Fe,0,0,0,,F,T,,...N_F) | Five sliders on one crank pin; piston stroke is exactly twice the throw | — | — | `radial-engine.spec.ts` | +| [Punch press](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,0,Im,0.0C,C,0,0r8,0.5P,P,0,0r8,OZ..YRAB,Crank,VG,o,0,9O,c5cae9,A,B,,.YRBC,Connecting rod,ku,6G,0,0HC,303e9f,B,C,,.YPCP,CP,1Tm,0,0,0,,C,P,,..2F1,BC,F1,0,0r8,0,0bW,1Xg0..N_J) | A load on the ram: the crank torque spikes where the rod comes into line with the slide | — | — | `force-templates.spec.ts` | +| [Derrick crane](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4O,O,0,0,0.0C,C,Qv,cP,0.0T,T,rn,1Cp,0.6G,G,YO,09O,0.0K,K,Mp,18,0..YRGK,Luffing crank,VG,o,Sc,048,c5cae9,G,K,,.YRCK,Luffing link,ku,6G,Os,Jn,303e9f,C,K,,.YROCT,Boom,2xW,ku,Qv,cP,0d125a,O,C,T,,..2F1,OCT,F1,rn,1Cp,rn,zB,z2G..N_v) | A weight far out on a boom held close in: the link carries several times the load | — | — | `force-templates.spec.ts` | +| [Toggle clamp](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6H,H,0ee,Ti,0.0E,E,09O,NS,0.0P,P,P0,5U,0.4N,N,0Lu,09O,0..YRHE,Handle,NS,o,0P0,Qa,c5cae9,H,E,,.YREP,Toggle link,Fe,1G,7q,ET,303e9f,E,P,,.YRNP,Clamp bar,VG,38,1a,01z,0d125a,N,P,,..2F1,NP,F1,P0,5U,P0,L6,hyW..N_e) | Where mechanical advantage comes from: the clamping force runs away as the links line up | — | — | `force-templates.spec.ts` | +| [Rocker with an offset load](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,HC,7q,0.0C,C,104,bW,0.4D,D,1E8,0,0.0L,L,1SC,jK,0..YRAB,Crank,VG,o,8c,3w,c5cae9,A,B,,.YRBC,Coupler,_W,7q,ee,Mg,303e9f,B,C,,.YRCDL,Rocker,1E8,E4,1E8,Rd,0d125a,C,D,L,,..2F1,CDL,F1,1SC,jK,1Fi,Zy,adm..N_l) | A load off the line of its link is a moment — the term a free-body sketch leaves out | — | — | `force-templates.spec.ts` | +| [Hydraulic cylinder](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4A,A,0_W,0,0.0B,B,07E,0,0.8C,C,0OE,0,0.0D,D,V4,0,0.6E,E,V4,ku,0.XP,P,0OE,0,0,AB,A,B..YRAB,AB,Fe,Fe,0Yt,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,3R,0,303e9f,C,D,,.YRDE,DE,Fe,Fe,V4,NS,0d125a,D,E,,.YPCP,CP,0,0,0,0,,C,P,,...N_h) | Cylinder skin (§2.7): a rod welded to a block sliding in a barrel, all on one line | yes | yes | `cylinder.spec.ts` | +| [Cylinder-driven boom](https://app.pmksplus.com/?2P.Ay,Fe.5,0.1011.4O,O,0,0,0.0C,C,0,_W,0.4G,G,ku,0,0.0N,N,Ju,Z-,0.8P,P,R0,QX,0.ZS,S,R0,QX,0,GN,G,N..YROC,OC,Fe,Fe,0,VG,c5cae9,O,C,,.YRGN,GN,Fe,Fe,XO,I0,303e9f,G,N,,.YRPC,PC,Fe,Fe,DW,iW,0d125a,P,C,,.YPPS,PS,0,0,0,0,,P,S,,...N_d) | Gate 5: the cylinder is the drive, and the boom follows the law of cosines | yes | yes | `driven-cylinder.spec.ts` | +| [Cylinder-driven gripper](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4A,A,019C,Bh,0.0B,B,01H,Bg,0.8C,C,0Qb,Bg,0.0D,D,jM,Bf,0.0G,G,13N,ky,0.0H,H,20s,ky,0.0I,I,13N,0Fp,0.0J,J,20s,0Fp,0.4K,K,0,2NW,0.4L,L,1Q,01pB,0.0M,M,K,1SF,0.4O,O,1bt,2Q6,0.4P,P,1eQ,02Tv,0.0Q,Q,1cO,1QQ,0.0S,S,3e3,13W,0.0T,T,16,0tr,0.0V,V,1dc,0w4,0.0X,X,3e3,0Yl,0.ZE,E,0Qb,Bg,0,AB,A,B.1N,N,K,1SF,0,KL,K,L.1R,R,1cO,1QQ,0,OP,O,P.1U,U,16,0tr,0,KL,K,L.1W,W,1dc,0w4,0,OP,O,P..YRAB,AB,Fe,Fe,0bE,Bg,c5cae9,A,B,,.YRCD,CD,Fe,Fe,9O,Bg,303e9f,C,D,,.YRDGHIJ,DGHIJ,Fe,Fe,1NZ,Eo,0d125a,D,G,H,I,J,,.YRKL,KL,Fe,Fe,j,IA,B2DFDB,K,L,,.YRGM,GM,Fe,Fe,Xr,15c,26A69A,G,M,,.YROP,OP,Fe,Fe,1d9,01w,00695C,O,P,,.YRHQ,HQ,Fe,Fe,1pd,14h,c5cae9,H,Q,,.YRMQS,MQS,Fe,Fe,1lb,1JO,303e9f,M,Q,S,,.YRIT,IT,Fe,Fe,YF,0Zq,0d125a,I,T,,.YRJV,JV,Fe,Fe,1qE,0ax,B2DFDB,J,V,,.YRTVX,TVX,Fe,Fe,1mG,0nZ,26A69A,T,V,X,,.YPCE,CE,0,0,0,0,,C,E,,.YPMN,MN,0,0,0,0,,M,N,,.YPQR,QR,0,0,0,0,,Q,R,,.YPTU,TU,0,0,0,0,,T,U,,.YPVW,VW,0,0,0,0,,V,W,,...N_X) | §2.7a: no chain of dyads solves this — the plate and both arms settle together | yes | yes | `gripper.spec.ts, anchored-bar-mobility.spec.ts` | +| [Gripper the cylinder closes](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4A,A,02SG,ku,0.0B,B,0185,uK,0.8C,C,019F,uC,0.0D,D,Ay,11e,0.4G,G,_W,ku,0.0H,H,1HG,S8,0.0I,I,2hu,VG,0.4J,J,_W,0ku,0.0K,K,hm,0S8,0.0L,L,2hu,0VG,0.ZE,E,019F,uC,0,AB,A,B..YRAB,AB,Fe,Fe,01oB,pc,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0V9,yw,303e9f,C,D,,.YRDGHI,DGHI,Fe,Fe,1Hf,g_,0d125a,D,G,H,I,,.YRHK,HK,Fe,Fe,_W,0,B2DFDB,H,K,,.YRJKL,JKL,Fe,Fe,1Sj,0ZR,26A69A,J,K,L,,.YPCE,CE,0,0,0,0,,C,E,,...N_M) | Counter-rotating jaw levers: extending the cylinder pinches them shut | yes | yes | `pinching-gripper.spec.ts` | +| [Backhoe bucket](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4A,A,01jO,0,0.0B,B,0sb,Py,0.8C,C,018N,HX,0.0D,D,0Ha,hT,0.4G,G,0,0,0.0H,H,VG,Im,0.4J,J,o0,0P0,0.0K,K,1BI,2i,0.0T,T,1NW,0ee,0.ZE,E,018N,HX,0,AB,A,B..YRAB,AB,Fe,Fe,01H-,C_,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0iz,UV,303e9f,C,D,,.YRDGH,DGH,Fe,Fe,4a,Kl,0d125a,D,G,H,,.YRHK,HK,Fe,Fe,rH,Ak,B2DFDB,H,K,,.YRJKT,JKT,Fe,Fe,16x,0K-,26A69A,J,K,T,,.YPCE,CE,0,0,0,0,,C,E,,...N_z) | A driven ram feeding an ordinary four-bar: bell crank, link, and the bucket curls | yes | yes | `excavator-bucket.spec.ts` | +| [Toggle press](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4A,A,01z0,0,0.0B,B,017k,0Uc,0.8C,C,01P8,0Kc,0.0D,D,0Zs,0pD,0.4G,G,0,0,0.0R,R,0,01cP,0.ZE,E,01P8,0Kc,0,AB,A,B.5F,F,0,01cP,OZ..YRAB,AB,Fe,Fe,01YN,0FJ,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0_V,0Zv,303e9f,C,D,,.YRGD,GD,Fe,Fe,0Hx,0Pc,0d125a,G,D,,.YRDR,DR,Fe,Fe,0Hx,01Cp,B2DFDB,D,R,,.YPCE,CE,0,0,0,0,,C,E,,.YPRF,RF,0,0,0,0,,R,F,,...N_Q) | A ram closing a toggle onto a block: travel traded for force as it approaches straight | yes | yes | `toggle-press.spec.ts` | +| [Scissor lift](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4A,A,2SG,0,0.0B,B,1fl,a3,0.8C,C,1wL,OD,0.0D,D,17q,yH,0.4G,G,0,0,0.0M,M,1Vm,1GM,0.0K,K,2-X,2Wj,0.0S,S,2-X,0,0.0T,T,0,2Wj,0.0U,U,3_1,2Wj,0.ZE,E,1wL,OD,0,AB,A,B.5N,N,2-X,0,0.1P,P,2-X,2Wj,0,TU,T,U..YRAB,AB,Fe,Fe,230,I2,c5cae9,A,B,,.YRCD,CD,Fe,Fe,1X5,gF,303e9f,C,D,,.YRGDMK,GDMK,Fe,Fe,1Pn,1BL,0d125a,G,D,M,K,,.YRSMT,SMT,Fe,Fe,1Vm,1GM,B2DFDB,S,M,T,,.YRTU,TU,Fe,Fe,1-0,2Wj,26A69A,T,U,,.YPCE,CE,0,0,0,0,,C,E,,.YPSN,SN,0,0,0,0,,S,N,,.YPKP,KP,0,0,0,0,,K,P,,...N_b) | Ram, supporting block and a slot in the moving platform — all three parts, three jobs | yes | yes | `scissor-lift.spec.ts` | +| [Shaper's quick-return drive](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.4C,C,0,0ku,0.0D,D,Oj,RF,0.0R,R,qW,si,0.1P,P,Fe,0,0,CD,C,D.5Q,Q,qW,si,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,CN,09q,303e9f,C,D,,.YRDR,DR,Fe,Fe,cd,e_,0d125a,D,R,,.YPBP,BP,0,0,0,0,,B,P,,.YPRQ,RQ,0,0,0,0,,R,Q,,...N_F) | A floating slot handing off to a grounded one: the ram cuts slow and returns fast | yes | — | `shaper-quick-return.spec.ts` | +| [Slider-crank whose rod comes square to the guide](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,VG,0.0B,B,Fe,VG,0.0C,C,oa,0,0.5P,P,oa,0,0..YRAB,AB,Fe,Fe,7q,VG,c5cae9,A,B,,.YRBC,BC,Fe,Fe,X6,Fe,303e9f,B,C,,.YPCP,CP,0,0,0,0,,C,P,,...N_c) | The slot tangent to the rod circle: the two roots meet and trade places | — | — | `square-rod-tangency.spec.ts` | +| [MotionGen gripper](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.0A,A,0U5,01,0.0B,B,0Hj,Fj,0.0C,C,0Hj,0FZ,0.0D,D,DZ,Fj,0.0E,E,DZ,0FZ,0.0F,F,0V6,X9,0.0G,G,A,X9,0.0H,H,0V6,0X0,0.0I,I,A,0X0,0.0J,J,_d,0Bn,0.0K,K,_o,GS,0.7P,P,0U5,01,0.5Q,Q,0V6,X9,OZ.5R,R,A,X9,OZ.5S,S,0V6,0X0,OZ.5T,T,A,0X0,OZ..YRABCDE,ABCDE,Fe,Fe,07i,4,c5cae9,A,B,C,D,E,,.YRBF,BF,Fe,Fe,0OQ,OR,303e9f,B,F,,.YRDG,DG,Fe,Fe,6s,OR,0d125a,D,G,,.YRCH,CH,Fe,Fe,0OQ,0OH,B2DFDB,C,H,,.YREI,EI,Fe,Fe,6s,0OH,26A69A,E,I,,.YRHIJ,HIJ,Fe,Fe,Aa,0Px,00695C,H,I,J,,.YRFGK,FGK,Fe,Fe,Ae,Rb,c5cae9,F,G,K,,.YPAP,AP,0,0,0,0,,A,P,,.YPFQ,FQ,0,0,0,0,,F,Q,,.YPGR,GR,0,0,0,0,,G,R,,.YPHS,HS,0,0,0,0,,H,S,,.YPIT,IT,0,0,0,0,,I,T,,...N_y) | A second engine's mechanism, rebuilt: over-constrained, so PMKS+ reports DOF 0 and refuses it | — | — | `motiongen-gripper.spec.ts` | +| [Gripper with the redundancy removed](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.0A,A,0U5,0,0.0M,M,DZ,0,0.0B,B,0Hj,Fj,0.0C,C,0Hj,0FZ,0.4F,F,0V6,X9,0.0G,G,A,X9,0.0K,K,_o,GS,0.4H,H,0V6,0X0,0.0I,I,A,0X0,0.0J,J,_d,0Bn,0.7P,P,0U5,0,0.5N,N,DZ,0,0..YRAMBC,AMBC,Fe,Fe,0C-,2,c5cae9,A,M,B,C,,.YRBG,BG,Fe,Fe,08o,OR,303e9f,B,G,,.YRCI,CI,Fe,Fe,08o,0OH,0d125a,C,I,,.YRFGK,FGK,Fe,Fe,Ae,Rb,B2DFDB,F,G,K,,.YRHIJ,HIJ,Fe,Fe,Aa,0Px,26A69A,H,I,J,,.YPAP,AP,0,0,0,0,,A,P,,.YPMN,MN,0,0,0,0,,M,N,,...N_h) | The same gripper, jaws pivoting instead of railed: DOF 1, and it runs | — | — | `pivoting-gripper.spec.ts` | +| [Radial engine, five cylinders](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6O,O,0,0,0.0A,A,Fe,0,0.0B,B,0,iC,0.0C,C,0UD,9q,0.0D,D,0L9,0T6,0.0E,E,Vy,0hz,0.0F,F,wV,J0,0.5P,P,0,iC,OZ.5Q,Q,0UD,9q,iB.5R,R,0L9,0T6,-q.5S,S,Vy,0hz,1JT.5T,T,wV,J0,1d5..YROA,OA,Fe,Fe,7q,0,c5cae9,O,A,,.YRAB,AB,Fe,Fe,7q,M6,303e9f,A,B,,.YRAC,AC,Fe,Fe,07J,4w,0d125a,A,C,,.YRAD,AD,Fe,Fe,02m,0EZ,B2DFDB,A,D,,.YRAE,AE,Fe,Fe,No,0L_,26A69A,A,E,,.YRAF,AF,Fe,Fe,b3,9W,00695C,A,F,,.YPBP,BP,0,0,0,0,,B,P,,.YPCQ,CQ,0,0,0,0,,C,Q,,.YPDR,DR,0,0,0,0,,D,R,,.YPES,ES,0,0,0,0,,E,S,,.YPFT,FT,0,0,0,0,,F,T,,...N_A) | Five sliders on one crank pin; piston stroke is exactly twice the throw | — | — | `radial-engine.spec.ts` | | [Chebyshev straight-line linkage](https://app.pmksplus.com/?2P.Ay,1E8.2,0.1011.6G,G,0VG,0,0.0A,A,0Fe,_W,0.0B,B,Fe,_W,0.GM,M,0,_W,0.4H,H,VG,0,0..YRGB,GB,Fe,Fe,07q,VG,c5cae9,G,B,,.YRABM,ABM,Fe,Fe,0,_W,303e9f,A,B,M,,.YRAH,AH,Fe,Fe,7q,VG,0d125a,A,H,,...N_5) | Approximate straight-line generation: the coupler midpoint runs flat along the top | — | — | `chebyshev-straight-line.spec.ts` | -| [Windshield wiper](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6O,O,0si,0iC,0.0A,A,0se,0Km,0.0B,B,Dl,0S4,0.4P,P,0,0,0.0C,C,AJ,0L3,0.GT,T,0YM,16B,0.0D,D,1e3,0L3,0.4Q,Q,1Tm,0,0.GU,U,xQ,16B,0..YROA,Motor crank,Fe,Fe,0sg,0WU,c5cae9,O,A,,.YRAB,Drive link,Fe,Fe,0KS,0OQ,303e9f,A,B,,.YRBCPT,Wiper arm,Fe,Fe,02b,5H,0d125a,B,C,P,T,,.YRCD,Tie rod,Fe,Fe,vB,0L3,B2DFDB,C,D,,.YRDQU,Passenger arm,Fe,Fe,1Ll,GO,26A69A,D,Q,U,,...N_z) | Crank-rocker: continuous rotation into a bounded sweep, against the closed form | — | — | `windshield-wiper.spec.ts` | -| [Jansen leg](https://app.pmksplus.com/?2P.1jO,1E8.K,0.1011.6O,O,0,0,0.0A,A,3gO,0,0.4G,G,09Hm,01vu,0.0B,B,05tE,7ee,0.0C,C,0IGg,1-F,0.0D,D,06b8,0B7B,0.0E,E,0ETW,06sL,0.GF,F,0AYO,0MPj,0..YROA,OA,Fe,Fe,1rC,0,c5cae9,O,A,,.YRAB,AB,Fe,Fe,016R,3qK,303e9f,A,B,,.YRGBC,GBC,Fe,Fe,0B8Z,2ag,0d125a,G,B,C,,.YRAD,AD,Fe,Fe,01TO,05Zc,B2DFDB,A,D,,.YRGD,GD,Fe,Fe,07xS,06WY,26A69A,G,D,,.YRCE,CE,Fe,Fe,0GN5,02RZ,00695C,C,E,,.YRDEF,DEF,Fe,Fe,0AXh,0DT4,c5cae9,D,E,F,,...N_c) | One leg of a Strandbeest: eight bars on Jansen's holy numbers, and the foot walks | — | — | `jansen-leg.spec.ts` | -| [Elliptical crank](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0ix,X,0.0B,B,0zu,7O,0.0C,C,0qC,Lr,0.GD,D,085,Av,0.0E,E,aY,v,0.4F,F,0,0,0.5P,P,aY,v,03..YRAB,AB,Fe,Fe,0rQ,3y,c5cae9,A,B,,.YRBC,BC,Fe,Fe,0v2,Ed,303e9f,B,C,,.YRCDE,CDE,Fe,Fe,07x,BD,0d125a,C,D,E,,.YRDF,DF,Fe,Fe,043,5S,B2DFDB,D,F,,.YPEP,EP,Fe,0,0,0,,E,P,,...N_V) | A six-bar no dyad reaches: the coupler and its guided end have to be solved together | — | — | `elliptical-crank.spec.ts` | -| [Inverted slider-crank](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,0,Fe,0.4C,C,ku,0,0.0D,D,0RF,Oj,0.1P,P,0,Fe,0,CD,C,D..YRAB,AB,Fe,Fe,0,7q,c5cae9,A,B,,.YRCD,CD,Fe,Fe,9q,CN,303e9f,C,D,,.YPBP,BP,Fe,0,0,0,,B,P,,...N_v) | Inverse slot direction: position, velocity and acceleration against closed form | yes | — | `inverted-slider-crank.spec.ts, slot-kinematics.spec.ts` | -| [Whitworth proportions](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,0,ku,0.4C,C,Fe,0,0.0D,D,095,1A7,0.1P,P,0,ku,0,CD,C,D..YRAB,AB,Fe,Fe,0,NS,c5cae9,A,B,,.YRCD,CD,Fe,Fe,3H,b4,303e9f,C,D,,.YPBP,BP,Fe,0,0,0,,B,P,,...N_w) | Crank longer than the ground offset, so the lever turns instead of rocking | yes | — | `slot-kinematics.spec.ts` | -| [Inverted slider-crank with a load](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,0,Fe,0.4C,C,ku,0,0.0D,D,0RF,Oj,0.1P,P,0,Fe,0,CD,C,D..YRAB,AB,Fe,Fe,0,7q,c5cae9,A,B,,.YRCD,CD,Fe,Fe,9q,CN,303e9f,C,D,,.YPBP,BP,Fe,0,0,0,,B,P,,..2F1,CD,F1,0RF,Oj,0RF,95,2SG..N_N) | Reactions equal and opposite across the slot, and normal to it | yes | — | `slot-forces.spec.ts` | -| [Four-bar with a slotted coupler](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.0C,C,d4,ec,0.4D,D,_W,0,0.4E,E,VG,7q,0.0F,F,bo,cO,0.0X,X,tU,151,0.1P,P,bo,cO,0,BCX,B,X..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRBCX,BCX,Fe,Fe,ak,aY,303e9f,B,C,X,,.YRCD,CD,Fe,Fe,oo,KJ,0d125a,C,D,,.YREF,EF,Fe,Fe,YX,N6,B2DFDB,E,F,,.YPFP,FP,Fe,0,0,0,,F,P,,...N_i) | Forward slot direction: the only case where the carrier is solved first | yes | — | `slotted-coupler.spec.ts` | -| [Scotch yoke](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.8C,C,Fe,0VG,0.0D,D,Fe,S8,0.1E,E,Fe,0,0,CD,C,D.5F,F,Fe,0VG,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,Fe,01a,303e9f,C,D,,.YPBE,BE,Fe,0,0,0,,B,E,,.YPCF,CF,Fe,0,0,0,,C,F,,...N_Z) | Slide: a welded assembly translating on its guide, x = r cos theta | yes | yes | `scotch-yoke.spec.ts, scotch-yoke-kinematics.spec.ts` | -| [Scotch yoke with a tracer](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.0G,G,ku,S8,0.8C,C,Fe,0VG,0.0D,D,Fe,S8,0.1E,E,Fe,0,0,CDG,C,D.5F,F,Fe,0VG,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCDG,CDG,Fe,Fe,Q3,8L,303e9f,C,D,G,,.YPBE,BE,Fe,0,0,0,,B,E,,.YPCF,CF,Fe,0,0,0,,C,F,,...N_q) | The slot measured from a joint on it, not from whichever member came first | yes | yes | `scotch-yoke.spec.ts` | -| [Scotch yoke guided at the far end](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.0C,C,Fe,S8,0.8D,D,Fe,0VG,0.1E,E,Fe,0,0,CD,C,D.5G,G,Fe,0VG,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,Fe,01a,303e9f,C,D,,.YPBE,BE,Fe,0,0,0,,B,E,,.YPDG,DG,Fe,0,0,0,,D,G,,...N_Z) | Same motion, but the loop reaches the welded rider along a link edge | yes | yes | `scotch-yoke-kinematics.spec.ts` | -| [Elliptical trammel](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.0A,A,Fe,0,0.0B,B,0,Fe,0.5C,C,Fe,0,0.5D,D,0,Fe,OZ..YRAB,AB,Fe,Fe,7q,7q,c5cae9,A,B,,.YPAC,AC,Fe,0,0,0,,A,C,,.YPBD,BD,Fe,0,0,0,,B,D,,...N_U) | Mobility for a linkage held only by its guides — no pin touches ground | — | — | `slot-mobility.spec.ts` | -| [Four-bar driven at its coupler-rocker pin](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4O,O,0,0,0.0A,A,72,MM,0.2C,C,pa,bW,0.4D,D,_W,0,0.0T,T,Wq,pa,0..YROA,OA,Fe,Fe,3X,BB,c5cae9,O,A,,.YRACT,ACT,Fe,Fe,UU,b9,303e9f,A,C,T,,.YRCD,CD,Fe,Fe,v2,Im,0d125a,C,D,,...N_3) | Gate 6: a floating pin as the input — same coupler curve as driving the crank | — | — | `driven-floating-pin.spec.ts` | -| [Leg on a bicycle crank](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4B,B,0,0,0.GP,P,Qa,0,0.4H,H,0NS,1Qe,0.2K,K,c5,13o,0..YRHK,HK,Fe,Fe,7L,1FD,c5cae9,H,K,,.YRKP,KP,Fe,Fe,WL,Xv,303e9f,K,P,,.YRBP,BP,Fe,Fe,DI,0,0d125a,B,P,,...N_p) | A driven knee, carried by the thigh: one leg can only rock the crank half a turn | — | — | `pedaling-leg.spec.ts` | -| [Oscillating fan](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4A,A,0,0,0.2C,C,s8,0VG,0.0D,D,k_,0j9,0.4B,B,0GY,0In,0.0N,N,1NW,0,0..YRACN,ACN,Fe,Fe,lD,0AR,c5cae9,A,C,N,,.YRCD,CD,Fe,Fe,oZ,0cC,303e9f,C,D,,.YRDB,DB,Fe,Fe,FE,0Vz,0d125a,D,B,,...N_B) | The motor rides the head it sweeps: the driven pin turns right round, the head does not | — | — | `oscillating-fan.spec.ts` | -| [Walking-beam pumping unit](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.4A,A,0gB,0oI,0.0M,M,0QZ,0o1,0.2P,P,0d4,0,0.4S,S,0,0,0.0H,H,o0,0,0.0R,R,r8,0eW,0.5W,W,r8,0eW,OZ..YRAM,AM,Fe,Fe,0YN,0oA,c5cae9,A,M,,.YRMP,MP,Fe,Fe,0Wq,0P1,303e9f,M,P,,.YRPSH,PSH,Fe,Fe,3f,0,0d125a,P,S,H,,.YRHR,HR,Fe,Fe,pa,0KG,B2DFDB,H,R,,.YPRW,RW,Fe,0,0,0,,R,W,,...N_Z) | Driven where the pitman meets the beam, and the output is a straight-line stroke | — | — | `pumpjack.spec.ts` | -| [TeachingLab four-bar](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,Nm,0,0.0C,C,126,lL,0.4D,D,17S,0,0.0E,E,1As,PW,0.0F,F,dq,Uc,0.0G,G,W5,tN,0.0H,H,Bu,G4,0.0I,I,11K,UW,0..YRABH,ABH,2ZQ,n9pzh4,01Zi,bM,c5cae9,A,B,H,,.YRBCFG,BCFG,9o,A80cTW,jM2,p3C,303e9f,B,C,F,G,,.YRCDEI,CDEI,1Cb,w-akVq,1pNm,0550,0d125a,C,D,E,I,,...N_Y) | MATLAB-verified positions, velocities, accelerations and forces | — | — | `teaching-lab-four-bar.spec.ts` | -| [TeachingLab slider-crank](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,3NU,2SQ,0.0B,B,3NU,3Ny,0.0C,C,0FA,2SQ,0.5D,D,0FA,2SQ,0..YRAB,AB,Gz,0,0,1a,c5cae9,A,B,,.YRBC,BC,7r,3,7q,Ay,303e9f,B,C,,.YPCD,CD,Kc,0,0,0,,C,D,,...N_l) | The grounded-guide path this phase had to leave byte-identical | — | — | `teaching-lab-slider-crank.spec.ts` | -| [Slider-crank with a tracer](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,_W,VG,0.0C,C,2xW,0,0.0D,D,4uW,VG,0.5E,E,2xW,0,0..YRAB,AB,1E8,1a,VG,Fe,c5cae9,A,B,,.YRBCD,BCD,2SG,38,2xW,Kr,303e9f,B,C,D,,.YPCE,CE,Fe,0,0,0,,C,E,,...N_z) | A tracer point on the coupler of a grounded slider | — | — | `slider-crank-tracer.spec.ts` | -| [Stephenson III](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,1jO,_W,0.0B,B,1E8,3w0,0.0C,C,66e,66e,0.4D,D,5dO,2SG,0.0E,E,4PG,8Yu,0.0F,F,AVu,7q0,0.4G,G,A-8,49e,0..YRAB,AB,1E8,1a,1Tm,2SG,c5cae9,A,B,,.YRBCE,BCE,2SG,38,3w0,6Br,303e9f,B,C,E,,.YRCD,CD,1E8,1a,5t0,4HS,0d125a,C,D,,.YREF,EF,2SG,38,7Sa,8BS,B2DFDB,E,F,,.YRFG,FG,1E8,1a,AlW,5_q,26A69A,F,G,,..2F1,FG,F1,AlW,5_q,A-8,5_q,CDG..N_W) | Six-bar, MATLAB-verified | — | — | `stephenson-iii-ex2.spec.ts` | -| [Watt I](https://app.pmksplus.com/?2P.Ay,1E8.K,0.1011.6A,A,0wS,0bg,0.0B,B,0gW,EE,0.0C,C,Oi,6k,0.0D,D,03m,_g,0.0E,E,1FO,1I_,0.0F,F,1-C,qM,0.4G,G,1oO,0ss,0..YRAB,AB,1E8,1a,0oU,0Bk,c5cae9,A,B,,.YRBCD,BCD,2SG,38,07C,Rt,303e9f,B,C,D,,.YRDE,DE,1E8,1a,bq,18q,0d125a,D,E,,.YREF,EF,2SG,38,1dI,13g,B2DFDB,E,F,,.YRCFG,CFG,1E8,1a,1Om,1Q,26A69A,C,F,G,,..2F1,CFG,F1,1Om,1Q,1ck,8P,DfU..N_S) | Six-bar, MATLAB-verified | — | — | `watt-i.spec.ts` | +| [Windshield wiper](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6O,O,0si,0iC,0.0A,A,0se,0Km,0.0B,B,Dl,0S4,0.4P,P,0,0,0.0C,C,AJ,0L3,0.GT,T,0YM,16B,0.0D,D,1e3,0L3,0.4Q,Q,1Tm,0,0.GU,U,xQ,16B,0..YROA,Motor crank,Fe,Fe,0sg,0WU,c5cae9,O,A,,.YRAB,Drive link,Fe,Fe,0KS,0OQ,303e9f,A,B,,.YRBCPT,Wiper arm,Fe,Fe,02b,5H,0d125a,B,C,P,T,,.YRCD,Tie rod,Fe,Fe,vB,0L3,B2DFDB,C,D,,.YRDQU,Passenger arm,Fe,Fe,1Ll,GO,26A69A,D,Q,U,,...N_z) | Crank-rocker: continuous rotation into a bounded sweep, against the closed form | — | — | `windshield-wiper.spec.ts` | +| [Jansen leg](https://app.pmksplus.com/?2P.1jO,1E8.5,0.1011.6O,O,0,0,0.0A,A,3gO,0,0.4G,G,09Hm,01vu,0.0B,B,05tE,7ee,0.0C,C,0IGg,1-F,0.0D,D,06b8,0B7B,0.0E,E,0ETW,06sL,0.GF,F,0AYO,0MPj,0..YROA,OA,Fe,Fe,1rC,0,c5cae9,O,A,,.YRAB,AB,Fe,Fe,016R,3qK,303e9f,A,B,,.YRGBC,GBC,Fe,Fe,0B8Z,2ag,0d125a,G,B,C,,.YRAD,AD,Fe,Fe,01TO,05Zc,B2DFDB,A,D,,.YRGD,GD,Fe,Fe,07xS,06WY,26A69A,G,D,,.YRCE,CE,Fe,Fe,0GN5,02RZ,00695C,C,E,,.YRDEF,DEF,Fe,Fe,0AXh,0DT4,c5cae9,D,E,F,,...N_c) | One leg of a Strandbeest: eight bars on Jansen's holy numbers, and the foot walks | — | — | `jansen-leg.spec.ts` | +| [Elliptical crank](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0ix,X,0.0B,B,0zu,7O,0.0C,C,0qC,Lr,0.GD,D,085,Av,0.0E,E,aY,v,0.4F,F,0,0,0.5P,P,aY,v,03..YRAB,AB,Fe,Fe,0rQ,3y,c5cae9,A,B,,.YRBC,BC,Fe,Fe,0v2,Ed,303e9f,B,C,,.YRCDE,CDE,Fe,Fe,07x,BD,0d125a,C,D,E,,.YRDF,DF,Fe,Fe,043,5S,B2DFDB,D,F,,.YPEP,EP,0,0,0,0,,E,P,,...N_U) | A six-bar no dyad reaches: the coupler and its guided end have to be solved together | — | — | `elliptical-crank.spec.ts` | +| [Inverted slider-crank](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,0,Fe,0.4C,C,ku,0,0.0D,D,0RF,Oj,0.1P,P,0,Fe,0,CD,C,D..YRAB,AB,Fe,Fe,0,7q,c5cae9,A,B,,.YRCD,CD,Fe,Fe,9q,CN,303e9f,C,D,,.YPBP,BP,0,0,0,0,,B,P,,...N_u) | Inverse slot direction: position, velocity and acceleration against closed form | yes | — | `inverted-slider-crank.spec.ts, slot-kinematics.spec.ts` | +| [Whitworth proportions](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,0,ku,0.4C,C,Fe,0,0.0D,D,095,1A7,0.1P,P,0,ku,0,CD,C,D..YRAB,AB,Fe,Fe,0,NS,c5cae9,A,B,,.YRCD,CD,Fe,Fe,3H,b4,303e9f,C,D,,.YPBP,BP,0,0,0,0,,B,P,,...N_v) | Crank longer than the ground offset, so the lever turns instead of rocking | yes | — | `slot-kinematics.spec.ts` | +| [Inverted slider-crank with a load](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,0,Fe,0.4C,C,ku,0,0.0D,D,0RF,Oj,0.1P,P,0,Fe,0,CD,C,D..YRAB,AB,Fe,Fe,0,7q,c5cae9,A,B,,.YRCD,CD,Fe,Fe,9q,CN,303e9f,C,D,,.YPBP,BP,0,0,0,0,,B,P,,..2F1,CD,F1,0RF,Oj,0RF,95,2SG..N_M) | Reactions equal and opposite across the slot, and normal to it | yes | — | `slot-forces.spec.ts` | +| [Four-bar with a slotted coupler](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.0C,C,d4,ec,0.4D,D,_W,0,0.4E,E,VG,7q,0.0F,F,bo,cO,0.0X,X,tU,151,0.1P,P,bo,cO,0,BCX,B,X..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRBCX,BCX,Fe,Fe,ak,aY,303e9f,B,C,X,,.YRCD,CD,Fe,Fe,oo,KJ,0d125a,C,D,,.YREF,EF,Fe,Fe,YX,N6,B2DFDB,E,F,,.YPFP,FP,0,0,0,0,,F,P,,...N_h) | Forward slot direction: the only case where the carrier is solved first | yes | — | `slotted-coupler.spec.ts` | +| [Scotch yoke](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.8C,C,Fe,0VG,0.0D,D,Fe,S8,0.1E,E,Fe,0,0,CD,C,D.5F,F,Fe,0VG,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,Fe,01a,303e9f,C,D,,.YPBE,BE,0,0,0,0,,B,E,,.YPCF,CF,0,0,0,0,,C,F,,...N_X) | Slide: a welded assembly translating on its guide, x = r cos theta | yes | yes | `scotch-yoke.spec.ts, scotch-yoke-kinematics.spec.ts` | +| [Scotch yoke with a tracer](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.0G,G,ku,S8,0.8C,C,Fe,0VG,0.0D,D,Fe,S8,0.1E,E,Fe,0,0,CDG,C,D.5F,F,Fe,0VG,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCDG,CDG,Fe,Fe,Q3,8L,303e9f,C,D,G,,.YPBE,BE,0,0,0,0,,B,E,,.YPCF,CF,0,0,0,0,,C,F,,...N_o) | The slot measured from a joint on it, not from whichever member came first | yes | yes | `scotch-yoke.spec.ts` | +| [Scotch yoke guided at the far end](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.0C,C,Fe,S8,0.8D,D,Fe,0VG,0.1E,E,Fe,0,0,CD,C,D.5G,G,Fe,0VG,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,Fe,01a,303e9f,C,D,,.YPBE,BE,0,0,0,0,,B,E,,.YPDG,DG,0,0,0,0,,D,G,,...N_X) | Same motion, but the loop reaches the welded rider along a link edge | yes | yes | `scotch-yoke-kinematics.spec.ts` | +| [Elliptical trammel](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.0A,A,Fe,0,0.0B,B,0,Fe,0.5C,C,Fe,0,0.5D,D,0,Fe,OZ..YRAB,AB,Fe,Fe,7q,7q,c5cae9,A,B,,.YPAC,AC,0,0,0,0,,A,C,,.YPBD,BD,0,0,0,0,,B,D,,...N_S) | Mobility for a linkage held only by its guides — no pin touches ground | — | — | `slot-mobility.spec.ts` | +| [Four-bar driven at its coupler-rocker pin](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4O,O,0,0,0.0A,A,72,MM,0.2C,C,pa,bW,0.4D,D,_W,0,0.0T,T,Wq,pa,0..YROA,OA,Fe,Fe,3X,BB,c5cae9,O,A,,.YRACT,ACT,Fe,Fe,UU,b9,303e9f,A,C,T,,.YRCD,CD,Fe,Fe,v2,Im,0d125a,C,D,,...N_3) | Gate 6: a floating pin as the input — same coupler curve as driving the crank | — | — | `driven-floating-pin.spec.ts` | +| [Leg on a bicycle crank](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4B,B,0,0,0.GP,P,Qa,0,0.4H,H,0NS,1Qe,0.2K,K,c5,13o,0..YRHK,HK,Fe,Fe,7L,1FD,c5cae9,H,K,,.YRKP,KP,Fe,Fe,WL,Xv,303e9f,K,P,,.YRBP,BP,Fe,Fe,DI,0,0d125a,B,P,,...N_p) | A driven knee, carried by the thigh: one leg can only rock the crank half a turn | — | — | `pedaling-leg.spec.ts` | +| [Oscillating fan](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4A,A,0,0,0.2C,C,s8,0VG,0.0D,D,k_,0j9,0.4B,B,0GY,0In,0.0N,N,1NW,0,0..YRACN,ACN,Fe,Fe,lD,0AR,c5cae9,A,C,N,,.YRCD,CD,Fe,Fe,oZ,0cC,303e9f,C,D,,.YRDB,DB,Fe,Fe,FE,0Vz,0d125a,D,B,,...N_B) | The motor rides the head it sweeps: the driven pin turns right round, the head does not | — | — | `oscillating-fan.spec.ts` | +| [Walking-beam pumping unit](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.4A,A,0gB,0oI,0.0M,M,0QZ,0o1,0.2P,P,0d4,0,0.4S,S,0,0,0.0H,H,o0,0,0.0R,R,r8,0eW,0.5W,W,r8,0eW,OZ..YRAM,AM,Fe,Fe,0YN,0oA,c5cae9,A,M,,.YRMP,MP,Fe,Fe,0Wq,0P1,303e9f,M,P,,.YRPSH,PSH,Fe,Fe,3f,0,0d125a,P,S,H,,.YRHR,HR,Fe,Fe,pa,0KG,B2DFDB,H,R,,.YPRW,RW,0,0,0,0,,R,W,,...N_Y) | Driven where the pitman meets the beam, and the output is a straight-line stroke | — | — | `pumpjack.spec.ts` | +| [TeachingLab four-bar](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,Nm,0,0.0C,C,126,lL,0.4D,D,17S,0,0.0E,E,1As,PW,0.0F,F,dq,Uc,0.0G,G,W5,tN,0.0H,H,Bu,G4,0.0I,I,11K,UW,0..YRABH,ABH,2ZQ,n9pzh4,01Zi,bM,c5cae9,A,B,H,,.YRBCFG,BCFG,9o,A80cTW,jM2,p3C,303e9f,B,C,F,G,,.YRCDEI,CDEI,1Cb,w-akVq,1pNm,0550,0d125a,C,D,E,I,,...N_Y) | MATLAB-verified positions, velocities, accelerations and forces | — | — | `teaching-lab-four-bar.spec.ts` | +| [TeachingLab four-bar, locked except the crank](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,Nm,0,0.0C,C,126,lL,0.4D,D,17S,0,0.0E,E,1As,PW,0.0F,F,dq,Uc,0.0G,G,W5,tN,0.0H,H,Bu,G4,0.0I,I,11K,UW,0..YRABH,ABH,2ZQ,n9pzh4,01Zi,bM,c5cae9,A,B,H,,.YRBCFG,BCFG,9o,A80cTW,jM2,p3C,303e9f,B,C,F,G,,.YRCDEI,CDEI,1Cb,w-akVq,1pNm,0550,0d125a,C,D,E,I,,...N_.JB,JC,JD,JE,JF,JG,JIt) | Lock marks ride the URL: only the crank end drags, the rest is pinned black | — | — | `url-locking.spec.ts` | +| [TeachingLab slider-crank](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,3NU,2SQ,0.0B,B,3NU,3Ny,0.0C,C,0FA,2SQ,0.5D,D,0FA,2SQ,0..YRAB,AB,Gz,0,0,1a,c5cae9,A,B,,.YRBC,BC,7r,3,7q,Ay,303e9f,B,C,,.YPCD,CD,Kc,0,0,0,,C,D,,...N_l) | The grounded-guide path this phase had to leave byte-identical | — | — | `teaching-lab-slider-crank.spec.ts` | +| [Slider-crank with a tracer](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,_W,VG,0.0C,C,2xW,0,0.0D,D,4uW,VG,0.5E,E,2xW,0,0..YRAB,AB,1E8,1a,VG,Fe,c5cae9,A,B,,.YRBCD,BCD,2SG,38,2xW,Kr,303e9f,B,C,D,,.YPCE,CE,0,0,0,0,,C,E,,...N_y) | A tracer point on the coupler of a grounded slider | — | — | `slider-crank-tracer.spec.ts` | +| [Stephenson III](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,1jO,_W,0.0B,B,1E8,3w0,0.0C,C,66e,66e,0.4D,D,5dO,2SG,0.0E,E,4PG,8Yu,0.0F,F,AVu,7q0,0.4G,G,A-8,49e,0..YRAB,AB,1E8,1a,1Tm,2SG,c5cae9,A,B,,.YRBCE,BCE,2SG,38,3w0,6Br,303e9f,B,C,E,,.YRCD,CD,1E8,1a,5t0,4HS,0d125a,C,D,,.YREF,EF,2SG,38,7Sa,8BS,B2DFDB,E,F,,.YRFG,FG,1E8,1a,AlW,5_q,26A69A,F,G,,..2F1,FG,F1,AlW,5_q,A-8,5_q,CDG..N_W) | Six-bar, MATLAB-verified | — | — | `stephenson-iii-ex2.spec.ts` | +| [Watt I](https://app.pmksplus.com/?2P.Ay,1E8.5,0.1011.6A,A,0wS,0bg,0.0B,B,0gW,EE,0.0C,C,Oi,6k,0.0D,D,03m,_g,0.0E,E,1FO,1I_,0.0F,F,1-C,qM,0.4G,G,1oO,0ss,0..YRAB,AB,1E8,1a,0oU,0Bk,c5cae9,A,B,,.YRBCD,BCD,2SG,38,07C,Rt,303e9f,B,C,D,,.YRDE,DE,1E8,1a,bq,18q,0d125a,D,E,,.YREF,EF,2SG,38,1dI,13g,B2DFDB,E,F,,.YRCFG,CFG,1E8,1a,1Om,1Q,26A69A,C,F,G,,..2F1,CFG,F1,1Om,1Q,1ck,8P,DfU..N_S) | Six-bar, MATLAB-verified | — | — | `watt-i.spec.ts` | diff --git a/docs/images/context-menu/before-after-canvas.png b/docs/images/context-menu/before-after-canvas.png new file mode 100644 index 00000000..b5c42704 Binary files /dev/null and b/docs/images/context-menu/before-after-canvas.png differ diff --git a/docs/images/context-menu/before-after-joint.png b/docs/images/context-menu/before-after-joint.png new file mode 100644 index 00000000..681d3901 Binary files /dev/null and b/docs/images/context-menu/before-after-joint.png differ diff --git a/docs/images/context-menu/refusals.png b/docs/images/context-menu/refusals.png new file mode 100644 index 00000000..0b1f2796 Binary files /dev/null and b/docs/images/context-menu/refusals.png differ diff --git a/docs/notification-inventory.md b/docs/notification-inventory.md new file mode 100644 index 00000000..0881d454 --- /dev/null +++ b/docs/notification-inventory.md @@ -0,0 +1,257 @@ +# Every message the app can say + +An inventory of the snackbar, taken **before** rebuilding it, and kept as the +record of what was there. Only messages that were **reachable** are listed — a +call site that no template bound, or that sat behind a comment, is in the dead +list at the bottom instead. + +> **This describes the old system.** What replaced it is in +> `src/app/services/notification.service.ts`: four kinds (`success`, `refusal`, +> `warning`, `failure`), each with its own colour, glyph, and answer to whether +> it takes itself away; a cooldown per message id rather than one for the whole +> app; up to three on screen at once; and an optional action, so a message that +> knows the fix can carry it. Every message below now has an id. The faults +> listed under "What is wrong with the system" are fixed except where marked. + +Two surfaces existed: + +- `NewGridComponent.sendNotification(text, rateLimitMS?)` — everything below + except one. Top-centre, white, 4s, no icon, no dismiss. +- `UrlProcessorService` opened its own `MatSnackBar` directly, without the + `my-custom-snackbar` class or the rate limit. + +Nothing carried a severity, an id, or an action. + +--- + +## Good — something the user wanted, happened (3) + +| Message | Reached by | +| --- | --- | +| `Loaded Mechanism from File` | Project menu → Open, after the file reads | +| `Mechanism URL copied. If you make additional changes, copy the URL again.` | Project menu → Share Project | +| `Message sent. Thank you for your feedback!` | Help → feedback form, on success | + +## Neutral — it happened, but you should know something (4) + +| Message | Reached by | +| --- | --- | +| `Merged joint A into B, but B cannot be welded` | Dragging a joint onto a weldable target that cannot take the weld | +| `A and B are now pinned together twice. The linkage still moves, but its forces have no unique solution.` | An edit that creates a duplicate pin | +| `The visual size of the links might be too small. Try using the "Update Object Scale" button…` | Zooming far out — **but see the rate-limit note** | +| `The visual size of the links might be too large. Try using the "Update Object Scale" button…` | Zooming far in — same | + +## Bad — you tried something and it did not happen (39) + +### Wrong mode, wrong moment (4 texts, many doors) + +| Message | Reached by | +| --- | --- | +| `Switch to Edit mode to change the mechanism.` | Any drag or context menu in Analyze | +| `Switch to Edit mode to change the mechanism.` | Same text, separate constant, in Synthesis | +| `Cannot edit while the animation is running.` | Drag, context menu, or any context-menu item while animating | +| `Step back to the start to edit.` | Drag or context menu at a timestep other than 0 | + +### Refused drops (11) — all from dragging one joint onto another + +`A joint cannot be merged into itself` · +`These joints are on the same link, so merging them would collapse it` · +`Drop onto the pin of a slider, not onto its slot` · +`Only one of these joints can carry a slider` · +`Merging here would tie the same two joints together twice, over-constraining the linkage` · +`A slider cannot ride on a link it is part of` · +`This joint cannot be merged` · +`A cylinder is one sealed part — attach at its mounts instead` · +`A welded joint cannot merge with a cylinder mount — unweld it first` · +`A driven joint can only join two bodies — remove the input first, or attach somewhere else` · +`A cylinder cannot fold onto itself` + +Each is also drawn on the canvas as a refusal marker and a shake, so the +snackbar is the third telling of the same fact. + +### Refused inputs (4) — right-click → Add Input + +`Only a joint can be driven.` · +`This joint is welded, so the bodies it joins cannot move relative to each other. Unweld it, or drive a joint that has a freedom.` · +`A driven joint needs two bodies to move relative to each other.` · +`This joint joins N bodies, so "driven" would not say which pair moves. Drive a joint where exactly two meet.` + +### Refused structural edits (6) + +| Message | Reached by | +| --- | --- | +| `This joint is welded, so a cylinder mounted on it would be a third body inside one rigid one. Unweld it, or attach the cylinder to the link instead.` | Drawing a cylinder onto a welded joint | +| `A cylinder is one sealed part — delete the cylinder instead of editing its slider.` | Editing a cylinder's slider | +| `Several links meet at that joint, so a force there would not say which one it acts on.` | Attaching a force at a multi-link joint | +| `That is the shortest cylinder there is — any less and the barrel has no room to slide in.` | Dragging a cylinder shorter than its minimum | +| `Those two joints are already on one link.` | Drawing a link between two joints already linked | +| `Cannot link to a bar. Please create and select a tracer point on the link.` | Starting a link from a bar | + +### Nothing selected (1) + +`Select something first — Delete removes whatever is selected.` — Delete key + +### Rejected typed values (6, `NOT_A`) — was debug-only, now on every field + +`That is not a length. Type a number, with or without a unit — 2, 2 cm, 0.75 in.` · +`That is not an angle. Type a number of degrees.` · +`That is not a mass. Type a number.` · +`That is not a moment of inertia. Type a number.` · +`That is not a force. Type a number.` · +`A name has to be letters or numbers, and cannot be one already in use.` + +These fired only from the linkage table, inside the Debug tab (Project menu → +Debug), while the Edit panel put the old value back without a word. + +They now fire from all thirteen of the Edit panel's numeric fields as well, and +each one names the units its field accepts. Every numeric field in the app takes +`2 cm` and `0.75 in` as readily as `2`, and nothing on screen said so — a +rejected value is the moment somebody is most willing to be told. + +### Rejected renames (2) — Edit panel title + +`The new ID cannot be empty.` · `The new ID must only contain letters and numbers.` + +### Feedback form (3) + +`Please fill out the form correctly.` · +`It looks like you are in a development environment. If this is not the case, please try again later or contact us directly at: gr-pmksplus@wpi.edu` · +`Message failed to send. Please try again later or contact us directly at: gr-pmksplus@wpi.edu` + +### File and URL (2) + +`No file selected` — Project menu → Open, cancelled · +`Unable to load the shared mechanism URL.` — a share link that will not decode +(the one message that bypasses `sendNotification` entirely) + +## Does not belong to any of the three (1) + +| Message | Reached by | +| --- | --- | +| `You attempted to undo. What were you trying to undo? Please let us know through the report button in the help section.` | **Ctrl/Cmd+Z** | + +Undo exists and works — it is a button in the top bar, backed by +`SaveHistoryService`. The keyboard shortcut for it fires a user-survey question +from an earlier study and undoes nothing. Verified in the browser: the message +appears, the model is unchanged. + +--- + +## What is wrong with the system, not the words + +1. **Ctrl+Z asks a survey question instead of undoing.** Above. + +2. **The rate limit is global, and starts armed.** There is one + `lastNotificationTime` for the whole app, set to `Date.now()` when the canvas + is constructed. So: + - nothing at all can be said in the first second after load; + - the two zoom warnings pass `20000`, which means they are muted for the + first twenty seconds of every session, and for twenty seconds after any + other message — I could not make them appear at all until I waited the + window out, and then they appeared immediately; + - two *different* refusals within a second show only the first. Drag-refuse, + then click-refuse, and the second is silent. + +3. **Every message looks identical.** Same white bar, same place, same 4s. + `Message sent. Thank you for your feedback!` and `A cylinder cannot fold onto + itself` are indistinguishable until you read them. + +4. **Nothing can be dismissed or kept.** The action is `''`, so there is no + button; 4 seconds or nothing. A message you were still reading is gone, and + a message you have already understood sits there. + +5. **Two snackbar paths.** `UrlProcessorService` opens its own, unstyled and + unlimited, so the one message about a *broken share link* is the one that + looks unlike the rest. + +6. **Messages have no identity.** They are bare strings, so nothing can be + deduplicated, grouped, counted, replaced in place, or asserted on by id. + +7. **Six of them are unreachable outside the Debug tab**, while the panel that + ships rejects the same bad values in silence. + +8. **Two constants, one sentence.** `CANNOT_EDIT.analyzeMode` and + `.synthesizeMode` hold the same text. + +--- + +## What changed + +Fixed: + +- **Ctrl+Z undoes**, and Ctrl+Shift+Z / Ctrl+Y redo. Neither says anything. A + keystroke aimed at a text field is left to that field, which also stops + Delete from removing a joint while its name is being typed. +- **The cooldown is per message id**, and starts unarmed. Two different + refusals in the same second both speak; the same one twice speaks once; a + message asking for a long quiet period can speak the moment the page loads. +- **The four kinds look different** — colour on the glyph and the left edge, + the sentence in the app's own text colour. +- **Warnings and failures wait to be dismissed**; successes and refusals take + themselves away. Everything has a close button. +- **One path.** `UrlProcessorService` goes through the service like everything + else. +- **Messages have ids**, so they can be deduplicated and asserted on. `e2e/notifications.mjs` + does both. +- **Up to three stand at once.** The old snackbar showed one and took the + previous one away to do it, so a refusal could silently swallow a warning + nobody had read. A fourth displaces the oldest message that was going to + leave by itself, never one still waiting to be dismissed. +- **A message can carry its own fix.** The zoom warnings offer *Fit to zoom*, + which is the button they used to send the reader to Settings to find. + +Removed entirely: + +- **The four mode and timing refusals** — "Switch to Edit mode to change the + mechanism", "Cannot edit while the animation is running", "Step back to the + start to edit". Every guard stays; none of them announces itself. Which mode + you are in is written across the top strip and down the side of the window, + and whether the animation is running is the thing you are watching. +- **The four driven-joint refusals**, which turned out to be unreachable. Both + the context menu item and the panel button are disabled on `canToggleInput`, + which is `input || canDrive` — and `canDrive` is exactly "describeActuator + did not refuse", asked of the same joint `adjustInput` re-tests. Checked + across all 25 templates: 147 presses of every enabled control, no refusal. + The guard stays, silent. The four sentences are still used by the setup + panel's blocker list, which is where they are actually read. + +Reworded or removed: + +- `No file selected` — **gone**. It fired when the file picker was dismissed, + and cancelling a dialogue is not a thing that went wrong. +- The two zoom warnings no longer name buttons; they say what is wrong and + where to fix it. +- `Mechanism URL copied. If you make additional changes, copy the URL again.` → + `Link copied. Copy again after your next change.` +- `Loaded Mechanism from File` → `Mechanism loaded.` +- `Cannot link to a bar. Please create and select a tracer point on the link.` → + `A link joins two joints. Add a tracer point to that bar, then draw to it.` +- `Merged joint A into B, but B cannot be welded` now says what became of the + weld, and waits. +- `Unable to load the shared mechanism URL.` now says what probably caused it. +- The context menu's guard said the animation was running when what it had + actually tested was the timestep. It now says what it tested. + +Still open: + +- **The eleven drop refusals** are still messages, on top of the canvas refusal + marker and the joint shake. Kept deliberately for now. +- **The six `NOT_A` messages** now fire from the Edit panel too, but the + linkage table they came from is still reachable only through the Debug tab. + Whether that table should exist at all is a separate question. + +Closed since: + +- **Deleting a joint is now undoable** (`Let Ctrl+Z put back a joint you + deleted`). It ended in `finishStructuralEdit(false)`, so no history entry was + written and Undo could not bring it back — found while wiring Ctrl+Z, which + is what made it visible. + +## Dead — coded but not reachable + +- `synthesis-panel.handleButton()` — `Call your backend function with these + values! A0: (…)`. No template binds it. +- `NOT_BUILT_YET` in `ui-text.ts` — exported, never imported. +- ~20 commented-out debug calls in `synthesis-panel.component.ts`. +- The Escape-key easter egg and the "tutorial is not ready yet" message, both + commented out. diff --git a/docs/phase-3-slide-spec.md b/docs/phase-3-slide-spec.md index f7d844e3..997ff940 100644 --- a/docs/phase-3-slide-spec.md +++ b/docs/phase-3-slide-spec.md @@ -555,6 +555,15 @@ message that names the joint, and §9 carries it. ## 9. Follow-up: statics for welded assemblies +**Landed.** The couple column exists (`GuideCouple` in `force-solver.ts`), the refusal in 3.8 is +retired, and the verification is the shape this section asked for: hand statics swept over the +loaded Scotch yoke (`slide-forces.spec.ts`), the two-force-member property of a sealed cylinder +(`cylinder-forces.spec.ts`), and a virtual-work audit across every topology class +(`force-power-balance.spec.ts`). One deviation from the sketch below: rider and block stay +separate bodies — the block is zero-length, so the couple passes through it untouched and no +merged mass/CoM/parallel-axis bookkeeping is needed. The section is kept as written for the +reasoning. + Deliberately **not** in Phase 3, and to be specified on its own rather than left as optional work inside this one. Recorded here so its scope is visible rather than implied — it is wider than the equilibrium change that makes it sound small: diff --git a/docs/ui-vocabulary.md b/docs/ui-vocabulary.md index f2e093c7..22102df4 100644 --- a/docs/ui-vocabulary.md +++ b/docs/ui-vocabulary.md @@ -55,9 +55,37 @@ the canvas needs to show it. | Fusing bodies | **Weld** / **Unweld** | `Weld Joint`, `Unweld Joint`, `Un-weld All` | | Removing anything | **Delete** | `Delete Joint`, `Delete Link`, `Delete Cylinder` | -`Make` survives in exactly one place — `Make Force Global` / `Make Force Local` -— because that pair is a switch between two states rather than the presence or -absence of one. Do not reach for it anywhere else. +`Make` is gone. It survived in one place — `Make Force Global` / `Make Force +Local` — precisely because that pair was a switch between two states rather +than the presence or absence of one, and the right-click menu now writes states +as states: the row is `Global Frame`, ticked or not. Do not reach for `Make` +anywhere. + +### Verbs, and the switches that are not verbs + +The rule above is for **controls that do something**. A control that *describes +a state the object is already in* is named after the state and carries a tick, +so no label rewrites itself as it is used: + +| A verb, because it acts | A state, because it describes | +| --- | --- | +| `Attach Link`, `Delete Joint`, `Reverse Direction` | `Grounded`, `Driven Input`, `Slider`, `Welded`, `Locked`, `Trace Path`, `Global Frame`, `Drawn as a Disc` | + +The Edit panel's toggles already worked this way; the right-click menu followed +in the context-menu redesign, which is why `Add Ground` / `Remove Ground` no +longer appear there. On a menu whose group heading already carries the verb — +`ADD` on the bare grid, `ATTACH` on a part — the rows below it are bare nouns +(`Link`, `Cylinder`, `Tracer Point`), because the heading has said the verb +once and repeating it down five rows says nothing new. + +### Refusals wear their reason + +A control a reader could reasonably expect is **greyed with the reason beside +it**, in three or four lower-case words — `needs 2 links`, `it is driven`, +`unlock first` — with the model's own longer sentence on hover. A control that +is *structurally* impossible for that kind of object is **absent**, because a +row that can never be used on any joint of this kind is noise rather than +information. Nothing is clickable and then refused by a snackbar. **Delete means the thing you named goes**, along with anything that cannot stand without it. `Delete Cylinder` on one of a cylinder's joints deletes the @@ -69,12 +97,12 @@ is why they have them. | Use | For | Not | | --- | --- | --- | -| **mechanism** | the thing on the grid | ~~linkage~~ (stops being true the moment it has a cylinder), ~~assembly~~ | +| **mechanism** | one machine on the grid — a drawing may hold several, named M1, M2… | ~~linkage~~ (stops being true the moment it has a cylinder), ~~assembly~~ | | **project** | the saved document | — | | **animation** | playing the mechanism | ~~simulation~~, ~~playback~~ (internal only) | | **input** | in controls: the driven joint | — | | **driven** | in prose about the mechanism | ~~actuator~~ (internal only) | -| **Edit / Analyze / Synthesis** | the three modes, capitalised as the tabs spell them | ~~analysis mode~~ | +| **Synthesis / Edit / Kinematic / Force** | the four modes, capitalised as the tabs spell them | ~~Analyze~~ (it split into Kinematic and Force), ~~analysis mode~~ | `Share Project` and `New Project` are about the document, and stay. diff --git a/e2e/README.md b/e2e/README.md index a4c59a24..19a2e61c 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -22,15 +22,19 @@ their source. Everything browser-driven lives here. nothing was said. `ONLY=4-Bar,Cylinder_Boom` narrows it - `phase1-drag.mjs` — drag gestures: joint snap ring and merge, merging onto a slider's pin, refusing an over-constraining merge, whole-link drag, one undo entry per gesture, - click-without-nudge, the canvas staying put after a merge, and Analyze mode refusing drags. + click-without-nudge, the canvas staying put after a merge, and an analysis mode refusing drags. Prints a PASS/FAIL check list and exits non-zero on any failure, so it can gate a change. - `force-analysis-panels.mjs` — Force Analysis rows on the joint and link analysis panels, and the shared Force Analysis Type toggle -- `left-nav-modes.mjs` — mode-scoped left nav rail: Edit/Analyze tool sections, the absorbed view controls, and the rewind-on-leaving-Analyze behavior +- `left-nav-modes.mjs` — mode navigation in the top strip that replaced the rail: the four modes, the + panel following the mode, the sliding highlight, the readiness chips, an analysis mode refusing to + open, and the rewind on leaving one - `template-open.mjs` — the template library: loads in place on an empty grid, and shows a new-tab / replace / cancel choice (with replace undoable) when the grid already holds work -- `template-graphs.mjs` — every template in the library, checked for *correct* kinematic graphs: opens each payload, selects each moving joint on the Analyze tab, reads the plotted series out of `AnalysisGraphComponent` (numbers, not pixels) and cross-checks position against the solved joint positions and velocity/acceleration against difference quotients of the series above them. Reversals and dead centres are identified from the source series and reported by sample index rather than tolerated silently. Exits non-zero on any failure +- `template-graphs.mjs` — every template in the library, checked for *correct* kinematic graphs: opens each payload, selects each moving joint in the Kinematic mode, reads the plotted series out of `AnalysisGraphComponent` (numbers, not pixels) and cross-checks position against the solved joint positions and velocity/acceleration against difference quotients of the series above them. Reversals and dead centres are identified from the source series and reported by sample index rather than tolerated silently. Exits non-zero on any failure - `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 +- `context-menu.mjs` — the right-click menu on every kind of part and in every mode: the fixed Attach/State/Machine ladder with Delete alone at the foot, states written as ticked switches rather than labels that flip, the model's own reason in the right-hand slot of every greyed row (a load at a shared pin, a weld with nothing to fuse, a sealed cylinder, a locked part), the deletion cascades named before the click, the counts beside Lock All and Unlock All, and the analysis modes offering the trace and the way back into Edit and nothing that edits ## Running @@ -47,7 +51,9 @@ node e2e/interaction-sweep.mjs Environment overrides: -- `PMKS_URL` — app URL (default `http://127.0.0.1:4200/`) +- `PMKS_BASE_URL` — app URL (default `http://127.0.0.1:4200`). `PMKS_URL` is still read by the + scripts that were written against it, but every script now takes `PMKS_BASE_URL` first, so one + variable drives the whole folder - `RUN_PREFIX` — prefix for screenshot/report filenames - `PMKS_PLAYWRIGHT_DIR` — Playwright install dir (default `/tmp/pmks-playwright`) - `PMKS_CHROME` — Chrome executable (default `/Applications/Google Chrome.app/...`) @@ -64,9 +70,20 @@ Interaction gotchas baked into these scripts: hover/move to the target before the finalizing click, and prefer the Edit panel's HTML controls over the SVG context menu (its hitboxes drift at some viewports). -- The playback controls (play/pause, speed, scrubber, time field) live in the - left nav rail's Analyze group, so they only exist while Analyze is the open - mode. Open Analyze before touching them — and check first, because the rail - buttons toggle their own panel, so a blind click closes what you need. -- The scrubber is a horizontal Material slider rotated 90°, so drag it down its - height (min at the top), not across its width. +- Modes are the `.tabButton`s in the top strip: Synthesis, Edit, Kinematic, Force. + What used to be called Analyze is **Kinematic**. Pressing a mode is idempotent — + they no longer toggle their own panel — so `page.locator('.tabButton', { hasText: 'Kinematic' })` + can be clicked without checking first. Below 1080px the labels are hidden, and + `hasText` stops matching with them; size the viewport wider than that. +- File actions (Templates, Open, Save, Share Project, Settings, Help / Feedback) live + behind the hamburger: click `.topStrip .iconButton`, then the `.projectMenu .menuItem`. + Undo and Redo are `.historyButton`s in the strip itself, in every mode. +- The playback controls (play/pause, speed, `#slider`, `#animationBar-input`) live in + `app-playback-bar`, which renders only in the Kinematic and Force modes. The scrubber + is now a plain horizontal `input[type=range]`. +- An analysis mode that cannot be entered does not open at all: the press raises the + `app-analysis-setup` drawer instead, so an absent play button — not a disabled one — + is what "this mechanism will not run" looks like. +- `#bottomBar` is a read-only status strip (mode, status phrase, units, version) with + `pointer-events: none`. It no longer prints the degrees of freedom; read the mobility + from `mechanismSrv.mechanisms[i].dof`, or from the setup drawer's `.factGrid`. diff --git a/e2e/analysis-audit.mjs b/e2e/analysis-audit.mjs index 31aff0db..bc09e577 100644 --- a/e2e/analysis-audit.mjs +++ b/e2e/analysis-audit.mjs @@ -1,5 +1,5 @@ /** - * Every Analyze panel, on every template, for every part of it. + * Every analysis panel, on every template, for every part of it. * * `template-graphs.mjs` already checks the *joint* kinematic graphs numerically, * against difference quotients of the plotted positions. This walks the rest: @@ -57,8 +57,11 @@ const load = async (id) => { await page.goto(`${BASE}/?${payloads[id]}`, { waitUntil: 'domcontentloaded' }); await waitForReady(page); errors = []; - // Analyze is where the panels live. - await page.click('text=Analyze').catch(() => undefined); + // Kinematic is where the panels live -- what used to be called Analyze. + await page + .locator('.tabButton', { hasText: 'Kinematic' }) + .click() + .catch(() => undefined); await page.waitForTimeout(900); }; @@ -130,7 +133,7 @@ const select = (kind, id) => ); /** - * Every series the Analyze panel can build, asked for directly. + * Every series the analysis panel can build, asked for directly. * * The panel builds each graph from four strings, so one live graph component * can be asked for all of them in turn. That is the whole reason this does not @@ -181,20 +184,14 @@ async function primeGraph() { grid.activeObjService.updateSelectedObj(grid.mechanismSrv.joints[0]); }); await page.waitForTimeout(700); - for (let pass = 0; pass < 3; pass++) { + // One card per quantity, each holding its graph only while it is open. Any + // one of them will do -- the audit drives whichever it gets through its own + // inputs rather than through the panel. + for (let pass = 0; pass < 3 && !(await page.$('app-analysis-graph')); pass++) { await page.evaluate(() => { - document.querySelectorAll('collapsible-subseciton').forEach((section) => { - const header = section.querySelector('button.panel-header'); - if (header && !section.querySelector('mat-icon.rotate180')) header.click(); - }); - document.querySelectorAll('mat-expansion-panel').forEach((panel) => { - if (!panel.classList.contains('mat-expanded')) - panel - .querySelector('mat-expansion-panel-header') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); + document.querySelector('app-analysis-graph-section .graphHeader')?.click(); }); - await page.waitForTimeout(700); + await page.waitForTimeout(800); } await page.waitForTimeout(500); return (await page.$('app-analysis-graph')) !== null; @@ -231,7 +228,7 @@ for (const id of MECHANISMS) { continue; } if (!(await primeGraph())) { - note('empty', id, 'Analyze never put a graph on screen', {}); + note('empty', id, 'the analysis panel never put a graph on screen', {}); continue; } diff --git a/e2e/analysis-setup.mjs b/e2e/analysis-setup.mjs new file mode 100644 index 00000000..5007fdc8 --- /dev/null +++ b/e2e/analysis-setup.mjs @@ -0,0 +1,188 @@ +/** + * What the app says when you press an analysis mode it cannot enter. + * + * The old answer was one sentence about the whole document, first blocker + * wins — and for a mode that simply did nothing, often no answer at all. This + * checks the replacement: that the refusal opens a list, that the list names + * the mechanism at fault rather than the drawing, that each entry says the way + * out, and that the button offering to go to the part actually goes there. + * + * PMKS_BASE_URL= node e2e/analysis-setup.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))); +page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); +}); + +const results = []; +const record = (what, ok, detail) => { + results.push([what, ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); +}; + +const drawerText = () => + page + .locator('app-analysis-setup') + .innerText() + .catch(() => ''); +const tab = (name) => page.locator('.tabButton', { hasText: name }); + +async function open(payload) { + await page.goto(`${BASE}/?${payload}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(page); +} + +// --- a mechanism that runs says so, and stays out of the way ---------------- +await open(payloads['4-Bar']); +// Weight is a load now: gravity hanging on links that have mass is a complete +// static problem, so the four-bar as it arrives is ready for force analysis. +// Turning gravity off is what makes this drawing genuinely unloaded, which is +// the refusal these three checks are about. +await page.evaluate(() => { + ng.getComponent(document.querySelector('app-new-grid')).settings.isGravity.next(false); +}); +await tab('Force').click(); +await page.waitForTimeout(600); +let text = await drawerText(); +record( + 'pressing a mode it cannot enter opens the list', + text.includes('Force analysis setup'), + text +); +// Each mode has a drawer of its own: a reader refused by one should not have +// to read past the other mode's list to find out why. +record( + 'and the list answers the question that was asked, not the other one', + text.includes('A load to react against') && !text.includes('Mechanism M1'), + text +); +// The wall is "nothing loads this mechanism". The way out is named in the +// sentence either way, and here -- everything drawn, one switch in another +// panel standing in the way -- the panel walks it for the reader. +record('naming the way out rather than only the wall', /Turn On Gravity/.test(text), text); +await page + .locator('app-analysis-setup .actionButton', { hasText: 'Turn On Gravity' }) + .first() + .click(); +await page.waitForTimeout(600); +record( + 'and pressing it lifts the blocker it was standing under', + !(await drawerText()).includes('A load to react against'), + await drawerText() +); +await tab('Force').click(); +await page.waitForTimeout(800); +// The drawer stays -- it carries the mass table, which force analysis reads +// from -- so what says the mode was entered is the analysis itself. +const entered = await page.evaluate(() => ({ + tab: ng.getComponent(document.querySelector('app-new-grid')).tabService.getCurrentTab(), + graphs: !!document.querySelector('app-analysis-panel'), +})); +record( + 'so the mode that refused the reader now opens', + entered.tab === 3 && entered.graphs, + entered +); + +// --- a mechanism with nothing driving it ------------------------------------ +await open(payloads['4-Bar']); +await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + srv.joints.forEach((joint) => (joint.input = false)); + srv.updateMechanism(); +}); +await page.waitForTimeout(400); +await tab('Kinematic').click(); +await page.waitForTimeout(600); +text = await drawerText(); +record( + 'an undriven mechanism is refused with its own reason', + text.includes('Nothing drives this mechanism'), + text +); +record( + 'which names a joint that could take the job', + /Right-click joint [A-Z] and switch on Driven Input/.test(text), + text +); + +const chip = await tab('Kinematic').locator('.chip').textContent(); +record('and the mode chip counts it', chip.trim() === '1 fix', { chip }); + +// --- the button that offers to take you there, does ------------------------- +const goTo = page.locator('.actionButton').first(); +record('the check offers to go to the part', (await goTo.count()) === 1); +const label = await goTo.textContent(); +await goTo.click(); +await page.waitForTimeout(700); +const landed = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + return { + tab: grid.tabService.getCurrentTab(), + selected: grid.activeObjService.getSelectedObjType(), + id: grid.activeObjService.objType === 'Joint' ? grid.activeObjService.selectedJoint.id : null, + }; +}); +record( + 'and pressing it lands in Edit with that joint selected', + landed.tab === 1 && landed.selected === 'Joint' && label.includes(landed.id), + { label, landed } +); + +// Undo must not have been armed by looking at something. +const undoDisabled = await page.locator('.historyButton', { hasText: 'Undo' }).isDisabled(); +record('going to a part is not an edit, so Undo stays where it was', undoDisabled === true); + +// --- geometry that is in no mechanism --------------------------------------- +// The drawer opens when a mode refuses, so the mechanism is left undriven too: +// a valid one simply enters Kinematic and there is no drawer to read. +await open(payloads['4-Bar']); +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const srv = grid.mechanismSrv; + // A joint on its own, with no link: unassigned, and reported as such. + const seed = srv.joints[0]; + const loose = Object.create(Object.getPrototypeOf(seed)); + Object.assign(loose, seed, { + id: 'Z', + name: 'Z', + links: [], + connectedJoints: [], + ground: false, + input: false, + }); + srv.joints.push(loose); + srv.joints.forEach((joint) => (joint.input = false)); + srv.updateMechanism(); +}); +await page.waitForTimeout(400); +await tab('Kinematic').click(); +await page.waitForTimeout(600); +text = await drawerText(); +record( + 'geometry in no mechanism gets its own section', + text.includes('Not in any mechanism'), + text +); + +record('nothing threw', errors.length === 0, errors.slice(0, 3)); + +await browser.close(); +process.exit(results.every(([, ok]) => ok) ? 0 : 1); diff --git a/e2e/attach-cylinder.mjs b/e2e/attach-cylinder.mjs index df1da297..ff84c51b 100644 --- a/e2e/attach-cylinder.mjs +++ b/e2e/attach-cylinder.mjs @@ -52,7 +52,15 @@ const state = () => return { joints: [...document.querySelectorAll('[id^="joint_"]')].map((n) => n.id.slice(6)).join(''), skins: document.querySelectorAll('.cylinder-mark').length, - dof: (document.body.textContent.match(/Degrees of Freedom:\s*(-?[\d.]+|—)/) ?? [])[1], + // The status strip stopped printing the mobility, so it is read from the + // machines themselves -- one number each, and a dash where there is none, + // exactly as the footer used to spell it. + dof: (() => { + const each = grid.mechanismSrv.mechanisms + .map((mechanism) => mechanism.dof) + .filter((value) => typeof value === 'number' && Number.isFinite(value)); + return each.length > 0 ? each.join(', ') : '—'; + })(), links: grid.mechanismSrv.links.map((link) => link.id), nan: [...document.querySelectorAll('svg *')].filter((node) => [...node.attributes].some((a) => /NaN/.test(a.value)) @@ -90,26 +98,26 @@ await page.mouse.click(on.x, on.y, { button: 'right' }); await page.waitForTimeout(600); const menu = await page.evaluate(() => - [...document.querySelectorAll('#contextMenu #menu-item')].map((item) => ({ - label: item.textContent.trim().replace(/\s+/g, ' '), - disabled: item.classList.contains('disabledItem'), + [...document.querySelectorAll('#contextMenu .cm-row')].map((item) => ({ + label: item.querySelector('.cm-row__label')?.textContent?.trim() ?? '', + disabled: item.classList.contains('cm-row--off'), })) ); -const entry = menu.find((item) => item.label === 'Attach Cylinder'); -record('a link offers Attach Cylinder', !!entry && !entry.disabled, menu); -// Beside Attach Link, not somewhere else in the list: they are the same act. +const entry = menu.find((item) => item.label === 'Cylinder'); +record('a link offers Cylinder, under Attach', !!entry && !entry.disabled, menu); +// Beside Link, not somewhere else in the group: they are the same act. record( - 'and offers it beside Attach Link', - menu.findIndex((item) => item.label === 'Attach Cylinder') === - menu.findIndex((item) => item.label === 'Attach Link') + 1, + 'and offers it beside Link', + menu.findIndex((item) => item.label === 'Cylinder') === + menu.findIndex((item) => item.label === 'Link') + 1, menu.map((item) => item.label) ); await page.evaluate(() => { - const item = [...document.querySelectorAll('#contextMenu #menu-item')].find((node) => - /Attach Cylinder/.test(node.textContent) + const item = [...document.querySelectorAll('#contextMenu .cm-row')].find( + (node) => node.querySelector('.cm-row__label')?.textContent?.trim() === 'Cylinder' ); - item.querySelector('button').click(); + item.click(); }); await page.waitForTimeout(400); await page.mouse.move(on.x + 260, on.y - 170); @@ -173,25 +181,29 @@ await page.mouse.click(onJoint.x, onJoint.y, { button: 'right' }); await page.waitForTimeout(600); const jointMenu = await page.evaluate(() => - [...document.querySelectorAll('#contextMenu #menu-item')].map((item) => ({ - label: item.textContent.trim().replace(/\s+/g, ' '), - disabled: item.classList.contains('disabledItem'), + [...document.querySelectorAll('#contextMenu .cm-row')].map((item) => ({ + label: item.querySelector('.cm-row__label')?.textContent?.trim() ?? '', + disabled: item.classList.contains('cm-row--off'), })) ); -const jointEntry = jointMenu.find((item) => item.label === 'Attach Cylinder'); -record('a joint offers Attach Cylinder too', !!jointEntry && !jointEntry.disabled, jointMenu); +const jointEntry = jointMenu.find((item) => item.label === 'Cylinder'); record( - 'and offers it beside Attach Link there as well', - jointMenu.findIndex((item) => item.label === 'Attach Cylinder') === - jointMenu.findIndex((item) => item.label === 'Attach Link') + 1, + 'a joint offers Cylinder, under Attach, too', + !!jointEntry && !jointEntry.disabled, + jointMenu +); +record( + 'and offers it beside Link there as well', + jointMenu.findIndex((item) => item.label === 'Cylinder') === + jointMenu.findIndex((item) => item.label === 'Link') + 1, jointMenu.map((item) => item.label) ); await page.evaluate(() => { - const item = [...document.querySelectorAll('#contextMenu #menu-item')].find((node) => - /Attach Cylinder/.test(node.textContent) + const item = [...document.querySelectorAll('#contextMenu .cm-row')].find( + (node) => node.querySelector('.cm-row__label')?.textContent?.trim() === 'Cylinder' ); - item.querySelector('button').click(); + item.click(); }); await page.waitForTimeout(400); await page.mouse.move(onJoint.x + 240, onJoint.y - 160); diff --git a/e2e/background-image.mjs b/e2e/background-image.mjs new file mode 100644 index 00000000..b80b51bc --- /dev/null +++ b/e2e/background-image.mjs @@ -0,0 +1,729 @@ +/** + * The background image, exercised the way a hand would: right-click the grid, + * pick a picture, watch it land behind the linkage, move and resize and fade it + * from the panel, prove it cannot be clicked or dragged, prove it rides the + * grid's own zoom, and delete it. + * + * PMKS_BASE_URL= node e2e/background-image.mjs + */ + +const { chromium } = await import( + (process.env.PMKS_PLAYWRIGHT_DIR ?? '/tmp/pmks-playwright') + '/node_modules/playwright/index.mjs' +); +import { waitForReady } from './app-ready.mjs'; +import { mkdirSync, writeFileSync } from 'node:fs'; + +const BASE = process.env.PMKS_BASE_URL ?? 'http://localhost:4710'; +const SHOTS = new URL('../artifacts/background-image/', import.meta.url).pathname; +mkdirSync(SHOTS, { recursive: true }); + +const FOUR_BAR = + '0P.TY.K,0.101.MA,A,0mv,0VU,0.GB,B,0e_,E6,0.GC,C,l1,WW,0.KD,D,qD,0Pk,0..YRAB,AB,Fe,Fe,0ix,08i,c5cae9,A,B,,.YRBC,BC,Fe,Fe,32,NJ,303e9f,B,C,,.YRCD,CD,Fe,Fe,nd,3P,0d125a,C,D,,...JBq'; + +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 image = () => + page.evaluate(() => { + const el = document.querySelector('#backgroundImageHolder image'); + if (!el) return null; + const rect = el.getBoundingClientRect(); + return { + x: Number(el.getAttribute('x')), + y: Number(el.getAttribute('y')), + width: Number(el.getAttribute('width')), + height: Number(el.getAttribute('height')), + opacity: Number(el.getAttribute('opacity')), + rotationDeg: + (ng.getComponent(document.querySelector('app-new-grid')).bgImage.image().rotationRad * + 180) / + Math.PI, + screen: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + }; + }); + +const menuLabels = () => + page.evaluate(() => + [...document.querySelectorAll('#contextMenu .cm-row')].map((item) => + (item.querySelector('.cm-row__label')?.textContent ?? '').trim() + ) + ); + +const closeMenu = async () => { + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); +}; + +/** Type into one of the panel's fields and blur, which is when it commits. */ +const typeField = async (label, value) => { + const field = page + .locator('input-block, dual-input-block') + .filter({ hasText: label }) + .locator('input') + .first(); + await field.click(); + await field.fill(value); + await field.blur(); + await page.waitForTimeout(350); +}; + +/** + * A picture with a shape and a handedness: 2:1, red on the left half, blue on + * the right. Both matter — the aspect proves the height follows the width, and + * the asymmetry would expose a mirrored draw, which is the mistake waiting in a + * layer that lives in SVG coordinates while the model has y up. + */ +const pngBuffer = async () => { + const base64 = await page.evaluate(() => { + const canvas = document.createElement('canvas'); + canvas.width = 400; + canvas.height = 200; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#d32f2f'; + ctx.fillRect(0, 0, 200, 200); + ctx.fillStyle = '#1565c0'; + ctx.fillRect(200, 0, 200, 200); + // A stripe along the top edge only, so an up/down flip is visible too. + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, 400, 16); + return canvas.toDataURL('image/png').split(',')[1]; + }); + return Buffer.from(base64, 'base64'); +}; + +await page.goto(`${BASE}/?${FOUR_BAR}`, { waitUntil: 'domcontentloaded', timeout: 60000 }); +await waitForReady(page); +await page.waitForTimeout(400); + +const emptyGrid = { x: 1150, y: 720 }; + +// --- The menu offers the picture before there is one ------------------------ + +await page.mouse.click(emptyGrid.x, emptyGrid.y, { button: 'right' }); +await page.waitForTimeout(300); +const before = await menuLabels(); +record( + 'the grid menu offers Add background image', + before.some((label) => label === 'Add background image'), + before +); +await closeMenu(); + +// --- Picking a file places it and opens its panel --------------------------- + +const buffer = await pngBuffer(); +const filePath = SHOTS + 'rig.png'; +writeFileSync(filePath, buffer); + +await page.setInputFiles('input.offscreenFileInput', { + name: 'rig.png', + mimeType: 'image/png', + buffer, +}); +await page.waitForTimeout(600); + +const placed = await image(); +record('the picture is drawn behind the grid', placed !== null, placed); +record( + 'it keeps the file’s own proportions', + placed && Math.abs(placed.height / placed.width - 0.5) < 1e-6, + placed +); +record( + 'the panel opens on it', + await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + return ( + c.activeObjService.objType === 'BackgroundImage' && + !!document.querySelector('app-edit-panel .bgTitle') + ); + }) +); +record( + 'the panel names the file it came from', + (await page.locator('app-edit-panel .bgFileName').innerText()).trim() === 'rig.png' +); +record( + 'the panel says the picture is not in the share link', + (await page.locator('app-edit-panel .bgNote').innerText()).includes('not saved in the share link') +); +record( + 'its edges are outlined while the panel has it', + await page.evaluate(() => !!document.querySelector('.bgImageOutline')) +); +await page.screenshot({ path: SHOTS + '01-placed.png' }); + +// --- It is behind the linkage and cannot be touched ------------------------- + +record( + 'it is drawn inside the grid group, under the ruling on it', + await page.evaluate(() => { + const holder = document.querySelector('#backgroundImageHolder'); + const group = holder.parentElement; + if (group.id !== 'backgroundAndGrid') return false; + // After the white paper, before the first grid line drawn on it. + const kids = [...group.children]; + const line = kids.findIndex((node) => node.tagName === 'line'); + return kids.indexOf(holder) > 0 && (line === -1 || kids.indexOf(holder) < line); + }), + await page.evaluate(() => document.querySelector('#backgroundImageHolder').parentElement.id) +); +record( + 'and the grid group itself is under every part of the mechanism', + // svg-pan-zoom moves every layer into a viewport group of its own, so the + // layer order lives one level down from the canvas. + await page.evaluate(() => { + const layers = document.querySelector('#backgroundAndGrid').parentElement; + const order = [...layers.children].map((node) => node.id); + const bg = order.indexOf('backgroundAndGrid'); + return bg > -1 && bg < order.indexOf('linkHolder') && bg < order.indexOf('jointHolder'); + }), + await page.evaluate(() => + [...document.querySelector('#backgroundAndGrid').parentElement.children].map((n) => n.id) + ) +); +record( + 'its outline and drag surface sit above the ruling and below the parts', + await page.evaluate(() => { + const layers = document.querySelector('#backgroundImageHandles').parentElement; + const order = [...layers.children].map((node) => node.id); + const handles = order.indexOf('backgroundImageHandles'); + return handles > order.indexOf('backgroundAndGrid') && handles < order.indexOf('linkHolder'); + }), + await page.evaluate(() => + [...document.querySelector('#backgroundImageHandles').parentElement.children].map((n) => n.id) + ) +); +record( + 'its corners and turn knob sit above the parts, so a link cannot cover one', + await page.evaluate(() => { + const layers = document.querySelector('#backgroundImageGrips').parentElement; + const order = [...layers.children].map((node) => node.id); + return order.indexOf('backgroundImageGrips') > order.indexOf('jointHolder'); + }), + await page.evaluate(() => + [...document.querySelector('#backgroundImageGrips').parentElement.children].map((n) => n.id) + ) +); +record( + 'the whole layer is deaf to the pointer', + await page.evaluate(() => { + const holder = document.querySelector('#backgroundImageHolder'); + return getComputedStyle(holder).pointerEvents === 'none'; + }) +); + +// A click that lands on the picture but on no part: the grid answers, not the +// picture, and dragging there pans as it always did rather than moving it. +const overPicture = await page.evaluate(() => { + const rect = document.querySelector('#backgroundImageHolder image').getBoundingClientRect(); + return { x: rect.x + 24, y: rect.y + 24 }; +}); +await page.mouse.click(overPicture.x, overPicture.y); +await page.waitForTimeout(400); +record( + 'clicking the picture selects the grid, not the picture', + await page.evaluate( + () => + ng.getComponent(document.querySelector('app-new-grid')).activeObjService.objType === 'Grid' + ) +); + +const beforeDrag = await image(); +await page.mouse.move(overPicture.x, overPicture.y); +await page.mouse.down(); +for (let step = 1; step <= 8; step++) { + await page.mouse.move(overPicture.x + step * 12, overPicture.y + step * 8); + await page.waitForTimeout(15); +} +await page.mouse.up(); +await page.waitForTimeout(400); +const afterDrag = await image(); +record( + 'dragging on the picture never moves the picture', + beforeDrag.x === afterDrag.x && beforeDrag.y === afterDrag.y, + { beforeDrag, afterDrag } +); + +// --- The menu's second half: edit the one already there --------------------- + +await page.mouse.click(overPicture.x, overPicture.y, { button: 'right' }); +await page.waitForTimeout(300); +const withPicture = await menuLabels(); +record( + 'a right-click on the picture opens the grid menu, now offering Edit', + withPicture.some((label) => label === 'Background Image') && + !withPicture.some((label) => label === 'Add background image'), + withPicture +); +await page.locator('#contextMenu .cm-row', { hasText: 'Background Image' }).first().click(); +await page.waitForTimeout(400); +record( + 'that item opens the panel again', + await page.evaluate( + () => + ng.getComponent(document.querySelector('app-new-grid')).activeObjService.objType === + 'BackgroundImage' + ) +); + +// --- The placement fields ------------------------------------------------- + +const atOpen = await image(); +await typeField('Image Center', '3'); +const movedX = await image(); +record( + 'typing an X slides the picture across the grid', + Math.abs(movedX.x - atOpen.x - 3 * 200) < 1, + { atOpen, movedX } +); + +await typeField('Image Width', '8'); +const resized = await image(); +record('typing a width resizes the picture', Math.abs(resized.width - 8 * 200) < 1, resized); +record( + 'the height follows, so the proportions hold', + Math.abs(resized.height / resized.width - 0.5) < 1e-6, + resized +); + +await typeField('Opacity', '25'); +const faded = await image(); +record('the opacity field fades the picture', Math.abs(faded.opacity - 0.25) < 1e-6, faded); +await page.screenshot({ path: SHOTS + '02-placed-and-faded.png' }); + +// --- Values the picture cannot have are refused, not quietly adjusted ------- +// A number left in the field that is not the number that took effect is the +// panel lying about where the picture is. + +const refusals = [ + [ + 'Opacity', + '400', + 'an opacity over 100 is refused', + () => image().then((i) => i.opacity === 0.25), + ], + [ + 'Opacity', + '', + 'a blank opacity is refused rather than read as zero', + () => image().then((i) => i.opacity === 0.25), + ], + [ + 'Image Width', + '-2', + 'a negative width is refused', + () => image().then((i) => Math.abs(i.width - 8 * 200) < 1), + ], + [ + 'Image Width', + '0', + 'a width the picture cannot have is refused', + () => image().then((i) => Math.abs(i.width - 8 * 200) < 1), + ], +]; +for (const [label, entry, what, stillTrue] of refusals) { + await typeField(label, entry); + const held = await stillTrue(); + const shown = await page + .locator('input-block, dual-input-block') + .filter({ hasText: label }) + .locator('input') + .first() + .inputValue(); + record(`${what}, and the field is put back`, held && shown !== entry, { shown, entry }); +} + +await typeField('Opacity', '55'); + +// --- Moved and resized by hand, while its panel is open --------------------- + +const dragBy = async (from, dx, dy, steps = 10) => { + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + for (let step = 1; step <= steps; step++) { + await page.mouse.move(from.x + (dx * step) / steps, from.y + (dy * step) / steps); + await page.waitForTimeout(15); + } + await page.mouse.up(); + await page.waitForTimeout(300); +}; + +const boxOnScreen = async () => (await image()).screen; + +/** The four corner grips and the turn knob, in screen coordinates. */ +const gripsOnScreen = () => + page.evaluate(() => + [...document.querySelectorAll('#backgroundImageGrips rect.bgImageHandle')].map((node) => { + const box = node.getBoundingClientRect(); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + }) + ); +const knobOnScreen = () => + page.evaluate(() => { + const box = document.querySelector('#backgroundImageGrips circle').getBoundingClientRect(); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + }); + +// Park it small and low on the canvas: every grip has to be inside the viewport +// for a real pointer to reach it, and the checks below grab actual grips rather +// than the picture's own corners. +const park = () => + page.evaluate(() => { + ng.getComponent(document.querySelector('app-new-grid')).bgImage.place({ + centerX: 0, + centerY: -600, + width: 800, + rotationRad: 0, + }); + }); +await park(); +await page.waitForTimeout(300); + +const beforeMove = await image(); +const middle = await boxOnScreen(); +// The drag surface is under the parts on purpose, so a press over a link +// belongs to the link — parked below the linkage, the middle is clear. +await dragBy({ x: middle.x + middle.width / 2, y: middle.y + middle.height / 2 }, 120, 70); +const afterMove = await image(); +record( + 'dragging the picture slides it, without changing its size', + Math.abs(afterMove.width - beforeMove.width) < 1e-6 && + Math.abs(afterMove.x - beforeMove.x) > 10 && + Math.abs(afterMove.y - beforeMove.y) > 5, + { beforeMove, afterMove } +); +record( + 'the panel’s own numbers follow the drag', + await page.evaluate(() => { + const shown = [...document.querySelectorAll('app-edit-panel dual-input-block input')] + .slice(0, 2) + .map((input) => parseFloat(input.value)); + const c = ng.getComponent(document.querySelector('app-new-grid')); + const bg = c.bgImage.image(); + return ( + Math.abs(shown[0] - bg.centerX / 200) < 0.02 && Math.abs(shown[1] - bg.centerY / 200) < 0.02 + ); + }) +); +await page.screenshot({ path: SHOTS + '11-dragged.png' }); + +// A corner keeps the opposite corner where it is, and keeps the proportions. +await park(); +await page.waitForTimeout(300); +const beforeResize = await image(); +const corners = await gripsOnScreen(); +await dragBy(corners[3], 140, 0); +const afterResize = await image(); +record('dragging a corner resizes the picture', afterResize.width > beforeResize.width + 10, { + beforeResize, + afterResize, +}); +record( + 'and it keeps the file’s proportions while doing it', + Math.abs(afterResize.height / afterResize.width - 0.5) < 1e-6, + afterResize +); +record( + 'the opposite corner stays exactly where it was', + Math.abs(afterResize.x - beforeResize.x) < 1 && Math.abs(afterResize.y - beforeResize.y) < 1, + { beforeResize, afterResize } +); +await page.screenshot({ path: SHOTS + '12-resized.png' }); + +// Pulled back through its own anchor, the picture shrinks and stays the right +// way round rather than flipping to the other side of the corner it pivots on. +await park(); +await page.waitForTimeout(300); +const beforePull = await image(); +const spread = await gripsOnScreen(); +await dragBy(spread[3], -(spread[3].x - spread[0].x) - 120, -(spread[3].y - spread[0].y) - 120); +const pulled = await image(); +record( + 'a corner pulled back through its anchor shrinks the picture without flipping it', + pulled.width > 0 && pulled.width < beforePull.width, + { beforePull, pulled } +); + +// --- Turned, by the knob and by the field ----------------------------------- + +await park(); +await page.waitForTimeout(300); +const knobAt = knobOnScreen; + +// The knob has to be reachable: it sits at the picture's own top-centre, which +// at the default placement is exactly on the y axis — and an axis line drawn +// over it took the press and panned the canvas instead. +record( + 'the turn knob is the thing under the pointer, not the grid drawn over it', + await page.evaluate( + async (at) => { + const hit = document.elementFromPoint(at.x, at.y); + return hit?.classList.contains('bgImageRotateKnob') ?? false; + }, + await knobAt() + ) +); + +const squareOn = await image(); +await dragBy(await knobAt(), 150, 120); +const turned = await image(); +record( + 'dragging the knob turns the picture, and lands on a whole 15°', + Math.abs(turned.rotationDeg) > 1 && Math.abs(turned.rotationDeg % 15) < 1e-6, + turned +); +record( + 'turning changes nothing but the angle', + Math.abs(turned.width - squareOn.width) < 1e-6 && + Math.abs(turned.x - squareOn.x) < 1e-6 && + Math.abs(turned.y - squareOn.y) < 1e-6, + { squareOn, turned } +); +await page.screenshot({ path: SHOTS + '15-turned.png' }); + +// A turned picture still resizes by the corner the hand is holding: the maths +// runs in the picture's own frame, so the opposite corner stays put on screen. +const beforeTurnedResize = await gripsOnScreen(); +await dragBy(beforeTurnedResize[3], 90, 90); +const afterTurnedResize = await image(); +const anchorNow = (await gripsOnScreen())[0]; +record( + 'a turned picture resizes about its opposite corner, which does not move', + Math.abs(anchorNow.x - beforeTurnedResize[0].x) < 3 && + Math.abs(anchorNow.y - beforeTurnedResize[0].y) < 3, + { was: beforeTurnedResize[0], now: anchorNow } +); +record( + 'and it holds its proportions and its angle through the resize', + Math.abs(afterTurnedResize.height / afterTurnedResize.width - 0.5) < 1e-6 && + Math.abs(afterTurnedResize.rotationDeg - turned.rotationDeg) < 1e-6, + afterTurnedResize +); + +await typeField('Rotation', '30'); +record('typing an angle turns the picture', Math.abs((await image()).rotationDeg - 30) < 0.01); +await typeField('Rotation', 'sideways'); +record( + 'a nonsense angle is refused and the field put back', + Math.abs((await image()).rotationDeg - 30) < 0.01 +); +await typeField('Rotation', '0'); +await typeField('Image Width', '8'); + +// A picture drag is not a mechanism edit, so it must not enter the history. +record( + 'moving and resizing the picture left no undo entries behind', + await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + return !c.saveHistoryService.canUndo(); + }) +); + +// --- It rides the grid ------------------------------------------------------ + +const jointSpan = () => + page.evaluate(() => { + const box = (id) => + document.querySelector(`#joint_${id}`).closest('svg[x]').getBoundingClientRect(); + return Math.abs(box('B').x - box('C').x); + }); + +const spanBefore = await jointSpan(); +const screenBefore = (await image()).screen.width; +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.svgGrid.zoomIn(); +}); +await page.waitForTimeout(500); +const spanAfter = await jointSpan(); +const screenAfter = (await image()).screen.width; +record( + 'the picture zooms with the grid, by the same factor the linkage does', + Math.abs(screenAfter / screenBefore - spanAfter / spanBefore) < 0.02, + { screenBefore, screenAfter, spanBefore, spanAfter } +); + +// Fit to zoom frames the LINKAGE. A picture ten times its size counted towards +// the bounding box, and the mechanism came back too small to work on -- 59px of +// joint span against the ~300 a fit gives it with no picture there. A threshold +// rather than an equality: the grid's own hiding races the render, so the exact +// figure moves between the two legitimate answers. +await typeField('Image Width', '100'); +await page.evaluate(() => { + ng.getComponent(document.querySelector('app-new-grid')).svgGrid.scaleToFitLinkage(false); +}); +await page.waitForTimeout(1200); +const spanFitted = await jointSpan(); +record('Fit to zoom frames the linkage, not the scenery behind it', spanFitted > 150, { + spanFitted, +}); +await page.screenshot({ path: SHOTS + '13-fitted.png' }); +await typeField('Image Width', '8'); + +// --- A change of length unit keeps picture and linkage in step -------------- + +const alignment = async () => { + const span = await jointSpan(); + const width = (await image()).screen.width; + return { span, width, ratio: width / span }; +}; +const inCm = await alignment(); + +// Through the settings panel's own path, which is the one a user goes through: +// it rescales the mechanism's stored geometry and compensates the viewport, and +// the picture has to be carried along by the same factor or it is left the +// conversion's worth of wrong size against the very linkage it is under. +const openSettings = async () => { + await page.locator('.topStrip .iconButton').first().click(); + await page.locator('.menuItem', { hasText: 'Settings' }).first().click(); + await page.waitForTimeout(600); +}; +const setLengthUnit = async (value) => { + await openSettings(); + await page.evaluate((unit) => { + const panel = ng.getComponent(document.querySelector('app-settings-panel')); + panel.settingsForm.controls.lengthunit.setValue(unit); + }, value); + await page.waitForTimeout(1200); +}; +await setLengthUnit('2'); // METER +const inMeters = await alignment(); +record( + 'switching cm to m leaves the picture the same size against the linkage', + Math.abs(inMeters.ratio - inCm.ratio) / inCm.ratio < 0.02, + { inCm, inMeters } +); +record( + 'and the panel restates the width in the new unit rather than renaming it', + await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + // Whatever it was in centimetres, it is a hundredth of that in metres. + return c.bgImage.image().width > 0; + }) +); +await page.screenshot({ path: SHOTS + '14-meters.png' }); +await setLengthUnit('1'); // back to CM +await page.locator('.tabButton, .modeTab', { hasText: 'Edit' }).first().click(); +await page.waitForTimeout(600); + +// --- Nothing about it reaches the URL or the undo history ------------------- + +record( + 'the picture is nowhere in the share URL', + await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + const url = c.saveHistoryService.urlGenerationService.generateUrlQuery(); + return !url.includes('data:image') && url.length < 4000; + }) +); + +// A real undoable edit, made with the picture's panel open. `getSelectedObj()` +// answers for a joint, a link or a force and throws for anything else, so a +// deny-list in the history's selection-holding threw on Undo and left the +// mechanism edited with the history index already moved past it. +const undone = await page.evaluate(async () => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + const joint = c.mechanismSrv.joints.find((candidate) => !candidate.ground); + const before = joint.x; + joint.x += 60; + // updateMechanism(true) is the save; calling save() too would make one edit + // into two entries, and one Undo would only walk half of it back. + c.mechanismSrv.updateMechanism(true); + c.activeObjService.selectBackgroundImage(); + let threw = null; + try { + c.saveHistoryService.undo(); + } catch (error) { + threw = String(error); + } + return { before, edited: before + 60, id: joint.id, threw }; +}); +await page.waitForTimeout(700); +record('Undo works with the picture’s panel open', undone.threw === null, undone); +record( + 'and it puts the mechanism back', + await page.evaluate( + (state) => + Math.abs( + ng + .getComponent(document.querySelector('app-new-grid')) + .mechanismSrv.joints.find((j) => j.id === state.id).x - state.before + ) < 1, + undone + ), + undone +); +record('an undo of the mechanism leaves the picture where it is', (await image()) !== null); + +// --- Save closes the editor, Delete removes the picture --------------------- + +await page.mouse.click(emptyGrid.x, emptyGrid.y, { button: 'right' }); +await page.waitForTimeout(300); +await page.locator('#contextMenu .cm-row', { hasText: 'Background Image' }).first().click(); +await page.waitForTimeout(400); +await page.locator('app-edit-panel button-block', { hasText: 'Save' }).locator('button').click(); +await page.waitForTimeout(500); +record( + 'Save closes the editor and takes the outline with it', + await page.evaluate( + () => + ng.getComponent(document.querySelector('app-new-grid')).activeObjService.objType !== + 'BackgroundImage' && !document.querySelector('.bgImageOutline') + ) +); +record('Save leaves the picture behind the grid', (await image()) !== null); +await page.screenshot({ path: SHOTS + '03-saved.png' }); + +// --- Scenery in the analysis modes too -------------------------------------- + +await page.evaluate(() => { + ng.getComponent(document.querySelector('app-new-grid')).tabService.setTab(2); // ANALYZE +}); +await page.waitForTimeout(500); +record('the picture stays put in an analysis mode', (await image()) !== null); +record( + 'and nothing is outlined there, because nothing is being edited', + await page.evaluate(() => !document.querySelector('.bgImageOutline')) +); +await page.evaluate(() => { + ng.getComponent(document.querySelector('app-new-grid')).tabService.setTab(1); // EDIT +}); +await page.waitForTimeout(400); + +// --- Delete ----------------------------------------------------------------- + +await page.mouse.click(emptyGrid.x, emptyGrid.y, { button: 'right' }); +await page.waitForTimeout(300); +await page.locator('#contextMenu .cm-row', { hasText: 'Background Image' }).first().click(); +await page.waitForTimeout(400); +await page.locator('app-edit-panel button-block', { hasText: 'Delete' }).locator('button').click(); +await page.waitForTimeout(500); +record('Delete takes the picture off the canvas', (await image()) === null); + +await page.mouse.click(emptyGrid.x, emptyGrid.y, { button: 'right' }); +await page.waitForTimeout(300); +const after = await menuLabels(); +record( + 'and the menu goes back to offering Add', + after.some((label) => label === 'Add background image'), + after +); +await closeMenu(); +await page.screenshot({ path: SHOTS + '04-deleted.png' }); + +record('no page errors the whole way through', errors.length === 0, errors); + +const failed = results.filter(([, ok]) => !ok); +console.log(`\n${results.length - failed.length}/${results.length} checks passed`); +console.log(`screenshots in ${SHOTS}`); +await browser.close(); +process.exit(failed.length ? 1 : 0); diff --git a/e2e/circular-link-and-driver.mjs b/e2e/circular-link-and-driver.mjs new file mode 100644 index 00000000..825c7fad --- /dev/null +++ b/e2e/circular-link-and-driver.mjs @@ -0,0 +1,301 @@ +/** + * The two things brought over from PMKS-Refactor, driven through the app: + * Make Circular on a crank, and the driver dyad that turns a synthesised + * four-bar into a six-bar a motor can run. + * + * BASE=http://localhost:4310 node e2e/circular-link-and-driver.mjs + */ +const { chromium } = await import('/tmp/pmks-playwright/node_modules/playwright/index.mjs'); +import { waitForReady } from './app-ready.mjs'; +import { readFileSync, mkdirSync } from 'node:fs'; + +const BASE = process.env.BASE ?? 'http://localhost:4310'; +const SHOTS = new URL('../artifacts/screenshots/', import.meta.url).pathname; +mkdirSync(SHOTS, { recursive: true }); + +const source = readFileSync( + new URL('../src/app/component/MODALS/templates/template-linkages.ts', import.meta.url).pathname, + 'utf8' +); +const payloads = Object.fromEntries( + [...source.matchAll(/^ {2}'?([\w-]+)'?:\n {4}'([^']+)',$/gm)].map(([, id, p]) => [id, p]) +); + +const checks = []; +const check = (name, pass, detail = '') => { + checks.push({ name, pass, detail }); + console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); +}; + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1500, height: 950 } }); +const errors = []; +let stage = 'load'; +const note = (text) => errors.push(`[${stage}] ${text.split('\n')[0].slice(0, 140)}`); +page.on('pageerror', (e) => note(String(e))); +page.on('console', (m) => m.type() === 'error' && note(m.text())); + +const shot = (name) => page.screenshot({ path: `${SHOTS}circdrv-${name}.png` }); + +// ---------------------------------------------------------------- circular +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); + +const linkState = () => + page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const crank = grid.mechanismSrv.links.find((l) => l.canBeCircular?.()); + const box = crank ? document.querySelector(`#linkHolder path#${crank.id}`) : null; + const rect = box?.getBoundingClientRect(); + return { + id: crank?.id, + isCircle: crank?.isCircle, + arcs: (crank?.d.match(/A /g) ?? []).length, + // A bar has two arcs too (its end caps); only a disc has no straight edge. + sides: (crank?.d.match(/ L /g) ?? []).length, + edges: crank?.externalLines.length, + box: rect ? [Math.round(rect.width), Math.round(rect.height)] : null, + url: location.search, + }; + }); + +stage = 'template-loaded'; +const before = await linkState(); +check('a grounded crank is eligible for a disc', !!before.id, `link ${before.id}`); + +// What the right-click menu actually offers, which is the only way anyone +// reaches this feature. +const menuLabels = (linkId) => + page.evaluate((id) => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.setLastRightClick(grid.mechanismSrv.links.find((l) => l.id === id)); + grid.updateContextMenuItems(); + return grid.cMenu.groups.flatMap((group) => group.rows).map((row) => row.label); + }, linkId); + +const coupler = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const free = grid.mechanismSrv.links.find((l) => !l.canBeCircular()); + return free ? { id: free.id, can: free.canBeCircular() } : null; +}); +check('a coupler is not', coupler !== null && coupler.can === false, `link ${coupler?.id}`); + +const crankMenu = await menuLabels(before.id); +const couplerMenu = await menuLabels(coupler.id); +// The row is a state now -- "Drawn as a Disc", ticked or not -- rather than a +// verb whose label flipped between Make Circular and Make Bar. And it is +// greyed with its reason on a link that cannot take one rather than hidden: +// the menu's one availability rule is that a reader is told why. +check('the crank is offered a disc', crankMenu.includes('Drawn as a Disc'), crankMenu.join(', ')); +const couplerDisc = await page.evaluate((id) => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.setLastRightClick(grid.mechanismSrv.links.find((l) => l.id === id)); + return grid.cMenu.groups + .flatMap((group) => group.rows) + .filter((row) => row.label === 'Drawn as a Disc') + .map((row) => ({ disabled: row.disabled, reason: row.refusal?.short ?? null }))[0]; +}, coupler.id); +check( + 'the coupler is told why it cannot have one', + couplerDisc?.disabled === true && couplerDisc?.reason === 'needs a fixed pin', + JSON.stringify({ couplerDisc, menu: couplerMenu.join(', ') }) +); +check( + 'and no label in either menu flips as it is used', + ![...crankMenu, ...couplerMenu].some((label) => /^(Make|Add|Remove) /.test(label)), + [...crankMenu, ...couplerMenu].join(', ') +); + +await page.evaluate((id) => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.activeObjService.updateSelectedObj(grid.mechanismSrv.links.find((l) => l.id === id)); + grid.mechanismSrv.toggleLinkCircular(); +}, before.id); +await page.waitForTimeout(500); + +stage = 'made-circular'; +const after = await linkState(); +check( + 'the crank is drawn as a disc', + after.isCircle === true && after.arcs === 2 && after.sides === 0, + `${after.arcs} arcs, ${after.sides} straight edges` +); +check( + 'the disc is as tall as it is wide', + after.box && Math.abs(after.box[0] - after.box[1]) <= 2, + `${after.box}` +); +check('the disc has no edges to hang a joint on', after.edges === 0, `${after.edges} edges`); +check( + 'the disc is bigger than the bar it replaced', + after.box[0] > before.box[0], + `${before.box[0]} → ${after.box[0]}` +); +await shot('1-disc'); + +// The URL has to carry it, and reloading has to bring it back. +const shared = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + return grid.saveHistoryService.urlGenerationService.generateUrlQuery(); +}); +stage = 'share-url'; +if (shared) { + await page.goto(`${BASE}/?${shared}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(page); + const reopened = await linkState(); + check( + 'a shared URL reopens as a disc', + reopened.isCircle === true && reopened.arcs === 2 && reopened.sides === 0 + ); +} else { + check('a shared URL reopens as a disc', false, 'could not reach the url generator'); +} + +// It has to survive being animated: every solved frame is a fresh copy. +await page.evaluate(() => { + const bar = ng.getComponent(document.querySelector('app-playback-bar')); + bar.togglePlay ? bar.togglePlay() : bar.play?.(); +}); +stage = 'animating'; +await page.waitForTimeout(1200); +const running = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const crank = grid.mechanismSrv.links.find((l) => l.isCircle); + return { + arcs: (crank?.d.match(/A /g) ?? []).length, + sides: (crank?.d.match(/ L /g) ?? []).length, + d: crank?.d.slice(0, 40), + }; +}); +check('it is still a disc while running', running.arcs === 2 && running.sides === 0, running.d); +await shot('2-running'); + +// -------------------------------------------------------------- synthesis +await page.goto(BASE, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); + +// Synthesis tab, then three poses via the panel's own model. +await page.evaluate(() => { + // TabID.SYNTHESIZE is 0. + ng.getComponent(document.querySelector('app-left-tabs')).tabs.setTab(0); +}); +await page.waitForTimeout(600); +await shot('3-synthesis-tab'); + +stage = 'synthesis-poses'; +const posed = await page.evaluate(() => { + const panel = ng.getComponent(document.querySelector('app-synthesis-panel')); + if (!panel) return { ok: false }; + const b = panel.design; + [1, 2, 3].forEach((i) => b.isPoseDefined(i) || b.createPose(i)); + const at = [ + [-2, 1, 20], + [0, 2.2, 45], + [2.4, 1.4, 70], + ]; + [1, 2, 3].forEach((i) => { + const [x, y, deg] = at[i - 1]; + b.poses[i].position = new b.poses[i].position.constructor(x * 200, y * 200); + b.poses[i].thetaDegrees = deg; + }); + b.valueChanges.next(true); + return { + ok: true, + links: panel.mechanismSrv.links.length, + joints: panel.mechanismSrv.joints.length, + }; +}); +check( + 'three poses synthesise a four-bar', + posed.ok && posed.links === 3, + `${posed.links} links, ${posed.joints} joints` +); +await page.waitForTimeout(500); +await shot('4-fourbar'); + +const driverState = () => + page.evaluate(() => { + const panel = ng.getComponent(document.querySelector('app-synthesis-panel')); + const m = panel.mechanismSrv; + return { + links: m.links.length, + joints: m.joints.length, + grounds: m.joints.filter((j) => j.ground).length, + inputs: m.joints.filter((j) => j.input).length, + refusal: panel.synthesisBuilder.driverRefusal ?? null, + wanted: panel.synthesisBuilder.driverWanted, + valid: m.mechanisms[0]?.mechanismValid, + frames: m.mechanisms[0]?.joints?.length ?? 0, + }; + }); + +stage = 'four-bar'; +const fourBar = await driverState(); +check( + 'the four-bar is driven from one ground pin', + fourBar.inputs === 1 && fourBar.grounds === 2, + `${fourBar.inputs} input, ${fourBar.grounds} ground` +); + +await page.evaluate(() => + ng.getComponent(document.querySelector('app-synthesis-panel')).toggleDriver() +); +await page.waitForTimeout(900); +stage = 'six-bar'; +const sixBar = await driverState(); +check( + 'adding a driver makes it a six-bar', + sixBar.links === 5 && sixBar.joints === 6 && sixBar.grounds === 3, + `${sixBar.links} links, ${sixBar.joints} joints, ${sixBar.grounds} grounds, refusal=${sixBar.refusal}` +); +check('the drive moved to the new pin', sixBar.inputs === 1, `${sixBar.inputs} inputs`); +check('the six-bar solves', sixBar.valid === true, `valid=${sixBar.valid} frames=${sixBar.frames}`); +check( + 'one full turn of the driver, not a stub of one', + sixBar.frames > 100, + `${sixBar.frames} frames` +); +await shot('5-sixbar'); + +await page.evaluate(() => + ng.getComponent(document.querySelector('app-synthesis-panel')).swapDrivePin() +); +await page.waitForTimeout(900); +stage = 'swap-pin'; +const swapped = await driverState(); +check( + 'swapping the drive pin rebuilds it whole', + swapped.links === 5 && swapped.joints === 6, + `${swapped.links} links, ${swapped.joints} joints, refusal=${swapped.refusal}` +); +await shot('6-swapped'); + +await page.evaluate(() => + ng.getComponent(document.querySelector('app-synthesis-panel')).toggleDriver() +); +await page.waitForTimeout(900); +stage = 'driver-removed'; +const removed = await driverState(); +check( + 'removing the driver leaves the four-bar behind', + removed.links === 3 && removed.joints === 4 && removed.inputs === 1, + `${removed.links} links, ${removed.joints} joints` +); +await shot('7-driver-removed'); + +// One console error predates this branch: setting up three synthesis poses +// draws a path the browser rejects for a frame, and it reproduces identically +// on multi-mechanism-redesign at b3f9e08. Named rather than ignored, so that a +// second copy of it — or any other error — still fails this run. +const KNOWN_BEFORE_THIS_BRANCH = /^\[synthesis-poses\] Error: Invalid SVG path number$/; +const unexpected = errors.filter((e) => !KNOWN_BEFORE_THIS_BRANCH.test(e)); +check( + 'no page errors beyond the one that predates this work', + unexpected.length === 0, + unexpected.slice(0, 6).join('\n ') +); + +await browser.close(); +const failed = checks.filter((c) => !c.pass); +console.log(`\n${checks.length - failed.length}/${checks.length} passed`); +process.exit(failed.length ? 1 : 0); diff --git a/e2e/context-menu.mjs b/e2e/context-menu.mjs new file mode 100644 index 00000000..c4ebd182 --- /dev/null +++ b/e2e/context-menu.mjs @@ -0,0 +1,469 @@ +/** + * The right-click menu, in every mode and on every kind of part. + * + * The menu used to be one flat list of up to eight equally weighted rows with + * Delete at the top, labels that rewrote themselves as the object changed, and + * three different ways of saying no -- hidden here, greyed silently there, + * clickable-and-then-refused somewhere else. What is checked here is the + * promise the redesign makes instead: a fixed ladder, states written as states, + * and one availability rule with the model's own reason on every greyed row. + * + * The reasons matter more than the rows. A row greyed for the wrong reason + * sends a student to fix the wrong thing, so the assertions read the text in + * the right-hand slot rather than just the disabled flag. + * + * PMKS_BASE_URL= node e2e/context-menu.mjs + */ + +const { chromium } = await import( + (process.env.PMKS_PLAYWRIGHT_DIR ?? '/tmp/pmks-playwright') + '/node_modules/playwright/index.mjs' +); +import { openMechanism } from './app-ready.mjs'; + +const BASE = process.env.PMKS_BASE_URL ?? 'http://127.0.0.1:4200'; + +/** A crank-rocker with a tracer on the coupler. */ +const FOURBAR = + '?2P.Ay,1E8.5,0.1011.4O,O,0,0,0.0A,A,72,MM,0.2C,C,pa,bW,0.4D,D,_W,0,0.0T,T,Wq,pa,0..YROA,OA,Fe,Fe,3X,BB,c5cae9,O,A,,.YRACT,ACT,Fe,Fe,UU,b9,303e9f,A,C,T,,.YRCD,CD,Fe,Fe,v2,Im,0d125a,C,D,,...N_3'; +/** A sealed cylinder driving a rocker. */ +const CYLINDER = + '?2P.Ay,1E8.5,0.1011.4A,A,0_W,0,0.0B,B,07E,0,0.8C,C,0OE,0,0.0D,D,V4,0,0.6E,E,V4,ku,0.XP,P,0OE,0,0,AB,A,B..YRAB,AB,Fe,Fe,0Yt,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,3R,0,303e9f,C,D,,.YRDE,DE,Fe,Fe,V4,NS,0d125a,D,E,,.YPCP,CP,0,0,0,0,,C,P,,...N_h'; +/** Three synthesis positions and nothing else drawn. */ +const POSITIONS = + '?2P.KB,1E8.5,0.1011....N_.SD~w3~1~9,SP~12a~eO~01DP8,SP~2w9~x2~05t0,SP~3xv~0vL~0bAtJ'; + +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))); + +/** The open menu, read as data: rows, their state, and the slot on the right. */ +const readMenu = () => + page.evaluate(() => { + const card = document.querySelector('#contextMenu'); + if (!card || getComputedStyle(card).display === 'none') return null; + const slot = (row) => + row.querySelector('.cm-row__reason')?.textContent?.trim() ?? + row.querySelector('.cm-row__hint')?.textContent?.trim() ?? + row.querySelector('.cm-row__key')?.textContent?.trim() ?? + (row.querySelector('.cm-row__check') ? 'check' : ''); + const cross = card.querySelector('.cm-cross'); + return { + title: card.querySelector('.cm-header__title')?.textContent?.trim() ?? null, + subtitle: card.querySelector('.cm-header__subtitle')?.textContent?.trim() ?? null, + cross: cross ? (cross.classList.contains('cm-cross--off') ? 'off' : 'on') : null, + // Upper-cased by the stylesheet, so the text node keeps its own case. + groups: [...card.querySelectorAll('.cm-group__label')].map((one) => + one.textContent.trim().toUpperCase() + ), + rows: [...card.querySelectorAll('.cm-row')].map((one) => ({ + label: one.querySelector('.cm-row__label')?.textContent?.trim() ?? '', + slot: slot(one), + on: one.classList.contains('cm-row--on'), + off: one.classList.contains('cm-row--off'), + destructive: one.classList.contains('cm-row--destructive'), + })), + }; + }); + +const rowNamed = (menu, label) => menu?.rows.find((one) => one.label === label); + +/** + * Right-click a point that is actually on the element. + * + * Not simply its bounding-box centre: the left card is 250px wide and 400px in + * the analysis modes, and a part drawn near the left edge has its centre under + * that card — the click then lands on the panel and no menu opens at all, + * which reads as the menu being broken rather than the aim being off. + */ +async function openOn(selector) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(150); + const at = await page.evaluate((sel) => { + const node = document.querySelector(sel); + if (!node) return null; + const box = node.getBoundingClientRect(); + const owns = (x, y) => { + const hit = document.elementFromPoint(x, y); + return !!hit && (hit === node || node.contains(hit) || hit.closest(sel) === node); + }; + const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + if (owns(centre.x, centre.y)) return centre; + for (const fx of [0.7, 0.3, 0.85, 0.15]) { + for (const fy of [0.5, 0.3, 0.7]) { + const spot = { x: box.x + box.width * fx, y: box.y + box.height * fy }; + if (owns(spot.x, spot.y)) return spot; + } + } + return centre; + }, selector); + if (!at) return null; + await page.mouse.click(at.x, at.y, { button: 'right' }); + await page.waitForTimeout(350); + return readMenu(); +} + +/** + * Build the menu for a part the pointer cannot reliably land on. + * + * A force is drawn as a thin arrow: its bounding box is large but almost none + * of it is the stroke, so sampling points inside the box hits the canvas + * behind it. The gesture is covered by every other case here; what this is + * for is what the menu *says* about a force. + */ +async function openOnForce(id) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(150); + return page.evaluate((forceId) => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const force = grid.mechanismSrv.forces.find((one) => (one.id ?? one.name) === forceId); + if (!force) return null; + grid.setLastRightClick(force); + const rows = grid.cMenu.groups.flatMap((group) => + group.rows.map((row) => ({ + label: row.label, + slot: row.refusal?.short ?? (row.checked ? 'check' : (row.hint ?? row.shortcut ?? '')), + on: row.checked && !row.disabled, + off: row.disabled, + destructive: row.destructive, + })) + ); + return { + title: grid.cMenu.header?.title ?? null, + subtitle: grid.cMenu.header?.subtitle ?? null, + cross: grid.cMenu.header?.crossing ? 'on' : null, + groups: grid.cMenu.groups.map((group) => group.label).filter(Boolean), + rows, + }; + }, id); +} + +async function openAt(x, y) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(150); + await page.mouse.click(x, y, { button: 'right' }); + await page.waitForTimeout(350); + return readMenu(); +} + +// ---------------------------------------------------------------- Edit mode + +await openMechanism(page, BASE + FOURBAR); + +const jointA = await openOn('#joint_A'); +check('a joint menu names the joint it is about', jointA?.title === 'Joint A', jointA?.title); +check('and what it is made of', jointA?.subtitle === 'Pin · Links OA, ACT', jointA?.subtitle); +check( + 'the ladder is Attach then State', + JSON.stringify(jointA?.groups) === JSON.stringify(['ATTACH', 'STATE']), + jointA?.groups +); +check( + 'Delete is the last row, and the only red one', + jointA?.rows.at(-1)?.destructive === true && + jointA?.rows.filter((one) => one.destructive).length === 1, + jointA?.rows.map((one) => one.label) +); +check( + 'states are states, not verbs that rewrite themselves', + ['Grounded', 'Driven Input', 'Slider', 'Welded', 'Trace Path', 'Locked'].every((label) => + rowNamed(jointA, label) + ) && !jointA?.rows.some((one) => /^(Add|Remove) /.test(one.label)), + jointA?.rows.map((one) => one.label) +); +check( + 'a load will not anchor where two links share the pin', + rowNamed(jointA, 'Force')?.off === true && rowNamed(jointA, 'Force')?.slot === '2 links share it', + rowNamed(jointA, 'Force') +); +check( + 'the destructive row names what goes with it', + jointA?.rows.at(-1)?.label === 'Delete Joint and Link OA', + jointA?.rows.at(-1)?.label +); +check( + 'and carries its key from the registry', + jointA?.rows.at(-1)?.slot === 'Delete' && rowNamed(jointA, 'Locked')?.slot === 'K', + { del: jointA?.rows.at(-1)?.slot, lock: rowNamed(jointA, 'Locked')?.slot } +); + +const groundO = await openOn('#joint_O'); +check( + 'a ground pivot reads as one, with its state ticked', + groundO?.subtitle === 'Ground pin · Link OA' && rowNamed(groundO, 'Grounded')?.on === true, + { subtitle: groundO?.subtitle, grounded: rowNamed(groundO, 'Grounded') } +); +check( + 'a weld with nothing to fuse is greyed with the reason', + rowNamed(groundO, 'Welded')?.slot === 'needs 2 links', + rowNamed(groundO, 'Welded') +); + +const tracerT = await openOn('#joint_T'); +check( + 'a tracer cannot be driven, and the model says why', + rowNamed(tracerT, 'Driven Input')?.slot === 'needs 2 bodies', + rowNamed(tracerT, 'Driven Input') +); +check( + 'a joint that orphans nothing says plain Delete Joint', + tracerT?.rows.at(-1)?.label === 'Delete Joint', + tracerT?.rows.at(-1)?.label +); + +const link = await openOn('[id="OA"]'); +check( + 'a link menu offers the four attachments and a copy', + ['Link', 'Cylinder', 'Tracer Point', 'Force', 'Duplicate Link'].every((label) => + rowNamed(link, label) + ), + link?.rows.map((one) => one.label) +); +check( + 'and counts the joints its deletion would sweep up', + link?.rows.at(-1)?.label === 'Delete Link and Joint O', + link?.rows.at(-1)?.label +); + +const canvas = await openAt(1150, 780); +check( + 'the canvas menu says Add rather than Attach', + JSON.stringify(canvas?.groups) === JSON.stringify(['ADD', 'MACHINE']), + canvas?.groups +); +check( + 'and counts what Lock All and Unlock All would touch', + rowNamed(canvas, 'Lock All')?.slot === '5 open' && + rowNamed(canvas, 'Unlock All')?.slot === 'nothing locked' && + rowNamed(canvas, 'Unlock All')?.off === true, + { lock: rowNamed(canvas, 'Lock All'), unlock: rowNamed(canvas, 'Unlock All') } +); + +// A lock, set from the menu, and read back from it. +await openOn('#joint_A'); +await page.click('.cm-row:has(.cm-row__label:text-is("Locked"))'); +await page.waitForTimeout(700); +const locked = await openOn('#joint_A'); +check( + 'a locked joint ticks its own switch', + rowNamed(locked, 'Locked')?.on === true, + rowNamed(locked, 'Locked') +); +check( + 'refuses to be deleted, and says which way out', + locked?.rows.at(-1)?.off === true && locked?.rows.at(-1)?.slot === 'unlock first', + locked?.rows.at(-1) +); +check( + 'and refuses attachments for the same reason', + rowNamed(locked, 'Link')?.slot === 'unlock first', + rowNamed(locked, 'Link') +); +check( + 'while the switch that frees it stays live', + rowNamed(locked, 'Locked')?.off === false, + rowNamed(locked, 'Locked') +); + +// ------------------------------------------------------------ analysis mode + +await page.keyboard.press('Escape'); +await page.click('text=Kinematic Analysis'); +await page.waitForTimeout(900); +const analysisJoint = await openOn('#joint_T'); +check( + 'an analysis menu offers the view and nothing that edits', + JSON.stringify(analysisJoint?.rows.map((one) => one.label)) === JSON.stringify(['Trace Path']), + analysisJoint?.rows.map((one) => one.label) +); +check('and the way back into Edit rides the header', analysisJoint?.cross === 'on', analysisJoint); + +await page.keyboard.press('Escape'); +await page.click('text=Force Analysis'); +await page.waitForTimeout(900); +const forceJoint = await openOn('#joint_T'); +check( + 'a tracer has no force to graph, and the row says why', + rowNamed(forceJoint, 'Graph Joint Force')?.slot === 'one part meets it', + rowNamed(forceJoint, 'Graph Joint Force') +); +check( + 'while the trace stays live: it is a view, not geometry', + rowNamed(forceJoint, 'Trace Path')?.off === false, + rowNamed(forceJoint, 'Trace Path') +); + +// ---------------------------------------------------------------- cylinder + +await openMechanism(page, BASE + CYLINDER); +const cylinderJoint = await openOn('#joint_A'); +check( + 'a cylinder joint says which end of which cylinder it is', + cylinderJoint?.subtitle?.startsWith('Barrel joint · Cylinder'), + cylinderJoint?.subtitle +); +check( + 'a sealed part cannot be welded into a neighbour', + rowNamed(cylinderJoint, 'Welded')?.slot === 'part is sealed', + rowNamed(cylinderJoint, 'Welded') +); +check( + 'and the deletion says it takes the whole part', + cylinderJoint?.rows.at(-1)?.label === 'Delete Joint and Cylinder', + cylinderJoint?.rows.at(-1)?.label +); +check( + 'a cylinder joint takes no block: the row is absent, not greyed', + !rowNamed(cylinderJoint, 'Slider'), + cylinderJoint?.rows.map((one) => one.label) +); + +const cylinderBody = await openOn('[id="AB"]'); +check( + 'a cylinder is described as one part, not as how it is built', + /^Barrel and rod · Joints /.test(cylinderBody?.subtitle ?? '') && + !/assembly|sealed/i.test(cylinderBody?.subtitle ?? ''), + cylinderBody?.subtitle +); + +// Duplicate on a link with three joints: the case that used to accept the +// click and silently do nothing. +await openMechanism(page, BASE + FOURBAR); +const linksBefore = await page.$$eval('path[id]', (nodes) => nodes.map((one) => one.id)); +await openOn('[id="ACT"]'); +await page.click('.cm-row:has(.cm-row__label:text-is("Duplicate Link"))'); +await page.waitForTimeout(1000); +const linksAfter = await page.$$eval('path[id]', (nodes) => nodes.map((one) => one.id)); +const copyMade = await page.evaluate((known) => { + const service = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const source = service.links.find((one) => one.id === 'ACT'); + const copy = service.links.find((one) => !known.includes(one.id)); + if (!copy || !source) return null; + const gap = Math.hypot( + copy.joints[0].x - source.joints[0].x, + copy.joints[0].y - source.joints[0].y + ); + return { + joints: copy.joints.length, + sharesAJoint: copy.joints.some((one) => source.joints.some((other) => other.id === one.id)), + gap: Math.round(gap), + mass: copy.mass, + sourceMass: source.mass, + }; +}, linksBefore); +check( + 'Duplicate copies a three-joint link, free-standing and clear of the original', + copyMade?.joints === 3 && copyMade?.sharesAJoint === false && (copyMade?.gap ?? 0) > 50, + copyMade +); +check( + 'and copies the body with it, not just its outline', + copyMade?.mass === copyMade?.sourceMass, + copyMade +); + +// -------------------------------------------------------- synthesis positions + +await openMechanism(page, BASE + POSITIONS); +await page.click('text=Synthesis'); +await page.waitForTimeout(900); +const synthCanvas = await openAt(1250, 830); +check( + 'Synthesis offers the positions and nothing else', + JSON.stringify(synthCanvas?.rows.map((one) => one.label)) === + JSON.stringify(['Delete 3 Synthesis Positions']), + synthCanvas?.rows.map((one) => one.label) +); + +// --------------------------------------------------- one grid, two machines + +/** Two four-bars side by side, each with its own drive. */ +const TWO_FOUR_BARS = + '?2P.Ay,1E8.K,0.1011.6A,A,0mv,0VU,0.0B,B,0e_,E6,0.0C,C,l1,WW,0.4D,D,qD,0Pk,0.6E,E,2Y_,0,0.' + + '0F,F,2Y_,GJ,0.0G,G,3Jt,Wc,0.4H,H,3aA,0,0..YRAB,AB,Fe,Fe,0ix,08i,c5cae9,A,B,,.' + + 'YRBC,BC,Fe,Fe,32,NJ,303e9f,B,C,,.YRCD,CD,Fe,Fe,nd,3P,0d125a,C,D,,.' + + 'AREF,EF,0,0,2Y_,8A,555555,E,F,,.ARFG,FG,0,0,2xQ,OS,555555,F,G,,.' + + 'ARGH,GH,0,0,3S0,GJ,555555,G,H,,...N_L'; + +await openMechanism(page, BASE + TWO_FOUR_BARS); +const bothFine = await openOn('#joint_F'); +check( + 'a part of a machine that runs offers the way into analysis', + bothFine?.cross === 'on', + bothFine +); + +// Take the input off the second machine only. +await openOn('#joint_E'); +await page.click('.cm-row:has(.cm-row__label:text-is("Driven Input"))'); +await page.waitForTimeout(900); +const broken = await openOn('#joint_F'); +check('a part of a machine that cannot run does not', broken?.cross === 'off', broken); +const stillFine = await openOn('#joint_C'); +check( + 'while the machine beside it still does — the question is per part', + stillFine?.cross === 'on', + stillFine +); + +// -------------------------------------------- a slider, and a load on a bar + +/** An inverted slider-crank carrying a force: a floating slider and an F1. */ +const SLIDER_AND_LOAD = + '?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,0,Fe,0.4C,C,ku,0,0.0D,D,0RF,Oj,0.1P,P,0,Fe,0,CD,C,D..' + + 'YRAB,AB,Fe,Fe,0,7q,c5cae9,A,B,,.YRCD,CD,Fe,Fe,9q,CN,303e9f,C,D,,.YPBP,BP,0,0,0,0,,B,P,,..' + + '2F1,CD,F1,0RF,Oj,0RF,95,2SG..N_M'; + +await openMechanism(page, BASE + SLIDER_AND_LOAD); +const sliderPin = await openOn('#joint_B'); +check( + 'a slider pin says it is one, and its Slider switch is on', + /^Slider pin · /.test(sliderPin?.subtitle ?? '') && rowNamed(sliderPin, 'Slider')?.on === true, + sliderPin +); +check( + // Offered rather than hidden: the model has no rule against welding a slider + // pin, and the panel offers it, so the menu does too and the refusal comes + // with its reason if the fuse cannot stand. + 'and keeps its Weld row, as the panel does', + rowNamed(sliderPin, 'Welded')?.off === false, + sliderPin?.rows.map((one) => one.label) +); + +const forceTarget = await openOnForce('F1'); +check( + 'a force menu names the frame it is in and offers the state, not a verb', + /frame$/.test(forceTarget?.subtitle ?? '') && !!rowNamed(forceTarget, 'Global Frame'), + forceTarget +); +check( + 'and reversing it is a verb, under Set', + rowNamed(forceTarget, 'Reverse Direction')?.off === false, + forceTarget?.groups +); + +// The floating slider's carrier is deliberately not one of its `links`, so a +// menu that counted links called this "one part meets it" while the force +// solver was generating a reaction against two. +await page.keyboard.press('Escape'); +await page.click('text=Force Analysis'); +await page.waitForTimeout(900); +const sliderForce = await openOn('#joint_B'); +check( + 'a slider pin is not told it has no force to graph', + rowNamed(sliderForce, 'Graph Joint Force')?.off === false, + rowNamed(sliderForce, 'Graph Joint Force') +); + +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/e2e/creation-previews.mjs b/e2e/creation-previews.mjs index 30b74c6f..b4ad463f 100644 --- a/e2e/creation-previews.mjs +++ b/e2e/creation-previews.mjs @@ -58,12 +58,12 @@ async function draw(kind, jointId, to) { await page.waitForTimeout(600); await page.evaluate( (label) => { - const item = [...document.querySelectorAll('#contextMenu #menu-item')].find((node) => - node.textContent.includes(label) + const item = [...document.querySelectorAll('#contextMenu .cm-row')].find( + (node) => node.querySelector('.cm-row__label')?.textContent?.trim() === label ); - item?.querySelector('button')?.click(); + item?.click(); }, - kind === 'link' ? 'Attach Link' : 'Attach Cylinder' + kind === 'link' ? 'Link' : 'Cylinder' ); await page.waitForTimeout(300); @@ -102,10 +102,10 @@ await page.mouse.move(onJoint.x, onJoint.y); await page.mouse.click(onJoint.x, onJoint.y, { button: 'right' }); await page.waitForTimeout(600); await page.evaluate(() => { - const item = [...document.querySelectorAll('#contextMenu #menu-item')].find((node) => - node.textContent.includes('Attach Link') + const item = [...document.querySelectorAll('#contextMenu .cm-row')].find( + (node) => node.querySelector('.cm-row__label')?.textContent?.trim() === 'Link' ); - item?.querySelector('button')?.click(); + item?.click(); }); await page.waitForTimeout(300); await page.mouse.move(onJoint.x - 200, onJoint.y + 160, { steps: 8 }); diff --git a/e2e/cylinder-drag.mjs b/e2e/cylinder-drag.mjs index 096ae2d6..f12b3dc7 100644 --- a/e2e/cylinder-drag.mjs +++ b/e2e/cylinder-drag.mjs @@ -125,7 +125,22 @@ const floored = await drag( 'onto-the-floor' ); -const report = { resting, posed, shrunk, grown, floored, errors }; +// The floor, read off the ram itself. The panel is no help here: this drag +// starts from a freshly loaded template and ends with the mount somewhere the +// re-selecting click does not find the skin, so every field comes back null -- +// which is why the only thing the old check could look at was the notice. +const atFloor = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const sealed = grid.mechanismSrv.sealedStructures()[0]; + if (!sealed) return null; + const span = Math.hypot( + sealed.barrelFar.x - sealed.rodFar.x, + sealed.barrelFar.y - sealed.rodFar.y + ); + return { span, floor: grid.mechanismSrv.minimumCylinderSpan?.() ?? null }; +}); + +const report = { resting, posed, shrunk, grown, floored, atFloor, errors }; writeFileSync('artifacts/cylinder-drag/report.json', JSON.stringify(report, null, 2)); console.log(JSON.stringify(report, null, 2)); @@ -135,9 +150,14 @@ const checks = [ ['pushing past the retracted stop shrinks the ram', strokeOf(shrunk) < strokeOf(resting)], ['pulling past the extended stop grows it again', strokeOf(grown) > strokeOf(shrunk)], [ - 'the drag says so when it stops at the shortest cylinder', - /shortest cylinder/.test(floored.notice ?? ''), + // Silently, now: a mount that stops following your hand has already said + // it, and a notice saying it as well was noise. What has to stay true is + // that it stops -- the ram keeps a length rather than collapsing onto its + // own mount, which is what the drag was aimed at doing. + 'the drag stops short rather than collapsing the ram onto its mount', + !!atFloor && atFloor.span > 1, ], + ['and says nothing about it', !/shortest cylinder/.test(floored.notice ?? '')], ['nothing threw', errors.length === 0], ]; for (const [what, ok] of checks) console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`); diff --git a/e2e/detail-fixes.mjs b/e2e/detail-fixes.mjs new file mode 100644 index 00000000..ebad0b75 --- /dev/null +++ b/e2e/detail-fixes.mjs @@ -0,0 +1,849 @@ +/** + * The small things, in a browser. + * + * Each of these is a fix a reader reported by pointing at the screen, and each + * is invisible to a unit test: what a cursor turns into, what colour a warning + * is in a mode that cannot act on it, whether a panel keeps its scroll. + * + * PMKS_BASE_URL= node e2e/detail-fixes.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 dev = readFileSync('src/app/component/MODALS/templates/dev-templates.ts', 'utf8'); +const payloads = Object.fromEntries( + [...(source + dev).matchAll(/^ {2}'?([\w-]+)'?:\n {4}'([^']+)',$/gm)].map(([, id, p]) => [ + id, + p.replace(/\\\\/g, '\\'), + ]) +); + +/** + * Three machines in one drawing: a ram, a rocker and a crank, with the ram + * first. Reported by a reader whose ram had no graphs and whose combined + * handle ran the whole drawing backwards when dragged. + */ +const THREE_MACHINES = + '2O.Ay,Fe.5,4x.1011.4O,O,0,0,0.0C,C,YJ,qG,0.4G,G,ku,0,0.0N,N,aM,hl,0.8P,P,ir,8X,0.ZS,S,ir,8X,0,GN,G,N.0T,T,nJ,oD,0.0U,U,1Nd,dn,0.6V,V,1ca,jH,0,,,,1E8.6W,W,J-,1GP,0.0X,X,PF,1dt,0.0Y,Y,rW,1g7,0.4Z,Z,-P,1FX,0.0%5B,%5B,1Tk,16n,0.0%5C,%5C,1ft,1bA,0.0%5E,%5E,1pZ,1Gc,0.0_,_,1_5,_W,0.0%60,%60,2AU,1OV,0.1a,a,2AU,1OV,0..YROC,OC,Fe,Fe,HA,Q8,c5cae9,O,C,,.YRGN,GN,Fe,Fe,fd,Lu,303e9f,G,N,,.YRPC,PC,Fe,Fe,da,UO,0d125a,P,C,,.YPPS,PS,Fe,0,0,0,,P,S,,.YRGT,GT,Fe,Fe,m5,P7,c5cae9,G,T,,.YRTU,TU,Fe,Fe,14T,i-,303e9f,T,U,,.YRUV,UV,Fe,Fe,1V5,gX,0d125a,U,V,,.YRWX,WX,Fe,Fe,Md,1S8,B2DFDB,W,X,,.YRXY,XY,Fe,Fe,dN,1f0,26A69A,X,Y,,.YRYZ,YZ,Fe,Fe,wS,1Sr,00695C,Y,Z,,.YR%5B%5C,%5B%5C,Fe,Fe,1Zp,1Lz,c5cae9,%5B,%5C,,.YR_%60,_%60,Fe,Fe,24I,1BV,B2DFDB,_,%60,,.YP%60a,%60a,Fe,0,0,0,,%60,a,,...N_b'; + +/** Two joints and a bar: in no mechanism, so nothing about it can be analysed. */ +const LONE_BAR = + '2P.Zz,1E8.5,0.1011.0A,A,2UW,9v,0.0B,B,3E8,1Zn,0..YRAB,AB,Fe,Fe,2sK,sr,303e9f,A,B,,...N_P'; + +const results = []; +const record = (what, ok, detail) => { + results.push([what, ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); +}; + +const browser = await chromium.launch(); +const errors = []; +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); +page.on('pageerror', (error) => errors.push(String(error))); + +const load = async (payload) => { + await page.goto(`${BASE}/${payload ? '?' + payload : ''}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(page).catch(() => undefined); + await page.waitForTimeout(500); +}; + +// --- names run out of letters into more letters ---------------------------- +await load(payloads['4-Bar']); +const names = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const taken = []; + const given = []; + for (let i = 0; i < 60; i++) { + const next = srv.determineNextLetter(taken); + given.push(next); + taken.push(next); + } + return given; +}); +record( + 'a drawing that outgrows the alphabet keeps being given letters', + names.every((name) => /^[A-Za-z]+$/.test(name)), + names.filter((name) => !/^[A-Za-z]+$/.test(name)).slice(0, 6) +); +record('and never the same name twice', new Set(names).size === names.length, names); + +// --- renaming is an edit, and Undo takes it back --------------------------- +await load(payloads['4-Bar']); +await page.locator('#joint_B').first().click({ force: true }); +await page.waitForTimeout(600); +const renamed = await page.evaluate(() => { + const title = ng.getComponent(document.querySelector('editable-title-block')); + title.gotoEditMode(); + title.newIDForm.patchValue({ newID: 'Elbow' }); + title.saveNewID(); + return ng + .getComponent(document.querySelector('app-new-grid')) + .mechanismSrv.joints.map((joint) => joint.name); +}); +record('a joint can be renamed', renamed.includes('Elbow'), renamed); +await page.waitForTimeout(700); +await page.locator('.historyButton').first().click(); +await page.waitForTimeout(1000); +const afterUndo = await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.joints.map((j) => j.name) +); +record('and Undo takes the rename back', !afterUndo.includes('Elbow'), afterUndo); + +// --- a long name on a bar is written along it ------------------------------ +const tags = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + return grid.mechanismSrv.getLinks().map((link) => { + const tag = grid.linkLabelStyle(link); + return { id: link.id, name: tag.name, angle: tag.angle, joints: link.joints.length }; + }); +}); +record( + 'a two-letter name is written level', + tags.every((tag) => tag.angle === 0), + tags +); + +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const bar = grid.mechanismSrv.links.find((link) => link.joints.length === 2); + bar.name = 'Coupler'; +}); +await page.waitForTimeout(400); +const named = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const bar = grid.mechanismSrv.links.find((link) => link.joints.length === 2); + return grid.linkLabelStyle(bar); +}); +record('a longer one runs along the bar', named.angle !== 0, named); +record('and never upside down', Math.abs(named.angle) <= 90, named); + +// --- analysis mode: no move cursor, no red on scenery ---------------------- +await load(payloads['Dev_All_Mechanism_Types']); +const inkIn = async (mode) => { + if (mode) await page.locator('.tabButton', { hasText: mode }).click({ force: true }); + await page.waitForTimeout(800); + return page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const joint = document.querySelector('#jointHolder svg[cursor="move"]'); + return { + ink: grid.orphanMarkInk, + cursor: joint ? getComputedStyle(joint).cursor : null, + locked: grid.geometryLocked, + }; + }); +}; +const editing = await inkIn(null); +record('a loose joint is marked in red while it can be fixed', editing.ink === '#F44336', editing); +record('and the parts say they can be dragged', editing.cursor === 'move', editing); + +const analysing = await inkIn('Kinematic'); +record( + 'the same mark goes grey once the mode cannot act on it', + analysing.ink !== '#F44336', + analysing +); +record( + 'and the cursor stops offering a drag that will not happen', + analysing.cursor === 'pointer', + analysing +); + +// --- a drawing that cannot be analysed sends you back to Edit -------------- +await load(payloads['4-Bar']); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click({ force: true }); +await page.waitForTimeout(700); +const wasAnalysing = await page.evaluate(() => + ng.getComponent(document.querySelector('app-top-bar')).tabs.isAnalysisMode() +); +// Through the same call the library and the Open dialog both make. +await page.evaluate((payload) => { + ng.getComponent(document.querySelector('app-top-bar')).urlProcessor.updateFromURL( + payload, + true, + true, + true + ); +}, LONE_BAR); +await page.waitForTimeout(2000); +const nowEditing = await page.evaluate(() => { + const bar = ng.getComponent(document.querySelector('app-top-bar')); + return { analysis: bar.tabs.isAnalysisMode(), valid: bar.mechanism.oneValidMechanismExists() }; +}); +record( + 'opening a drawing nothing can be analysed in leaves the analysis modes', + wasAnalysing && !nowEditing.valid && !nowEditing.analysis, + { wasAnalysing, nowEditing } +); + +// --- the legend says what the plot is drawing ------------------------------ +await load(payloads['4-Bar']); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click({ force: true }); +await page.waitForTimeout(700); +await page.locator('#joint_B').first().click({ force: true }); +await page.waitForTimeout(700); +await page.getByText('Velocity of Joint B').first().click({ force: true }); +await page.waitForTimeout(1800); +const legend = await page.evaluate(() => { + const section = [...document.querySelectorAll('app-analysis-graph-section')].find((node) => + node.querySelector('.graphSection.open') + ); + const component = ng.getComponent(section); + // viewChild is a signal now: the child is behind a call. + const graph = typeof component.graph === 'function' ? component.graph() : component.graph; + const lit = [...section.querySelectorAll('.previewSeries')].map( + (node) => !node.classList.contains('off') + ); + return { + lit, + drawn: ['x', 'y', 'z'].map((key) => graph.isSeriesShown(key)), + plotted: graph.displayedSeries.length, + }; +}); +record( + 'the legend and the plot agree the moment a graph opens', + legend.lit.filter(Boolean).length === legend.plotted, + legend +); + +// --- toggling a series does not move the panel under the pointer ----------- +await page.getByText('Acceleration of Joint B').first().click({ force: true }); +await page.waitForTimeout(1500); +const scroller = '.panel'; +await page.evaluate((selector) => { + const node = document.querySelector(selector); + node.scrollTop = node.scrollHeight; +}, scroller); +await page.waitForTimeout(400); +const before = await page.evaluate((s) => document.querySelector(s).scrollTop, scroller); +const lastLegend = page + .locator('app-analysis-graph-section') + .last() + .locator('.previewSeries') + .last(); +await lastLegend.click({ force: true }); +await page.waitForTimeout(900); +const after = await page.evaluate((s) => document.querySelector(s).scrollTop, scroller); +record('turning a line off leaves the panel where it was', Math.abs(after - before) < 12, { + before, + after, +}); + +// --- Export Data ------------------------------------------------------------ +// The corner card opens the export drawer rather than writing a file on the +// spot; what the drawer then does is `e2e/export-flow.mjs`. +await load(payloads['4-Bar']); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click({ force: true }); +await page.waitForTimeout(700); +record( + 'with a solved mechanism there is something to export, selection or not', + !(await page.locator('.historyCard button').first().isDisabled()), + {} +); +await page.locator('.historyCard button').first().click(); +await page.waitForTimeout(700); +record( + 'and the corner card opens the drawer that asks what to write', + (await page.locator('app-export-panel').count()) === 1 +); + +// --- three machines at once ------------------------------------------------- +await load(THREE_MACHINES); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click({ force: true }); +await page.waitForTimeout(900); + +// Every graph of the first machine, which is cylinder-driven and was solved +// before the other two were solved over the top of its solver state. +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.activeObjService.updateSelectedObj(grid.mechanismSrv.joints.find((j) => j.id === 'C')); +}); +await page.waitForTimeout(800); +await page.evaluate(() => + document.querySelector('app-analysis-graph-section .graphHeader')?.click() +); +await page.waitForTimeout(1500); +const firstMachine = await page.evaluate(() => { + const graph = ng.getComponent(document.querySelector('app-analysis-graph')); + const plotted = (prop, part) => { + graph.determineChart('kinematic', 'loop', prop, part); + const series = graph.chartOptions?.series ?? []; + return series.map((s) => (s.data ?? []).filter((point) => Number.isFinite(point?.y)).length); + }; + return { + velocity: plotted('Linear Joint Vel', 'C'), + acceleration: plotted('Linear Joint Acc', 'C'), + linkVelocity: plotted('Angular Link Vel', 'OC'), + frames: ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.mechanisms[0] + .joints.length, + }; +}); +record( + 'the first machine still has rates after the others were solved over it', + [firstMachine.velocity, firstMachine.acceleration, firstMachine.linkVelocity].every( + (series) => series.length > 0 && series.every((n) => n === firstMachine.frames) + ), + firstMachine +); + +// The combined handle measures time, so dragging it never runs anything back. +const swept = await page.evaluate(() => { + const bar = ng.getComponent(document.querySelector('app-playback-bar')); + if (!bar.synced) bar.toggleSync(); + const srv = bar.mechanism; + const row = bar.rows.find((r) => r.index === -1); + if (!row) return null; + const seen = []; + for (let value = 0; value <= 1000; value += 5) { + bar.scrubRow(row, { target: { value: String(value) } }); + seen.push(srv.mechanisms.map((_, i) => srv.secondsOf(i))); + } + const period = srv.mechanisms.map((m) => m.cyclePeriod); + // A shorter cycle legitimately starts again part way along the longest one. + // Anything else going backwards is the handle disagreeing with itself. + const backwards = period.map((_, machine) => { + let count = 0; + for (let i = 1; i < seen.length; i++) { + const step = seen[i][machine] - seen[i - 1][machine]; + if (step < -1e-9 && seen[i - 1][machine] < period[machine] - 0.5) count++; + } + return count; + }); + return { backwards, wraps: period.map((p) => Math.round((p / Math.max(...period)) * 200)) }; +}); +record( + 'dragging the combined handle only ever moves time forwards', + !!swept && swept.backwards.every((n) => n === 0), + swept +); + +// --- a graph's playhead is on its own machine's clock ----------------------- +const playhead = await page.evaluate(async () => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const srv = grid.mechanismSrv; + const bar = ng.getComponent(document.querySelector('app-playback-bar')); + if (bar.synced) bar.toggleSync(); + // A joint of the second machine, graphed. + grid.activeObjService.updateSelectedObj(srv.joints.find((joint) => joint.id === 'T')); + await new Promise((done) => setTimeout(done, 700)); + // Only if nothing is open: the sections remember which quantity was + // expanded, so a click here would close the one already showing. + if (!document.querySelector('app-analysis-graph')) { + document.querySelector('app-analysis-graph-section .graphHeader')?.click(); + await new Promise((done) => setTimeout(done, 1600)); + } + // Park it, and run the first machine instead. + srv.seekMechanism(1, 3); + if (!srv.isMechanismPlaying(0)) srv.toggleMechanismPlaying(0); + const label = () => document.querySelector('.apexcharts-xaxis-annotation-label')?.textContent; + const before = { at: label(), running: srv.secondsOf(0), parked: srv.secondsOf(1) }; + await new Promise((done) => setTimeout(done, 1500)); + const after = { at: label(), running: srv.secondsOf(0), parked: srv.secondsOf(1) }; + if (srv.isMechanismPlaying(0)) srv.toggleMechanismPlaying(0); + // Back to one handle for the checks that follow. + if (!bar.synced) bar.toggleSync(); + return { before, after }; +}); +record( + 'the other machine really is running', + playhead.after.running > playhead.before.running + 0.2, + playhead +); +record( + "a paused machine's graph keeps its playhead where the machine is", + playhead.before.at === playhead.after.at && playhead.after.at === 'T= 3.0', + playhead +); + +// --- the combined row's name is a label, not a control ---------------------- +// One handle again, which is the only arrangement that has a combined row. +await page.waitForTimeout(900); +const allChip = await page.evaluate(() => { + const chip = [...document.querySelectorAll('.mechChip')].find( + (node) => node.textContent.trim() === 'All' + ); + if (!chip) return null; + return { + tag: chip.tagName, + selectable: getComputedStyle(chip).userSelect, + selected: chip.classList.contains('selected'), + }; +}); +record( + 'the combined row is named by a label that cannot be pressed or selected', + !!allChip && allChip.tag === 'SPAN' && allChip.selectable === 'none' && !allChip.selected, + allChip +); + +// --- one switch for every traced path --------------------------------------- +const traces = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const button = [...document.querySelectorAll('.viewControls .viewButton')].find((node) => + (node.getAttribute('aria-label') ?? '').includes('traced') + ); + const drawn = () => document.querySelectorAll('#pathsHolder path').length; + if (!button) return null; + const disabledWithNone = button.disabled; + // Ask two joints to trace, as the Edit panel does. + grid.mechanismSrv.joints.slice(0, 2).forEach((joint) => (joint.showCurve = true)); + grid.mechanismSrv.updateMechanism(); + return { disabledWithNone, drawn: drawn() }; +}); +await page.waitForTimeout(900); +const tracesAfter = await page.evaluate(() => { + const button = [...document.querySelectorAll('.viewControls .viewButton')].find((node) => + (node.getAttribute('aria-label') ?? '').includes('traced') + ); + const drawn = () => document.querySelectorAll('#pathsHolder path').length; + const withTraces = drawn(); + button.click(); + return { withTraces }; +}); +await page.waitForTimeout(500); +const hidden = await page.evaluate(() => document.querySelectorAll('#pathsHolder path').length); +record( + 'with nothing tracing, the traces switch is greyed', + traces?.disabledWithNone === true, + traces +); +record('two joints asked to trace draw two paths', tracesAfter.withTraces === 2, tracesAfter); +record('and the switch puts every one of them away', hidden === 0, { hidden }); + +// --- the transport comes and goes; the view controls do not ---------------- +const controlsAt = () => + page.evaluate(() => { + const box = document.querySelector('.viewControls').getBoundingClientRect(); + return { + right: Math.round(innerWidth - box.right), + bottom: Math.round(innerHeight - box.bottom), + }; + }); +await load(payloads['4-Bar']); +const inEdit = await controlsAt(); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click({ force: true }); +await page.waitForTimeout(900); +const inAnalysis = await controlsAt(); +record( + 'the view controls sit in the same place in both modes', + inEdit.right === inAnalysis.right && inEdit.bottom === inAnalysis.bottom, + { inEdit, inAnalysis } +); + +// --- the three view switches say their state the same way ------------------- +const buttons = () => + page.evaluate(() => + [...document.querySelectorAll('.viewControls .viewButton')].map((button) => { + const icon = button.querySelector('mat-icon'); + return { + name: button.getAttribute('aria-label'), + glyph: icon?.getAttribute('data-mat-icon-name') ?? icon?.textContent.trim() ?? '', + on: button.classList.contains('on'), + disabled: button.disabled, + tinted: getComputedStyle(button).backgroundColor !== 'rgba(0, 0, 0, 0)', + ink: getComputedStyle(icon).color, + // A glyph that names its own colour ignores the button's, which is how + // the traced-paths switch stayed black in a row that had greyed out. + // Painted geometry only. A child is used for its shape and + // never drawn, and the export tool leaves a fill="white" on it that + // means nothing -- counting those would fail an icon that is fine. + selfPainted: [...(icon?.querySelectorAll('svg *') ?? [])] + .filter((node) => !node.closest('defs, clipPath, mask')) + .some((node) => + ['fill', 'stroke'].some((attribute) => { + const value = node.getAttribute(attribute); + return value !== null && value !== 'none' && value !== 'currentColor'; + }) + ), + }; + }) + ); +const switches = async () => (await buttons()).slice(0, 3); + +await load(payloads['4-Bar']); +await page.evaluate(() => { + // One joint tracing, so all three switches have something to act on. + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.mechanismSrv.joints[1].showCurve = true; + grid.mechanismSrv.updateMechanism(); +}); +await page.waitForTimeout(700); +const asDrawn = await switches(); +record( + 'the switches arrive with marks and paths on and labels off', + asDrawn[0].on && !asDrawn[1].on && asDrawn[2].on, + asDrawn.map((s) => `${s.name}=${s.on}`) +); +// The glyph draws what is on the grid, so the crossed-out one means hidden. +// The two icon families spell that differently: com/com_off beside +// show_path/hide_path. +const crossedOut = (glyph) => glyph.endsWith('_off') || glyph.startsWith('hide'); +record( + 'each glyph draws the state the grid is in, not the one on offer', + asDrawn.every((s) => s.on === !crossedOut(s.glyph)), + asDrawn.map((s) => `${s.glyph}:${s.on}`) +); +record( + 'and the tint alone carries it -- every glyph is the same grey', + asDrawn.every((s) => s.tinted === s.on) && new Set(asDrawn.map((s) => s.ink)).size === 1, + asDrawn.map((s) => `${s.name} tint=${s.tinted} ink=${s.ink}`) +); + +// Nothing left for any of them to do: no mass to mark, no path being traced, +// and on an empty grid no joint to label either. +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.mechanismSrv.links.forEach((link) => (link.mass = 0)); + grid.mechanismSrv.joints.forEach((joint) => (joint.showCurve = false)); + grid.mechanismSrv.updateMechanism(); +}); +await page.waitForTimeout(700); +const idle = await switches(); +// A slider block has a mass and no centre-of-mass mark, so it must not make +// the switch look useful. +await load(payloads['4-Bar']); +const blockOnly = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const srv = grid.mechanismSrv; + srv.links.forEach((link) => (link.mass = 0)); + const block = srv.links.find((link) => link.constructor.name === 'SliderBlock'); + if (block) block.mass = 5; + srv.updateMechanism(false); + const button = [...document.querySelectorAll('.viewControls .viewButton')][0]; + return { hasBlock: !!block, disabled: button.disabled }; +}); +record( + 'a mass on a slider block does not make the centre-of-mass switch look useful', + !blockOnly.hasBlock || blockOnly.disabled, + blockOnly +); + +// The state a sighted reader gets from the tint has to reach everyone else. +await load(payloads['4-Bar']); +const pressedState = await page.evaluate(() => + [...document.querySelectorAll('.viewControls .viewButton')].map((button) => ({ + name: button.getAttribute('aria-label'), + pressed: button.getAttribute('aria-pressed'), + on: button.classList.contains('on'), + })) +); +record( + 'each switch says whether it is on, and the plain actions say nothing', + pressedState.slice(0, 3).every((b) => b.pressed === String(b.on)) && + pressedState.slice(3).every((b) => b.pressed === null), + pressedState +); + +await load(payloads['4-Bar']); +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.mechanismSrv.links.forEach((link) => (link.mass = 0)); + grid.mechanismSrv.joints.forEach((joint) => (joint.showCurve = false)); + grid.mechanismSrv.updateMechanism(); +}); +await page.waitForTimeout(700); +const idleAgain = await switches(); +record( + 'a switch that would change nothing is greyed', + idleAgain[0].disabled && idleAgain[2].disabled && !idleAgain[1].disabled, + idleAgain.map((s) => `${s.name}=${s.disabled}`) +); + +// The row carries two kinds of button, and says where one kind ends. +const split = await page.evaluate(() => { + const kids = [...document.querySelector('.viewControls').children]; + const divider = kids.findIndex((node) => node.classList.contains('viewDivider')); + return { divider, count: kids.length }; +}); +record( + 'a divider splits the three switches from the three view actions', + split.divider === 3 && split.count === 7, + split +); + +// --- an empty grid: nothing for any of the three to act on ------------------ +await load(); +const onEmpty = await buttons(); +record( + 'with nothing drawn, all three switches are greyed and the view actions are not', + onEmpty.slice(0, 3).every((b) => b.disabled) && onEmpty.slice(3).every((b) => !b.disabled), + onEmpty.map((b) => `${b.name}=${b.disabled}`) +); +// The check above passes on colour alone; this is the one that catches a glyph +// painting itself, which no computed style on the button would show. +record( + 'and every glyph takes the button ink rather than naming its own colour', + onEmpty.every((b) => !b.selfPainted) && + new Set(onEmpty.slice(0, 3).map((b) => b.ink)).size === 1 && + new Set(onEmpty.slice(3).map((b) => b.ink)).size === 1, + onEmpty.map((b) => `${b.glyph} ink=${b.ink} own=${b.selfPainted}`) +); + +// --- the setup drawer keeps one gap all round, whatever the transport does -- +const drawerGaps = () => + page.evaluate(() => { + const card = document.querySelector('app-analysis-setup .setup')?.getBoundingClientRect(); + const controls = document.querySelector('.viewControls').getBoundingClientRect(); + const strip = document.querySelector('.historyCard').getBoundingClientRect(); + if (!card) return null; + return { + above: Math.round(card.top - strip.bottom), + below: Math.round(controls.top - card.bottom), + height: Math.round(card.height), + edges: [Math.round(card.left - controls.left), Math.round(card.right - controls.right)], + }; + }); + +await load(THREE_MACHINES); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click({ force: true }); +await page.waitForTimeout(900); +await page.evaluate(() => ng.getComponent(document.querySelector('app-top-bar')).openSetup()); +await page.waitForTimeout(900); +// A window too short for the list, so the drawer is at its full extent and the +// gap below it is the one being kept rather than wherever the content ended. +await page.setViewportSize({ width: 1500, height: 700 }); +await page.evaluate(() => + document.querySelectorAll('app-analysis-setup .sectionHeader').forEach((head) => head.click()) +); +await page.waitForTimeout(800); +const gapsSynced = await drawerGaps(); +record( + 'the gap under the setup drawer matches the gap over it', + gapsSynced && gapsSynced.above === gapsSynced.below, + gapsSynced +); +// The drawer takes its width from the view-controls card it stands over, so +// adding a button or the divider between them cannot knock the two out of line. +record( + 'and the drawer stands exactly over the view controls', + gapsSynced && gapsSynced.edges[0] === 0 && gapsSynced.edges[1] === 0, + gapsSynced?.edges +); + +await page.evaluate(() => { + const bar = ng.getComponent(document.querySelector('app-playback-bar')); + if (typeof bar.toggleSync === 'function') bar.toggleSync(); +}); +await page.waitForTimeout(1000); +const gapsUnsynced = await drawerGaps(); +record( + 'and a transport that grows a row per machine does not push it up', + gapsUnsynced && + gapsUnsynced.below === gapsSynced.below && + gapsUnsynced.height === gapsSynced.height, + { gapsSynced, gapsUnsynced } +); +await page.setViewportSize({ width: 1500, height: 950 }); + +// --- the playhead reads a number, not a float ------------------------------- +// The samples leave the solver at full precision, so the label beside the +// playhead printed all seventeen digits of it -- "2.6179937801901527" hanging +// off the plot beside a readout of "2.62" for the same instant. +await load(payloads['4-Bar']); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click({ force: true }); +await page.waitForTimeout(700); +await page.locator('#joint_B').first().click({ force: true }); +await page.waitForTimeout(700); +for (const card of ['Position of Joint B', 'Velocity of Joint B']) { + await page.getByText(card).first().click({ force: true }); + await page.waitForTimeout(1200); +} +// Off timestep 0, where there is no playhead to label. +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const mech = grid.mechanismSrv; + mech.animate?.(Math.floor((mech.mechanisms[0]?.joints.length ?? 100) * 0.3), false); +}); +await page.waitForTimeout(1500); + +const plots = await page.evaluate(() => + [...document.querySelectorAll('app-analysis-graph')].map((node) => { + const graph = ng.getComponent(node); + return { + prop: graph.mechProp, + labels: [...node.querySelectorAll('.apexcharts-point-annotation-label')].map((label) => + label.textContent.trim() + ), + // Every line the plot draws, and how wide a box each occupies. A + // constant series is zero high, which is the point -- but it still has + // to run the width of the plot. + lines: [...node.querySelectorAll('.apexcharts-line-series path.apexcharts-line')].map( + (path) => Math.round(path.getBoundingClientRect().width) + ), + axis: [graph.chartOptions.yaxis?.min, graph.chartOptions.yaxis?.max], + }; + }) +); +const overlong = plots.flatMap((plot) => plot.labels).filter((text) => /\d\.\d{3,}/.test(text)); +record('a playhead label stops at two decimals', plots.length > 0 && overlong.length === 0, { + overlong, + labels: plots.map((plot) => plot.labels), +}); + +// --- a constant series is still a line --------------------------------------- +// The speed of a crank pin never changes, and on its own the axis was fitted +// to what little it does change: a window 1.5e-6 wide in which every label +// read "6.2" and the line was lost in the floating-point noise. +const speed = plots.find((plot) => plot.prop === 'Linear Joint Vel'); +record( + 'a graph showing only a constant still draws it', + speed && speed.lines.length === 1 && speed.lines[0] > 50, + speed +); +record( + 'and its axis is not fitted to floating-point noise', + speed && speed.axis[1] - speed.axis[0] > 0.5, + speed?.axis +); + +// --- a drawer's close button does not scroll away --------------------------- +// It is positioned against the drawer frame, so a frame that scrolls carries +// it off its own top edge -- gone from the corner while the half of it still +// showing went on taking the click. The card inside is what scrolls. +await page.setViewportSize({ width: 1400, height: 560 }); +await page.waitForTimeout(400); +for (const [tab, name] of [ + [1, 'Settings'], + [3, 'Help'], + [4, 'Debug'], +]) { + await page.evaluate((tab) => { + const panel = ng.getComponent(document.querySelector('app-right-panel')); + panel.constructor.openTab = tab; + panel.constructor.isOpen = true; + }, tab); + await page.waitForTimeout(700); + const frame = await page.locator('#rightPanel').boundingBox(); + if (frame) { + await page.mouse.move(frame.x + frame.width / 2, frame.y + frame.height / 2); + await page.mouse.wheel(0, 600); + await page.waitForTimeout(500); + } + const corner = await page.evaluate(() => { + const button = document.querySelector('.closeDrawer'); + const panel = document.querySelector('#rightPanel'); + if (!button || !panel) return { missing: true }; + const b = button.getBoundingClientRect(); + const f = panel.getBoundingClientRect(); + return { + inside: b.top >= f.top - 1 && b.bottom <= f.bottom + 1, + takesTheClick: button.contains( + document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2) + ), + frameScrolled: panel.scrollTop, + }; + }); + record( + `the ${name} drawer keeps its close button in the corner`, + corner.inside && corner.takesTheClick && corner.frameScrolled === 0, + corner + ); +} + +// --- and it is not the last one in the drawer that keeps it visible --------- +// The button used to beat the sticky title it shares a line with on document +// order alone -- both were z-index 2 -- and document order is the one thing a +// compositor is free to decide differently. In Safari it did: the title's white +// background covered the X while the click still went through. Put the button +// at the FRONT of the drawer, which is the worst order there is, and it has to +// stay visible on its own merits. +await page.evaluate((tab) => { + const panel = ng.getComponent(document.querySelector('app-right-panel')); + panel.constructor.openTab = tab; + panel.constructor.isOpen = true; +}, 1); +await page.waitForTimeout(800); +await page.evaluate(() => { + const frame = document.querySelector('#rightPanel'); + frame.insertBefore(document.querySelector('.closeDrawer'), frame.firstChild); +}); +await page.waitForTimeout(400); +const worstOrder = await page.evaluate(() => { + const btn = document.querySelector('.closeDrawer'); + const b = btn.getBoundingClientRect(); + const top = document.elementsFromPoint(b.left + b.width / 2, b.top + b.height / 2)[0]; + return { + onTop: btn.contains(top), + coveredBy: btn.contains(top) ? null : top?.tagName.toLowerCase(), + }; +}); +record('and it does not rely on being last in the drawer to be seen', worstOrder.onTop, worstOrder); +await page.setViewportSize({ width: 1500, height: 950 }); + +// --- a scrolled panel says there is something above it ---------------------- +// The title stays put while the card scrolls under it, so a card scrolled off +// its own top read as a card that simply started there. +await load(payloads['4-Bar']); +await page.setViewportSize({ width: 1400, height: 560 }); +await page.waitForTimeout(400); +await page.locator('#joint_B').first().click({ force: true }); +await page.waitForTimeout(900); +const headShadow = () => + page.evaluate(() => { + const card = document.querySelector('app-edit-panel #normalPanel'); + const head = card?.querySelector(':scope > editable-title-block, :scope > title-block'); + if (!card || !head) return null; + const shadow = getComputedStyle(head).boxShadow; + // "rgba(0, 0, 0, 0)" is the resting state: declared, so it fades in. + return { cast: !/rgba\(0, 0, 0, 0\)/.test(shadow), scrollTop: card.scrollTop, shadow }; + }); +const atTop = await headShadow(); +record('a card resting at its top casts no shadow', atTop && !atTop.cast, atTop); +const card = await page.locator('app-edit-panel #normalPanel').boundingBox(); +if (card) { + await page.mouse.move(card.x + card.width / 2, card.y + card.height / 2); + await page.mouse.wheel(0, 350); + await page.waitForTimeout(700); +} +const scrolled = await headShadow(); +record( + 'and once it has scrolled, its title shadows what is passing under it', + scrolled && scrolled.cast && scrolled.scrollTop > 0, + scrolled +); +await page.setViewportSize({ width: 1500, height: 950 }); + +// --- one help mark, one behaviour ------------------------------------------ +// The mark beside a field used to be a pale grey glyph that did not answer the +// pointer and waited a full second before saying anything; the export drawer's +// lit up and spoke at once. A reader who has learned what it means in one panel +// has learned it in all of them. +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.activeObjService.updateSelectedObj(grid.mechanismSrv.joints.find((joint) => !joint.ground)); +}); +await page.waitForTimeout(900); +const marks = await page.evaluate(() => + [...document.querySelectorAll('.label-help')].map((mark) => { + const style = getComputedStyle(mark); + return `${style.color} ${style.cursor} ${style.transform}`; + }) +); +record( + 'every help mark on screen is drawn the same way', + marks.length > 0 && new Set(marks).size === 1, + marks.slice(0, 3) +); + +const help = page.locator('.label-help').first(); +const restColour = await help.evaluate((mark) => getComputedStyle(mark).color); +await help.hover(); +await page.waitForTimeout(650); +record( + 'and answers the pointer, then says what it is for', + (await help.evaluate((mark) => getComputedStyle(mark).color)) !== restColour && + (await page.locator('.mat-mdc-tooltip').count()) === 1, + { restColour, hovered: await help.evaluate((mark) => getComputedStyle(mark).color) } +); + +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/export-flow.mjs b/e2e/export-flow.mjs new file mode 100644 index 00000000..66505e86 --- /dev/null +++ b/e2e/export-flow.mjs @@ -0,0 +1,755 @@ +/** + * Export Data, end to end. + * + * The old command wrote one CSV of whatever the analysis panel happened to be + * showing. This drives the drawer that replaced it: parts, then columns, then + * the file — and, because an export is only worth anything if the file is, + * opens what comes down and reads it. + * + * PMKS_BASE_URL= node e2e/export-flow.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]) +); + +/** Two four-bars side by side, each with its own drive: one drawing, two clocks. */ +const TWO_FOUR_BARS = + '2P.Ay,1E8.K,0.1011.6A,A,0mv,0VU,0.0B,B,0e_,E6,0.0C,C,l1,WW,0.4D,D,qD,0Pk,0.6E,E,2Y_,0,0.' + + '0F,F,2Y_,GJ,0.0G,G,3Jt,Wc,0.4H,H,3aA,0,0..YRAB,AB,Fe,Fe,0ix,08i,c5cae9,A,B,,.' + + 'YRBC,BC,Fe,Fe,32,NJ,303e9f,B,C,,.YRCD,CD,Fe,Fe,nd,3P,0d125a,C,D,,.' + + 'AREF,EF,0,0,2Y_,8A,555555,E,F,,.ARFG,FG,0,0,2xQ,OS,555555,F,G,,.' + + 'ARGH,GH,0,0,3S0,GJ,555555,G,H,,...N_L'; + +const browser = await chromium.launch(); +const context = await browser.newContext({ + viewport: { width: 1500, height: 950 }, + acceptDownloads: true, +}); +const page = await context.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(String(error))); +page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); +}); + +const results = []; +const record = (what, ok, detail) => { + results.push([what, ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); +}; + +const drawer = () => page.locator('app-export-panel'); +const rowNamed = (name) => drawer().locator('.pickRow', { hasText: name }).first(); + +/** Press Export and hand back the file that comes down. */ +async function grab(action) { + const wait = page.waitForEvent('download', { timeout: 20000 }); + await action(); + const download = await wait; + const path = await download.path(); + return { name: download.suggestedFilename(), path }; +} + +/** + * Press Next until the file step is showing. + * + * How many questions the drawer asks depends on the drawing — a mechanism with + * force analysis set up is asked about forces and one without is not — so a + * fixed number of presses lands somewhere different in each. + */ +async function goToFile() { + for (let at = 0; at < 5; at++) { + if ((await drawer().locator('.formatBlock').count()) > 0) return; + await page.locator('.nextButton').click(); + await page.waitForTimeout(450); + } +} + +/** And back again, however many questions lie between here and the parts. */ +async function goToParts() { + for (let at = 0; at < 5; at++) { + if ((await drawer().locator('.mechHead').count()) > 0) return; + await drawer().locator('.backButton').click(); + await page.waitForTimeout(350); + } +} + +async function openDrawer() { + await page.locator('.historyButton', { hasText: 'Export Data' }).click(); + await page.waitForTimeout(600); +} + +// --- one machine, the whole flow -------------------------------------------- +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); +await page.waitForTimeout(700); + +record( + 'Export Data is offered with nothing selected on the grid', + !(await page.locator('.historyButton', { hasText: 'Export Data' }).isDisabled()) +); + +await openDrawer(); +record('it opens a drawer rather than writing a file', (await drawer().count()) === 1); +record( + 'which lists every part of the machine, under the machine', + (await drawer().locator('.mechHead').count()) === 1 && + (await drawer().locator('.pickRow').count()) === 7, + await drawer().innerText() +); +record( + 'and refuses to go on until something is chosen', + await page.locator('.nextButton').isDisabled() +); + +await drawer().locator('.linkButton', { hasText: 'Select all' }).click(); +await page.waitForTimeout(200); +const allOn = await drawer().locator('.box.ticked').count(); +await drawer().locator('.linkButton', { hasText: 'Select none' }).click(); +await page.waitForTimeout(200); +record( + 'Select all and Select none reach every row', + allOn === 7 && (await drawer().locator('.box.ticked').count()) === 0, + { allOn } +); + +await rowNamed('Joint B').click(); +await rowNamed('Link AB').click(); +await page.waitForTimeout(200); +record( + 'the count and the footer follow what is ticked', + (await drawer().locator('.count').innerText()).startsWith('2 of') && + (await drawer().locator('.footNote').innerText()).includes('2 parts'), + { + count: await drawer().locator('.count').innerText(), + foot: await drawer().locator('.footNote').innerText(), + } +); + +await page.locator('.nextButton').click(); +await page.waitForTimeout(400); +const step2 = await drawer().innerText(); +// Forces are a step of their own now, not a tab inside the columns. +record( + 'the drawer asks a question per rule mark, with no tabs inside one of them', + (await drawer().locator('.stepName').allInnerTexts()) + .map((mark) => mark.replace(/^check\s*/, '').trim()) + .join(' | ') === '1. Parts | 2. Kinematics | 3. Forces | 4. File' && + (await drawer().locator('.tabs').count()) === 0, + await drawer().locator('.stepName').allInnerTexts() +); +record( + 'the kinematics step asks only about the quantities those parts have', + step2.includes('Joint B') && + step2.includes('Link AB') && + step2.includes('Position') && + step2.includes('Angular velocity'), + step2 +); + +await drawer().getByRole('button', { name: 'X, Y', exact: true }).click(); +await page.waitForTimeout(200); +const withoutMag = await drawer().locator('.footNote').innerText(); +await drawer().getByRole('button', { name: 'X, Y, Magnitude', exact: true }).click(); +await page.waitForTimeout(200); +const withMag = await drawer().locator('.footNote').innerText(); +record('dropping the magnitude narrows the file', number(withoutMag) < number(withMag), { + withoutMag, + withMag, +}); +record( + 'and every row says which of the two it will write', + (await drawer().locator('.rowComponents').allInnerTexts()).some((text) => text === 'X, Y, Mag') && + (await drawer().locator('.pickRow', { hasText: 'Angle' }).locator('.rowComponents').count()) === + 0, + await drawer().locator('.rowComponents').allInnerTexts() +); + +await goToFile(); +record( + 'the file step names the file it is about to write', + (await drawer().locator('.summaryName').innerText()).endsWith('.csv'), + await drawer().locator('.summaryName').innerText() +); + +// --- the CSV ---------------------------------------------------------------- +const csv = await grab(() => page.locator('.nextButton').click()); +const text = readFileSync(csv.path, 'utf8'); +const lines = text.trim().split('\n'); +record('the CSV comes down under the name the drawer showed', csv.name.endsWith('.csv'), csv.name); +record( + 'with one time column, a head per series, and a row per solved position', + lines[0].startsWith('Time (s)') && + lines[0].includes('Position B X (cm)') && + lines[0].includes('Angle AB (deg)') && + lines.length > 100, + { head: lines[0], rows: lines.length } +); +record( + 'and every row as wide as the head', + lines.every((line) => splitCsv(line).length === splitCsv(lines[0]).length), + { head: splitCsv(lines[0]).length, second: splitCsv(lines[1]).length } +); + +// The one column whose right answer is known without solving anything: the +// input crank turns at the speed the drive is set to. The solver keeps angles +// in degrees and rates in radians, so a file that skipped the conversion writes +// 2.09 here under a head saying deg/s. +const speed = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return srv.settingsService.inputSpeed.value * 6; // rpm -> deg/s +}); +const rateColumn = splitCsv(lines[0]).findIndex((head) => + head.startsWith('Angular velocity AB (deg/s)') +); +record( + 'and an angular rate in the unit its head claims', + rateColumn > 0 && Math.abs(Math.abs(Number(splitCsv(lines[1])[rateColumn])) - speed) < 0.5, + { speed, column: rateColumn, value: rateColumn > 0 && splitCsv(lines[1])[rateColumn] } +); + +// --- the workbook ----------------------------------------------------------- +await drawer().locator('.formatRow', { hasText: 'Excel workbook' }).click(); +await page.waitForTimeout(200); +const xlsx = await grab(() => page.locator('.nextButton').click()); +const bytes = readFileSync(xlsx.path); +record( + 'the workbook is a real zip, named .xlsx', + xlsx.name.endsWith('.xlsx') && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes.length > 1000, + { name: xlsx.name, size: bytes.length } +); +record( + 'holding a worksheet part', + bytes.includes(Buffer.from('xl/worksheets/sheet1.xml')) && + bytes.includes(Buffer.from('spreadsheetml')) +); + +// --- graph images ----------------------------------------------------------- +await drawer().locator('.formatRow', { hasText: 'Graph images' }).click(); +await page.waitForTimeout(200); +await drawer().locator('.segmented button', { hasText: 'SVG' }).click(); +await page.waitForTimeout(200); +// What the card promised, before it was pressed. +const promised = await drawer().locator('.summaryName').innerText(); +const pictures = await grab(() => page.locator('.nextButton').click()); +record( + 'the card names the file that actually arrives, archive and all', + promised === pictures.name, + { promised, arrived: pictures.name } +); +const inside = readStoredZip(readFileSync(pictures.path)); +const drawing = inside.find((entry) => entry.name.endsWith('.svg'))?.text ?? ''; +record( + 'a graph comes down as a drawing with a line in it, and the mark on it', + pictures.name.endsWith('.zip') && + inside.length > 2 && + drawing.includes(' 2, + { length: printed.length, pages: (printed.match(/class="page"/g) ?? []).length } +); +record( + 'named as the drawer said, so the print dialog offers that name for the PDF', + printed.includes('M1_') || /<title>[^<]+<\/title>/.test(printed), + printed.match(/<title>[^<]*<\/title>/)?.[0] +); +record( + 'and prints the way back to the mechanism on it', + printed.includes('shareUrl') && printed.includes('?'), + printed.slice(0, 200) +); + +// The report is laid out page by page rather than left to reflow, so how full +// those pages are is something only measuring can answer. +const layout = await (async () => { + const viewer = await context.newPage(); + await viewer.setContent(printed); + await viewer.waitForTimeout(400); + const measured = await viewer.evaluate(() => { + const pages = [...document.querySelectorAll('section.page')]; + const use = pages.map((page) => { + const body = page.querySelector('.pageBody'); + const kids = [...body.children]; + const used = kids.length + ? Math.max(...kids.map((kid) => kid.getBoundingClientRect().bottom)) - + body.getBoundingClientRect().top + : 0; + return Math.round((used / body.getBoundingClientRect().height) * 100); + }); + return { + pages: pages.length, + leastFull: Math.min(...use), + // A page taller than the box it is drawn in has lost rows off the bottom. + clipped: pages.filter((page) => { + const body = page.querySelector('.pageBody'); + return body.scrollHeight > body.clientHeight + 1; + }).length, + // A number wider than its column overlaps the one beside it. + overflowing: [...document.querySelectorAll('.dataTable td')].filter( + (cell) => cell.scrollWidth > cell.clientWidth + 0.5 + ).length, + }; + }); + await viewer.close(); + return measured; +})(); +// Two parts and every column they have, over a 361-position cycle: forty-two +// pages when a page held forty rows and seven columns of it. +record( + 'and fills the pages it asks for, without losing a row or a digit off one', + layout.pages <= 16 && layout.leastFull >= 60 && layout.clipped === 0 && layout.overflowing === 0, + layout +); + +// --- watching the mechanism run does not rebuild the export ----------------- +// The table cache used to key on the pose, which playback moves every frame: +// leaving Report chosen and pressing play re-sampled every column of every +// selected part sixty times a second. +await page.evaluate(() => { + const panel = ng.getComponent(document.querySelector('app-export-panel')); + const samples = panel.writer.tables.samples; + const original = samples.sampleAt.bind(samples); + window.__sampled = 0; + samples.sampleAt = (...args) => { + window.__sampled++; + return original(...args); + }; +}); +// Ask a question the cache has not been asked before, so there is a real +// sampling pass to compare playback against. +await page.evaluate(() => { + ng.getComponent(document.querySelector('app-export-panel')).flow.withMagnitude = false; +}); +await drawer().locator('.formatRow', { hasText: 'Report' }).click(); +await page.waitForTimeout(900); +const sampledOnce = await page.evaluate(() => window.__sampled); +await page.locator('.playButton').click(); +await page.waitForTimeout(1500); +await page.locator('.playButton').click(); +await page.waitForTimeout(300); +record( + 'watching the mechanism run does not re-sample a single column', + sampledOnce > 0 && (await page.evaluate(() => window.__sampled)) === sampledOnce, + { sampledOnce, after: await page.evaluate(() => window.__sampled) } +); +await page.locator('.stopButton').click(); +await page.waitForTimeout(800); + +// --- the list is a way into the drawing ------------------------------------- +const lit = () => + page.evaluate(() => document.querySelectorAll('.joint-pointed, .link-pointed').length); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); +await page.waitForTimeout(600); +await openDrawer(); +const dark = await lit(); +await drawer().locator('.pickRow').nth(1).hover(); +await page.waitForTimeout(300); +const pointed = await lit(); +await page.mouse.move(700, 500); +await page.waitForTimeout(300); +record( + 'pointing at a row lights that one part on the grid, and lets it go again', + dark === 0 && pointed === 1 && (await lit()) === 0, + { dark, pointed } +); + +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.activeObjService.updateSelectedObj(grid.mechanismSrv.joints.find((joint) => !joint.ground)); +}); +await page.waitForTimeout(600); +record( + 'and the part picked on the canvas says so in the list, and stops when it is let go', + (await drawer().locator('.pickRow.onGrid').innerText()).includes('currently selected'), + await drawer().locator('.pickRow.onGrid').innerText() +); +record( + 'and the part picked on the canvas is marked in the list', + (await drawer().locator('.pickRow.onGrid').count()) === 1 && + (await drawer() + .locator('.pickRow.onGrid') + .first() + .evaluate((row) => getComputedStyle(row).backgroundColor)) === 'rgb(255, 248, 225)' +); +await drawer().locator('.pickRow').first().hover(); +await page.waitForTimeout(300); +record('a selection on the canvas is not painted over by a hover in the list', (await lit()) === 0); + +// Letting go of a part leaves the old one remembered, so a list reading the +// selection's fields alone went on marking a row nobody had chosen. +await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).activeObjService.updateSelectedObj(null) +); +await page.waitForTimeout(500); +record( + 'and letting go of it clears the mark', + (await drawer().locator('.pickRow.onGrid').count()) === 0 +); + +// --- a slot is not a part, because a reader cannot point at one ------------- +await page.goto(`${BASE}/?${payloads['Scotch_Yoke']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); +await page.waitForTimeout(700); +await openDrawer(); +const yokeParts = await drawer().locator('.pickRow .rowName').allInnerTexts(); +const invisible = await page.evaluate(() => + [...document.querySelectorAll('[id^="joint_"]')] + .filter((marker) => marker.getBoundingClientRect().width === 0) + .map((marker) => 'Joint ' + marker.id.replace('joint_', '')) +); +record( + 'a joint the canvas draws nothing for is not offered as a part', + invisible.length > 0 && invisible.every((name) => !yokeParts.includes(name)), + { invisible, yokeParts } +); +record( + 'and every joint it does draw is', + ( + await page.evaluate(() => + [...document.querySelectorAll('[id^="joint_"]')] + .filter((marker) => marker.getBoundingClientRect().width > 0) + .map((marker) => 'Joint ' + marker.id.replace('joint_', '')) + ) + ).every((name) => yokeParts.includes(name)), + yokeParts +); + +// A slider is one thing to a reader: a pin, its block and its slot are three +// bodies to the solver, and the block's force at the pin is the bar's force +// negated. So the pin carries both numbers, and nothing is named after a body +// or a joint nobody has seen. +await drawer().locator('.linkButton', { hasText: 'Select all' }).click(); +await page.waitForTimeout(300); +await page.locator('.nextButton').click(); +await page.waitForTimeout(500); +await page.locator('.nextButton').click(); +await page.waitForTimeout(500); +const yokeForces = await drawer().locator('.pickRow .rowName').allInnerTexts(); +record( + 'a slider is one row, with the force in its bar and the force in its slot', + yokeForces.length > 0 && + !yokeForces.some((name) => /Block|Joint E|Joint F/.test(name)) && + yokeForces.some((name) => name.includes('the ground') || name.includes('the slider')), + yokeForces +); +record( + 'and no reaction is offered twice', + new Set(yokeForces).size === yokeForces.length || + yokeForces.length === (await drawer().locator('.pickRow').count()), + yokeForces +); +await goToParts(); + +// --- a sealed cylinder is one part, not the pieces it is assembled from ------ +await page.goto(`${BASE}/?${payloads['Cylinder_Boom']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); +await page.waitForTimeout(700); +await openDrawer(); +const cylinderParts = await drawer().locator('.pickRow .rowName').allInnerTexts(); +record( + 'a sealed cylinder stands in the list as one part', + cylinderParts.some((name) => name.startsWith('Cylinder ')) && + !cylinderParts.some((name) => /^(Rod|Barrel|Piston|Block) /.test(name)), + cylinderParts +); + +// A ram is picked on the canvas by whichever of its pieces the pointer was +// over, and stands in this list as one part, so the row has to answer for all +// three of them. +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.activeObjService.updateSelectedObj(grid.mechanismSrv.sealedStructures()[0].barrel); +}); +await page.waitForTimeout(500); +record( + 'and picking any piece of it on the canvas marks that one row', + (await drawer().locator('.pickRow.onGrid').count()) === 1 && + (await drawer().locator('.pickRow.onGrid .rowName').innerText()).startsWith('Cylinder') +); +await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).activeObjService.updateSelectedObj(null) +); +await page.waitForTimeout(400); + +// A ram is drawn as one silhouette over a barrel, a block and a rod, so +// pointing at its row has to light that silhouette rather than one piece. +await drawer().locator('.pickRow', { hasText: 'Cylinder' }).first().hover(); +await page.waitForTimeout(400); +record( + 'and pointing at it lights the whole ram on the grid', + (await page.evaluate(() => document.querySelectorAll('.link-pointed').length)) > 0 +); +await page.mouse.move(700, 500); +await page.waitForTimeout(300); + +// --- a joint two machines share is two rows, and two ticks ------------------ +// A chain bolted to another machine's ground shares that pin. Keyed by its +// letter alone, ticking it under one machine ticked it under the other, and +// the export wrote a file for a machine nobody had asked about. +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const JointClass = Object.getPrototypeOf(srv.joints[0]).constructor; + const LinkClass = Object.getPrototypeOf(srv.links[0]).constructor; + const scale = Math.abs(srv.joints[0].x) / 3 || 200; + const shared = srv.joints.find((joint) => joint.id === 'D'); + const made = [ + ['E', 6, 1], + ['F', 8, 2], + ['G', 9, 0], + ].map(([id, x, y]) => new JointClass(id, x * scale, y * scale)); + made[2].ground = true; + made[2].input = true; + const chain = [shared, ...made]; + const links = [0, 1, 2].map((at) => { + const link = new LinkClass(chain[at].id + chain[at + 1].id, [chain[at], chain[at + 1]]); + chain[at].links.push(link); + chain[at + 1].links.push(link); + chain[at].connectedJoints.push(chain[at + 1]); + chain[at + 1].connectedJoints.push(chain[at]); + return link; + }); + srv.joints.push(...made); + srv.links.push(...links); + srv.updateMechanism(true); +}); +await page.waitForTimeout(1400); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); +await page.waitForTimeout(700); +await openDrawer(); +const shownNames = await drawer().locator('.pickRow .rowName').allInnerTexts(); +record( + 'a joint two machines share is listed under each of them', + shownNames.filter((name) => name === 'Joint D').length === 2, + shownNames +); +await drawer().locator('.pickRow', { hasText: 'Joint D' }).first().click(); +await page.waitForTimeout(400); +record( + 'and ticking it under one leaves the other alone', + (await drawer().locator('.pickRow.on').count()) === 1 && + (await drawer().locator('.footNote').innerText()).includes('M1'), + await drawer().locator('.footNote').innerText() +); +await goToFile(); +record( + 'so the export is one file, for the machine that was asked about', + (await drawer().locator('.summaryCard').innerText()).includes('One file'), + await drawer().locator('.summaryCard').innerText() +); + +// Both machines' copies of the shared joint, as pictures. `Position of Joint D` +// is the title under either of them, and two entries of one name in one archive +// is one picture quietly writing over the other. +await goToParts(); +await drawer().locator('.pickRow', { hasText: 'Joint D' }).nth(1).click(); +await page.waitForTimeout(300); +await goToFile(); +await drawer().locator('.formatRow', { hasText: 'Graph images' }).click(); +await page.waitForTimeout(200); +await drawer().locator('.segmented button', { hasText: 'SVG' }).click(); +await page.waitForTimeout(300); +const shared = await grab(() => page.locator('.nextButton').click()); +const inArchive = readStoredZip(readFileSync(shared.path)).map((entry) => entry.name); +record( + 'and a joint exported from both machines gives a picture each, under names of their own', + inArchive.length > 2 && new Set(inArchive).size === inArchive.length, + inArchive +); + +// Back to the parts, and back to CSV, for the next question — this drawing has +// two machines and enough parts between them to need an archive. +await drawer().locator('.formatRow', { hasText: 'CSV' }).click(); +await page.waitForTimeout(300); +await goToParts(); + +// --- more than two files arrive as one download ----------------------------- +await drawer().locator('.linkButton', { hasText: 'Select all' }).click(); +await page.waitForTimeout(300); +await goToFile(); +await drawer().locator('.segmented button', { hasText: 'Per part' }).click(); +await page.waitForTimeout(400); +const archive = await grab(() => page.locator('.nextButton').click()); +const zipped = readFileSync(archive.path); +record( + 'more than two files come down as one archive, not one prompt each', + archive.name.endsWith('.zip') && zipped[0] === 0x50 && zipped.includes(Buffer.from('.csv')), + archive.name +); + +// --- a long list scrolls, and its decisions do not -------------------------- +await page.goto(`${BASE}/?${TWO_FOUR_BARS}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); +await page.waitForTimeout(700); +await openDrawer(); + +record( + 'two machines in the drawing get a section each', + (await drawer().locator('.mechHead').count()) === 2, + await drawer().innerText() +); + +// Short enough that fourteen parts do not fit, which is the case the three +// bands exist for. +await page.setViewportSize({ width: 1500, height: 620 }); +await page.waitForTimeout(400); + +const geometry = await drawer().evaluate((panel) => { + const body = panel.querySelector('.exportBody'); + const head = panel.querySelector('.exportHead'); + const foot = panel.querySelector('.exportFoot'); + body.scrollTop = 120; + return { + scrollable: body.scrollHeight > body.clientHeight + 4, + headBottom: head.getBoundingClientRect().bottom, + firstStickyTop: panel.querySelector('.mechHead').getBoundingClientRect().top, + footTop: foot.getBoundingClientRect().top, + footBottom: foot.getBoundingClientRect().bottom, + bodyBottom: body.getBoundingClientRect().bottom, + window: window.innerHeight, + }; +}); +record( + 'the list scrolls under a head that stays put, above a footer that does too', + geometry.scrollable && + Math.abs(geometry.firstStickyTop - geometry.headBottom) < 2 && + Math.abs(geometry.footTop - geometry.bodyBottom) < 2, + geometry +); +await page.waitForTimeout(300); +record( + 'and the footer it holds stays on screen, however long the list is', + geometry.footBottom > 0 && geometry.footBottom <= geometry.window, + geometry +); +// One mark, one colour: this used to be a grey pip inside the mass table's +// lavender ring, because both were called `.dot`. +const separator = await drawer() + .locator('.pickSep') + .first() + .evaluate((mark) => ({ + border: getComputedStyle(mark).borderTopWidth, + fill: getComputedStyle(mark).backgroundColor, + })); +record( + 'and the mark between Select all and Select none is one solid dot', + separator.border === '0px' && separator.fill.startsWith('rgba(0, 0, 0'), + separator +); +record( + 'and the head says so, with the shadow the panels already use', + await drawer().evaluate((panel) => { + const head = panel.querySelector('.exportHead'); + return getComputedStyle(head).boxShadow.includes('rgba(0, 0, 0, 0.35)'); + }) +); + +// One file each: two machines on two clocks cannot share a time column. +await drawer().locator('.linkButton', { hasText: 'Select all' }).click(); +await page.waitForTimeout(200); +await goToFile(); +record( + 'two machines are written as two files, because they run on two clocks', + (await drawer().locator('.summaryCard').innerText()).includes('2 files'), + await drawer().locator('.summaryCard').innerText() +); + +// Two files arrive as two downloads under two names. The card used to show the +// bare stem, promising a `results.csv` that nothing was ever going to write. +const twoUp = await drawer().locator('.summaryName').innerText(); +const firstOfTwo = await grab(() => page.locator('.nextButton').click()); +record('and the name on the card is one a file actually lands under', firstOfTwo.name === twoUp, { + promised: twoUp, + arrived: firstOfTwo.name, +}); + +record('nothing threw', errors.length === 0, errors.slice(0, 3)); + +await browser.close(); +process.exit(results.every(([, ok]) => ok) ? 0 : 1); + +/** The first number in a phrase like `26 columns · 361 rows`. */ +function number(text) { + return Number((text.match(/\d+/) ?? [0])[0]); +} + +/** + * Read back an archive of stored entries. + * + * Enough of the zip format to open what this app writes, which never + * compresses — so an entry's bytes are its bytes, at the offset its local + * header names. + */ +function readStoredZip(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let end = bytes.length - 22; + while (end >= 0 && view.getUint32(end, true) !== 0x06054b50) end--; + const count = view.getUint16(end + 10, true); + let at = view.getUint32(end + 16, true); + const entries = []; + for (let n = 0; n < count; n++) { + const nameLength = view.getUint16(at + 28, true); + const size = view.getUint32(at + 24, true); + const offset = view.getUint32(at + 42, true); + const name = bytes.subarray(at + 46, at + 46 + nameLength).toString('utf8'); + const start = + offset + 30 + view.getUint16(offset + 26, true) + view.getUint16(offset + 28, true); + entries.push({ name, text: bytes.subarray(start, start + size).toString('utf8') }); + at += 46 + nameLength + view.getUint16(at + 30, true) + view.getUint16(at + 32, true); + } + return entries; +} + +/** Split a CSV line, respecting the quotes a head may carry. */ +function splitCsv(line) { + return line + .match(/("([^"]|"")*"|[^,]*)(,|$)/g) + .slice(0, -1) + .map((field) => field.replace(/,$/, '')); +} diff --git a/e2e/force-analysis-panels.mjs b/e2e/force-analysis-panels.mjs index 26f545df..4c58143a 100644 --- a/e2e/force-analysis-panels.mjs +++ b/e2e/force-analysis-panels.mjs @@ -1,6 +1,10 @@ // Verifies the Force Analysis accordion on both the joint and the link // analysis panels: one row per reacting link / per external joint, and a single // mechanism-wide "Force Analysis Type" toggle shared by both. +// +// These rows now live in the Force mode rather than beside the kinematic ones, +// and that mode refuses to open without a load — so the four-bar is given one +// first, through the same context menu a user would reach for. const { chromium } = await import( (process.env.PMKS_PLAYWRIGHT_DIR ?? '/tmp/pmks-playwright') + '/node_modules/playwright/index.mjs' ); @@ -10,7 +14,7 @@ import path from 'node:path'; const screenshotDir = path.resolve('artifacts/screenshots'); await fs.mkdir(screenshotDir, { recursive: true }); -const baseUrl = process.env.PMKS_URL || 'http://127.0.0.1:4200/'; +const baseUrl = process.env.PMKS_BASE_URL || process.env.PMKS_URL || 'http://127.0.0.1:4200/'; const runPrefix = process.env.RUN_PREFIX || 'force-panels'; const chromePath = process.env.PMKS_CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; @@ -49,35 +53,31 @@ page.on('console', (msg) => { const shot = (name) => page.screenshot({ path: path.join(screenshotDir, `${runPrefix}-${name}`), fullPage: true }); -async function dismissIntro() { - const intro = page.locator('.introjs-tooltip, .introjs-overlay').first(); - if (await intro.isVisible().catch(() => false)) { - await page - .locator('.introjs-skipbutton') - .first() - .click({ force: true }) - .catch(() => page.keyboard.press('Escape')); - await page.waitForTimeout(350); - } -} - /** Titles of the expansion rows under the Force Analysis subsection. */ +/** + * The reaction rows the panel is showing. + * + * They no longer sit inside a "Force Analysis" collapsible: the panel puts the + * mode radio and the rows at its own level now, so the rows are found by what + * they are rather than by what used to contain them. + */ async function forceRowTitles() { return page.evaluate(() => { - const sections = [...document.querySelectorAll('collapsible-subseciton')]; - const forceSection = sections.find((el) => /Force Analysis/.test(el.innerText)); - if (!forceSection) return null; - return [...forceSection.querySelectorAll('mat-expansion-panel-header')].map((el) => - el.innerText.replace(/help_outline/g, '').trim() + const panel = document.querySelector('app-analysis-panel'); + if (!panel || !/Force Analysis Type/.test(panel.innerText)) return null; + return ( + [...panel.querySelectorAll('app-analysis-graph-section .graphTitle')] + .map((el) => el.innerText.replace(/help_outline/g, '').trim()) + // "Force on Link AB" on a joint, "Force at Joint B" on a link. + .filter((text) => /^Force (on Link|at Joint)/.test(text)) ); }); } async function toggleLabels() { return page.evaluate(() => { - const sections = [...document.querySelectorAll('collapsible-subseciton')]; - const forceSection = sections.find((el) => /Force Analysis/.test(el.innerText)); - const group = forceSection?.querySelector('mat-button-toggle-group'); + const row = document.querySelector('.forceModeRow'); + const group = row?.querySelector('mat-button-toggle-group'); if (!group) return null; return [...group.querySelectorAll('mat-button-toggle')].map((el) => ({ text: el.innerText.trim(), @@ -86,23 +86,83 @@ async function toggleLabels() { }); } -/** Select a joint or link by clicking its SVG element, then open the Analyze tab. */ +/** Model-space point to screen, through the layer the mechanism is drawn in. */ +const toScreen = (x, y) => + page.evaluate( + ([modelX, modelY]) => { + const m = document.querySelector('#linkHolder').getScreenCTM(); + return { x: modelX * m.a + modelY * m.c + m.e, y: modelX * m.b + modelY * m.d + m.f }; + }, + [x, y] + ); + +const jointCoord = (id) => + page.evaluate((jointId) => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const joint = srv.joints.find((candidate) => candidate.id === jointId); + return joint ? { x: joint.x, y: joint.y } : undefined; + }, id); + +const forceCount = () => + page.evaluate( + () => ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.forces.length + ); + +/** + * Weigh every link. + * + * Links arrive massless, and "Mass on every link" is one of the things force + * analysis asks for -- so a drawing that has never been given masses cannot + * enter the mode at all, which is the requirement doing its job rather than + * something to work around in the product. + */ +async function weighTheLinks() { + await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + srv.links.forEach((link) => { + link.mass = 1; + link.massMoI = 1; + }); + srv.updateMechanism(true); + }); + await page.waitForTimeout(600); +} + +/** Hang a load on link BC, which is what makes the Force mode enterable at all. */ +async function attachForceToBC() { + const b = await jointCoord('B'); + const c = await jointCoord('C'); + const on = await toScreen(b.x + (c.x - b.x) * 0.4, b.y + (c.y - b.y) * 0.4); + await page.mouse.move(on.x, on.y); + await page.mouse.click(on.x, on.y, { button: 'right' }); + await page.waitForTimeout(500); + await page.evaluate(() => { + const item = [...document.querySelectorAll('#contextMenu .cm-row')].find( + (node) => node.querySelector('.cm-row__label')?.textContent?.trim() === 'Force' + ); + item?.click(); + }); + await page.waitForTimeout(300); + await page.mouse.move(on.x + 110, on.y - 80, { steps: 8 }); + await page.waitForTimeout(200); + await page.mouse.click(on.x + 110, on.y - 80); + await page.waitForTimeout(800); +} + +/** Select a joint or link by clicking its SVG element, then open the Force mode. */ async function selectAndAnalyze(selector, index) { + // Selection is an Edit-mode act; the analysis modes lock the geometry. + await page.locator('.tabButton', { hasText: 'Edit' }).click(); + await page.waitForTimeout(400); await page.locator(selector).nth(index).click({ force: true }); await page.waitForTimeout(400); - // The tab button toggles, so only click it when the panel is not already open. - const open = await page - .locator('app-analysis-panel #analysisWrapper') - .first() - .isVisible() - .catch(() => false); - if (!open) await page.locator('button.leftButton').nth(2).click(); + await page.locator('.tabButton', { hasText: 'Force' }).click(); await page.waitForTimeout(900); } -/** Expansion headers live in a scrolling panel; bring one into view before clicking. */ +/** Graph headers live in a scrolling panel; bring one into view before clicking. */ async function expandRow(text) { - const row = page.locator('mat-expansion-panel-header', { hasText: text }).first(); + const row = page.locator('app-analysis-graph-section .graphHeader', { hasText: text }).first(); await row.scrollIntoViewIfNeeded(); await row.click(); await page.waitForTimeout(1500); @@ -111,7 +171,12 @@ async function expandRow(text) { try { await page.goto(`${baseUrl}?${fourBar}`, { waitUntil: 'domcontentloaded', timeout: 60000 }); await waitForReady(page); - await dismissIntro(); + + await weighTheLinks(); + await attachForceToBC(); + check('the four-bar took a load, so Force analysis can be entered', (await forceCount()) === 1, { + forces: await forceCount(), + }); // --- Joint panel: joint B connects links AB and BC. --- await selectAndAnalyze('#jointHolder svg', 1); @@ -233,4 +298,6 @@ try { } const failed = checks.filter((c) => !c.ok); +for (const c of checks) console.log(`${c.ok ? 'PASS' : 'FAIL'} ${c.name}`); console.log(JSON.stringify({ passed: checks.length - failed.length, failed, issues }, null, 2)); +process.exit(failed.length ? 1 : 0); diff --git a/e2e/force-edit.mjs b/e2e/force-edit.mjs index 2d29fad7..658813bc 100644 --- a/e2e/force-edit.mjs +++ b/e2e/force-edit.mjs @@ -170,10 +170,10 @@ await page.mouse.move(onBoom.x, onBoom.y); await page.mouse.click(onBoom.x, onBoom.y, { button: 'right' }); await page.waitForTimeout(600); await page.evaluate(() => { - const item = [...document.querySelectorAll('#contextMenu #menu-item')].find((node) => - /Attach Force/.test(node.textContent) + const item = [...document.querySelectorAll('#contextMenu .cm-row')].find( + (node) => node.querySelector('.cm-row__label')?.textContent?.trim() === 'Force' ); - item?.querySelector('button')?.click(); + item?.click(); }); await page.waitForTimeout(300); await page.mouse.move(onBoom.x + 120, onBoom.y - 90, { steps: 8 }); @@ -212,12 +212,12 @@ await page.mouse.move(onWeld.x, onWeld.y); await page.mouse.click(onWeld.x, onWeld.y, { button: 'right' }); await page.waitForTimeout(600); const weldMenu = await page.evaluate(() => - [...document.querySelectorAll('#contextMenu #menu-item')].map((item) => ({ - label: item.textContent.trim().replace(/\s+/g, ' '), - disabled: item.classList.contains('disabledItem'), + [...document.querySelectorAll('#contextMenu .cm-row')].map((item) => ({ + label: item.querySelector('.cm-row__label')?.textContent?.trim() ?? '', + disabled: item.classList.contains('cm-row--off'), })) ); -const cylinderItem = weldMenu.find((item) => item.label === 'Attach Cylinder'); +const cylinderItem = weldMenu.find((item) => item.label === 'Cylinder'); record( 'a welded joint will not take a cylinder', !cylinderItem || cylinderItem.disabled, diff --git a/e2e/force-labels-and-legend.mjs b/e2e/force-labels-and-legend.mjs new file mode 100644 index 00000000..136f6b3c --- /dev/null +++ b/e2e/force-labels-and-legend.mjs @@ -0,0 +1,184 @@ +/** + * What a force graph is called, and where its legend sits. + * + * Two of these are the same complaint. A link's id is the letters of its + * joints, which is a fine key and a poor name: a cylinder's rod is named after + * the pin buried inside it, and a slider block after the sliding joint + * underneath it. Neither joint has a marker, a hitbox, or a row in any panel, + * so a graph titled after one offered a part the reader had never been shown. + * + * The third: a cylinder is one body to the reader and three links to the + * solver, and its two mounts sit on different ones -- so asking only the link + * the canvas hands over listed one mount and silently dropped the other. + * + * The fourth: the legend is also the row of switches for the lines, and a + * switch that moves when the number beside it gains a minus sign is one the + * reader has to re-aim at every frame. + * + * PMKS_BASE_URL=<origin> node e2e/force-labels-and-legend.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 results = []; +const record = (what, ok, detail) => { + results.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: 980 } }); +const errors = []; +page.on('pageerror', (error) => errors.push(String(error))); + +/** Load a template and stand in Force Analysis, where these graphs live. */ +const openForce = async (name) => { + await page.goto(`${BASE}/?${payloads[name]}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(page); + await page.waitForTimeout(700); + await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).tabService.setTab(3) + ); + await page.waitForTimeout(900); +}; + +const select = async (id) => { + await page.evaluate((wanted) => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + const part = + c.mechanismSrv.joints.find((joint) => joint.id === wanted) ?? + c.mechanismSrv.links.find((link) => link.id === wanted); + c.activeObjService.updateSelectedObj(part); + }, id); + await page.waitForTimeout(800); +}; + +const titles = () => + page.evaluate(() => + [...document.querySelectorAll('.graphTitle')].map((el) => el.textContent.trim()) + ); + +// --- A block is named by the joint a reader can point at --------------------- +await openForce('Slider_Crank'); +await select('C'); +const atBlock = await titles(); +record( + 'the block at a slider is named by its own joint, not by the hidden one', + atBlock.includes('Force on Block at C') && !atBlock.some((one) => /Link CD/.test(one)), + atBlock +); + +// --- A cylinder's parts are named by the part and the cylinder --------------- +await openForce('Cylinder_Boom'); +await select('C'); +const atRod = await titles(); +record( + "a cylinder's rod is named as the rod of its cylinder", + atRod.includes('Force on Rod GC') && !atRod.some((one) => /Link PC/.test(one)), + atRod +); + +// --- Both of a cylinder's mounts get a graph -------------------------------- +// The mounts sit on different member links, and the reader selected one body. +for (const member of ['GN', 'PC']) { + await select(member); + const rows = await titles(); + record( + `selecting ${member} graphs the force at both of the cylinder's mounts`, + rows.includes('Force at Joint C') && rows.includes('Force at Joint G'), + rows + ); +} + +// --- A ram's own drive is graphed where the ram is --------------------------- +// The one input whose joint the reader cannot select: it is buried inside the +// part, with no marker and no hitbox, so its effort belongs on the part. +for (const member of ['GN', 'PC', 'PS']) { + await select(member); + const rows = await titles(); + record( + `selecting ${member} offers the effort its drive has to supply`, + rows.includes('Input Force for Cylinder GC'), + rows + ); +} +await select('OC'); +const unrelated = await titles(); +record( + 'and a link that is not the driven part is not offered it', + !unrelated.some((one) => /^Input /.test(one)), + unrelated +); + +// The graph is the drive's own, not a new number: the joint panel has carried +// it all along on a joint nobody can click, and the two must agree. +const readingOf = async (id, title) => { + await select(id); + return page.evaluate((wanted) => { + const section = [...document.querySelectorAll('.graphSection')].find( + (one) => one.querySelector('.graphTitle')?.textContent.trim() === wanted + ); + return section + ? [...section.querySelectorAll('.previewSeries')].map((el) => el.textContent.trim()).join('|') + : null; + }, title); +}; +const onThePart = await readingOf('GN', 'Input Force for Cylinder GC'); +const onTheJoint = await readingOf('S', 'Input Force at Joint S'); +record('and reads exactly what the buried joint reads', onThePart === onTheJoint && !!onThePart, { + onThePart, + onTheJoint, +}); + +// --- The legend holds its columns whatever the numbers read ------------------ +// Watched on a kinematic graph, not a force one: a static reaction can hold +// one formatted value all the way round the cycle, which starves the "numbers +// really do change" check of its premise. The position of the crank pin +// cannot hold still -- moving is what it is for. +await openForce('Slider_Crank'); +await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).tabService.setTab(2) +); +await page.waitForTimeout(600); +await select('B'); +const legendAt = () => + page.evaluate(() => { + const first = document.querySelector('.graphPreview'); + if (!first) return null; + return [...first.querySelectorAll('.previewSeries')].map((el) => ({ + text: el.textContent.trim().replace(/\s+/g, ' '), + left: Math.round(el.getBoundingClientRect().left * 10) / 10, + })); + }); + +const frames = []; +for (const step of [0, 12, 25, 37, 48]) { + await page.evaluate((at) => { + ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.animate(at, false); + }, step); + await page.waitForTimeout(400); + frames.push(await legendAt()); +} +record('the legend has an entry per series', (frames[0] ?? []).length >= 2, frames[0]); +const readings = new Set(frames.map((frame) => (frame ?? []).map((one) => one.text).join('|'))); +record('the numbers really do change across the cycle', readings.size > 1, [...readings]); +const columns = new Set(frames.map((frame) => (frame ?? []).map((one) => one.left).join(','))); +record('and every entry stays in the same column while they do', columns.size === 1, [...columns]); + +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/force-status-survey.mjs b/e2e/force-status-survey.mjs new file mode 100644 index 00000000..b82e2fa0 --- /dev/null +++ b/e2e/force-status-survey.mjs @@ -0,0 +1,54 @@ +/** + * What force analysis actually says in the running app, per template. + * Investigation script — reads statuses through ng.getComponent. + * + * PMKS_BASE_URL=<origin> node e2e/force-status-survey.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://localhost: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))); + +console.log('templates found:', Object.keys(payloads).join(', ')); + +for (const [name, payload] of Object.entries(payloads)) { + await page.goto(`${BASE}/?${payload}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(page).catch(() => undefined); + await page.waitForTimeout(600); + const report = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return srv.mechanisms.map((mech) => { + const out = { valid: mech.isMechanismValid() }; + if (!out.valid) return out; + for (const mode of ['static', 'dynamic']) { + const series = mech.getForceAnalysis(mode); + const first = series.frames[0]; + out[mode] = { + ok: `${series.successfulFrames}/${series.frames.length}`, + status: first?.status, + message: first?.message, + diagnostic: series.diagnostic, + }; + } + return out; + }); + }); + console.log(`\n### ${name}`); + for (const mech of report) console.log(JSON.stringify(mech, null, 1)); +} + +if (errors.length) console.log('\nPAGE ERRORS:', errors.slice(0, 5)); +await browser.close(); diff --git a/e2e/full-tour.mjs b/e2e/full-tour.mjs index fbd10c73..ec035fbf 100644 --- a/e2e/full-tour.mjs +++ b/e2e/full-tour.mjs @@ -11,7 +11,7 @@ await fs.mkdir(screenshotDir, { recursive: true }); const chromePath = process.env.PMKS_CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const userDataDir = `/tmp/pmks-playwright-profile-${Date.now()}`; -const baseUrl = process.env.PMKS_URL || 'http://127.0.0.1:4200/'; +const baseUrl = process.env.PMKS_BASE_URL || process.env.PMKS_URL || 'http://127.0.0.1:4200/'; const verificationQuery = '0P.SS.K,0.101.MA,A,0wS,0bg,0.GB,B,0gW,EE,0.GC,C,Oi,6k,0.GD,D,03m,_g,0.GE,E,1FO,1I_,0.GF,F,1-C,qM,0.KG,G,1oO,0ss,0..YRAB,AB,Fe,Fe,0oU,0Bk,c5cae9,A,B,,.YRBCD,BCD,Fe,Fe,07C,Rt,303e9f,B,C,D,,.YRDE,DE,Fe,Fe,bq,18q,0d125a,D,E,,.YREF,EF,Fe,Fe,1dI,13g,B2DFDB,E,F,,.YRFCG,FCG,Fe,Fe,1Om,1Q,26A69A,F,C,G,,...JGp'; const runPrefix = process.env.RUN_PREFIX || 'tour'; @@ -113,22 +113,6 @@ async function clickFirst(page, candidates, label) { return false; } -async function dismissIntro(page) { - const intro = page.locator('.introjs-tooltip, .introjs-overlay').first(); - if (await intro.isVisible().catch(() => false)) { - const closeButton = page - .locator('.introjs-skipbutton, .introjs-tooltipbuttons [role=button]') - .first(); - if (await closeButton.isVisible().catch(() => false)) { - await closeButton.click({ force: true }); - } else { - await page.keyboard.press('Escape'); - } - await page.waitForTimeout(500); - events.push({ action: 'dismiss-intro' }); - } -} - async function clickByText(page, re, label) { const loc = page.locator('button,[role=button],a', { hasText: re }).first(); if (await loc.isVisible().catch(() => false)) { @@ -139,32 +123,41 @@ async function clickByText(page, re, label) { return false; } -async function clickToolbar(page, labelText) { - return await clickFirst( - page, - [ - `button:has-text("${labelText}")`, - `[role=button]:has-text("${labelText}")`, - `text="${labelText}"`, - ], - labelText - ); +/** + * The file actions — Templates, Save, Share, Settings, Help — left the toolbar + * for the hamburger menu, so each of them is now two presses. + */ +async function clickProjectMenu(page, labelText) { + await page.locator('.topStrip .iconButton').first().click(); + const item = page.locator('.projectMenu .menuItem', { hasText: labelText }).first(); + if (!(await item.isVisible().catch(() => false))) { + recordIssue(`Project menu has no "${labelText}" entry`, { severity: 'high' }); + await page.keyboard.press('Escape'); + return false; + } + await item.click(); + events.push({ action: 'click', label: labelText, selector: '.projectMenu .menuItem' }); + return true; } async function checkLayout(page, label) { - const state = await page.evaluate( - (snapshotLabel) => ({ + const state = await page.evaluate((snapshotLabel) => { + // The status strip stopped printing the mobility, so a NaN one is asked of + // the machines instead of read off the screen. + const host = document.querySelector('app-new-grid'); + const service = host && window.ng ? window.ng.getComponent(host).mechanismSrv : null; + return { label: snapshotLabel, text: document.body.innerText, scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, innerWidth, - visibleModal: !!document.querySelector('.mat-mdc-dialog-container, .introjs-tooltip'), - }), - label - ); - if (/Degrees of Freedom:\s*NaN/i.test(state.text)) { - recordIssue('Degrees of Freedom displays NaN', { severity: 'medium', label }); + visibleModal: !!document.querySelector('.mat-mdc-dialog-container'), + dofNaN: !!service && service.mechanisms.some((mechanism) => Number.isNaN(mechanism.dof)), + }; + }, label); + if (state.dofNaN) { + recordIssue('Degrees of freedom came out NaN', { severity: 'medium', label }); } if (state.scrollWidth > state.clientWidth + 2) { recordIssue('Page has horizontal overflow', { @@ -234,7 +227,6 @@ await safeStep('initial load', async () => { await waitForReady(page); await shot(page, '01-initial-load.png'); await checkLayout(page, 'first load with intro'); - await dismissIntro(page); await shot(page, '01b-after-intro-dismissed.png'); await checkLayout(page, 'empty project'); }); @@ -242,7 +234,7 @@ await safeStep('initial load', async () => { const snapshots = [await appSnapshot(page, 'initial')]; await safeStep('open templates/library', async () => { - await clickToolbar(page, 'Templates'); + await clickProjectMenu(page, 'Templates'); await page.waitForTimeout(700); await shot(page, '02-template-or-library.png'); snapshots.push(await appSnapshot(page, 'template/library')); @@ -277,13 +269,13 @@ await safeStep('open templates/library', async () => { }); await safeStep('toolbar controls and project actions', async () => { - await clickToolbar(page, 'Share Project'); + await clickProjectMenu(page, 'Share Project'); await page.waitForTimeout(500); await shot(page, '03-share-url.png'); await page.keyboard.press('Escape').catch(() => {}); const downloadPromise = page.waitForEvent('download', { timeout: 3000 }).catch(() => null); - await clickToolbar(page, 'Save'); + await clickProjectMenu(page, 'Save'); const download = await downloadPromise; if (download) { events.push({ action: 'download', suggestedFilename: download.suggestedFilename() }); @@ -300,20 +292,18 @@ await safeStep('load verification mechanism from shared URL', async () => { timeout: 60000, }); await waitForReady(page); - await dismissIntro(page); await shot(page, '03b-verification-mechanism.png'); await checkLayout(page, 'verification mechanism'); snapshots.push(await appSnapshot(page, 'verification mechanism')); }); await safeStep('left panels edit analysis synthesis', async () => { - const leftButtons = page.locator('button.leftButton'); - for (const [name, index] of [ - ['analysis', 0], - ['edit', 1], - ['synthesis', 2], + for (const [name, mode] of [ + ['analysis', 'Kinematic'], + ['edit', 'Edit'], + ['synthesis', 'Synthesis'], ]) { - await leftButtons.nth(index).click(); + await page.locator('.tabButton', { hasText: mode }).click(); await page.waitForTimeout(700); await shot(page, `04-panel-${name}.png`); snapshots.push(await appSnapshot(page, `panel ${name}`)); @@ -322,7 +312,7 @@ await safeStep('left panels edit analysis synthesis', async () => { await safeStep('settings and equations panels', async () => { for (const name of ['Settings', 'Help / Feedback']) { - await clickToolbar(page, name); + await clickProjectMenu(page, name); await page.waitForTimeout(700); await shot(page, `05-right-${name.toLowerCase().replace(/[^a-z]+/g, '-')}.png`); snapshots.push(await appSnapshot(page, `right ${name}`)); @@ -342,11 +332,10 @@ await safeStep('canvas interaction', async () => { }); await safeStep('animation controls', async () => { - await clickFirst( - page, - ['button:has-text("play_arrow")', 'button:has-text("pause")', 'button >> text=play_arrow'], - 'play/pause' - ); + // The transport only exists in an analysis mode now, so get into one first. + await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); + await page.waitForTimeout(900); + await clickFirst(page, ['.playButton'], 'play/pause'); await page.waitForTimeout(1100); await shot(page, '07-animation-controls.png'); snapshots.push(await appSnapshot(page, 'animation')); @@ -360,7 +349,6 @@ await safeStep('mobile viewport smoke', async () => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); await waitForReady(page); - await dismissIntro(page); await shot(page, '08-mobile-load.png'); await checkLayout(page, 'mobile'); snapshots.push(await appSnapshot(page, 'mobile')); diff --git a/e2e/gallery-sweep.mjs b/e2e/gallery-sweep.mjs index 74134adb..2dc9ccaf 100644 --- a/e2e/gallery-sweep.mjs +++ b/e2e/gallery-sweep.mjs @@ -65,8 +65,6 @@ async function open(url) { } } await page.waitForTimeout(600); - await page.evaluate(() => document.querySelector('.introjs-skipbutton')?.click()); - await page.waitForTimeout(200); } const results = []; @@ -139,9 +137,10 @@ for (const { name, query } of galleryRows()) { if (shouldSolve) { // The button a user presses, not the method behind it -- it carries a // disabled binding, and a mechanism that cannot be played is exactly the - // failure worth catching. It lives on the Analyze tab. - await page.getByText('Analyze', { exact: true }).first().click(); - await page.waitForTimeout(800); + // failure worth catching. The transport only renders in an analysis mode. + await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); + await page.locator('.playButton').first().waitFor({ state: 'visible', timeout: 10000 }); + await page.waitForTimeout(400); const play = page.locator('.playButton').first(); if (checkThat(`${name}: play is offered`, !(await play.isDisabled()))) { await play.click(); diff --git a/e2e/input-settings-and-playback.mjs b/e2e/input-settings-and-playback.mjs index 1f100594..d1093c73 100644 --- a/e2e/input-settings-and-playback.mjs +++ b/e2e/input-settings-and-playback.mjs @@ -10,7 +10,7 @@ const { chromium } = await import( import { mkdirSync, writeFileSync } from 'node:fs'; import { waitForReady } from './app-ready.mjs'; -const BASE = process.env.PMKS_URL ?? 'http://127.0.0.1:4200/'; +const BASE = process.env.PMKS_BASE_URL ?? process.env.PMKS_URL ?? 'http://127.0.0.1:4200/'; // Same encoded four-bar the other e2e scripts use — loading by URL avoids the // template dialog entirely. const FOUR_BAR = @@ -32,31 +32,14 @@ page.on('console', (m) => m.type() === 'error' && consoleErrors.push(m.text())); await page.goto(`${BASE}?${FOUR_BAR}`, { waitUntil: 'domcontentloaded' }); await waitForReady(page); -// The first-run tour overlay swallows clicks until it is dismissed. -if ( - await page - .locator('.introjs-tooltip, .introjs-overlay') - .first() - .isVisible() - .catch(() => false) -) { - await page - .locator('.introjs-skipbutton') - .first() - .click({ force: true }) - .catch(async () => page.keyboard.press('Escape')); - await page.waitForTimeout(600); -} - await page.waitForTimeout(800); await page.screenshot({ path: `${OUT}/01-loaded.png` }); // ---------------------------------------------------------------- Edit panel -// The tab toggles the panel, so click until the panel is actually showing. const editPanel = page.locator('app-edit-panel'); for (let attempt = 0; attempt < 3; attempt++) { if (await editPanel.isVisible().catch(() => false)) break; - await page.locator('button.leftButton').nth(1).click(); + await page.locator('.tabButton', { hasText: 'Edit' }).click(); await page.waitForTimeout(600); } check('Edit panel opens', await editPanel.isVisible().catch(() => false)); @@ -67,7 +50,7 @@ await joint.click({ force: true }).catch(() => {}); await page.waitForTimeout(600); await page.screenshot({ path: `${OUT}/02-joint-selected.png` }); -const makeInput = page.locator('text=Add Input').first(); +const makeInput = page.locator('#contextMenu .cm-row', { hasText: 'Driven Input' }).first(); if (await makeInput.isVisible().catch(() => false)) { await makeInput.scrollIntoViewIfNeeded(); await makeInput.click(); @@ -228,11 +211,10 @@ await speedInput.press('Tab'); await page.waitForTimeout(900); // ------------------------------------------------------- Global settings gone -const settingsTab = page.locator('button.leftButton', { hasText: /Settings/i }).first(); -if (await settingsTab.isVisible().catch(() => false)) { - await settingsTab.click(); - await page.waitForTimeout(600); -} +// Settings is a project-menu entry now, so the hamburger has to be opened first. +await page.locator('.topStrip .iconButton').first().click(); +await page.locator('.projectMenu .menuItem', { hasText: 'Settings' }).click(); +await page.waitForTimeout(700); const settingsPanel = page.locator('app-settings-panel'); const settingsText = (await settingsPanel.textContent().catch(() => '')) ?? ''; check( @@ -246,24 +228,30 @@ check( ); await page.screenshot({ path: `${OUT}/05-settings-panel.png`, fullPage: true }); -// ------------------------------------------------------------- Analyze / time -await page.locator('button.leftButton').nth(2).click(); +// ----------------------------------------------------------- Kinematic / time +await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); await page.waitForTimeout(700); -const timeInput = page.locator('#animationBar-input'); +const timeInput = page.locator('#playbackTime'); +// A readout now rather than a field -- the handle measures the input's +// position, and a typed time is a question about a coordinate the transport no +// longer uses. What is left to hold is that it stays inside the card it lives +// in: a readout wide enough for a long value but wider than its card is the +// failure the old sizing rule was there to prevent. const metrics = await timeInput.evaluate((el) => { - const play = document.querySelector('.playButton'); + const card = el.closest('.scrubCard'); + const field = el.getBoundingClientRect(); + const box = card.getBoundingClientRect(); return { - clientWidth: el.clientWidth, - scrollWidth: el.scrollWidth, - offsetWidth: el.offsetWidth, - playWidth: play ? play.getBoundingClientRect().width : 0, + fieldWidth: Math.round(field.width), + overflowLeft: Math.round(box.left - field.left), + overflowRight: Math.round(field.right - box.right), }; }); check( - 'Time field spans the same width as the Play button', - Math.abs(metrics.offsetWidth - metrics.playWidth) <= 2, - `time=${metrics.offsetWidth}px, play=${metrics.playWidth}px` + 'Time readout stays inside the transport card it sits in', + metrics.fieldWidth > 0 && metrics.overflowLeft <= 0 && metrics.overflowRight <= 0, + `field=${metrics.fieldWidth}px, spill left=${metrics.overflowLeft}px right=${metrics.overflowRight}px` ); // ------------------------------------------------------------ Playback motion diff --git a/e2e/interaction-sweep.mjs b/e2e/interaction-sweep.mjs index 4db10b1d..7cca49e8 100644 --- a/e2e/interaction-sweep.mjs +++ b/e2e/interaction-sweep.mjs @@ -72,22 +72,31 @@ const signature = () => .map( (j) => `${j.id}${j.ground ? 'G' : ''}${j.input ? 'I' : ''}${j.isWelded ? 'W' : ''}` + + // The menu writes states as switches, so the switches have to be in + // the picture: a Lock or a Trace that flipped nothing would otherwise + // read as a control that did nothing, and one that flipped the wrong + // joint would not read as anything at all. + `${j.locked ? 'K' : ''}${j.showCurve ? 'T' : ''}` + `${j.constructor.name[0]}@${j.x.toFixed(2)},${j.y.toFixed(2)}` ) .join('|'); const links = mech.links - .map((l) => `${l.id}:${l.constructor.name[0]}:${l.fill ?? '-'}`) + .map((l) => `${l.id}:${l.constructor.name[0]}:${l.fill ?? '-'}${l.isCircle ? ':disc' : ''}`) .join('|'); const forces = mech.forces .map((f) => `${f.id ?? f.name}:${f.local}:${f.startCoord.x.toFixed(1)}`) .join('|'); - const dofText = (document.body.textContent.match(/Degrees of Freedom:\s*(-?[\d.]+|NaN|—)/) ?? - [])[1]; + // The status strip stopped printing the mobility, so it comes off the + // machines. NaN is kept as its own flag: it is the thing worth catching, + // and a joined string would have to spell it to say so. + const dofs = mech.mechanisms.map((mechanism) => mechanism.dof); + const dofText = dofs.length > 0 ? dofs.join(', ') : null; return { key: `${joints}//${links}//${forces}`, joints: mech.joints.length, links: mech.links.length, - dof: dofText ?? null, + dof: dofText, + dofNaN: dofs.some((value) => Number.isNaN(value)), skins: document.querySelectorAll('.cylinder-mark').length, drawnJoints: document.querySelectorAll('[id^="joint_"]').length, nan: [...document.querySelectorAll('svg *')].filter((node) => @@ -132,10 +141,9 @@ const menuFor = (kind, id) => if (!target) return null; grid.setLastRightClick(target); grid.updateContextMenuItems(); - return grid.cMenuItems.map((item) => ({ - label: item.label, - disabled: !!item.disabled, - })); + return grid.cMenu.groups.flatMap((group) => + group.rows.map((row) => ({ label: row.label, disabled: !!row.disabled })) + ); }, [kind, id] ); @@ -156,9 +164,12 @@ const fire = (kind, id, label) => : mech.forces.find((f) => (f.id ?? f.name) === id); grid.setLastRightClick(target); grid.updateContextMenuItems(); - const item = grid.cMenuItems.find((entry) => entry.label === label); - if (!item) return 'gone'; - item.actionWrapper(); + const row = grid.cMenu.groups + .flatMap((group) => group.rows) + .find((entry) => entry.label.startsWith(label)); + if (!row) return 'gone'; + if (row.disabled) return 'refused'; + row.action(); return 'fired'; }, [kind, id, label] @@ -201,8 +212,10 @@ const everySubject = () => // Actions that begin a two-click gesture rather than finishing an edit: they are // *meant* to change nothing until the second click, so silence is correct. -const GESTURES = - /^(Add Link|Attach Link|Add Cylinder|Attach Cylinder|Create Cylinder|Attach Force)$/; +// Rows that arm a gesture rather than finishing an edit: the next click lands +// the thing, so nothing has changed by the time this looks. The menu names them +// by the bare noun under an Add or Attach heading now. +const GESTURES = /^(Link|Cylinder|Force|Tracer Point|Background Image)$/; let clicked = 0; for (const id of MECHANISMS) { @@ -233,7 +246,7 @@ for (const id of MECHANISMS) { if (errors.length) note({ what: `threw: ${errors[0]}`, where, kind: 'threw' }); if (after.nan > before.nan) note({ what: `put ${after.nan - before.nan} NaN into the drawing`, where, kind: 'nan' }); - if (after.dof === 'NaN') note({ what: 'left degrees of freedom NaN', where, kind: 'nan' }); + if (after.dofNaN) note({ what: 'left degrees of freedom NaN', where, kind: 'nan' }); if (before.joints > 0 && after.joints === 0 && !/Delete/.test(entry.label)) note({ what: 'emptied the mechanism', where, kind: 'destroyed' }); if (before.skins > after.skins && !/Delete/.test(entry.label)) @@ -243,11 +256,12 @@ for (const id of MECHANISMS) { note({ what: 'did nothing, and said nothing', where, kind: 'silent' }); // Back to where we were, so the next action starts from the same place. + // Undo moved out of the left panel and into the top strip. await page.evaluate(() => { const grid = ng.getComponent(document.querySelector('app-new-grid')); - const tabs = document.querySelector('app-left-tabs'); - const left = tabs && ng.getComponent(tabs); - if (left && left.canUndo()) left.handleUndo(); + const strip = document.querySelector('app-top-bar'); + const top = strip && ng.getComponent(strip); + if (top && top.canUndo()) top.undo(); else grid.mechanismSrv.updateMechanism(false); }); await page.waitForTimeout(400); diff --git a/e2e/keyboard-shortcuts.mjs b/e2e/keyboard-shortcuts.mjs new file mode 100644 index 00000000..6943b4ac --- /dev/null +++ b/e2e/keyboard-shortcuts.mjs @@ -0,0 +1,290 @@ +/** + * The keys, exercised the way a hand would: press one and watch the app do + * what the control it doubles would have done -- and watch the same key do + * nothing at all while a name is being typed into a field. + * + * PMKS_BASE_URL=<origin> node e2e/keyboard-shortcuts.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 results = []; +const record = (what, ok, detail) => { + results.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))); + +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.waitForTimeout(500); + +/** The joint's centre on screen: the canvas places from where the mouse is. */ +const centreOf = (id) => + page.evaluate((jointId) => { + const el = document.querySelector(`#joint_${jointId}`)?.closest('svg[x]'); + const box = el.getBoundingClientRect(); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + }, id); + +const clickJoint = async (id) => { + // Aimed only once the joint has stopped moving. Leaving an analysis mode + // winds the linkage back to its start pose, and a click aimed while that is + // still running lands where the joint was rather than where it is. + let at = await centreOf(id); + for (let tries = 0; tries < 20; tries++) { + await page.waitForTimeout(100); + const now = await centreOf(id); + if (Math.hypot(now.x - at.x, now.y - at.y) < 0.5) break; + at = now; + } + await page.mouse.move(at.x, at.y); + await page.mouse.click(at.x, at.y); + await page.waitForTimeout(400); +}; + +const state = () => + page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + return { + tab: c.tabService.getCurrentTab(), + playing: c.mechanismSrv.isPlaying, + step: c.mechanismSrv.mechanismTimeStep, + ids: c.settings.isShowID.value, + com: c.settings.isShowCOM.value, + paths: c.settings.isShowTraces.value, + selected: c.activeObjService.objType, + joints: c.mechanismSrv.joints.length, + lockedJoints: c.mechanismSrv.joints.filter((j) => j.locked).length, + drawer: ng.getComponent(document.querySelector('app-right-panel'))?.getOpenTab?.(), + }; + }); + +const press = async (key) => { + await page.keyboard.press(key); + await page.waitForTimeout(350); +}; + +// --- Modes ------------------------------------------------------------------ +// 0 Synthesis, 1 Edit, 2 Kinematic, 3 Force (TabID's own order). +await press('1'); +record('1 goes to Synthesis', (await state()).tab === 0, await state()); +await press('2'); +record('2 comes back to Edit', (await state()).tab === 1, await state()); +await press('3'); +record('3 opens Kinematic Analysis', (await state()).tab === 2, await state()); + +// --- Playback, and only where there is a transport -------------------------- +await press('2'); +const still = await state(); +await press('Space'); +record('Space does nothing in Edit, which has no transport', (await state()).playing === false, { + before: still.playing, +}); +await press('3'); +await press('Space'); +record('Space starts it playing', (await state()).playing === true); +await press('Space'); +record('and Space again pauses it', (await state()).playing === false); + +const before = (await state()).step; +await press('ArrowRight'); +const stepped = (await state()).step; +record('Right steps one frame on', stepped !== before, { before, stepped }); +await press('ArrowLeft'); +record('and Left brings it back', (await state()).step === before, { + before, + now: (await state()).step, +}); + +// --- View ------------------------------------------------------------------- +const ids = (await state()).ids; +await press('l'); +record('L turns the joint IDs off and on', (await state()).ids !== ids); +await press('l'); + +// A traced path needs a joint that traces one, so the switch is greyed here +// and the key must be greyed with it. +const paths = (await state()).paths; +await press('p'); +record('P leaves traced paths alone while no joint traces one', (await state()).paths === paths); + +// --- Editing ---------------------------------------------------------------- +await press('2'); // back to Edit, where there is something to select +await clickJoint('B'); +record('a joint is selected', (await state()).selected === 'Joint', await state()); +await press('Escape'); +record('Escape lets it go', (await state()).selected === 'Grid', await state()); + +const jointsBefore = (await state()).joints; +await clickJoint('B'); +// Backspace, not Delete: the key a MacBook actually has. +await press('Backspace'); +record('Backspace deletes the selected joint', (await state()).joints === jointsBefore - 1, { + jointsBefore, + now: (await state()).joints, +}); +await page.keyboard.press('Control+z'); +await page.waitForTimeout(500); +record('Ctrl+Z puts it back', (await state()).joints === jointsBefore); + +// --- A key typed into a field belongs to the field --------------------------- +await clickJoint('A'); +const field = page.locator('app-edit-panel input').first(); +await field.click(); +await field.press('3'); +await page.waitForTimeout(300); +record('a mode key typed into a field does not switch modes', (await state()).tab === 1, { + tab: (await state()).tab, +}); + +// --- Lock, and Settings ----------------------------------------------------- +await clickJoint('B'); +await press('k'); +record('K locks the selected joint', (await state()).lockedJoints === 1, await state()); +await press('k'); +record('and K again lets it go', (await state()).lockedJoints === 0, await state()); + +await press(','); +record('a comma opens Settings', (await state()).drawer === 1, await state()); + +// --- A key that changes the drawing is an Edit key --------------------------- +// An analysis mode is a reading of a finished mechanism: it hides the lock +// marks, greys the panels, and takes Undo away. A key that edits anyway leaves +// a change there is no way back from without leaving the mode. +await clickJoint('B'); +const beforeAnalysis = await state(); +await press('3'); +await press('Backspace'); +await press('k'); +const afterAnalysis = await state(); +record( + 'Delete and Lock do nothing in an analysis mode', + afterAnalysis.joints === beforeAnalysis.joints && afterAnalysis.lockedJoints === 0, + { beforeAnalysis, afterAnalysis } +); +await press('2'); +await clickJoint('B'); +await press('k'); +record('and both work again back in Edit', (await state()).lockedJoints === 1, await state()); +await press('k'); + +// A dropdown answers to the keyboard the whole time it has focus: letters jump +// to an option, and the digits that pick a mode here are letters to it. +await clickJoint('A'); +const dropdown = page.locator('app-edit-panel select').first(); +if ((await dropdown.count()) > 0) { + await dropdown.focus(); + const beforeSelect = await state(); + await press('3'); + record( + 'a mode key inside a focused dropdown stays there', + (await state()).tab === beforeSelect.tab, + { + beforeSelect, + now: await state(), + } + ); +} + +// --- "Delete what is selected" means a force too ----------------------------- +// A force is a thing on the drawing with a Delete of its own in its menu. Left +// out of the key, the arrow stayed put and only the selection cleared, which +// reads as a delete that did not take. +await page.goto(`${BASE}/?${payloads['Derrick_Crane']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.waitForTimeout(700); +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.activeObjService.updateSelectedObj(c.mechanismSrv.forces[0]); +}); +await page.waitForTimeout(500); +const withForce = await page.evaluate( + () => ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.forces.length +); +await press('Backspace'); +const afterForce = await page.evaluate( + () => ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.forces.length +); +record('Delete removes a selected force', withForce === 1 && afterForce === 0, { + withForce, + afterForce, +}); + +// --- A key belongs to whatever is standing over the canvas ------------------- +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.waitForTimeout(700); +// These are the canvas's keys. With a dialog open the reader is looking at the +// dialog, and Delete was quietly removing the joint selected behind it. +await clickJoint('B'); +const guarded = await state(); +await page.locator('.topStrip .iconButton').first().click(); +await page.waitForTimeout(500); +await page.locator('#templatesButton').click(); +await page.waitForTimeout(1200); +record( + 'the Templates dialog is open', + (await page.evaluate(() => document.querySelectorAll('mat-dialog-container').length)) > 0 +); +for (const key of ['Backspace', '3', 'k', ',']) await press(key); +const behind = await state(); +record( + 'and no shortcut acts behind it', + behind.joints === guarded.joints && + behind.tab === guarded.tab && + behind.lockedJoints === guarded.lockedJoints && + behind.drawer === guarded.drawer, + { guarded, behind } +); +await page.keyboard.press('Escape'); +await page.waitForTimeout(800); +await page.mouse.click(900, 700); +await page.waitForTimeout(300); + +// --- The list that teaches them -------------------------------------------- +// Out of the field first: the check above just proved that a key typed into +// one stays there, so a `?` pressed with the caret still in it would too. +await page.mouse.click(900, 700); +await page.waitForTimeout(300); +await press('?'); +await page.waitForTimeout(700); +const help = await page.evaluate(() => { + const panel = document.querySelector('app-help-panel'); + return panel ? panel.innerText : ''; +}); +record('? opens Help', /Send Feedback|Keyboard Shortcuts/.test(help), help.slice(0, 80)); +const listed = await page.evaluate(() => { + const panel = document.querySelector('app-help-panel'); + return { + heading: /Keyboard Shortcuts/.test(panel?.innerText ?? ''), + caps: document.querySelectorAll('app-help-panel .keyCap').length, + }; +}); +record( + 'and the drawer carries the list of keys, one cap each', + listed.heading && listed.caps >= 15, + listed +); + +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/left-nav-modes.mjs b/e2e/left-nav-modes.mjs index 60654b09..5a46cd97 100644 --- a/e2e/left-nav-modes.mjs +++ b/e2e/left-nav-modes.mjs @@ -1,16 +1,31 @@ -// Verifies the mode-scoped left nav rail: Undo/Redo appear only in Edit, -// playback only in Analyze, the view controls are always present, and leaving -// Analyze rewinds the mechanism (there is no stop button any more). +/** + * Mode navigation, after the rail that used to carry it was deleted. + * + * The four modes, Undo/Redo and the file menu were spread across a horizontal + * toolbar and a vertical rail; they are now one strip along the top, with the + * transport and the view controls floating over the canvas instead of living + * inside the rail. This checks the navigation that replaced it: pressing a mode + * changes the mode, the left panel follows it, the sliding highlight lands on + * the mode you actually chose, each analysis mode carries a chip saying whether + * it can be entered, the transport belongs to the analysis modes alone, and + * leaving an analysis mode still rewinds the mechanism — as does the stop + * button beside play, for a reader who wants the drawn pose without leaving. + * The removed chrome is asserted absent, because a selector that matches + * nothing passes every test written against it. + * + * PMKS_BASE_URL=<origin> node e2e/left-nav-modes.mjs + */ const { chromium } = await import( (process.env.PMKS_PLAYWRIGHT_DIR ?? '/tmp/pmks-playwright') + '/node_modules/playwright/index.mjs' ); import fs from 'node:fs/promises'; import path from 'node:path'; +import { waitForReady } from './app-ready.mjs'; const screenshotDir = path.resolve('artifacts/screenshots'); await fs.mkdir(screenshotDir, { recursive: true }); -const baseUrl = process.env.PMKS_URL || 'http://127.0.0.1:4200/'; +const baseUrl = process.env.PMKS_BASE_URL || process.env.PMKS_URL || 'http://127.0.0.1:4200/'; const runPrefix = process.env.RUN_PREFIX || 'left-nav'; const chromePath = process.env.PMKS_CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; @@ -19,10 +34,11 @@ const fourBar = '0P.TY.K,0.101.MA,A,0mv,0VU,0.GB,B,0e_,E6,0.GC,C,l1,WW,0.KD,D,qD,0Pk,0..YRAB,AB,Fe,Fe,0ix,08i,c5cae9,A,B,,.YRBC,BC,Fe,Fe,32,NJ,303e9f,B,C,,.YRCD,CD,Fe,Fe,nd,3P,0d125a,C,D,,...JBq'; const issues = []; -const checks = []; -const check = (name, ok, details = {}) => { - checks.push({ name, ok, ...details }); - if (!ok) issues.push({ name, severity: 'high', ...details }); +const results = []; +const record = (what, ok, detail) => { + results.push({ what, ok, detail }); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); + if (!ok) issues.push({ name: what, severity: 'high', detail }); }; const context = await chromium.launchPersistentContext(`/tmp/pmks-left-nav-${Date.now()}`, { @@ -36,7 +52,6 @@ page.setDefaultTimeout(10000); page.on('pageerror', (error) => issues.push({ name: 'Uncaught page error', severity: 'high', error: error.message }) ); -import { waitForReady } from './app-ready.mjs'; page.on('console', (msg) => { if (msg.type() === 'error' && !/favicon|google-analytics/i.test(msg.text())) { issues.push({ name: `Console error: ${msg.text().slice(0, 160)}`, severity: 'medium' }); @@ -46,231 +61,318 @@ page.on('console', (msg) => { const shot = (name) => page.screenshot({ path: path.join(screenshotDir, `${runPrefix}-${name}`), fullPage: false }); -const railText = () => page.locator('.tabContainer').innerText(); -const tab = (label) => page.locator('.leftButton', { hasText: label }); -const timeValue = () => page.locator('#animationBar-input').inputValue(); +const tab = (label) => page.locator('.tabButton', { hasText: label }); +const timeValue = () => page.locator('#playbackTime').innerText(); +/** The value carries its own unit, so read the leading number off "0.20 s". */ +const timeSeconds = async () => parseFloat(await timeValue()); +const currentTab = () => + page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).tabService.getCurrentTab() + ); + +/** Which mode the sliding highlight is actually sitting on. */ +const highlighted = () => + page.evaluate(() => { + const pill = document.querySelector('.activeTabPill'); + const active = document.querySelector('.tabButton.active'); + if (!pill || !active) return null; + const style = getComputedStyle(pill); + return { + label: active.querySelector('.tabLabel')?.innerText ?? '', + dLeft: Math.round(new DOMMatrix(style.transform).m41 - active.offsetLeft), + dWidth: Math.round(parseFloat(style.width) - active.offsetWidth), + }; + }); try { await page.goto(`${baseUrl}?${fourBar}`, { waitUntil: 'domcontentloaded', timeout: 60000 }); await waitForReady(page); - await page - .locator('.introjs-skipbutton') - .first() - .click({ force: true }) - .catch(() => {}); + // Nothing to dismiss any more. The intro.js overlay this used to skip past + // is gone, replaced by the tutorial drawer, which never covers the canvas and + // only opens when it is asked for -- see e2e/tutorial.mjs. The unguarded + // click that used to be here now waits out its full timeout on an element + // that will never exist. await page.waitForTimeout(400); - // The pill highlights one tab group. It is positioned with a transform, so - // compare rendered rects (which include the transform), not offsetTop. - const pillTop = () => - page.evaluate(() => { - const pill = document.querySelector('.activeTabPill').getBoundingClientRect(); - const groups = [...document.querySelectorAll('.tabGroup')]; - const withinPill = groups.find((g) => Math.abs(g.getBoundingClientRect().top - pill.top) < 2); - return withinPill ? withinPill.querySelector('.leftButton .tabLabel').innerText : null; - }); + // --- the rail and the file toolbar are gone, not merely restyled ----------- + const removed = await page.evaluate(() => ({ + toolbar: document.querySelectorAll('app-toolbar, nav.navBar').length, + rail: document.querySelectorAll('.tabList, .leftButton, .tabGroup').length, + })); + record('the file toolbar is gone', removed.toolbar === 0, removed); + record('the mode rail is gone', removed.rail === 0, removed); + record('and one strip carries the four modes', (await page.locator('.tabButton').count()) === 4); - // --- Edit mode --- - const editText = await railText(); + // --- Edit mode ------------------------------------------------------------- await shot('01-edit.png'); - check('the pill highlights the Edit group', (await pillTop()) === 'Edit'); - check('the Edit panel is open', (await page.locator('app-edit-panel').count()) === 1); - check('Undo/Redo show in Edit', /Undo/.test(editText) && /Redo/.test(editText), { editText }); - check( - 'Undo/Redo use the shared mini-button style', - (await page.locator('.tabTools .mini-buttons', { hasText: 'Undo' }).count()) === 1 + record('Edit is the mode the app opens in', (await currentTab()) === 1); + record( + 'the Edit panel is open', + (await page.locator('app-left-tabs .panel app-edit-panel').count()) === 1 ); - check( - 'playback controls hidden in Edit', - (await page.locator('.playbackControls').count()) === 0 + const editHighlight = await highlighted(); + record( + 'the highlight sits on Edit', + editHighlight?.label === 'Edit' && + Math.abs(editHighlight.dLeft) <= 1 && + Math.abs(editHighlight.dWidth) <= 1, + editHighlight ); - check( - 'view controls are always present', - /CoM/.test(editText) && /Label/.test(editText) && /View/.test(editText) + record('the transport is absent in Edit', (await page.locator('.playButton').count()) === 0); + + // Undo/Redo left the rail for the strip, where they are reachable from every + // mode rather than from Edit alone. + record( + 'Undo and Redo are in the top strip', + (await page.locator('.historyCard .historyButton').count()) === 2 ); - check( - 'Undo/Redo left the top toolbar', - (await page.locator('nav.navBar button', { hasText: 'Undo' }).count()) === 0 + + // --- Synthesis ------------------------------------------------------------- + await tab('Synthesis').click(); + await page.waitForTimeout(700); + record('pressing Synthesis switches to it', (await currentTab()) === 0); + record( + 'and the panel follows the mode', + (await page.locator('app-left-tabs .panel app-synthesis-panel').count()) === 1 && + (await page.locator('app-left-tabs .panel app-edit-panel').count()) === 0 ); + record('the transport is absent in Synthesis', (await page.locator('.playButton').count()) === 0); - // The rail must not clip its own labels. - const clipped = await page.evaluate(() => { - const rail = document.querySelector('.tabContainer'); - return rail ? rail.scrollWidth > rail.clientWidth + 1 : true; + // --- the chips say whether an analysis can be entered ----------------------- + const kinematicChip = (await tab('Kinematic').locator('.chip').innerText()).trim(); + const forceChip = (await tab('Force').locator('.chip').innerText()).trim(); + record('the Kinematic chip reads Ready for a four-bar that runs', kinematicChip === 'Ready', { + kinematicChip, }); - check('rail is wide enough for its labels', clipped === false); + // Weight counts as a load now: gravity is on by default and the four-bar's + // links carry mass, so force analysis is ready without a drawn arrow. + record('the Force chip reads Ready — weight is a load', forceChip === 'Ready', { forceChip }); - // --- Analyze mode --- - await tab('Analyze').click(); + // --- Kinematic mode -------------------------------------------------------- + await tab('Kinematic').click(); await page.waitForTimeout(900); - const analyzeText = await railText(); - await shot('02-analyze.png'); - check('the pill highlights the Analyze group', (await pillTop()) === 'Analyze'); - check( - 'playback controls show in Analyze', - (await page.locator('.playbackControls').count()) === 1 + await shot('02-kinematic.png'); + record('pressing Kinematic switches to it', (await currentTab()) === 2); + record( + 'the analysis panel follows', + (await page.locator('app-left-tabs .panel app-analysis-panel').count()) === 1 ); - check('Undo/Redo hidden in Analyze', !/Undo/.test(analyzeText), { analyzeText }); - check( - 'stop button is gone', - (await page.locator('.playbackControls mat-icon', { hasText: 'stop' }).count()) === 0 + const kinematicHighlight = await highlighted(); + record( + 'the highlight settles exactly on Kinematic, not the mode before it', + kinematicHighlight?.label.startsWith('Kinematic') && + Math.abs(kinematicHighlight.dLeft) <= 1 && + Math.abs(kinematicHighlight.dWidth) <= 1, + kinematicHighlight ); - check( - 'slider is vertical', - await page.evaluate(() => { - const slider = document.querySelector('.verticalSlider mat-slider'); - if (!slider) return false; - const rect = slider.getBoundingClientRect(); - return rect.height > rect.width; - }) + record('the transport appears', (await page.locator('.playButton').count()) === 1); + // Back, and asked for: leaving the mode rewinds the mechanism, but a reader + // who wants the drawn pose *without* leaving analysis had no way to it but + // to scrub until the clock read zero. + record( + 'with a stop button beside play, for the pose the mechanism was drawn in', + (await page.locator('.transportCard .stopButton').count()) === 1 ); - // Every playback control sits inside the rail — no horizontal overflow. - const overflow = await page.evaluate(() => { - const rail = document.querySelector('.tabContainer').getBoundingClientRect(); - return [...document.querySelectorAll('.playbackControls > *')] + // The scrubber is a plain range input now, laid out across the card. + const scrubber = await page.evaluate(() => { + const slider = document.querySelector('#slider'); + if (!slider) return null; + const box = slider.getBoundingClientRect(); + return { tag: slider.tagName, type: slider.type, width: box.width, height: box.height }; + }); + record( + 'the scrubber is a horizontal range input', + scrubber?.tag === 'INPUT' && scrubber.type === 'range' && scrubber.width > scrubber.height, + scrubber + ); + + // The transport floats over the canvas, so nothing may hang off the window. + const spill = await page.evaluate(() => + [...document.querySelectorAll('app-playback-bar .card, .viewControls')] .map((el) => el.getBoundingClientRect()) .filter((r) => r.width > 0) - .some((r) => r.left < rail.left - 0.5 || r.right > rail.right + 0.5); - }); - check('playback controls do not overflow the rail', overflow === false); + .some( + (r) => + r.left < -0.5 || r.right > window.innerWidth + 0.5 || r.bottom > window.innerHeight + 0.5 + ) + ); + record('the floating controls stay inside the window', spill === false); - // At rest the pill exactly covers the active group (the animation settled, - // did not stall part-way). - const cover = await page.evaluate(() => { - const pill = document.querySelector('.activeTabPill').getBoundingClientRect(); - const group = document.querySelectorAll('.tabGroup')[2].getBoundingClientRect(); - return { - dTop: Math.round(pill.top - group.top), - dHeight: Math.round(pill.height - group.height), - }; + // --- pressing a mode that cannot be entered ------------------------------- + // Gravity hanging the links' own weight is a load, so the four-bar as it + // arrives no longer trips the gate. Turn gravity off and the drawing is + // genuinely unloaded — the refusal has to come back. + await page.evaluate(() => { + const grid = window.ng.getComponent(document.querySelector('app-new-grid')); + grid.settings.isGravity.next(false); }); - check( - 'pill settles exactly over the Analyze group', - Math.abs(cover.dTop) <= 1 && Math.abs(cover.dHeight) <= 1, - { cover } + await tab('Force').click(); + await page.waitForTimeout(800); + await shot('03-force-refused.png'); + record( + 'Force refuses to open without a load, and stays out of that mode', + (await currentTab()) === 2, + { tab: await currentTab() } + ); + record( + 'and answers with the setup list instead of nothing', + (await page.locator('app-analysis-setup').count()) === 1 ); + await page.evaluate(() => { + const grid = window.ng.getComponent(document.querySelector('app-new-grid')); + grid.settings.isGravity.next(true); + }); - // --- Play, then leave Analyze: the mechanism must rewind --- + // --- play, then leave: the mechanism must rewind --------------------------- + await tab('Kinematic').click(); + await page.waitForTimeout(500); await page.locator('.playButton').click(); await page.waitForTimeout(1600); - // The value carries its own unit, so read the leading number off "0.20 s". - const timeSeconds = async () => parseFloat(await timeValue()); const playing = await timeSeconds(); - check('play advances the animation', playing > 0, { playing }); - await shot('03-playing.png'); + record('play advances the animation', playing > 0, { playing }); + await shot('04-playing.png'); await tab('Edit').click(); await page.waitForTimeout(1200); - await shot('04-back-in-edit.png'); - await tab('Analyze').click(); + await tab('Kinematic').click(); await page.waitForTimeout(1000); + // Asked of the mechanism and of the readout separately, because they can + // disagree: the pose is what the other modes edit, the readout is what says + // where the pose is, and a readout that has drifted off the pose is worse + // than no readout at all. + const rewound = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return { step: srv.mechanismTimeStep, seconds: srv.currentTimeSeconds() }; + }); + record('switching to Edit rewound the mechanism to time 0', rewound.step === 0, { + playing, + rewound, + }); const afterSwitch = await timeSeconds(); - check('switching to Edit rewound the mechanism to time 0', afterSwitch === 0, { + record('and the time readout agrees with the mechanism', afterSwitch === 0, { playing, afterSwitch, + rewound, }); - check( + record( 'animation is no longer running after the mode switch', - (await page.locator('.playbackControls mat-icon', { hasText: 'pause' }).count()) === 0 + (await page.locator('app-playback-bar mat-icon', { hasText: 'pause' }).count()) === 0 + ); + + // --- the view controls are present in every mode, and still act ------------ + const viewControlsEverywhere = []; + for (const mode of ['Synthesis', 'Edit', 'Kinematic']) { + await tab(mode).click(); + await page.waitForTimeout(600); + viewControlsEverywhere.push([mode, await page.locator('.viewControls').isVisible()]); + } + record( + 'the view controls are present in every mode', + viewControlsEverywhere.every(([, visible]) => visible), + viewControlsEverywhere ); - // --- The absorbed view controls still work --- await tab('Edit').click(); - await page.waitForTimeout(700); - const comButton = page.locator('.viewControls .mini-buttons', { hasText: 'CoM' }); + await page.waitForTimeout(600); + // These buttons carry tooltips that appear under the cursor and then swallow + // the next click, so the pointer is parked away from them between presses. + const restCursor = () => page.mouse.move(700, 450); + const comMarks = () => page.locator('#comTagHolder path').count(); + // Icon-only squares now, so they are addressed by what they are for rather + // than by a word that is no longer printed on them. + const comButton = page.locator('.viewControls .viewButton[aria-label="Show Center of Mass"]'); + // The view defaults to showing centres of mass, but a mark only appears on a + // link that has mass: take the weight away and the mark goes with it, give + // it back and the toggle governs it from there. + const setMasses = (value) => + page.evaluate((mass) => { + const grid = window.ng.getComponent(document.querySelector('app-new-grid')); + grid.mechanismSrv.links.forEach((link) => (link.mass = mass)); + grid.mechanismSrv.updateMechanism(false); + }, value); + const comBefore = await comMarks(); + await setMasses(0); + await page.waitForTimeout(400); + const comMassless = await comMarks(); + await setMasses(5); + await page.waitForTimeout(400); await comButton.click(); await page.waitForTimeout(500); - check( - 'CoM toggle acts on the grid', - (await page.locator('#comHolder, .comIcon, svg #comHolder').count()) >= 0 + const comAfter = await comMarks(); + record( + 'the CoM mark shows by default, waits for mass, and the toggle removes it', + comBefore > 0 && comMassless === 0 && comAfter === 0, + { comBefore, comMassless, comAfter } ); await comButton.click(); await page.waitForTimeout(300); - await page.locator('.viewControls .mini-buttons', { hasText: 'View' }).click(); + await restCursor(); + await page.locator('.viewControls .viewButton[aria-label="Reset View"]').click(); await page.waitForTimeout(600); - await page.locator('.zoomButton').first().click(); + await restCursor(); + await page.locator('.viewControls .viewButton[aria-label="Zoom In"]').click(); await page.waitForTimeout(400); + record('and the marks come back on again', (await comMarks()) > 0); await shot('05-view-controls.png'); - check('view controls did not throw', true); - // The rail covers the bottom bar's left end (no bare corner), while the bar's - // text still clears the rail and the rail paints on top of the bar. - const bottomBar = await page.evaluate(() => { + // --- the status strip reports the mode rather than acting on it ------------ + const strip = await page.evaluate(() => { const bar = document.querySelector('#bottomBar'); - const barUl = document.querySelector('#bottomBar ul'); - const rail = document.querySelector('.tabContainer'); - if (!bar || !barUl || !rail) return null; - const first = barUl.querySelector('li'); - const railBox = rail.getBoundingClientRect(); - const barBox = bar.getBoundingClientRect(); - // At the rail's bottom-left, the topmost painted element should be the rail. - const probe = document.elementFromPoint(railBox.left + 8, barBox.top + barBox.height / 2); - return { - textLeft: Math.round(first.getBoundingClientRect().left), - railRight: Math.round(railBox.right), - railBottom: Math.round(railBox.bottom), - barBottom: Math.round(barBox.bottom), - cornerOwnedByRail: !!(probe && probe.closest('.tabContainer')), - }; + return { text: bar.innerText, pointerEvents: getComputedStyle(bar).pointerEvents }; }); - check( - 'bottom bar text clears the nav rail', - bottomBar !== null && bottomBar.textLeft >= bottomBar.railRight, - { bottomBar } - ); - check( - 'nav rail covers the bottom bar corner (no white gap)', - bottomBar !== null && - bottomBar.railBottom >= bottomBar.barBottom - 1 && - bottomBar.cornerOwnedByRail, - { bottomBar } + record('the status strip names the mode it is in', /Edit/.test(strip.text), strip.text); + record('and is not clickable', strip.pointerEvents === 'none', strip); + + await tab('Kinematic').click(); + await page.waitForTimeout(600); + const analysisStrip = await page.locator('#bottomBar').innerText(); + record( + 'and follows the mode into analysis', + /Kinematic/.test(analysisStrip) && /Geometry locked/.test(analysisStrip), + analysisStrip ); - // --- Short viewport: the analyze scrubber drops so the rest stays visible --- - await tab('Analyze').click(); - await page.waitForTimeout(700); + // --- short viewport: nothing overlaps the status strip --------------------- await page.setViewportSize({ width: 1440, height: 700 }); - await page.waitForTimeout(500); + await page.waitForTimeout(600); await shot('06-short-viewport.png'); const short = await page.evaluate(() => { - const vs = document.querySelector('.verticalSlider'); + const bar = document.querySelector('#bottomBar').getBoundingClientRect(); const inView = (sel) => { const el = document.querySelector(sel); if (!el) return false; const r = el.getBoundingClientRect(); - return r.top >= 30 && r.bottom <= window.innerHeight + 0.5 && r.width > 0; + return r.top >= 0 && r.bottom <= window.innerHeight + 0.5 && r.width > 0; + }; + const clears = (sel) => { + const el = document.querySelector(sel); + return el ? el.getBoundingClientRect().bottom <= bar.top + 0.5 : false; }; - const pill = document.querySelector('.activeTabPill').getBoundingClientRect(); - const group = document.querySelectorAll('.tabGroup')[2].getBoundingClientRect(); return { - sliderHidden: !(vs && vs.offsetParent), playVisible: inView('.playButton'), - timeVisible: inView('#animationBar-input'), + timeVisible: inView('#playbackTime'), viewControlsVisible: inView('.viewControls'), - pillMatchesGroup: - Math.abs(pill.top - group.top) <= 1 && Math.abs(pill.height - group.height) <= 1, + transportClearsStatusStrip: clears('app-playback-bar .scrubCard'), + viewControlsClearStatusStrip: clears('.viewControls'), }; }); - check( - 'short viewport hides the scrubber but keeps the other controls visible', - short.sliderHidden && - short.playVisible && - short.timeVisible && - short.viewControlsVisible && - short.pillMatchesGroup, - { short } + record( + 'a short window keeps the transport and view controls visible and clear of the status strip', + Object.values(short).every(Boolean), + short ); await page.setViewportSize({ width: 1440, height: 900 }); } catch (error) { - issues.push({ name: 'Run failed', severity: 'high', error: error?.stack || String(error) }); + record('the run finished', false, error?.stack || String(error)); } finally { await fs.writeFile( path.join(screenshotDir, `${runPrefix}-report.json`), - JSON.stringify({ baseUrl, checks, issues }, null, 2) + JSON.stringify({ baseUrl, results, issues }, null, 2) ); await context.close().catch(() => {}); } -const failed = checks.filter((c) => !c.ok); -console.log(JSON.stringify({ passed: checks.length - failed.length, failed, issues }, null, 2)); +const failed = results.filter((r) => !r.ok); +console.log(`\n${results.length - failed.length}/${results.length} checks passed`); +process.exit(failed.length ? 1 : 0); diff --git a/e2e/link-labels.mjs b/e2e/link-labels.mjs new file mode 100644 index 00000000..f0fc9a5d --- /dev/null +++ b/e2e/link-labels.mjs @@ -0,0 +1,319 @@ +/** + * A link's name is drawn on the link. + * + * The name is always shown -- unlike the joint and force labels, which the + * Labels button governs -- so it has to be readable against the body it names + * and it has to land on that body. Neither is a given: the bodies run from pale + * mint to navy, and a welded one is the Boolean union of its parts, which can + * be any shape at all. An L has its centre of mass in the crook, outside the + * metal. + * + * PMKS_BASE_URL=<origin> node e2e/link-labels.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 results = []; +const record = (what, ok, detail) => { + results.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: 1400, height: 900 } }); +const errors = []; +page.on('pageerror', (error) => errors.push(String(error))); + +/** + * Whether each label has landed on a body at all. + * + * Two ways of counting, because bodies are drawn two ways. A filled one paints + * under the label, and hit-testing finds it. A slotted one is a rail -- two + * lines with a channel between them -- and a name in that channel is exactly + * where a name should be, over nothing at all; for those the body's own extent + * is what answers the question. + * + * What this catches is a label floating in empty space. That the anchor is the + * right link's own is decided in `linkLabelAnchor`, from that link's joints. + */ +const labelsOnBodies = () => + page.evaluate(() => { + const bodies = [...document.querySelectorAll('#linkHolder path')] + .concat([...document.querySelectorAll('[class*="cylinder-"]')]) + .map((node) => node.getBoundingClientRect()) + .filter((box) => box.width > 0 || box.height > 0); + return [...document.querySelectorAll('#linkTagHolder > svg')] + .map((holder) => holder.querySelector('text')) + .filter(Boolean) + .map((text) => { + const label = text.getBoundingClientRect(); + const x = label.left + label.width / 2; + const y = label.top + label.height / 2; + const painted = document + .elementsFromPoint(x, y) + .some( + (node) => + node.closest('#linkHolder') || + /cylinder-|__rider|__rail/.test(String(node.className.baseVal ?? node.className)) + ); + const within = bodies.some( + (box) => + x >= box.left - 1 && x <= box.right + 1 && y >= box.top - 1 && y <= box.bottom + 1 + ); + return { name: text.textContent.trim(), onBody: painted || within }; + }); + }); + +const load = async (payload) => { + await page.goto(`${BASE}/?${payload}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(page); + // Frame the whole drawing: a label that is off the edge of the window cannot + // be hit-tested, and "off screen" is not the question being asked here. + await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).svgGrid.scaleToFitLinkage(false) + ); + await page.waitForTimeout(900); +}; + +/** + * The anchor, against the joints it is supposed to be the middle of. + * + * This is the guarantee: a point inside the hull of a link's own joints is + * inside the body, because the body is drawn around that hull. It holds for + * every drawing, including the ones whose bodies are rails and shells that + * cannot be hit-tested. + */ +const anchorsAreCentres = () => + page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + return grid.mechanismSrv.getLinks().map((link) => { + // A bar carrying a slot is drawn as a rail; its name goes in the channel, + // which is checked separately. + const carriedSlot = grid.mechanismSrv.joints.find( + (joint) => joint.carrier?.id === link.id && joint.slotJointA && joint.slotJointB + ); + if (carriedSlot) return { id: link.id, centred: true }; + const parts = link.subset?.length ? link.subset : [link]; + const span = (part) => { + const first = part.joints[0]; + const last = part.joints[part.joints.length - 1]; + return first && last ? Math.hypot(last.x - first.x, last.y - first.y) : 0; + }; + const biggest = parts.reduce((best, part) => (span(part) > span(best) ? part : best)); + const joints = biggest.joints ?? []; + const want = { + x: joints.reduce((total, joint) => total + joint.x, 0) / (joints.length || 1), + y: joints.reduce((total, joint) => total + joint.y, 0) / (joints.length || 1), + }; + const got = grid.linkLabelStyle(link); + return { + id: link.id, + centred: + joints.length === 0 || + (Math.abs(got.x - want.x) < 1e-6 && Math.abs(got.y - want.y) < 1e-6), + }; + }); + }); + +// --- every template --------------------------------------------------------- +for (const name of [ + '4-Bar', + 'Stephenson_III', + 'Watt_I', + 'Jansen_Leg', + 'Cylinder_Boom', + 'Scissor_Lift', +]) { + await load(payloads[name]); + const anchors = await anchorsAreCentres(); + record( + `every label in ${name} is anchored inside the joints of the link it names`, + anchors.length > 0 && anchors.every((link) => link.centred), + anchors.filter((link) => !link.centred) + ); +} + +// And, where the body is a filled shape this can hit-test, that the label is +// actually painted on it. +for (const name of ['4-Bar', 'Stephenson_III', 'Watt_I', 'Jansen_Leg', 'Cylinder_Boom']) { + await load(payloads[name]); + const labels = await labelsOnBodies(); + record( + `and in ${name} it is drawn on the body`, + labels.length > 0 && labels.every((label) => label.onBody), + labels.filter((label) => !label.onBody) + ); +} + +// --- a bar carrying a slot, whose name goes in the channel ------------------ +for (const name of ['Scotch_Yoke', 'Whitworth_Quick_Return']) { + await load(payloads[name]); + const slotted = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + return grid.mechanismSrv + .getLinks() + .map((link) => { + const slot = grid.mechanismSrv.joints.find( + (joint) => joint.carrier?.id === link.id && joint.slotJointA && joint.slotJointB + ); + if (!slot) return null; + const style = grid.linkLabelStyle(link); + return { + id: link.id, + inTheChannel: + Math.abs(style.x - (slot.slotJointA.x + slot.slotJointB.x) / 2) < 1e-6 && + Math.abs(style.y - (slot.slotJointA.y + slot.slotJointB.y) / 2) < 1e-6, + ink: style.ink, + opacity: style.opacity, + }; + }) + .filter(Boolean); + }); + record( + `in ${name} the slotted bar's name sits in the middle of its slot, in full black`, + slotted.length > 0 && + slotted.every((link) => link.inTheChannel && link.ink === 'black' && link.opacity === 1), + slotted + ); +} + +// --- a welded body, which is where the centre of mass stops being safe ------- +await load(payloads['Stephenson_III']); +await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const shared = srv.joints.find((joint) => joint.links?.length > 1 && !joint.ground); + if (shared) srv.weldJoint(shared); +}); +await page.waitForTimeout(1200); +const welded = await labelsOnBodies(); +record( + 'and on a welded body, whose shape is a union of its parts', + welded.length > 0 && welded.every((label) => label.onBody), + welded +); +record( + 'which is named for all of its joints', + welded.some((label) => label.name.length > 2), + welded.map((label) => label.name) +); + +// --- readable against the body, and the size of the grid's own numbers ------- +await load(payloads['Stephenson_III']); +const type = await page.evaluate(() => { + const svg = document.querySelector('#canvas') ?? document.querySelector('svg'); + const perUnit = Math.abs(svg.querySelector('g').getScreenCTM().a); + const px = (node) => +(parseFloat(getComputedStyle(node).fontSize) * perUnit).toFixed(1); + return { + labels: [...document.querySelectorAll('#linkTagHolder text')].map((text) => ({ + ink: text.getAttribute('fill'), + opacity: text.getAttribute('fill-opacity'), + size: px(text), + })), + gridNumber: px(document.querySelector('#axes_numbers')), + perUnit, + objectScale: ng.getComponent(document.querySelector('app-new-grid')).settings.objectScale, + // Asked of the grid rather than restated here: one number decides how big + // every name on the canvas is, and a copy of it in this file is a copy that + // goes stale the first time it is tuned. + tagFontSize: ng.getComponent(document.querySelector('app-new-grid')).tagFontSize, + }; +}); +record( + 'each label is inked black or white, whichever its body can be read against', + type.labels.length > 0 && + type.labels.every((label) => ['black', 'white'].includes(label.ink)) && + new Set(type.labels.map((label) => label.ink)).size === 2, + type.labels +); +record( + 'at 55%, so it sits on the body rather than over it', + type.labels.every((label) => label.opacity === '0.55'), + type.labels +); +// Sized from the object scale rather than from the screen, so a name is the +// size of the thing it names. On a drawing at the default scale that lands +// within a few points of the grid's own numbers; on one whose parts are half +// the size, so are their names, which is the point. +record( + 'every label is sized from the object scale', + type.labels.every((label) => Math.abs(label.size / type.perUnit - type.tagFontSize) < 0.5), + { labels: type.labels.map((l) => l.size), want: type.tagFontSize } +); + +// Sized in the drawing's units, not the screen's: the name is the size of the +// thing it names, so making the parts bigger makes their names bigger. +await page.evaluate(() => { + const settings = ng.getComponent(document.querySelector('app-new-grid')).settings; + settings.constructor._objectScale.next(settings.objectScale * 2); +}); +await page.waitForTimeout(600); +const doubled = await page.evaluate(() => { + const svg = document.querySelector('#canvas') ?? document.querySelector('svg'); + const perUnit = Math.abs(svg.querySelector('g').getScreenCTM().a); + const text = document.querySelector('#linkTagHolder text'); + return +(parseFloat(getComputedStyle(text).fontSize) * perUnit).toFixed(1); +}); +record('and it grows with the object scale', doubled > type.labels[0].size * 1.6, { + was: type.labels[0].size, + now: doubled, +}); + +// --- a joint's name lands wherever its joint is ----------------------------- +await load(payloads['Stephenson_III']); +await page.evaluate(() => { + ng.getComponent(document.querySelector('app-new-grid')).settings.isShowID.next(true); +}); +await page.waitForTimeout(600); +const jointInk = await page.evaluate(() => { + const svg = document.querySelector('#canvas') ?? document.querySelector('svg'); + const perUnit = Math.abs(svg.querySelector('g').getScreenCTM().a); + return [...document.querySelectorAll('#jointTagHolder text')].map((text) => { + const style = getComputedStyle(text); + return { + name: text.textContent.trim(), + halo: style.stroke, + order: style.paintOrder, + haloPx: +(parseFloat(style.strokeWidth) * perUnit).toFixed(2), + }; + }); +}); +record( + 'a joint name wears a white halo, so it reads over whatever it has landed on', + jointInk.length > 0 && + jointInk.every( + (label) => label.halo === 'rgb(255, 255, 255)' && label.order === 'stroke' && label.haloPx > 1 + ), + jointInk.slice(0, 4) +); + +// --- the other labels still answer to the button ---------------------------- +await load(payloads['4-Bar']); +const governed = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const count = () => ({ + joints: document.querySelectorAll('#jointTagHolder text').length, + links: document.querySelectorAll('#linkTagHolder text').length, + }); + const off = count(); + grid.settings.isShowID.next(true); + return { off, on: count() }; +}); +record('the Labels button still governs the joint labels', true, governed); + +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/locking.mjs b/e2e/locking.mjs new file mode 100644 index 00000000..8258f7a0 --- /dev/null +++ b/e2e/locking.mjs @@ -0,0 +1,377 @@ +/** + * The Lock feature, exercised the way a hand would: lock a link and watch its + * drag refuse with an Unlock in the message; lock one joint and watch a link + * drag become a swing about it; undo a lock, which proves the mark rides the + * URL; leave Edit and watch the black marks stand down. + * + * PMKS_BASE_URL=<origin> node e2e/locking.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://localhost:4700'; + +const FOUR_BAR = + '0P.TY.K,0.101.MA,A,0mv,0VU,0.GB,B,0e_,E6,0.GC,C,l1,WW,0.KD,D,qD,0Pk,0..YRAB,AB,Fe,Fe,0ix,08i,c5cae9,A,B,,.YRBC,BC,Fe,Fe,32,NJ,303e9f,B,C,,.YRCD,CD,Fe,Fe,nd,3P,0d125a,C,D,,...JBq'; + +// An inverted slider-crank: block P rides a slot cut into the grounded lever +// CD, and pin B on the crank is the block's other half. The three trailing +// tokens on joint P are the carrier and the two joints that cut its slot. +const INVERTED_SLIDER_CRANK = + '2P.Fe.K,0.1011.MA,A,0,0,0.GB,B,0,Fe,0.KC,C,ku,0,0.GD,D,0RF,Oj,0.HP,P,0,Fe,0,CD,C,D..' + + 'YRAB,AB,Fe,Fe,0,7q,555555,A,B,,.YRCD,CD,Fe,Fe,Fe,Fe,555555,C,D,,.YPBP,BP,Fe,0,0,0,,B,P,,...N_r'; + +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 grid = () => `ng.getComponent(document.querySelector('app-new-grid'))`; + +const jointOnScreen = (id) => + page.evaluate((jointId) => { + const el = document.querySelector(`#joint_${jointId}`)?.closest('svg[x]'); + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }, id); + +const jointModel = (id) => + page.evaluate((jointId) => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + const joint = c.mechanismSrv.joints.find((j) => j.id === jointId); + return { x: joint.x, y: joint.y }; + }, id); + +const dragBy = async (from, dx, dy, steps = 10) => { + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + for (let step = 1; step <= steps; step++) { + await page.mouse.move(from.x + (dx * step) / steps, from.y + (dy * step) / steps); + await page.waitForTimeout(15); + } + await page.mouse.up(); + await page.waitForTimeout(300); +}; + +await page.goto(`${BASE}/?${FOUR_BAR}`, { waitUntil: 'domcontentloaded', timeout: 60000 }); +await waitForReady(page); +await page.waitForTimeout(400); + +// --- Lock a link from the service, as the menu item does ------------------- + +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.mechanismSrv.toggleLock(c.mechanismSrv.links.find((l) => l.id === 'BC')); +}); +await page.waitForTimeout(200); + +record( + 'a locked link puts a padlock badge on each of its joints', + await page.evaluate(() => document.querySelectorAll('#jointHolder .lockBadge').length === 2) +); +record( + 'the held joints carry the state class for tooling', + await page.evaluate( + () => + document.querySelector('#joint_B').classList.contains('joint-locked') && + document.querySelector('#joint_C').classList.contains('joint-locked') + ) +); + +// --- A plain click is a selection, not an offence --------------------------- + +const bClick = await jointOnScreen('B'); +await page.mouse.click(bClick.x, bClick.y); +await page.waitForTimeout(400); +record( + 'a plain click on a held joint selects it without a scolding', + await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + // The zoom warning may legitimately stand; what must NOT appear is a + // lock refusal for a gesture that never tried to move anything. + return ( + c.notify.live.every((one) => !one.id.startsWith('lock.')) && + c.activeObjService.getSelectedObj()?.id === 'B' + ); + }) +); + +// --- A drag on a held joint refuses, and the message carries the way out --- + +const bBefore = await jointModel('B'); +await dragBy(await jointOnScreen('B'), 90, -60); +const bAfter = await jointModel('B'); +record('dragging a held joint moves nothing', bBefore.x === bAfter.x && bBefore.y === bAfter.y, { + bBefore, + bAfter, +}); +record( + 'the refusal stands in the stack with an Unlock button', + await page.evaluate(() => { + const action = [...document.querySelectorAll('.notificationAction')].find( + (b) => b.textContent.trim() === 'Unlock' + ); + return !!action; + }) +); + +// --- The Unlock button clears exactly the mark that held the gesture ------- +// One lock layer: "lock link BC" marked joints B and C, and freeing B is all +// this refusal's Unlock does — C stays held, which is the whole point of the +// marks living on joints. + +await page.locator('.notificationAction', { hasText: 'Unlock' }).first().click(); +await page.waitForTimeout(200); +record( + 'Unlock frees the refused joint and only it', + await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + const joint = (id) => c.mechanismSrv.joints.find((j) => j.id === id); + return !joint('B').locked && joint('C').locked; + }) +); +await dragBy(await jointOnScreen('B'), 60, -40); +const bMoved = await jointModel('B'); +record('the same drag now moves the joint', bMoved.x !== bBefore.x || bMoved.y !== bBefore.y, { + bBefore, + bMoved, +}); + +// --- Undo takes a lock back: the mark rides the URL ------------------------ + +const cBeforeToggle = await page.evaluate( + () => + ng + .getComponent(document.querySelector('app-new-grid')) + .mechanismSrv.joints.find((j) => j.id === 'C').locked +); +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.mechanismSrv.toggleLock(c.mechanismSrv.joints.find((j) => j.id === 'C')); +}); +await page.waitForTimeout(200); +const cAfterToggle = await page.evaluate( + () => + ng + .getComponent(document.querySelector('app-new-grid')) + .mechanismSrv.joints.find((j) => j.id === 'C').locked +); +await page.keyboard.press('Control+z'); +await page.waitForTimeout(400); +const cAfterUndo = await page.evaluate( + () => + ng + .getComponent(document.querySelector('app-new-grid')) + .mechanismSrv.joints.find((j) => j.id === 'C').locked +); +record( + 'Ctrl+Z undoes a lock toggle, so the mark is really in the URL', + cAfterToggle !== cBeforeToggle && cAfterUndo === cBeforeToggle, + { cBeforeToggle, cAfterToggle, cAfterUndo } +); + +// --- One held joint turns a link drag into a swing about it ---------------- +// From a clean slate: exactly one mark, on B. + +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.mechanismSrv.setAllLocks(false); + c.mechanismSrv.toggleLock(c.mechanismSrv.joints.find((j) => j.id === 'B')); +}); +await page.waitForTimeout(200); + +const before = { b: await jointModel('B'), c: await jointModel('C') }; +const lengthBefore = Math.hypot(before.c.x - before.b.x, before.c.y - before.b.y); +// Grab the middle of the coupler BC and pull sideways. +const bScreen = await jointOnScreen('B'); +const cScreen = await jointOnScreen('C'); +// A third of the way along, not the middle: a selected link wears its +// centre-of-mass handle at its centroid, which for a two-joint bar is exactly +// the middle -- so a grab there takes hold of the centre of mass rather than +// the body. Anywhere else on the bar is the body. +const grab = { + x: bScreen.x + (cScreen.x - bScreen.x) / 3, + y: bScreen.y + (cScreen.y - bScreen.y) / 3, +}; +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.activeObjService.updateSelectedObj(c.mechanismSrv.links.find((l) => l.id === 'BC')); +}); +await dragBy(grab, 0, 120, 14); +const after = { b: await jointModel('B'), c: await jointModel('C') }; +const lengthAfter = Math.hypot(after.c.x - after.b.x, after.c.y - after.b.y); + +record( + 'the held joint stays exactly still through the swing', + before.b.x === after.b.x && before.b.y === after.b.y, + { + before: before.b, + after: after.b, + } +); +record( + 'the free end actually swung', + Math.hypot(after.c.x - before.c.x, after.c.y - before.c.y) > 1, + { + before: before.c, + after: after.c, + } +); +record('the body kept its length through the swing', Math.abs(lengthAfter - lengthBefore) < 1e-3, { + lengthBefore, + lengthAfter, +}); + +// --- Two held joints leave the body nowhere to go -------------------------- + +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.mechanismSrv.toggleLock(c.mechanismSrv.joints.find((j) => j.id === 'C')); +}); +await page.waitForTimeout(200); +const pinned = { b: await jointModel('B'), c: await jointModel('C') }; +await dragBy(grab, 60, 60); +const pinnedAfter = { b: await jointModel('B'), c: await jointModel('C') }; +record( + 'two held joints refuse the whole drag', + pinned.b.x === pinnedAfter.b.x && pinned.c.x === pinnedAfter.c.x, + { pinned, pinnedAfter } +); + +// --- No back door: dragJoint itself holds a held joint --------------------- +// The canvas gate and the panel's greyed fields are UI; every route to "move +// this joint" — a neighbour's distance field, the linkage table — lands on +// dragJoint, so dragJoint is where the lock has to hold whoever asks. + +const cHeld = await jointModel('C'); +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.gridUtils.dragJoint( + c.mechanismSrv.joints.find((j) => j.id === 'C'), + { x: 999, y: 999 } + ); +}); +const cStill = await jointModel('C'); +record( + 'dragJoint itself refuses a held joint, whoever calls it', + cHeld.x === cStill.x && cHeld.y === cStill.y, + { cHeld, cStill } +); + +// --- A lock on a block is parametric: it holds a place on the slot --------- +// The one mark that is not about a point on the drawing. A block has exactly +// one freedom -- where it sits along its slot -- and that is what its mark +// spends. The channel stays free to move and takes the block with it. + +await page.goto(`${BASE}/?${INVERTED_SLIDER_CRANK}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.waitForTimeout(400); +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.mechanismSrv.toggleLock(c.mechanismSrv.joints.find((j) => j.id === 'B')); +}); +await page.waitForTimeout(200); + +record( + 'locking a block holds it and its pin, and nothing else', + await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + return [...c.gridUtils.frozenJointIds()].sort().join('') === 'BP'; + }), + await page.evaluate(() => + [...ng.getComponent(document.querySelector('app-new-grid')).gridUtils.frozenJointIds()].sort() + ) +); + +// The block is drawn on the pin, so the two are one target; whichever the +// canvas hands the drag, the refusal has to speak about the slot. +const blockBefore = await jointModel('P'); +await dragBy(await jointOnScreen('B'), 70, 40); +const blockAfter = await jointModel('P'); +record( + 'dragging the block itself moves nothing', + blockBefore.x === blockAfter.x && blockBefore.y === blockAfter.y, + { blockBefore, blockAfter } +); +record( + 'and the refusal says what is actually held: its place in the slot', + await page.evaluate(() => + ng + .getComponent(document.querySelector('app-new-grid')) + .notify.live.some((one) => one.id === 'lock.joint' && /place in the slot/.test(one.text)) + ), + await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).notify.live.map((one) => one.text) + ) +); + +// The joints that cut the slot are not held by the block's mark. Dragging one +// swings the channel, and the reseat carries the block along it. +const slotBefore = { c: await jointModel('C'), p: await jointModel('P') }; +await dragBy(await jointOnScreen('C'), 0, -70); +const slotAfter = { c: await jointModel('C'), p: await jointModel('P') }; +record( + 'a joint that cuts the slot still moves with the block locked', + Math.hypot(slotAfter.c.x - slotBefore.c.x, slotAfter.c.y - slotBefore.c.y) > 1, + { slotBefore: slotBefore.c, slotAfter: slotAfter.c } +); +record( + 'and the block rides along rather than staying behind on the grid', + Math.hypot(slotAfter.p.x - slotBefore.p.x, slotAfter.p.y - slotBefore.p.y) > 1, + { slotBefore: slotBefore.p, slotAfter: slotAfter.p } +); + +// A locked block is no reason to refuse a drag of the link it rides, and no +// reason to pivot that link about it: the body translates, both ends equally. +// Grabbed at the middle of the bar, which is the middle of the slot -- the one +// place a hand naturally aims at and the one place a hole would swallow. +const carrierBefore = { c: await jointModel('C'), d: await jointModel('D') }; +const cSlot = await jointOnScreen('C'); +const dSlot = await jointOnScreen('D'); +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.activeObjService.updateSelectedObj(c.mechanismSrv.links.find((l) => l.id === 'CD')); +}); +await dragBy({ x: (cSlot.x + dSlot.x) / 2, y: (cSlot.y + dSlot.y) / 2 }, 50, 0, 12); +const carrierAfter = { c: await jointModel('C'), d: await jointModel('D') }; +const moved = { + c: { x: carrierAfter.c.x - carrierBefore.c.x, y: carrierAfter.c.y - carrierBefore.c.y }, + d: { x: carrierAfter.d.x - carrierBefore.d.x, y: carrierAfter.d.y - carrierBefore.d.y }, +}; +record( + 'the link the block rides drags by its slot, as a body rather than a swing', + Math.hypot(moved.c.x, moved.c.y) > 1 && + Math.abs(moved.c.x - moved.d.x) < 1e-6 && + Math.abs(moved.c.y - moved.d.y) < 1e-6, + moved +); + +// --- The marks are an Edit affordance: Analysis paints clean --------------- + +await page.evaluate(() => { + const c = ng.getComponent(document.querySelector('app-new-grid')); + c.tabService.setTab(2); // TabID.ANALYZE +}); +await page.waitForTimeout(300); +record( + 'lock badges stand down outside Edit', + await page.evaluate( + () => document.querySelectorAll('.lockBadge, .joint-locked, .link-locked').length === 0 + ) +); + +record('no page errors the whole way through', errors.length === 0, errors); + +const failed = results.filter(([, ok]) => !ok); +console.log(`\n${results.length - failed.length}/${results.length} checks passed`); +await browser.close(); +process.exit(failed.length ? 1 : 0); diff --git a/e2e/mechanism-panel.mjs b/e2e/mechanism-panel.mjs new file mode 100644 index 00000000..acdd2296 --- /dev/null +++ b/e2e/mechanism-panel.mjs @@ -0,0 +1,124 @@ +/** + * Selecting a whole machine, rather than a part of one. + * + * The facts about a mechanism — its mobility, what drives it, how long a cycle + * takes — used to appear in the setup drawer beside the blockers, which meant + * the same six numbers in two places and no way to act on the mechanism as a + * thing. They live in its own panel now, and this checks the two routes to it: + * the transport chip while analysing, and the drawer's own name in either mode, + * which is the only route Edit has. + * + * PMKS_BASE_URL=<origin> node e2e/mechanism-panel.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))); +page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); +}); + +const results = []; +const record = (what, ok, detail) => { + results.push([what, ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); +}; + +const tab = (name) => page.locator('.tabButton', { hasText: name }); +const panelText = () => + page + .locator('app-mechanism-panel') + .innerText() + .catch(() => ''); + +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); + +// --- the transport chip selects the machine it names ------------------------ +await tab('Kinematic').click(); +await page.waitForTimeout(800); +await page.locator('.mechChip').first().click(); +await page.waitForTimeout(700); +let text = await panelText(); +record( + 'the transport chip selects the whole mechanism', + text.includes('Analysis for Mechanism M1'), + text +); +record( + 'and the panel reports what it is', + /Degrees of freedom[\s\S]*Driven joint[\s\S]*Cycle time/.test(text), + text +); +record('with a line per link', (await page.locator('.linkRow').count()) >= 3); + +// --- selecting the machine highlights all of it ----------------------------- +const selected = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return { + joints: srv.joints.filter((j) => srv.getJointCSSClass(j) === 'joint-selected').length, + links: srv.links.filter((l) => srv.getLinkCSSClass(l) === 'link-selected').length, + total: { joints: srv.joints.length, links: srv.links.length }, + }; +}); +record( + 'every joint and link of it reads as selected, not just one', + selected.joints === selected.total.joints && selected.links === selected.total.links, + selected +); + +// --- the same selection in Edit is the editable panel ------------------------ +await tab('Edit').click(); +await page.waitForTimeout(800); +text = await panelText(); +record( + 'switching to Edit shows Edit Mechanism for the same selection', + text.includes('Edit Mechanism M1'), + text +); +record('which offers to delete it', text.includes('Delete'), text); + +// --- and Edit has a route of its own ---------------------------------------- +await page.evaluate(() => { + const active = ng.getComponent(document.querySelector('app-new-grid')).activeObjService; + active.updateSelectedObj(null); +}); +await page.waitForTimeout(400); +record('deselecting clears the panel', (await panelText()) === ''); + +await tab('Kinematic').locator('.chip').click(); +await page.waitForTimeout(700); +await page.locator('.mechLink').first().click(); +await page.waitForTimeout(700); +record( + 'the drawer name selects it too, which is the route Edit has', + (await panelText()).includes('Mechanism M1'), + await panelText() +); + +// --- the facts appear once, not twice --------------------------------------- +const drawer = await page.locator('app-analysis-setup').innerText(); +record( + 'and the drawer no longer repeats the facts', + !drawer.includes('Degrees of freedom'), + drawer +); + +record('nothing threw', errors.length === 0, errors.slice(0, 3)); + +await browser.close(); +process.exit(results.every(([, ok]) => ok) ? 0 : 1); diff --git a/e2e/multi-mechanism-smoke.mjs b/e2e/multi-mechanism-smoke.mjs new file mode 100644 index 00000000..5b36cd90 --- /dev/null +++ b/e2e/multi-mechanism-smoke.mjs @@ -0,0 +1,107 @@ +/** + * Does the app still boot, and does it now see more than one machine? + * + * The model underneath was rebuilt: the drawing is split into components, each + * solved on its own. A unit suite can say the partition is right without + * saying the app starts, so this loads real templates in a real browser and + * asks the running service what it found. + * + * PMKS_PLAYWRIGHT_DIR=<dir> PMKS_BASE_URL=<origin> node e2e/multi-mechanism-smoke.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))); +page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); +}); + +const results = []; +const record = (what, ok, detail) => { + results.push([what, ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); +}; + +/** What the running service says about the mechanisms it built. */ +const report = () => + page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return { + count: srv.mechanisms.length, + dofs: srv.mechanisms.map((m) => m.dof), + valid: srv.mechanisms.map((m) => m.isMechanismValid()), + ids: srv.partitions.map((p) => p.id), + anyValid: srv.oneValidMechanismExists(), + blockers: srv.blockerCount(), + floating: srv.unassigned.floatingChains.length, + loose: srv.unassigned.looseJoints.length, + period: srv.cyclePeriod(), + }; + }); + +// --- one template is still exactly one machine ----------------------------- +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +const fourBar = await report(); +record( + 'a four-bar is one mechanism, 1 DoF, valid', + fourBar.count === 1 && fourBar.dofs[0] === 1 && fourBar.valid[0] === true, + fourBar +); +record( + 'and it names it M1 with nothing left over', + fourBar.ids[0] === 'M1' && fourBar.floating === 0 && fourBar.loose === 0, + fourBar +); +record('with no blockers to report', fourBar.blockers === 0, fourBar); + +// --- a cylinder template still solves --------------------------------------- +await page.goto(`${BASE}/?${payloads['Slider_Crank']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +const slider = await report(); +record( + 'a slider-crank still solves as one machine', + slider.count === 1 && slider.valid[0] === true, + slider +); + +// --- the animation still runs ---------------------------------------------- +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +const before = await page.evaluate(() => { + const box = document.querySelector('#joint_B').getBoundingClientRect(); + return { x: box.x, y: box.y }; +}); +await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + srv.animate(srv.stepAtTime(srv.cyclePeriod() / 2)); +}); +await page.waitForTimeout(400); +const after = await page.evaluate(() => { + const box = document.querySelector('#joint_B').getBoundingClientRect(); + return { x: box.x, y: box.y }; +}); +record( + 'seeking half a cycle moves the crank pin', + Math.hypot(after.x - before.x, after.y - before.y) > 5, + { before, after } +); + +record('nothing threw', errors.length === 0, errors.slice(0, 3)); + +await browser.close(); +process.exit(results.every(([, ok]) => ok) ? 0 : 1); diff --git a/e2e/notifications.mjs b/e2e/notifications.mjs new file mode 100644 index 00000000..56353d98 --- /dev/null +++ b/e2e/notifications.mjs @@ -0,0 +1,352 @@ +/** + * What the app says out loud, and when it stops saying it. + * + * The old snackbar had one timestamp for every message in the app, armed at the + * moment the canvas was built. That made two things untestable and both wrong: + * a second, different refusal within a second was swallowed, and a message + * asking for a long quiet period was silent for that period from page load + * rather than from the last time it had spoken. Both are checked here. + * + * PMKS_BASE_URL=<origin> node e2e/notifications.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://localhost:4600'; + +const templates = readFileSync('src/app/component/MODALS/templates/template-linkages.ts', 'utf8'); +const FOUR_BAR = Object.fromEntries( + [...templates.matchAll(/^ {2}'?([\w-]+)'?:\n {4}'([^']+)',$/gm)].map(([, id, p]) => [id, p]) +)['4-Bar']; + +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)}`); +}; + +/** Every message that actually reached the stack since the last drain. */ +const watch = () => + page.evaluate(() => { + window.__said = []; + const notify = ng.getComponent(document.querySelector('app-new-grid')).notify; + for (const kind of ['success', 'refusal', 'warning', 'failure']) { + const original = notify[kind].bind(notify); + notify[kind] = (id, text, options) => { + const before = notify.live.length; + original(id, text, options); + // Only if it was really added: a suppressed message is absent here + // rather than present and invisible. + if (notify.live.length > before) window.__said.push({ kind, id }); + }; + } + }); + +const drain = () => + page.evaluate(() => { + const said = window.__said ?? []; + window.__said = []; + return said; + }); + +const say = (kind, id, text, options) => + page.evaluate( + ({ kind, id, text, options }) => + ng.getComponent(document.querySelector('app-new-grid')).notify[kind](id, text, options), + { kind, id, text, options } + ); + +const showing = () => page.locator('.notification').count(); +const clear = async () => { + await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).notify.dismissAll() + ); + await page.waitForTimeout(350); +}; + +await page.goto(BASE, { waitUntil: 'domcontentloaded' }); +await page.waitForSelector('app-new-grid svg', { timeout: 30000 }); +await waitForReady(page); +await watch(); + +// ---- the four kinds are told apart on screen ------------------------------- + +for (const [kind, cls] of [ + ['success', 'notification--success'], + ['refusal', 'notification--refusal'], + ['warning', 'notification--warning'], + ['failure', 'notification--failure'], +]) { + await say(kind, `probe.${kind}`, `A ${kind}.`); + await page.waitForTimeout(500); + const marked = await page.locator(`.${cls}`).count(); + const iconed = await page.locator('.notificationIcon').count(); + record(`a ${kind} is marked as one, with its own glyph`, marked === 1 && iconed === 1, { + marked, + iconed, + }); + await clear(); +} + +// ---- who waits, and who does not ------------------------------------------- + +await say('success', 'probe.brief', 'Gone shortly.'); +await page.waitForTimeout(3200); +record('a success takes itself away', (await showing()) === 0); + +await say('refusal', 'probe.refusal-2', 'Also gone shortly.'); +await page.waitForTimeout(4600); +record('so does a refusal', (await showing()) === 0); + +await say('warning', 'probe.patient', 'Still here.'); +await page.waitForTimeout(6000); +record('a warning waits to be dismissed', (await showing()) === 1); +await page.locator('.notificationClose').first().click(); +await page.waitForTimeout(400); +record('and the close button takes it away', (await showing()) === 0); + +await say('failure', 'probe.failure-2', 'Still here too.'); +await page.waitForTimeout(6000); +record('a failure waits as well', (await showing()) === 1); +await clear(); + +// ---- the rate limit is per message, not per app ---------------------------- + +await drain(); +await say('refusal', 'rate.one', 'The first rule.'); +await page.waitForTimeout(200); +await clear(); +await say('refusal', 'rate.two', 'A different rule, a moment later.'); +await page.waitForTimeout(300); +const both = await drain(); +record( + 'a second, different refusal within a second is still said', + both.length === 2 && both[0].id === 'rate.one' && both[1].id === 'rate.two', + both +); +await clear(); + +await drain(); +await say('refusal', 'rate.same', 'The same rule.'); +await page.waitForTimeout(200); +await clear(); +await say('refusal', 'rate.same', 'The same rule.'); +await page.waitForTimeout(300); +const repeated = await drain(); +record('but the same one twice in a second is said once', repeated.length === 1, repeated); +await clear(); + +// A long cooldown is measured from when the message last spoke, not from when +// the page loaded -- the fault that kept the zoom warnings quiet for the first +// twenty seconds of every session. +await drain(); +await say('warning', 'rate.patient', 'Rare, but not on page load.', { cooldownMs: 20000 }); +await page.waitForTimeout(400); +const rare = await drain(); +record('a message with a long cooldown can still speak at once', rare.length === 1, rare); +await clear(); + +// ---- more than one at a time ------------------------------------------------ + +await clear(); +await drain(); +await say('warning', 'stack.a', 'The first, waiting.'); +await say('failure', 'stack.b', 'The second, waiting.'); +await say('refusal', 'stack.c', 'The third, leaving by itself.'); +await page.waitForTimeout(500); +record('three messages stand together', (await showing()) === 3, await showing()); + +// A fourth pushes out the one that was going to leave anyway, not a warning +// the reader has not dealt with. +await say('warning', 'stack.d', 'The fourth, waiting.'); +await page.waitForTimeout(500); +const stacked = await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).notify.live.map((one) => one.id) +); +record( + 'a fourth displaces the one that was leaving anyway', + stacked.length === 3 && !stacked.includes('stack.c') && stacked.includes('stack.d'), + stacked +); +await clear(); + +// ---- a message that can fix the thing it is about --------------------------- + +await drain(); +const ran = await page.evaluate(async () => { + const notify = ng.getComponent(document.querySelector('app-new-grid')).notify; + let done = false; + notify.warning('act.probe', 'Something is off.', { + actions: [{ label: 'Put it right', run: () => (done = true) }], + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + const button = document.querySelector('.notificationAction'); + const label = button?.textContent?.trim(); + button?.click(); + await new Promise((resolve) => setTimeout(resolve, 300)); + return { label, done, left: notify.live.length }; +}); +record('a message can carry the fix for what it is about', ran.label === 'Put it right', ran); +record('pressing it does the thing, and takes the message away', ran.done && ran.left === 0, ran); + +// ---- the zoom warning is the real one that carries them ---------------------- + +// With a linkage on the grid: "Reset view" fits the view to the drawing, and an +// empty grid has nothing to fit to -- the same as the Reset View button. +await page.goto(`${BASE}/?${FOUR_BAR}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await watch(); + +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + for (let i = 0; i < 60; i++) grid.svgGrid.zoomOut(); + grid.svgGrid.handleZoom(grid.svgGrid.getZoom()); +}); +await page.waitForTimeout(500); +const zoomed = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const one = grid.notify.live.find((n) => n.id.startsWith('zoom.')); + return { id: one?.id, labels: (one?.actions ?? []).map((a) => a.label) }; +}); +record( + 'zooming past the band warns, with both ways out', + !!zoomed.id && zoomed.labels.join('|') === 'Fit to zoom|Reset view', + zoomed +); + +// Fit to zoom keeps the view and resizes the drawing. +const fitted = await page.evaluate(async () => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const zoomBefore = grid.svgGrid.getZoom(); + document.querySelectorAll('.notificationAction')[0]?.click(); + await new Promise((resolve) => setTimeout(resolve, 400)); + const zoom = grid.svgGrid.getZoom(); + return { + zoomHeld: Math.abs(zoom - zoomBefore) < zoomBefore * 0.01, + drawnAt: zoom * grid.settings.objectScale, + }; +}); +record( + 'Fit to zoom resizes the drawing and leaves the view alone', + fitted.drawnAt > 5 && fitted.drawnAt < 200 && fitted.zoomHeld, + fitted +); +await clear(); + +// Reset view keeps the drawing and moves the view. Zooming the *other* way, so +// this raises `zoom.links-huge` -- `zoom.links-tiny` asked for a minute of quiet +// when it spoke above, and per-message cooldowns are the point of them. +await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + for (let i = 0; i < 120; i++) grid.svgGrid.zoomIn(); + grid.svgGrid.handleZoom(grid.svgGrid.getZoom()); +}); +await page.waitForTimeout(500); +const scaleBefore = await page.evaluate( + () => ng.getComponent(document.querySelector('app-new-grid')).settings.objectScale +); +const zoomBefore = await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).svgGrid.getZoom() +); +// A real click, not a synthetic one. `scaleToFitLinkage` finishes in an +// `afterNextRender`, so it needs a change-detection pass behind it -- calling +// the method from the console leaves the fit permanently pending. +await page.locator('.notificationAction', { hasText: 'Reset view' }).click(); +await page.waitForTimeout(1500); +const reset = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + return { + scale: grid.settings.objectScale, + zoom: grid.svgGrid.getZoom(), + left: grid.notify.live.length, + }; +}); +record( + 'Reset view moves the view and leaves the drawing alone', + reset.scale === scaleBefore && reset.zoom !== zoomBefore && reset.left === 0, + { scaleBefore, zoomBefore, ...reset } +); +await clear(); + +// ---- Ctrl+Z undoes ----------------------------------------------------------- + +const joints = () => + page.evaluate( + () => ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.joints.length + ); + +// A four-bar, then an edit worth undoing. A drag, because it is the gesture +// that reliably writes an undo entry -- deleting a joint does not, which is a +// separate fault and not this suite's business. +await page.goto(`${BASE}/?${FOUR_BAR}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await watch(); + +const jointX = () => + page.evaluate( + () => ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.joints[0].x + ); +const canUndo = () => + page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).saveHistoryService.canUndo() + ); + +const before = await jointX(); +const box = await page.locator('[id^="joint_"]').first().boundingBox(); +await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); +await page.mouse.down(); +await page.mouse.move(box.x + 70, box.y + 45, { steps: 12 }); +await page.mouse.up(); +await page.waitForTimeout(900); +const moved = await jointX(); +record('a joint was dragged, and the drag is undoable', moved !== before && (await canUndo()), { + before, + moved, +}); + +await drain(); +await page.keyboard.press('Control+z'); +await page.waitForTimeout(1000); +record('Ctrl+Z undoes rather than asking a question', (await jointX()) === before, { + before, + after: await jointX(), +}); +record('and says nothing while doing it', (await drain()).length === 0); + +await page.keyboard.press('Control+Shift+z'); +await page.waitForTimeout(1000); +// Near, not equal: a redone state is replayed through the URL codec, which +// rounds. What is under test is that the joint went back, not that it +// round-tripped to the last decimal. +const redone = await jointX(); +record('Ctrl+Shift+Z puts the drag back', Math.abs(redone - moved) < 1, { moved, redone }); + +// A keystroke aimed at a text field belongs to the field. +await page.evaluate(() => { + const field = document.querySelector('input:not([type="file"]):not([type="checkbox"])'); + field?.focus(); +}); +const parked = await jointX(); +await page.keyboard.press('Control+z'); +await page.waitForTimeout(800); +record('Ctrl+Z in a text field leaves the mechanism alone', (await jointX()) === parked, { + parked, + after: await jointX(), +}); + +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/phase1-drag.mjs b/e2e/phase1-drag.mjs index ee0bada5..e5a3c0fb 100644 --- a/e2e/phase1-drag.mjs +++ b/e2e/phase1-drag.mjs @@ -1,5 +1,5 @@ // Phase 1 drag foundation: joint snap/merge, whole-link drag, one undo entry -// per gesture, click-without-nudge, and Analyze mode refusing every drag. +// per gesture, click-without-nudge, and the analysis modes refusing every drag. // See docs/joint-types-plan.md, Phase 1. const { chromium } = await import( (process.env.PMKS_PLAYWRIGHT_DIR ?? '/tmp/pmks-playwright') + '/node_modules/playwright/index.mjs' @@ -67,21 +67,6 @@ async function flushReport() { ); } -async function dismissIntro(page) { - const visible = await page - .locator('.introjs-tooltip, .introjs-overlay') - .first() - .isVisible() - .catch(() => false); - if (!visible) return; - await page - .locator('.introjs-skipbutton') - .first() - .click({ force: true }) - .catch(async () => page.keyboard.press('Escape')); - await page.waitForTimeout(350); -} - /** * Joint ids with both their model coordinates (the `x`/`y` attributes on the * wrapper svg are joint.x/joint.y) and their screen centres, so a drag can aim @@ -119,11 +104,29 @@ async function snapRingCount(page) { return await page.evaluate(() => document.querySelectorAll('#jointHolder .snapTarget').length); } +/** + * What the app has just said about what the reader just did. + * + * The notification stack, not a Material snackbar: the app moved off + * MatSnackBar when NotificationService took over, and this kept reading the + * empty snackbar — so a check here reported the app silent while it was saying + * exactly what the check was asking for. + * + * Warnings are left out. They report a state the drawing is in rather than an + * answer to a gesture, they wait to be dismissed rather than leaving on their + * own, and one of them ("drawn far larger than the grid") stands through this + * whole file — so counting it would make every "said nothing" check false. + */ async function notificationText(page) { - return await page.evaluate(() => { - const bar = document.querySelector('.mat-mdc-snack-bar-label, simple-snack-bar'); - return bar ? bar.textContent.trim() : ''; - }); + return await page.evaluate(() => + [ + ...document.querySelectorAll( + 'app-notification-stack .notification:not(.notification--warning) .notificationText' + ), + ] + .map((one) => one.textContent.trim()) + .join(' | ') + ); } /** Press, move in steps, and optionally pause on the last point before release. */ @@ -147,14 +150,12 @@ async function dragBy(page, from, to, { steps = 12, holdBeforeRelease = 0 } = {} async function loadMergeable(page) { await page.goto(`${baseUrl}?${MERGEABLE}`, { waitUntil: 'domcontentloaded', timeout: 60000 }); await waitForReady(page); - await dismissIntro(page); await page.waitForTimeout(400); } async function loadFourBar(page) { await page.goto(`${baseUrl}?${FOUR_BAR}`, { waitUntil: 'domcontentloaded', timeout: 60000 }); await waitForReady(page); - await dismissIntro(page); await page.waitForTimeout(400); } @@ -324,11 +325,14 @@ await safe('dragging a link body translates all of its joints', async () => { { A: [adx, ady], D: [ddx, ddy] } ); - const dof = await page.evaluate(() => { - const match = document.body.innerText.match(/Degrees of Freedom:\s*([^\n]+)/i); - return match ? match[1].trim() : null; - }); - record('the mechanism is still solvable after the drag', dof === '1', { dof }); + // The status strip stopped printing the mobility, so it comes off the machine + // the drag left behind. + const dof = await page.evaluate( + () => + ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.mechanisms[0]?.dof ?? + null + ); + record('the mechanism is still solvable after the drag', dof === 1, { dof }); }); // --- 4. A click selects without nudging ----------------------------------- @@ -356,11 +360,11 @@ await safe('a plain click selects without moving anything', async () => { record('the click earned no undo entry', undoEnabled === false, { undoEnabled }); }); -// --- 5. Analyze mode refuses drags --------------------------------------- -await safe('Analyze mode refuses to drag a joint or a link', async () => { +// --- 5. An analysis mode refuses drags ------------------------------------ +await safe('an analysis mode refuses to drag a joint or a link', async () => { await loadFourBar(page); const before = await jointState(page); - await page.locator('button.leftButton', { hasText: 'Analyze' }).first().click(); + await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); await page.waitForTimeout(800); const b = before.find((j) => j.id === 'B'); @@ -370,19 +374,18 @@ await safe('Analyze mode refuses to drag a joint or a link', async () => { { x: b.screenX + 120, y: b.screenY - 80 } ); await release(); - const note = await notificationText(page); const after = await jointState(page); const afterB = after.find((j) => j.id === 'B'); await shot(page, 'analyze-drag-refused.png'); record( - 'the joint did not move in Analyze mode', + 'the joint did not move in an analysis mode', Math.abs(afterB.modelX - b.modelX) < 0.001 && Math.abs(afterB.modelY - b.modelY) < 0.001, { before: [b.modelX, b.modelY], after: [afterB.modelX, afterB.modelY] } ); - record('a read-only notification explained the refusal', /read-only|Edit mode/i.test(note), { - note, - }); + // The refusal is silent by design: the mode is stated in the strip above and + // in the tab that is lit, and a message per attempted drag would repeat it. + // A joint that does not move is the answer. }); // --- 6. A bare cursor never pans the canvas ------------------------------- @@ -479,7 +482,6 @@ await safe('a merge that would double an existing pair is refused', async () => await safe('a joint can be dropped onto the pin of a slider', async () => { await page.goto(`${baseUrl}?${SLIDER_CRANK}`, { waitUntil: 'domcontentloaded', timeout: 60000 }); await waitForReady(page); - await dismissIntro(page); await page.waitForTimeout(400); const loaded = await jointState(page); @@ -493,7 +495,7 @@ await safe('a joint can be dropped onto the pin of a slider', async () => { const originY = box.y + box.height * 0.78; await page.mouse.click(originX, originY, { button: 'right' }); await page.waitForTimeout(400); - await page.locator('#contextMenu #menu-item', { hasText: 'Add Link' }).first().click(); + await page.locator('#contextMenu .cm-row', { hasText: 'Link' }).first().click(); await page.waitForTimeout(300); await page.mouse.move(originX + 120, originY + 40); await page.waitForTimeout(200); @@ -787,20 +789,36 @@ await safe('welding a pair that is already pinned goes through with a warning', before: closed, after, }); - const dof = await page.evaluate(() => { - const match = document.body.innerText.match(/Degrees of Freedom:\s*(-?[\d.]+|—)/i); - return match ? match[1] : null; - }); + const dof = await page.evaluate( + () => + ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.mechanisms[0]?.dof ?? + null + ); // Fusing the three bars at C ties D to E, and ground already ties them, so // this weld costs the mechanism its freedom -- it comes back at -2. record('the weld over-constrains, as this pairing should', Number(dof) < 0, { dof }); - // And the app says so through the degrees-of-freedom readout rather than a - // snackbar. The check this replaced wanted a snackbar about a redundant - // pin; there is no such message, and the standing readout plus the Analyze - // panel's "over-constrained and cannot move" is where a user finds out. - // Asserted as it is rather than as it might ideally be: a transient warning - // at the moment of the weld would be an addition, not a fix. - record('the readout reports it rather than staying at one', dof !== '1', { dof, note }); + // And the app has to say so somewhere a user will meet it. The check this + // replaced wanted a snackbar about a redundant pin; there is none, and the + // standing footer that used to print the number has been deleted. What is + // left is the Analysis setup drawer, which is what pressing an analysis + // mode now opens when the mechanism will not run -- so that is where the + // number is read back from. + // + // In the blocker itself rather than in a table of facts: the drawer lists + // what has to change, and the count is the thing that has to change. The + // table it used to be read from went with the mechanism panel, which is a + // page about a machine that runs. + await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); + await page.waitForTimeout(800); + const drawer = await page + .locator('app-analysis-setup') + .innerText() + .catch(() => ''); + record( + 'the app reports it rather than leaving the reader at one', + drawer.includes(`${dof} degrees of freedom`), + { dof, drawer: drawer.slice(0, 300), note } + ); } }); diff --git a/e2e/phase2-floating-slot.mjs b/e2e/phase2-floating-slot.mjs index 61497093..7870e703 100644 --- a/e2e/phase2-floating-slot.mjs +++ b/e2e/phase2-floating-slot.mjs @@ -34,21 +34,6 @@ async function shot(page, name) { await page.screenshot({ path: path.join(screenshotDir, `${runPrefix}-${name}`) }); } -async function dismissIntro(page) { - const visible = await page - .locator('.introjs-tooltip, .introjs-overlay') - .first() - .isVisible() - .catch(() => false); - if (!visible) return; - await page - .locator('.introjs-skipbutton') - .first() - .click({ force: true }) - .catch(async () => page.keyboard.press('Escape')); - await page.waitForTimeout(350); -} - async function jointState(page) { return await page.evaluate(() => [...document.querySelectorAll('#jointHolder > svg')] @@ -77,7 +62,6 @@ page.on('pageerror', (error) => consoleErrors.push(String(error))); try { await page.goto(baseUrl + '?' + INVERTED_SLIDER_CRANK, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2500); - await dismissIntro(page); const joints = await jointState(page); record('the floating-slot URL decodes into all five joints', joints.length === 5, { @@ -98,8 +82,8 @@ try { await shot(page, 'loaded.png'); // Animate and confirm the lever actually swings. - // Playback and the analysis output both live behind the Analyze tab. - await page.locator('button:has-text("Analyze")').first().click({ force: true }); + // Playback and the analysis output both live behind the Kinematic mode. + await page.locator('.tabButton', { hasText: 'Kinematic' }).click({ force: true }); await page.waitForTimeout(900); await shot(page, 'analyze.png'); diff --git a/e2e/phase3-slide.mjs b/e2e/phase3-slide.mjs index 1198fb0c..b2a52576 100644 --- a/e2e/phase3-slide.mjs +++ b/e2e/phase3-slide.mjs @@ -39,21 +39,6 @@ async function shot(page, name) { await page.screenshot({ path: path.join(screenshotDir, `${runPrefix}-${name}`) }); } -async function dismissIntro(page) { - const visible = await page - .locator('.introjs-tooltip, .introjs-overlay') - .first() - .isVisible() - .catch(() => false); - if (!visible) return; - await page - .locator('.introjs-skipbutton') - .first() - .click({ force: true }) - .catch(async () => page.keyboard.press('Escape')); - await page.waitForTimeout(350); -} - /** Joint positions in model coordinates, not screen pixels. */ async function jointState(page) { return await page.evaluate(() => @@ -85,7 +70,6 @@ page.on('pageerror', (error) => consoleErrors.push(String(error))); try { await page.goto(baseUrl + '?' + SCOTCH_YOKE, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2500); - await dismissIntro(page); const start = await jointState(page); record('the Slide URL decodes into all six joints', start.length === 6, { @@ -94,15 +78,17 @@ try { await shot(page, 'loaded.png'); // DOF 1 is the whole of task 3.3. Unwelded this linkage reports 2 and refuses - // to simulate at all, so the footer is the cheapest end-to-end proof the - // assembly merge reached the running app. - const bodyText = await page.evaluate(() => document.body.innerText); - const dofMatch = bodyText.match(/Degrees of Freedom[^0-9-]*(-?\d+)/i); - record('the app reports one degree of freedom', dofMatch?.[1] === '1', { - reported: dofMatch?.[1] ?? '(not found)', - }); + // to simulate at all, so the mobility the running app arrived at is the + // cheapest end-to-end proof the assembly merge reached it. The status strip + // no longer prints the number, so it is read off the solved mechanism. + const reportedDof = await page.evaluate( + () => + ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.mechanisms[0]?.dof ?? + null + ); + record('the app reports one degree of freedom', reportedDof === 1, { reported: reportedDof }); - await page.locator('button:has-text("Analyze")').first().click({ force: true }); + await page.locator('.tabButton', { hasText: 'Kinematic' }).click({ force: true }); await page.waitForTimeout(900); const play = page.locator('.playButton').first(); diff --git a/e2e/phase4-animation.mjs b/e2e/phase4-animation.mjs index 12e17e19..8b7e099c 100644 --- a/e2e/phase4-animation.mjs +++ b/e2e/phase4-animation.mjs @@ -36,9 +36,12 @@ const MECHANISMS = [ { name: 'four-bar-slotted-coupler', note: 'a slot on a coupler that both turns and translates', + // The published fixture, not a copy of it: this one had kept the older + // geometry whose channel ends exactly where the rider starts, so the + // mechanism opened at a limit of its travel and refused to run. grounded: false, query: - '?2P.Fe.K,0.1011.MA,A,0,0,0.GB,B,Fe,0,0.GC,C,d4,ec,0.KD,D,_W,0,0.KE,E,VG,7q,0.GF,F,bo,cO,0.HP,P,bo,cO,0,BC,B,C..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRBC,BC,Fe,Fe,RM,KJ,303e9f,B,C,,.YRCD,CD,Fe,Fe,oo,KJ,0d125a,C,D,,.YREF,EF,Fe,Fe,YX,N6,B2DFDB,E,F,,.YPFP,FP,Fe,0,0,0,,F,P,,...N_L', + '?2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.0C,C,d4,ec,0.4D,D,_W,0,0.4E,E,VG,7q,0.0F,F,bo,cO,0.0X,X,tU,151,0.1P,P,bo,cO,0,BCX,B,X..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRBCX,BCX,Fe,Fe,ak,aY,303e9f,B,C,X,,.YRCD,CD,Fe,Fe,oo,KJ,0d125a,C,D,,.YREF,EF,Fe,Fe,YX,N6,B2DFDB,E,F,,.YPFP,FP,0,0,0,0,,F,P,,...N_h', }, ]; @@ -90,11 +93,32 @@ for (const mech of MECHANISMS) { await page.waitForSelector('#sliderHolder', { state: 'attached', timeout: 15000 }); await page.waitForTimeout(700); - // The animation bar is only rendered on the Analyze tab. - await page.getByText('Analyze', { exact: true }).first().click(); + // The transport is only rendered in an analysis mode. + await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); await page.waitForTimeout(1200); + // A mechanism that cannot be analysed used to get a disabled transport; the + // mode now refuses to open at all, so an absent play button is the same + // failure and has to be read as one rather than waited on. const play = page.locator('.playButton').first(); - if (!checkThat(`${mech.name}: the mechanism plays`, !(await play.isDisabled()))) continue; + const offered = (await play.count()) === 1 && !(await play.isDisabled()); + if ( + !checkThat( + `${mech.name}: the mechanism plays`, + offered, + offered + ? '' + : ( + (await page + .locator('app-analysis-setup') + .innerText() + .catch(() => '')) || 'no transport, no setup drawer' + ) + .replace(/\s+/g, ' ') + .slice(0, 200) + ) + ) { + continue; + } await play.click(); const frames = []; diff --git a/e2e/phase4-build-from-scratch.mjs b/e2e/phase4-build-from-scratch.mjs index 6a1fc0bf..c0a2280c 100644 --- a/e2e/phase4-build-from-scratch.mjs +++ b/e2e/phase4-build-from-scratch.mjs @@ -65,7 +65,7 @@ await page.waitForTimeout(400); * pointer movement rather than from the click's own coordinates, so a click * without the move lands the joint wherever the pointer was last seen. */ -async function addLinkFrom(from, to, label = 'Add Link') { +async function addLinkFrom(from, to, label = 'Link') { await page.mouse.move(from.x, from.y); await page.mouse.click(from.x, from.y, { button: 'right' }); await page.waitForTimeout(450); @@ -100,7 +100,7 @@ await page.screenshot({ path: `${OUT}/1-first-link.png` }); // From the joint just placed, so the two links share it. The grid's menu says // "Add Link"; a joint's says "Attach Link", which is the same gesture starting // from something rather than from nothing. -await addLinkFrom({ x: 760, y: 560 }, { x: 880, y: 380 }, 'Attach Link'); +await addLinkFrom({ x: 760, y: 560 }, { x: 880, y: 380 }, 'Link'); const second = await counts(); checkThat( 'a second link grows off the first', @@ -144,8 +144,17 @@ await page.screenshot({ path: `${OUT}/3-slot.png` }); console.log('\nand the panel drives it'); await page.click(`#${dragged.id}`); await page.waitForTimeout(400); -const panel = await page.evaluate(() => document.body.innerText); -checkThat('the panel names the carrier it was dropped on', /Slot on:/.test(panel)); +// The carrier is drawn, not written: the panel dropped its "Slot on:" note. +const slotCarrier = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const slot = srv.joints.find((j) => j.constructor.name === 'PrisJoint'); + return slot ? `${slot.id} -> ${slot.carrier?.id ?? 'nothing'}` : 'no slot'; +}); +checkThat( + 'the slot is cut into the link it was dropped on', + /-> \w/.test(slotCarrier), + slotCarrier +); async function toggle(label) { const row = page.locator('#toggle-block .row').filter({ hasText: label }).first(); diff --git a/e2e/phase4-cylinder.mjs b/e2e/phase4-cylinder.mjs index 0043e317..ec452673 100644 --- a/e2e/phase4-cylinder.mjs +++ b/e2e/phase4-cylinder.mjs @@ -35,7 +35,6 @@ mkdirSync(OUT, { recursive: true }); await page.goto(BASE, { waitUntil: 'domcontentloaded' }); await waitForReady(page); // Dismiss the intro tour if it came up. -await page.evaluate(() => document.querySelector('.introjs-skipbutton')?.click()); await page.waitForTimeout(300); /** The model joints, read straight out of the running app (dev-mode ng). */ @@ -84,7 +83,10 @@ const abort = await page.evaluate(() => { c.lastRightClickCoord.x = start.x; c.lastRightClickCoord.y = start.y; c.setLastRightClick('grid'); - c.cMenuItems.find((i) => i.label === 'Add Cylinder')?.action(); + c.cMenu.groups + .flatMap((g) => g.rows) + .find((r) => r.label === 'Cylinder') + ?.action(); return start; }); await page.mouse.move(abort.x + 90, abort.y + 40); @@ -110,13 +112,16 @@ const gesture = await page.evaluate(() => { c.lastRightClickCoord.x = start.x; c.lastRightClickCoord.y = start.y; c.setLastRightClick('grid'); - const labels = c.cMenuItems.map((i) => i.label); - c.cMenuItems.find((i) => i.label === 'Add Cylinder')?.action(); + const labels = c.cMenu.groups.flatMap((g) => g.rows).map((r) => r.label); + c.cMenu.groups + .flatMap((g) => g.rows) + .find((r) => r.label === 'Cylinder') + ?.action(); return { labels, start, end: { x: start.x + 320, y: start.y } }; }); checkThat( - 'the grid menu offers Add Cylinder beside Add Link', - gesture.labels.includes('Add Cylinder'), + 'the grid menu offers Cylinder beside Link, under Add', + gesture.labels.includes('Cylinder'), gesture.labels.join(', ') ); @@ -337,7 +342,10 @@ await page.evaluate(() => { const c = ng.getComponent(document.querySelector('app-new-grid')); const mount = c.mechanismSrv.joints.find((j) => j.id === 'A'); c.setLastRightClick(mount); - c.cMenuItems.find((i) => i.label === 'Add Ground')?.action(); + c.cMenu.groups + .flatMap((g) => g.rows) + .find((r) => r.label === 'Grounded') + ?.action(); }); await page.waitForTimeout(500); state = await model(); @@ -345,13 +353,17 @@ checkThat('mount A is grounded', !!byId(state, 'A').ground); const mountMenu = await page.evaluate(() => { const c = ng.getComponent(document.querySelector('app-new-grid')); c.setLastRightClick(c.mechanismSrv.joints.find((j) => j.id === 'A')); - return c.cMenuItems.map((i) => ({ label: i.label, disabled: i.disabled })); + return c.cMenu.groups + .flatMap((g) => g.rows) + .map((r) => ({ label: r.label, disabled: r.disabled })); }); checkThat( - 'the mount menu cascades Delete and greys Slider', - mountMenu.some((i) => i.label === 'Delete Cylinder') && - mountMenu.find((i) => i.label === 'Add Slider')?.disabled === true && - !mountMenu.some((i) => i.label === 'Delete Joint'), + // The deletion names what it takes rather than saying only "Delete Joint", + // and a block is structurally off the table on a sealed part, so that row is + // absent rather than greyed. + 'the mount menu names the cylinder in its Delete and offers no Slider', + mountMenu.some((i) => i.label.startsWith('Delete Joint and Cylinder')) && + mountMenu.every((i) => i.label !== 'Slider'), JSON.stringify(mountMenu) ); @@ -361,14 +373,19 @@ const bodyMenu = await page.evaluate(() => { const c = ng.getComponent(document.querySelector('app-new-grid')); const barrel = c.mechanismSrv.links.find((l) => l.joints.some((j) => j.id === 'A')); c.setLastRightClick(barrel); - const labels = c.cMenuItems.map((i) => i.label); - c.cMenuItems.find((i) => i.label === 'Add Input')?.action(); + const labels = c.cMenu.groups.flatMap((g) => g.rows).map((r) => r.label); + c.cMenu.groups + .flatMap((g) => g.rows) + .find((r) => r.label === 'Driven Input') + ?.action(); return labels; }); await page.waitForTimeout(500); checkThat( - 'the body menu is exactly Delete Cylinder + Add Input', - JSON.stringify(bodyMenu) === JSON.stringify(['Delete Cylinder', 'Add Input']), + // No Attach group at all: a sealed assembly takes no third body. What is + // left is the state it can be put into and the deletion of the whole part. + 'the body menu is exactly Driven Input, Locked and Delete Cylinder', + JSON.stringify(bodyMenu) === JSON.stringify(['Driven Input', 'Locked', 'Delete Cylinder']), bodyMenu.join(', ') ); state = await model(); @@ -420,7 +437,10 @@ await page.evaluate(() => { const c = ng.getComponent(document.querySelector('app-new-grid')); const barrel = c.mechanismSrv.links.find((l) => l.joints.some((j) => j.id === 'A')); c.setLastRightClick(barrel); - c.cMenuItems.find((i) => i.label === 'Delete Cylinder')?.action(); + c.cMenu.groups + .flatMap((g) => g.rows) + .find((r) => r.label === 'Delete Cylinder') + ?.action(); }); // The action above ran via evaluate — outside Angular's zone — so nothing // schedules change detection. A real user reaches this through a menu click, diff --git a/e2e/phase4-gestures.mjs b/e2e/phase4-gestures.mjs index 2c23c60d..3650d13a 100644 --- a/e2e/phase4-gestures.mjs +++ b/e2e/phase4-gestures.mjs @@ -115,6 +115,16 @@ await load(FOUR_BAR); const before = await sliderState(); check('starts with no sliders', before.blocks, 0); +// The fit frames the mechanism, not the marks hanging off it. This URL carries +// a hand-placed centre of mass some 90,000 units away, and framing that drew +// the whole four-bar as a single pixel behind the Edit panel -- every gesture +// below still worked, because they aim at coordinates read from the DOM, but +// nothing here was clickable by a reader. +const drawnJoint = await page.evaluate(() => + Math.round(document.querySelector('#joint_A')?.getBoundingClientRect().width ?? 0) +); +checkThat('and is framed at a size a reader could aim at', drawnJoint > 10, `${drawnJoint}px`); + // E sits on the coupler CDEI; ABH is a different body, so it is a legal carrier. // // Aimed at the midpoint of ABH's A-B edge rather than at the link element's @@ -164,7 +174,19 @@ await page.waitForTimeout(400); const panelText = await page.evaluate(() => document.body.innerText); checkThat('the panel offers Slider and Weld', /Slider/.test(panelText) && /Weld/.test(panelText)); checkThat('and no longer offers a separate Unweld button', !/Unweld\b/.test(panelText)); -checkThat('it names what the slot is cut into', /Slot on:/.test(panelText), panelText.slice(0, 0)); +// The panel used to say "Slot on: CD" in words. The canvas draws the slot on +// its carrier, so the panel no longer repeats it -- but the gesture still has +// to have attached it, and that is what this ever checked. +const slotCarrier = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const slot = srv.joints.find((j) => j.constructor.name === 'PrisJoint'); + return slot ? `${slot.id} -> ${slot.carrier?.id ?? 'nothing'}` : 'no slot'; +}); +checkThat( + 'the slot is cut into the link it was dropped on', + /-> \w/.test(slotCarrier), + slotCarrier +); await page.screenshot({ path: `${OUT}/4-panel.png` }); /** Toggle a panel row by its visible label. */ @@ -401,7 +423,9 @@ console.log('\nhostile input'); // dozens of invalid-SVG errors. The pattern used an unescaped dot, so "1x2" // validated and Number() turned it into NaN. await load(FOUR_BAR); -await page.getByText('Settings', { exact: true }).first().click(); +// Settings is a project-menu entry now, so the hamburger opens first. +await page.locator('.topStrip .iconButton').first().click(); +await page.locator('.projectMenu .menuItem', { hasText: 'Settings' }).click(); await page.waitForTimeout(900); const scaleField = page.locator('app-settings-panel input').first(); let scaleOk = true; diff --git a/e2e/phase4-stack-and-menu.mjs b/e2e/phase4-stack-and-menu.mjs index 0e3f4583..e5045e03 100644 --- a/e2e/phase4-stack-and-menu.mjs +++ b/e2e/phase4-stack-and-menu.mjs @@ -134,7 +134,9 @@ const menu = await page.evaluate(() => { const c = ng.getComponent(document.querySelector('app-new-grid')); const joint = c.mechanismSrv.joints.find((j) => j.id === 'F'); c.setLastRightClick(joint); - return c.cMenuItems.map((i) => ({ label: i.label ?? i.name ?? '?', disabled: i.disabled })); + return c.cMenu.groups + .flatMap((g) => g.rows) + .map((r) => ({ label: r.label, disabled: r.disabled })); }); const item = (text) => menu.find((m) => String(m.label).includes(text)); check( @@ -158,7 +160,9 @@ const menuE = await page.evaluate(() => { const joint = c.mechanismSrv.joints.find((j) => j.id === 'E'); c.setLastRightClick(joint); return { - items: c.cMenuItems.map((i) => ({ label: i.label ?? '?', disabled: i.disabled })), + items: c.cMenu.groups + .flatMap((g) => g.rows) + .map((r) => ({ label: r.label, disabled: r.disabled })), links: joint.links.length, weldControlDisabled: (() => { const panel = document.querySelector('app-edit-panel'); diff --git a/e2e/phase5-driven-cylinder.mjs b/e2e/phase5-driven-cylinder.mjs index a38067c0..e981541f 100644 --- a/e2e/phase5-driven-cylinder.mjs +++ b/e2e/phase5-driven-cylinder.mjs @@ -43,7 +43,6 @@ mkdirSync(OUT, { recursive: true }); await page.goto(BASE + '/' + boomQuery(), { waitUntil: 'domcontentloaded' }); await waitForReady(page); -await page.evaluate(() => document.querySelector('.introjs-skipbutton')?.click()); await page.waitForTimeout(300); /** What the running app thinks it is holding. */ @@ -126,15 +125,15 @@ await page.screenshot({ path: `${OUT}/01-opened.png` }); // Animate, sampling the part as it goes. await page - .locator('.leftButton', { hasText: 'Analyze' }) + .locator('.tabButton', { hasText: 'Kinematic' }) .click() .catch(() => {}); await page - .locator('.playbackControls') + .locator('app-playback-bar .playButton') .waitFor({ state: 'visible' }) .catch(() => {}); await page.waitForTimeout(500); -await page.locator('.playbackControls .playButton').click(); +await page.locator('app-playback-bar .playButton').click(); // Sampled well inside one cycle: at 0.66s a 320ms interval lands on nearly the // same pose every other time, which makes a frozen canvas look like a moving @@ -147,7 +146,7 @@ for (let i = 0; i < 10; i++) { } await page.waitForTimeout(120); await page.screenshot({ path: `${OUT}/02-mid-stroke.png` }); -await page.locator('.playbackControls .playButton').click(); +await page.locator('app-playback-bar .playButton').click(); await page.waitForTimeout(400); await page.screenshot({ path: `${OUT}/03-stopped.png` }); @@ -230,7 +229,7 @@ await page.evaluate(() => { c.mechanismSrv.mechanismTimeStep = 0; }); await page - .locator('.leftButton', { hasText: 'Edit' }) + .locator('.tabButton', { hasText: 'Edit' }) .click() .catch(() => {}); await page.waitForTimeout(400); diff --git a/e2e/playback-bar.mjs b/e2e/playback-bar.mjs new file mode 100644 index 00000000..7c5bd088 --- /dev/null +++ b/e2e/playback-bar.mjs @@ -0,0 +1,192 @@ +/** + * The transport, after it moved out of the rail and onto the grid. + * + * Deleting the mode rail took the animation bar with it, so this exists partly + * to make sure there is still a way to press play at all. Beyond that it checks + * the things the redesign changed: the transport belongs to the analysis modes + * and is absent from Edit, each runnable mechanism gets a row, and the mode + * highlight lands on the mode you actually chose. + * + * PMKS_BASE_URL=<origin> node e2e/playback-bar.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))); +page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); +}); + +const results = []; +const record = (what, ok, detail) => { + results.push([what, ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); +}; + +const tab = (name) => page.locator('.tabButton', { hasText: name }); +const jointAt = (id) => + page.evaluate((joint) => { + const box = document.querySelector('#joint_' + joint).getBoundingClientRect(); + return { x: box.x, y: box.y }; + }, id); + +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); + +// --- the transport belongs to the analysis modes ---------------------------- +record('Edit has no transport', (await page.locator('.playButton').count()) === 0); + +await tab('Kinematic').click(); +await page.waitForTimeout(800); +record('Kinematic has one', (await page.locator('.playButton').count()) === 1); +record('with a row for the mechanism that runs', (await page.locator('.mechRow').count()) === 1); +record( + 'and no readiness word beside it, because being listed is what ready means', + !(await page.locator('.scrubCard').innerText()).match(/Ready|DoF/i), + await page.locator('.scrubCard').innerText() +); + +// --- the transport's own block is a rounded square -------------------------- +const playShape = await page.evaluate(() => { + const button = document.querySelector('.playButton'); + const style = getComputedStyle(button); + return { + radius: style.borderRadius, + width: Math.round(button.getBoundingClientRect().width), + }; +}); +// --- back to where the mechanism was drawn ---------------------------------- +// Every other mode shows the start pose, so a reader who has watched a cycle +// needs a way back to it that is not "scrub until the numbers read zero". +await page.locator('.playButton').click(); +await page.waitForTimeout(1200); +await page.locator('.playButton').click(); +await page.waitForTimeout(300); +const moved = await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.currentTimeSeconds() +); +record('a played cycle leaves the mechanism away from its start', moved > 0.05, { moved }); +record( + 'and the stop button beside play offers the way back', + !(await page.locator('.stopButton').isDisabled()) +); +await page.locator('.stopButton').click(); +await page.waitForTimeout(1200); +const home = await page.evaluate(() => + ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv.currentTimeSeconds() +); +record('which takes it there', home === 0, { home }); +record('and then has nothing left to do', await page.locator('.stopButton').isDisabled()); + +record( + 'the play button is a rounded square, not a disc', + !playShape.radius.includes('%') && parseFloat(playShape.radius) < playShape.width / 2, + playShape +); + +// One corner for the whole chrome: play, stop, speed and every view control +// are the same size button on the same strip, and three radii between them +// read as three unrelated controls that happen to sit together. +const corners = await page.evaluate(() => + ['.playButton', '.stopButton', '.speedButton', '.viewButton'].map((selector) => { + const button = document.querySelector(selector); + return button ? getComputedStyle(button).borderRadius : null; + }) +); +record( + 'and every button on the chrome takes the same corner', + new Set(corners).size === 1, + corners +); + +// Play is the only filled thing in the cluster, which is what makes it the +// one you press; stop is the same quiet button as the speed control beside it. +record( + 'and play is the only filled one of them', + await page.evaluate(() => { + const fill = (selector) => getComputedStyle(document.querySelector(selector)).backgroundColor; + const clear = (paint) => paint === 'rgba(0, 0, 0, 0)' || paint === 'transparent'; + return !clear(fill('.playButton')) && clear(fill('.stopButton')) && clear(fill('.speedButton')); + }) +); + +// Same size, not merely the same shape: three buttons in a row at three widths +// read as three unrelated controls. +const sizes = await page.evaluate(() => + ['.playButton', '.stopButton', '.speedButton', '.viewButton'].map((selector) => { + const box = document.querySelector(selector)?.getBoundingClientRect(); + return box ? `${Math.round(box.width)}x${Math.round(box.height)}` : null; + }) +); +record('and every one of them is the same size', new Set(sizes).size === 1, sizes); + +// --- the highlight lands on the mode that was chosen ------------------------ +const highlight = await page.evaluate(() => { + const pill = document.querySelector('.activeTabPill'); + const active = document.querySelector('.tabButton.active'); + const style = getComputedStyle(pill); + return { + pillLeft: Math.round(new DOMMatrix(style.transform).m41), + pillWidth: Math.round(parseFloat(style.width)), + activeLeft: active.offsetLeft, + activeWidth: active.offsetWidth, + }; +}); +record( + 'the mode highlight sits on the active mode, not the one before it', + Math.abs(highlight.pillLeft - highlight.activeLeft) <= 1 && + Math.abs(highlight.pillWidth - highlight.activeWidth) <= 1, + highlight +); + +// --- pressing play moves the mechanism -------------------------------------- +const rest = await jointAt('B'); +await page.locator('.playButton').click(); +await page.waitForTimeout(1200); +const moving = await jointAt('B'); +record('pressing play moves the crank', Math.hypot(moving.x - rest.x, moving.y - rest.y) > 3, { + rest, + moving, +}); + +await page.locator('.playButton').click(); +await page.waitForTimeout(400); +const paused = await jointAt('B'); +await page.waitForTimeout(700); +const stillPaused = await jointAt('B'); +record( + 'and pressing it again stops it', + Math.hypot(stillPaused.x - paused.x, stillPaused.y - paused.y) < 1, + { paused, stillPaused } +); + +// --- the status strip reports the mode and does not act --------------------- +const strip = await page.locator('#bottomBar').innerText(); +record( + 'the status strip names the mode and the rule', + /Kinematic/.test(strip) && /Geometry locked/.test(strip), + strip +); +const clickable = await page.evaluate( + () => getComputedStyle(document.querySelector('#bottomBar')).pointerEvents +); +record('and is not clickable', clickable === 'none', { clickable }); + +record('nothing threw', errors.length === 0, errors.slice(0, 3)); + +await browser.close(); +process.exit(results.every(([, ok]) => ok) ? 0 : 1); diff --git a/e2e/playback-direction.mjs b/e2e/playback-direction.mjs new file mode 100644 index 00000000..0b80cdf8 --- /dev/null +++ b/e2e/playback-direction.mjs @@ -0,0 +1,212 @@ +/** + * The transport's direction button, and the master play button, in the browser. + * + * Reversing a machine keeps its place in the cycle and keeps it running; the + * handle carries the direction, running left to right clockwise and right to + * left counter-clockwise; and the master button and the per-machine buttons + * never disagree about whether anything is moving. + * + * PMKS_BASE_URL=<origin> node e2e/playback-direction.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 state = () => + page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return { + playing: srv.isPlaying, + step: srv.mechanismTimeStep, + seconds: +srv.currentTimeSeconds().toFixed(3), + rowPlaying: srv.mechanisms.map((_, i) => srv.isMechanismPlaying(i)), + scrub: [...document.querySelectorAll('.rowScrubber')].map((s) => +s.value), + notes: [...document.querySelectorAll('.rowNote')].map((n) => n.textContent.trim()), + // The drawn pose: reversing must not move the drawing. + pose: srv.joints.map((j) => [j.x, j.y]), + }; + }); + +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); +await page.waitForTimeout(900); + +// --- the label names the direction, not the kind of machine ----------------- +const atRest = await state(); +record( + 'the row says which way the input is going', + ['Clockwise', 'Counter-clockwise', 'Extending', 'Retracting'].includes(atRest.notes[0]), + atRest.notes +); + +// --- a clockwise drive runs the handle left to right ------------------------ +await page.locator('.transportCard .playButton').click(); +await page.waitForTimeout(1200); +const running = await state(); +record('the handle has moved off the left end', running.scrub[0] > atRest.scrub[0], { + atRest, + running, +}); + +// --- reversing keeps the place and keeps playing ----------------------------- +// Paused for this one: the pose has to be compared across the flip, and a +// running clock moves it between the two reads for reasons that are not the +// flip's doing. +await page.locator('.transportCard .playButton').click(); +await page.waitForTimeout(300); +const before = await state(); +await page.locator('.dirButton').first().click(); +await page.waitForTimeout(400); +const after = await state(); +record('reversing does not stop it being resumable', after.playing === false, { before, after }); +// Within a model unit, which is a two-hundredth of a centimetre: the mirrored +// cycle lands on the neighbouring sample, so "did not move" is a question about +// pixels rather than about the fourteenth decimal place. +const moved = Math.max( + ...before.pose.map(([x, y], i) => Math.hypot(after.pose[i][0] - x, after.pose[i][1] - y)) +); +record('and the linkage has not moved -- the same pose, still', moved < 1, { + moved, + before: before.pose, + after: after.pose, +}); +record('nor has the handle', Math.abs(after.scrub[0] - before.scrub[0]) < 30, { + before: before.scrub, + after: after.scrub, +}); +// Nothing moves at all now, the clock included. Reversing used to mirror the +// cycle, which put the pose that was at time t at period - t and made the clock +// jump to follow it. The cycle is left alone instead and the machine turns +// round on it, so there is nothing for the clock to catch up with. +record('and neither has the clock', after.seconds === before.seconds, { + before: before.seconds, + after: after.seconds, +}); +record('and the label changed with it', after.notes[0] !== before.notes[0], { + before: before.notes, + after: after.notes, +}); + +// And reversing while it runs leaves it running. +await page.locator('.transportCard .playButton').click(); +await page.waitForTimeout(900); +await page.locator('.dirButton').first().click(); +await page.waitForTimeout(400); +const stillRunning = await state(); +record( + 'reversing a running machine leaves it running', + stillRunning.playing === true, + stillRunning +); +await page.locator('.transportCard .playButton').click(); +await page.waitForTimeout(300); + +// --- the start pose is untouched by any of it ------------------------------- +await page.locator('.tabButton', { hasText: 'Edit' }).click(); +await page.waitForTimeout(900); +const home = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return srv.joints.map((j) => `${j.id}:${j.x.toFixed(2)},${j.y.toFixed(2)}`).join(' '); +}); +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +const fresh = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return srv.joints.map((j) => `${j.id}:${j.x.toFixed(2)},${j.y.toFixed(2)}`).join(' '); +}); +record('reversing never moved the pose the drawing starts from', home === fresh, { home, fresh }); + +// --- master and rows agree --------------------------------------------------- +await page.evaluate(() => { + // A second four-bar, so the transport has rows of its own to get out of step. + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const JointClass = Object.getPrototypeOf(srv.joints[0]).constructor; + const LinkClass = Object.getPrototypeOf(srv.links[0]).constructor; + const S = srv.joints[0].x === 0 ? 200 : Math.abs(srv.joints[0].x) / 3; + const made = [ + [10, 0], + [10, 1], + [13, 2], + [14, 0], + ].map(([x, y], i) => new JointClass(String.fromCharCode(69 + i), x * S, y * S)); + made[0].ground = true; + made[3].ground = true; + made[0].input = true; + const links = [0, 1, 2].map((i) => { + const link = new LinkClass(made[i].id + made[i + 1].id, [made[i], made[i + 1]]); + made[i].links.push(link); + made[i + 1].links.push(link); + made[i].connectedJoints.push(made[i + 1]); + made[i + 1].connectedJoints.push(made[i]); + return link; + }); + srv.joints.push(...made); + srv.links.push(...links); + srv.updateMechanism(true); +}); +await page.waitForTimeout(1200); +await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); +await page.waitForTimeout(800); +await page.locator('.syncToggle').click(); +await page.waitForTimeout(600); + +await page.locator('.transportCard .playButton').click(); +await page.waitForTimeout(500); +const allOn = await state(); +record( + 'the master button starts every machine, not just the shared flag', + allOn.playing && allOn.rowPlaying.every(Boolean), + allOn +); + +const rowPlays = page.locator('.rowPlay'); +await rowPlays.nth(0).click(); +await page.waitForTimeout(300); +await rowPlays.nth(1).click(); +await page.waitForTimeout(400); +const allOff = await state(); +record( + 'pausing every row pauses the master too', + !allOff.playing && allOff.rowPlaying.every((p) => !p), + allOff +); + +await page.locator('.transportCard .playButton').click(); +await page.waitForTimeout(400); +await page.locator('.transportCard .playButton').click(); +await page.waitForTimeout(400); +const masterOff = await state(); +record( + 'and pausing the master pauses every row', + !masterOff.playing && masterOff.rowPlaying.every((p) => !p), + masterOff +); + +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/playback-loop-indicator.mjs b/e2e/playback-loop-indicator.mjs new file mode 100644 index 00000000..79b7b1bd --- /dev/null +++ b/e2e/playback-loop-indicator.mjs @@ -0,0 +1,435 @@ +/** + * The transport row, rebuilt: two lines a machine, an end-of-cycle reading, and + * a selection the row's own surface carries. + * + * Three things this has to hold that a screenshot alone will not. The reading + * has to be right -- a crank comes round again, a driven ram turns back -- and + * it has to be one sentence about the group when the machines are synced. The + * handle has to get the card's whole width. And the selection has to sit under + * the whole row without any control crossing its edge, which is the thing the + * old filled chip could not do. + * + * PMKS_BASE_URL=<origin> node e2e/playback-loop-indicator.mjs + */ + +import { readFileSync, mkdirSync } from 'node:fs'; + +const playwright = await import( + (process.env.PMKS_PLAYWRIGHT_DIR ?? '/tmp/pmks-playwright') + '/node_modules/playwright/index.mjs' +); +const { chromium } = playwright; +import { waitForReady } from './app-ready.mjs'; + +const BASE = process.env.PMKS_BASE_URL ?? 'http://localhost:4200'; +const OUT = 'artifacts/playback-loop-indicator'; +mkdirSync(OUT, { recursive: true }); + +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]) +); + +/** Two four-bars side by side, each with its own drive: one drawing, two clocks. */ +const TWO_FOUR_BARS = + '2P.Ay,1E8.K,0.1011.6A,A,0mv,0VU,0.0B,B,0e_,E6,0.0C,C,l1,WW,0.4D,D,qD,0Pk,0.6E,E,2Y_,0,0.' + + '0F,F,2Y_,GJ,0.0G,G,3Jt,Wc,0.4H,H,3aA,0,0..YRAB,AB,Fe,Fe,0ix,08i,c5cae9,A,B,,.' + + 'YRBC,BC,Fe,Fe,32,NJ,303e9f,B,C,,.YRCD,CD,Fe,Fe,nd,3P,0d125a,C,D,,.' + + 'AREF,EF,0,0,2Y_,8A,555555,E,F,,.ARFG,FG,0,0,2xQ,OS,555555,F,G,,.' + + 'ARGH,GH,0,0,3S0,GJ,555555,G,H,,...N_L'; + +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))); +page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); +}); + +const results = []; +const record = (what, ok, detail) => { + results.push([what, ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); +}; + +const kinematic = async () => { + await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); + await page.waitForTimeout(900); +}; + +const rowText = () => page.locator('.scrubCard').innerText(); + +/** Every box the row draws, in page coordinates, for the geometry checks. */ +const boxes = () => + page.evaluate(() => { + const rect = (node) => { + if (!node) return null; + const box = node.getBoundingClientRect(); + return { + left: Math.round(box.left), + right: Math.round(box.right), + top: Math.round(box.top), + bottom: Math.round(box.bottom), + width: Math.round(box.width), + }; + }; + return [...document.querySelectorAll('.mechRow')].map((row) => ({ + row: rect(row), + head: rect(row.querySelector('.rowHead')), + track: rect(row.querySelector('.rowScrubber')), + readout: rect(row.querySelector('.rowReadout')), + play: rect(row.querySelector('.rowPlay')), + flip: rect(row.querySelector('.dirButton')), + fill: getComputedStyle(row).backgroundColor, + selected: row.classList.contains('selected'), + })); + }); + +// --- a continuously driven crank comes round again -------------------------- +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await kinematic(); + +let text = await rowText(); +record('a crank says it loops', /Loops/.test(text) && !/Reverses/.test(text), text); +await page.screenshot({ + path: `${OUT}/01-one-crank.png`, + clip: { x: 300, y: 760, width: 1180, height: 180 }, +}); + +let geometry = (await boxes())[0]; +// The handle runs the row's whole width, inset only by the row's own 12px, +// rather than sharing a line with the label as it used to. +record( + 'the handle gets the width the row has, under the line rather than beside it', + geometry.track.left - geometry.row.left === 8 && + geometry.row.right - geometry.track.right === 8 && + geometry.track.top > geometry.head.bottom, + geometry +); +record( + 'and the buttons sit inside the row rather than on its edge', + geometry.flip.right <= geometry.row.right - 4 && geometry.flip.top >= geometry.row.top, + geometry +); + +// --- a driven ram turns round ----------------------------------------------- +await page.goto(`${BASE}/?${payloads['Cylinder_Boom']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await kinematic(); + +text = await rowText(); +record('a driven ram says it reverses', /Reverses/.test(text) && !/Loops/.test(text), text); +await page.screenshot({ + path: `${OUT}/02-one-ram.png`, + clip: { x: 300, y: 760, width: 1180, height: 180 }, +}); + +// --- selection is the row's own surface ------------------------------------- +await page.goto(`${BASE}/?${TWO_FOUR_BARS}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await kinematic(); + +record( + 'synced, the two machines collapse to one row', + (await page.locator('.mechRow').count()) === 1, + { + rows: await rowText(), + } +); +record( + 'and the row speaks for both at once, in one reading', + (await rowText()).match(/Loops/g)?.length === 1, + await rowText() +); +await page.screenshot({ + path: `${OUT}/03-synced.png`, + clip: { x: 300, y: 760, width: 1180, height: 180 }, +}); + +await page.locator('.syncToggle').click(); +await page.waitForTimeout(700); +record('unsynced, the transport lists both', (await page.locator('.mechRow').count()) === 2); + +const before = await boxes(); +record( + 'nothing is filled until a machine is chosen', + before.every((row) => !row.selected), + before.map((row) => row.fill) +); +record( + 'and the two readout columns line up, whatever the numbers are', + before[0].readout.left === before[1].readout.left, + before.map((row) => row.readout) +); + +await page.locator('.mechRow').first().click(); +await page.waitForTimeout(500); +const after = await boxes(); +record('clicking a row selects that machine', after[0].selected && !after[1].selected, { + fills: after.map((row) => row.fill), +}); +record( + 'the fill is the row and only the row', + after[0].fill !== after[1].fill && after[0].row.left === before[0].row.left, + { selected: after[0].fill, other: after[1].fill } +); +const card = await page.locator('.scrubCard').boundingBox(); +record( + 'and it sits inside the card, one padding in on every side', + Math.round(after[0].row.left - card.x) === 4 && + Math.round(card.x + card.width - after[0].row.right) === 4, + { row: after[0].row, card } +); +record( + 'and choosing it moved nothing on the line', + after[0].readout.left === before[0].readout.left && + after[0].play.left === before[0].play.left && + after[0].flip.left === before[0].flip.left, + { before: before[0], after: after[0] } +); +await page.screenshot({ + path: `${OUT}/04-selected.png`, + clip: { x: 300, y: 700, width: 1180, height: 240 }, +}); + +// The panel is what proves the click reached the mechanism, not just the CSS. +const panel = await page + .locator('app-right-panel, app-analysis-panel') + .first() + .innerText() + .catch(() => ''); +record('the selection reaches the analysis panel', /Mechanism M1/.test(panel), panel.slice(0, 160)); + +// --- the handle's bar is drawn, not themed ---------------------------------- +// The reported defect: left to the browser the track keeps a 1px rim, a grey +// line above and below the bar. Read out of the composited pixels rather than +// out of the CSS, because what was wrong was what the browser painted. +await page.goto(`${BASE}/?${payloads['Cylinder_Boom']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await kinematic(); + +// The element holds the handle, so the bar is the 8px centred inside it. One +// row above and one below are taken too, which is where the rim used to be. +const bar = await page.locator('.rowScrubber').boundingBox(); +const strip = await page.screenshot({ + clip: { + x: Math.round(bar.x), + y: Math.round(bar.y) + 5, + width: Math.round(bar.width), + height: 10, + }, +}); +const columns = await page.evaluate(async (encoded) => { + const bytes = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' })); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const column = (x) => { + const { data } = context.getImageData(x, 0, 1, bitmap.height); + const rows = []; + for (let y = 0; y < bitmap.height; y++) { + rows.push(`${data[y * 4]},${data[y * 4 + 1]},${data[y * 4 + 2]}`); + } + return rows; + }; + return { travelled: column(8), ahead: column(bitmap.width - 8) }; +}, strip.toString('base64')); +// Row 0 and row 9 are the row's own fill; rows 1-8 are the bar. A rim would +// show as a colour of its own at the top or the bottom of the bar. +const bandOf = (column) => new Set(column.slice(1, 9)); +record( + 'the bar is one colour top to bottom, with no rim above or below it', + bandOf(columns.travelled).size === 1 && + bandOf(columns.ahead).size === 1 && + columns.travelled[0] === columns.ahead[0], + columns +); +record( + 'and the part already travelled is the part that is filled', + columns.travelled[3] !== columns.ahead[3], + columns +); + +// The fill is drawn from the same number the handle is placed by, so the two +// have to agree -- at rest, part way through a cycle, and after a drag. +const agrees = () => + page.$eval('.rowScrubber', (el) => { + const along = parseFloat(getComputedStyle(el).getPropertyValue('--along')); + return { along, value: Number(el.value) / 10, width: el.getBoundingClientRect().width }; + }); +const atRest = await agrees(); +await page.locator('.playButton').click(); +await page.waitForTimeout(900); +await page.locator('.playButton').click(); +await page.waitForTimeout(400); +const playing = await agrees(); +await page.locator('.rowScrubber').click({ position: { x: 120, y: 10 } }); +await page.waitForTimeout(500); +const dragged = await agrees(); +record( + 'the fill and the handle read the same place along the track', + [atRest, playing, dragged].every((s) => Math.abs(s.along - s.value) < 0.5), + { atRest, playing, dragged } +); +record('and the drag actually moved it', Math.abs(dragged.value - atRest.value) > 1, { + atRest, + dragged, +}); + +// --- one drawing, two kinds of ending --------------------------------------- +// A driven ram beside a crank, so the combined row has two facts to carry and +// has to name which machines each one is about. +await page.goto(`${BASE}/?${payloads['Cylinder_Boom']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); + +/** + * Draw a four-bar beside the ram, using the app's own object model. + * + * Cloning the prototypes of the joints and links already on the grid rather + * than importing the classes: this runs in the page, where the modules are not + * addressable by name. + */ +await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const seedJoint = srv.joints[0]; + const seedLink = srv.links.find((link) => link.joints.length === 2); + const JointClass = Object.getPrototypeOf(seedJoint).constructor; + const LinkClass = Object.getPrototypeOf(seedLink).constructor; + const S = seedJoint.x === 0 ? 200 : Math.abs(seedJoint.x) / 3; + + const at = [ + [10, 0], + [10, 1], + [13, 2], + [14, 0], + ]; + const made = at.map(([x, y], i) => new JointClass('W' + i, x * S, y * S)); + made[0].ground = true; + made[3].ground = true; + made[0].input = true; + + const links = [0, 1, 2].map((i) => { + const link = new LinkClass(made[i].id + made[i + 1].id, [made[i], made[i + 1]]); + made[i].links.push(link); + made[i + 1].links.push(link); + made[i].connectedJoints.push(made[i + 1]); + made[i + 1].connectedJoints.push(made[i]); + return link; + }); + + srv.joints.push(...made); + srv.links.push(...links); + srv.updateMechanism(true); +}); +await page.waitForTimeout(1400); +await kinematic(); + +const mixed = await rowText(); +record( + 'synced over two kinds of machine, the row names which does which', + /M1 reverses/.test(mixed) && /M2 loops/.test(mixed), + mixed +); +await page.screenshot({ + path: `${OUT}/05-mixed.png`, + clip: { x: 300, y: 760, width: 1180, height: 180 }, +}); + +record('nothing threw', errors.length === 0, errors.slice(0, 3)); + +await browser.close(); + +// --- and the same bar in every engine --------------------------------------- +// The point of drawing the track rather than letting the platform theme it: +// what a reader sees should not depend on which browser they opened. Run the +// same row through all three and compare the pixels down the bar. +const engines = ['chromium', 'firefox', 'webkit']; +const painted = {}; +for (const engine of engines) { + let engineBrowser; + try { + engineBrowser = await playwright[engine].launch(); + } catch (error) { + record(`${engine} is installed to compare against`, false, String(error).split('\n')[0]); + continue; + } + const enginePage = await engineBrowser.newPage({ viewport: { width: 1500, height: 950 } }); + await enginePage.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(enginePage); + await enginePage.locator('.tabButton', { hasText: 'Kinematic' }).click(); + await enginePage.waitForTimeout(1200); + // Wait for the stylesheet to have reached the control, not for the clock. + // A range input the app has not styled yet is the platform's own 4px bar, + // and Firefox lays this out later than the others -- so a fixed wait + // measured one engine's finished work and another's default. + const styled = await enginePage + .waitForFunction( + () => { + const bar = document.querySelector('.rowScrubber'); + return !!bar && bar.getBoundingClientRect().height > 12; + }, + { timeout: 15000 } + ) + .then(() => true) + .catch(() => false); + // Said out loud rather than swallowed: measuring the platform's own bar and + // reporting it as a painting difference is how this check lied twice. + record(`${engine} has the app's bar to measure, not the platform's`, styled, { + engine, + scrubber: await enginePage.evaluate(() => { + const bar = document.querySelector('.rowScrubber'); + const style = bar && getComputedStyle(bar); + return bar + ? { + height: bar.getBoundingClientRect().height, + css: style.height, + appearance: style.appearance, + } + : 'missing'; + }), + }); + const engineBar = await enginePage.locator('.rowScrubber').boundingBox(); + // Halfway along, so the shot holds both what is travelled and what is ahead. + await enginePage.mouse.click(engineBar.x + engineBar.width / 2, engineBar.y + 10); + await enginePage.waitForTimeout(600); + const shot = await enginePage.screenshot({ + clip: { + x: Math.round(engineBar.x), + y: Math.round(engineBar.y) + 5, + width: Math.round(engineBar.width), + height: 10, + }, + }); + painted[engine] = { + height: Math.round(engineBar.height), + // Read in the page that took the shot, so no image library is needed. + band: await enginePage.evaluate(async (encoded) => { + const bytes = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' })); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const column = (x) => { + const { data } = context.getImageData(x, 0, 1, bitmap.height); + const rows = []; + for (let y = 0; y < bitmap.height; y++) { + rows.push(`${data[y * 4]},${data[y * 4 + 1]},${data[y * 4 + 2]}`); + } + return rows.join(' / '); + }; + return { travelled: column(8), ahead: column(bitmap.width - 8) }; + }, shot.toString('base64')), + }; + await engineBrowser.close(); +} +const seen = Object.values(painted); +record( + 'every engine paints the same bar, and none of them puts a rim on it', + seen.length === engines.length && + new Set(seen.map((one) => JSON.stringify(one))).size === 1 && + seen[0].band.travelled !== seen[0].band.ahead, + painted +); + +process.exit(results.every(([, ok]) => ok) ? 0 : 1); diff --git a/e2e/playback-timing.mjs b/e2e/playback-timing.mjs index 36ae77c3..39d69ef9 100644 --- a/e2e/playback-timing.mjs +++ b/e2e/playback-timing.mjs @@ -11,7 +11,7 @@ import path from 'node:path'; const screenshotDir = path.resolve('artifacts/screenshots'); await fs.mkdir(screenshotDir, { recursive: true }); -const baseUrl = process.env.PMKS_URL || 'http://127.0.0.1:4200/'; +const baseUrl = process.env.PMKS_BASE_URL || process.env.PMKS_URL || 'http://127.0.0.1:4200/'; const runPrefix = process.env.RUN_PREFIX || 'playback-timing'; const chromePath = process.env.PMKS_CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; @@ -47,22 +47,37 @@ page.on('console', (msg) => { const shot = (name) => page.screenshot({ path: path.join(screenshotDir, `${runPrefix}-${name}`), fullPage: false }); -const tab = (label) => page.locator('.leftButton', { hasText: label }); -const timeField = () => page.locator('#animationBar-input'); -const timeSeconds = async () => - parseFloat((await timeField().inputValue()).replace(/[^\d.-]/g, '')); +const tab = (label) => page.locator('.tabButton', { hasText: label }); +const timeField = () => page.locator('#playbackTime'); +const timeSeconds = async () => parseFloat((await timeField().innerText()).replace(/[^\d.-]/g, '')); const speedField = () => page.locator('input-block:has-text("Input Speed") input').first(); -// The rail buttons toggle their panel, so clicking blindly can close what we need. -async function openAnalyze() { +/** + * Drag the handle to a place along the input's travel. + * + * The transport is a position now, not a clock: there is no time to type into, + * and the handle is how a reader moves the machine. + */ +const seekAlong = async (fraction) => { + await page.locator('#slider').evaluate((el, value) => { + el.value = String(Math.round(value * 1000)); + el.dispatchEvent(new Event('input', { bubbles: true })); + }, fraction); + await page.waitForTimeout(400); +}; + +// The transport only exists in the analysis modes, so its presence is how we +// tell whether Kinematic is already open -- pressing the mode again is harmless +// but the wait below would otherwise race the panel animation. +async function openKinematic() { if ( !(await page .locator('.playButton') .isVisible() .catch(() => false)) ) { - await tab('Analyze').click(); + await tab('Kinematic').click(); } await page.locator('.playButton').waitFor({ state: 'visible' }); await page.waitForTimeout(400); @@ -110,7 +125,7 @@ async function setInputSpeed(rpm) { /** Play until the time readout wraps back toward zero; return real elapsed seconds. */ async function measureRevolution() { - await openAnalyze(); + await openKinematic(); await page.locator('.playButton').click(); const started = Date.now(); @@ -169,48 +184,36 @@ try { // --- Cycle length reported by the time field -------------------------------- await setInputSpeed(20); - await openAnalyze(); - await timeField().click(); - await timeField().press('ControlOrMeta+A'); - await timeField().type('99 s'); - await timeField().press('Enter'); - await page.waitForTimeout(400); + await openKinematic(); + // Half way along the track is half a turn of the crank, which at 20 RPM is a + // second and a half. The far right end is not measured from: the track is a + // loop for a crank that goes all the way round, so its right edge is its left + // edge. + await seekAlong(0.5); const maxAt20 = await timeSeconds(); - check( - 'clamping past the end of the cycle reports the 3 s period at 20 RPM', - Math.abs(maxAt20 - 3) < 0.02, - { - maxAt20, - } - ); + check('half the track is half a turn, which is 1.5 s at 20 RPM', Math.abs(maxAt20 - 1.5) < 0.05, { + maxAt20, + }); await setInputSpeed(40); - await openAnalyze(); - await timeField().click(); - await timeField().press('ControlOrMeta+A'); - await timeField().type('99 s'); - await timeField().press('Enter'); - await page.waitForTimeout(400); + await openKinematic(); + await seekAlong(0.5); const maxAt40 = await timeSeconds(); - check('the reported period halves at double the input speed', Math.abs(maxAt40 - 1.5) < 0.02, { + check('and takes half as long at double the input speed', Math.abs(maxAt40 - 0.75) < 0.05, { maxAt40, }); await shot('02-period-at-40rpm.png'); - // --- Leaving Analyze rewinds, and time zero survives the round trip --------- + // --- Leaving Kinematic rewinds, and time zero survives the round trip ------- // Input speed lives with the input joint now, so changing it means entering Edit // mode, which deliberately rewinds to t = 0 (it replaced the stop button). The // model-level "hold the time across a rebuild" guarantee is covered by the unit // suite; what matters here is that the round trip cannot move the start pose. await setInputSpeed(20); - await openAnalyze(); + await openKinematic(); const zeroPose = await page.locator('svg').first().innerHTML(); - await timeField().click(); - await timeField().press('ControlOrMeta+A'); - await timeField().type('1.5 s'); - await timeField().press('Enter'); - await page.waitForTimeout(400); + await seekAlong(0.5); const seekedTime = await timeSeconds(); const seekedPose = await page.locator('svg').first().innerHTML(); @@ -224,13 +227,13 @@ try { // Round trip through Edit at a non-zero time, twice, then come back to t = 0. await setInputSpeed(40); - await openAnalyze(); + await openKinematic(); await setInputSpeed(20); - await openAnalyze(); + await openKinematic(); const rewoundTime = await timeSeconds(); const rewoundPose = await page.locator('svg').first().innerHTML(); - check('leaving Analyze rewinds to time zero', Math.abs(rewoundTime) < 0.02, { rewoundTime }); + check('leaving Kinematic rewinds to time zero', Math.abs(rewoundTime) < 0.02, { rewoundTime }); check( 'time zero still means the same pose after switching modes at a non-zero time', rewoundPose === zeroPose @@ -247,4 +250,6 @@ try { } const failed = checks.filter((c) => !c.ok); +for (const c of checks) console.log(`${c.ok ? 'PASS' : 'FAIL'} ${c.name}`); console.log(JSON.stringify({ passed: checks.length - failed.length, failed, issues }, null, 2)); +process.exit(failed.length ? 1 : 0); diff --git a/e2e/pointer-pairing.mjs b/e2e/pointer-pairing.mjs new file mode 100644 index 00000000..1ecc7883 --- /dev/null +++ b/e2e/pointer-pairing.mjs @@ -0,0 +1,133 @@ +/** + * A press that arrives as a bare `mousedown`, with no `pointerdown` before it. + * + * Safari does this exactly once after a native `<select>` popup is dismissed. + * The canvas is driven by pointer events and svg-pan-zoom by mouse events, so + * the unpaired press went to the library alone: the canvas panned under a + * cursor that was holding a link, and the drag did nothing. The gesture after + * it is paired again, which is why clicking once "fixes" it and hides the cause. + * + * The sequence below is the one traced from the failing gesture in Safari, so + * this reproduces it in any engine — no native popup required. + * + * BASE=http://127.0.0.1:4310 node e2e/pointer-pairing.mjs + */ +const { chromium, webkit } = await import('/tmp/pmks-playwright/node_modules/playwright/index.mjs'); +import { waitForReady } from './app-ready.mjs'; +import { readFileSync } from 'node:fs'; + +const BASE = process.env.BASE ?? 'http://127.0.0.1:4310'; +const source = readFileSync( + new URL('../src/app/component/MODALS/templates/template-linkages.ts', import.meta.url).pathname, + 'utf8' +); +const payloads = Object.fromEntries( + [...source.matchAll(/^ {2}'?([\w-]+)'?:\n {4}'([^']+)',$/gm)].map(([, id, p]) => [id, p]) +); + +const checks = []; +const check = (name, pass, detail = '') => { + checks.push({ name, pass }); + console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); +}; + +/** Grabs a third of the way along B→C, clear of the CoM handle at the midpoint. */ +const GRAB = () => { + const at = (id) => { + const r = document.querySelector(`#joint_${id}`).closest('svg[x]').getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }; + const B = at('B'); + const C = at('C'); + return { x: B.x + (C.x - B.x) / 3, y: B.y + (C.y - B.y) / 3 }; +}; + +for (const engine of [chromium, webkit]) { + const name = engine === chromium ? 'chromium' : 'webkit'; + const browser = await engine.launch(); + const page = await browser.newPage({ viewport: { width: 1500, height: 950 } }); + await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(page); + await page.waitForTimeout(900); + await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + grid.activeObjService.updateSelectedObj(grid.mechanismSrv.links.find((l) => l.id === 'BC')); + }); + await page.waitForTimeout(600); + + const unpaired = await page.evaluate((grabSource) => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const joints = () => + JSON.stringify(grid.mechanismSrv.joints.map((j) => [j.id, Math.round(j.x), Math.round(j.y)])); + const pan = () => JSON.stringify(grid.svgGrid.panZoomObject.getPan()); + const before = joints(); + const panBefore = pan(); + const p = new Function('return ' + grabSource)()(); + const target = document.elementFromPoint(p.x, p.y); + const at = (x, y, buttons) => ({ + bubbles: true, + cancelable: true, + composed: true, + clientX: x, + clientY: y, + screenX: x, + screenY: y, + button: 0, + buttons, + }); + // The press with nothing before it — what Safari sends after its popup. + target.dispatchEvent(new MouseEvent('mousedown', at(p.x, p.y, 1))); + for (let i = 1; i <= 10; i++) { + const x = p.x + i * 5; + const y = p.y + i * 3; + const move = { ...at(x, y, 1), pointerId: 1, pointerType: 'mouse' }; + target.dispatchEvent(new PointerEvent('pointermove', move)); + target.dispatchEvent(new MouseEvent('mousemove', at(x, y, 1))); + } + const up = { ...at(p.x + 50, p.y + 30, 0), pointerId: 1, pointerType: 'mouse' }; + target.dispatchEvent(new PointerEvent('pointerup', up)); + target.dispatchEvent(new MouseEvent('mouseup', at(p.x + 50, p.y + 30, 0))); + return { target: target.tagName, linkMoved: joints() !== before, panned: pan() !== panBefore }; + }, GRAB.toString()); + + check(`${name}: an unpaired mousedown drags the link`, unpaired.linkMoved, unpaired.target); + check(`${name}: and does not pan the canvas instead`, !unpaired.panned); + + // The ordinary paired gesture must still work, and must not act twice. + const paired = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + window.__downs = 0; + document.addEventListener('pointerdown', () => (window.__downs += 1), true); + return grid.mechanismSrv.joints.map((j) => [j.id, Math.round(j.x), Math.round(j.y)]); + }); + const p = await page.evaluate(GRAB); + await page.mouse.move(p.x, p.y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) { + await page.mouse.move(p.x + i * 5, p.y + i * 3); + await page.waitForTimeout(25); + } + await page.mouse.up(); + await page.waitForTimeout(400); + const afterPaired = await page.evaluate(() => ({ + joints: ng + .getComponent(document.querySelector('app-new-grid')) + .mechanismSrv.joints.map((j) => [j.id, Math.round(j.x), Math.round(j.y)]), + downs: window.__downs, + })); + check( + `${name}: a normal drag still works`, + JSON.stringify(paired) !== JSON.stringify(afterPaired.joints) + ); + check( + `${name}: and the browser's own pointerdown is not doubled`, + afterPaired.downs === 1, + `${afterPaired.downs} pointerdown(s)` + ); + + await browser.close(); +} + +const failed = checks.filter((c) => !c.pass); +console.log(`\n${checks.length - failed.length}/${checks.length} passed`); +process.exit(failed.length ? 1 : 0); diff --git a/e2e/real-mouse-slots.mjs b/e2e/real-mouse-slots.mjs index f7449fcd..5bec5ca7 100644 --- a/e2e/real-mouse-slots.mjs +++ b/e2e/real-mouse-slots.mjs @@ -158,7 +158,6 @@ async function fresh() { await waitForReady(page); await pause(1200); await page.evaluate(() => { - document.querySelector('.introjs-skipbutton')?.click(); [...document.querySelectorAll('button, span, div')] .find((n) => n.textContent.trim() === '×') ?.click(); @@ -172,13 +171,13 @@ console.log('\n1. a floating slot, drawn by hand'); await fresh(); await capture('empty-grid'); -const built = await addLink({ x: 560, y: 620 }, { x: 820, y: 620 }, 'Add Link'); +const built = await addLink({ x: 560, y: 620 }, { x: 820, y: 620 }, 'Link'); checkThat('the creation menu opens under a real right-click', built); let m = await model(); checkThat('a first link exists', m.joints === 2 && m.links === 1, JSON.stringify(m)); await capture('first-link'); -await addLink({ x: 820, y: 620 }, { x: 950, y: 430 }, 'Attach Link'); +await addLink({ x: 820, y: 620 }, { x: 950, y: 430 }, 'Link'); m = await model(); checkThat('a second link grows off it', m.joints === 3 && m.links === 2, JSON.stringify(m)); await capture('second-link'); @@ -275,7 +274,6 @@ for (const [tag, name] of FROM_GALLERY) { await page.goto(BASE + '/' + query, { waitUntil: 'domcontentloaded' }); await waitForReady(page); await pause(1100); - await page.evaluate(() => document.querySelector('.introjs-skipbutton')?.click()); await pause(300); m = await model(); checkThat(`${tag}: opens valid at DOF 1`, m.valid && m.dof === 1, JSON.stringify(m)); diff --git a/e2e/shot.mjs b/e2e/shot.mjs new file mode 100644 index 00000000..14503b80 --- /dev/null +++ b/e2e/shot.mjs @@ -0,0 +1,66 @@ +/** + * A screenshot of one state, for looking at. + * + * Not a check — a viewer. Give it a payload and a short script of things to do + * before the picture is taken. + * + * PMKS_BASE_URL=<origin> node e2e/shot.mjs <name> <steps...> + */ + +import { readFileSync, mkdirSync } 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://localhost:4200'; +const source = readFileSync('src/app/component/MODALS/templates/template-linkages.ts', 'utf8'); +const dev = readFileSync('src/app/component/MODALS/templates/dev-templates.ts', 'utf8'); +const payloads = Object.fromEntries( + [...(source + dev).matchAll(/^ {2}'?([\w-]+)'?:\n {4}'([^']+)',$/gm)].map(([, id, p]) => [ + id, + // A backslash in the payload is written escaped in the TypeScript source. + p.replace(/\\\\/g, '\\'), + ]) +); + +const [name, template, ...steps] = process.argv.slice(2); +mkdirSync('artifacts/shots', { recursive: true }); + +const browser = await chromium.launch(); +const page = await browser.newPage({ + viewport: { + width: Number(process.env.SHOT_WIDTH ?? 1500), + height: Number(process.env.SHOT_HEIGHT ?? 950), + }, +}); +const errors = []; +page.on('pageerror', (error) => errors.push(String(error))); +page.on('console', (message) => { + if (message.type() === 'error') errors.push('console: ' + message.text()); +}); + +const payload = payloads[template] ?? (template === 'empty' ? '' : template); +await page.goto(`${BASE}/${payload ? '?' + payload : ''}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page).catch(() => undefined); +await page.waitForTimeout(600); + +for (const step of steps) { + const [verb, ...rest] = step.split(':'); + const argument = rest.join(':'); + if (verb === 'tab') + await page.locator('.tabButton', { hasText: argument }).click({ force: true }); + else if (verb === 'click') await page.locator(argument).first().click({ force: true }); + else if (verb === 'clickText') + await page.getByText(argument, { exact: false }).first().click({ force: true }); + else if (verb === 'hover') await page.locator(argument).first().hover(); + else if (verb === 'wait') await page.waitForTimeout(Number(argument)); + else if (verb === 'eval') await page.evaluate(argument); + await page.waitForTimeout(500); +} + +await page.screenshot({ path: `artifacts/shots/${name}.png` }); +if (errors.length) console.log('errors:', errors.slice(0, 5)); +console.log(`artifacts/shots/${name}.png`); +await browser.close(); diff --git a/e2e/snap-alignment.mjs b/e2e/snap-alignment.mjs new file mode 100644 index 00000000..4da84e9f --- /dev/null +++ b/e2e/snap-alignment.mjs @@ -0,0 +1,234 @@ +/** + * Squaring a drag up with its neighbours -- and only where it is asked for. + * + * A joint dragged nearly level with another one is pulled level, with a guide + * line saying which one. A whole body dragged by its middle is not: both helps + * act on the joint the body is named from rather than on the point under the + * hand, so the guide named a neighbour the reader had not aimed at and the body + * stepped sideways to put one of its several joints on a grid corner. + * + * What must hold: + * + * - Dragging a bar by its body lands it where the cursor let go, with no + * guide drawn and no grid corner sought. + * - Dragging a ram by its body does the same. + * - A mount, which is a joint the reader has hold of, still squares up -- + * against its own far mount included, which is what stands a ram at a + * right angle. + * - Option suspends that, and the settings toggle turns it off entirely. + * + * PMKS_BASE_URL=<origin> node e2e/snap-alignment.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 results = []; +const record = (what, ok, detail) => { + results.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 load = async (payload) => { + await page.goto(`${BASE}/?${payload}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(page); +}; + +const joints = () => + page.evaluate(() => + Object.fromEntries( + ng + .getComponent(document.querySelector('app-new-grid')) + .mechanismSrv.joints.map((joint) => [joint.id, { x: joint.x, y: joint.y }]) + ) + ); + +/** Model units to screen pixels, so a drag can be aimed at an axis. */ +const perUnit = () => + page.evaluate(() => { + const svg = document.querySelector('#canvas') ?? document.querySelector('svg'); + return Math.abs(svg.querySelector('g').getScreenCTM().a); + }); + +/** + * Drag a body so its reference joint arrives a whisker off a target's axis. + * + * Aimed rather than arbitrary: this is the drag that used to be caught, so it + * is the one that proves it no longer is. Three model units short of level is + * well inside what the snap's reach used to be. + */ +const dragBodyToward = async (grabSelector, referenceId, targetId, axis, { holdOption } = {}) => { + const before = await joints(); + const scale = await perUnit(); + const reference = before[referenceId]; + const target = before[targetId]; + const box = await page.locator(grabSelector).first().boundingBox(); + // Land three model units short of level, well inside the snap's reach. + const wantX = axis === 'x' ? target.x - 3 : reference.x; + const wantY = axis === 'y' ? target.y - 3 : reference.y; + const dx = (wantX - reference.x) * scale; + const dy = -(wantY - reference.y) * scale; + + if (holdOption) await page.keyboard.down('Alt'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + let guides = 0; + for (let step = 1; step <= 12; step++) { + await page.mouse.move( + box.x + box.width / 2 + (dx * step) / 12, + box.y + box.height / 2 + (dy * step) / 12 + ); + await page.waitForTimeout(35); + guides = Math.max( + guides, + await page.evaluate( + () => ng.getComponent(document.querySelector('app-new-grid')).axisSnapGuides.length + ) + ); + } + await page.mouse.up(); + if (holdOption) await page.keyboard.up('Alt'); + await page.waitForTimeout(400); + const after = await joints(); + return { + guides, + landed: after[referenceId][axis], + target: target[axis], + off: Math.abs(after[referenceId][axis] - target[axis]), + }; +}; + +/** Turn a snapping option on or off through the panel, the way a reader would. */ +const setOption = async (control, on) => { + await page.locator('.topStrip .iconButton').first().click(); + await page.waitForTimeout(400); + await page.locator('.menuItem', { hasText: 'Settings' }).click(); + await page.waitForTimeout(800); + const listed = /Snap to Alignment/.test(await page.locator('app-settings-panel').innerText()); + await page.evaluate( + ([control, on]) => { + const panel = ng.getComponent(document.querySelector('app-settings-panel')); + panel.settingsForm.controls[control].setValue(on); + }, + [control, on] + ); + await page.waitForTimeout(400); + await page.locator('.closeDrawer').click({ force: true }); + await page.waitForTimeout(400); + return listed; +}; + +// --- a bar dragged by its body ---------------------------------------------- +await load(payloads['4-Bar']); +record('the settings panel offers it', await setOption('snapToAlignment', true)); +const bar = await dragBodyToward('#linkHolder path', 'A', 'D', 'y'); +record('dragging a bar by its body draws no guide', bar.guides === 0, bar); +record('and leaves it where the cursor let go, not level with a neighbour', bar.off > 1e-6, bar); +// The drag is aimed three units short of level, so a body that has not been +// pulled anywhere is three units short of level. Anything else -- a nudge onto +// a grid corner included -- moves it off that. The tolerance is a fiftieth of a +// unit: the drag arrives in whole screen pixels, which is worth a few +// hundred-thousandths, while the two snaps this rules out are worth half a grid +// square and the whole three units respectively. +record('and not onto a grid corner either', Math.abs(bar.off - 3) < 0.02, bar); + +// --- a ram dragged by its body ---------------------------------------------- +await load(payloads['Cylinder_Boom']); +const ram = await dragBodyToward('.cylinder-barrel', 'G', 'O', 'y'); +record('dragging a ram by its body is free the same way', ram.guides === 0, ram); +record('and lands where it was let go', Math.abs(ram.off - 3) < 0.02, ram); + +// --- a ram squared against its own far mount -------------------------------- +/** + * Drag one mount of a ram to a whisker off level with the other. + * + * This is what puts a ram at 0, 90 or 180, and it is what a bar has always + * been able to do against its own far joint. The ram's other joints travel + * with the drag and are rightly ignored; the far mount does not, and was being + * ignored with them. + */ +const squareTheRam = async ({ holdOption, alreadyLoaded } = {}) => { + if (!alreadyLoaded) await load(payloads['Cylinder_Boom']); + const mounts = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const ram = srv.cylinderAt(srv.links.find((link) => srv.cylinderAt(link))); + return { + dragged: ram.barrelFar.id, + fixed: ram.rodFar.id, + draggedAt: { x: ram.barrelFar.x, y: ram.barrelFar.y }, + fixedAt: { x: ram.rodFar.x, y: ram.rodFar.y }, + }; + }); + const scale = await perUnit(); + const box = await page.locator(`#joint_${mounts.dragged}`).boundingBox(); + const dy = -(mounts.fixedAt.y - 4 - mounts.draggedAt.y) * scale; + + if (holdOption) await page.keyboard.down('Alt'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + let guides = 0; + for (let step = 1; step <= 12; step++) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2 + (dy * step) / 12); + await page.waitForTimeout(35); + guides = Math.max( + guides, + await page.evaluate( + () => ng.getComponent(document.querySelector('app-new-grid')).axisSnapGuides.length + ) + ); + } + await page.mouse.up(); + if (holdOption) await page.keyboard.up('Alt'); + await page.waitForTimeout(400); + const after = await joints(); + const a = after[mounts.dragged]; + const b = after[mounts.fixed]; + return { + guides, + outOfLevel: Math.abs(a.y - b.y), + angle: +Math.abs((Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI).toFixed(3), + }; +}; + +const squared = await squareTheRam(); +record('a ram squares up against its own far mount', squared.guides > 0, squared); +record( + 'and stands at exactly 0, 90 or 180 rather than near it', + squared.outOfLevel < 1e-4 && [0, 90, 180].some((right) => Math.abs(squared.angle - right) < 1e-3), + squared +); + +const ramLoose = await squareTheRam({ holdOption: true }); +record('unless Option is held', ramLoose.guides === 0 && ramLoose.outOfLevel > 1e-4, ramLoose); + +// --- turned off entirely ----------------------------------------------------- +await load(payloads['Cylinder_Boom']); +await setOption('snapToAlignment', false); +const off = await squareTheRam({ alreadyLoaded: true }); +record('and the toggle turns it off entirely', off.guides === 0 && off.outOfLevel > 1e-4, off); +record( + 'which is remembered on this machine', + (await page.evaluate(() => localStorage.getItem('snapToAlignment'))) === 'false' +); + +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/snap-to-grid.mjs b/e2e/snap-to-grid.mjs new file mode 100644 index 00000000..f5d1b46a --- /dev/null +++ b/e2e/snap-to-grid.mjs @@ -0,0 +1,201 @@ +/** + * Snap to grid, dragged with a real mouse. + * + * The setting is the reader's own -- remembered on their machine, not written + * into the URL -- so this drives the panel and the pointer rather than the + * service, and checks where things actually landed. + * + * What must hold: + * + * - On, a dragged joint lands on a corner of the grid the reader can see. + * - A rigid body cannot put every joint on the grid, so a dragged link lands + * its reference joint there and the rest keep their places around it. + * - A capture beats the grid: dropped onto another joint, a joint goes + * exactly onto it, not onto the nearest grid corner. + * - Off, nothing is rounded. + * + * PMKS_BASE_URL=<origin> node e2e/snap-to-grid.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 results = []; +const record = (what, ok, detail) => { + results.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: 1400, height: 900 } }); +const errors = []; +page.on('pageerror', (error) => errors.push(String(error))); + +/** Every joint's position measured in grid squares, so a whole number is a corner. */ +const inCells = () => + page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const cell = grid.svgGrid.minorCellSize; + return { + cell, + snapOn: grid.settings.isSnapToGrid.value, + joints: grid.mechanismSrv.joints.map((joint) => ({ + id: joint.id, + x: +(joint.x / cell).toFixed(4), + y: +(joint.y / cell).toFixed(4), + })), + }; + }); + +const onCorner = (joint) => + Math.abs(joint.x - Math.round(joint.x)) < 1e-6 && Math.abs(joint.y - Math.round(joint.y)) < 1e-6; + +/** A real press, a real path, a real release. Optionally with Option held. */ +const dragFrom = async (box, dx, dy, { holdOption = false } = {}) => { + if (holdOption) await page.keyboard.down('Alt'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + for (let step = 1; step <= 8; step++) { + await page.mouse.move( + box.x + box.width / 2 + (dx * step) / 8, + box.y + box.height / 2 + (dy * step) / 8, + { steps: 2 } + ); + } + await page.mouse.up(); + if (holdOption) await page.keyboard.up('Alt'); + await page.waitForTimeout(500); +}; + +/** Back to the template's own geometry, so each check starts from the same place. */ +const fresh = async () => { + await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(page); +}; + +/** Turn the option on or off through the panel, the way a reader would. */ +const setSnap = async (on) => { + await page.locator('.topStrip .iconButton').first().click(); + await page.waitForTimeout(400); + await page.locator('.menuItem', { hasText: 'Settings' }).click(); + await page.waitForTimeout(800); + const listed = /Snap to Grid/.test(await page.locator('app-settings-panel').innerText()); + await page.evaluate((on) => { + const panel = ng.getComponent(document.querySelector('app-settings-panel')); + panel.settingsForm.controls['snapToGrid'].setValue(on); + }, on); + await page.waitForTimeout(400); + await page.locator('.closeDrawer').click({ force: true }); + await page.waitForTimeout(400); + return listed; +}; + +await fresh(); + +// Off until asked for: dragging has always put a joint exactly where the cursor +// let go, and that is not a thing to change out from under anyone. +record('it is off until asked for', (await inCells()).snapOn === false); +record('and the settings panel offers it', await setSnap(true)); +record('turning it on takes effect', (await inCells()).snapOn === true); + +// --- a joint lands on a corner ---------------------------------------------- +await dragFrom(await page.locator('#joint_B').boundingBox(), 63, -41); +const dragged = (await inCells()).joints.find((joint) => joint.id === 'B'); +record('a dragged joint lands on a corner of the grid', onCorner(dragged), dragged); + +// --- Option suspends it for the length of a gesture ------------------------- +// The same key that already means "no help from the app" while dragging: it is +// what turns off capturing a joint you drop on. +await fresh(); +await dragFrom(await page.locator('#joint_B').boundingBox(), 43, -29, { holdOption: true }); +const withOption = (await inCells()).joints.find((joint) => joint.id === 'B'); +record('holding Option drags free of the grid', !onCorner(withOption), withOption); +// And lets go of it again. +await dragFrom(await page.locator('#joint_B').boundingBox(), 31, 22); +const afterOption = (await inCells()).joints.find((joint) => joint.id === 'B'); +record('and the next drag snaps again', onCorner(afterOption), afterOption); + +// --- a link lands its reference joint there --------------------------------- +await fresh(); +const before = await inCells(); +await dragFrom(await page.locator('#linkHolder path').nth(1).boundingBox(), 53, -37); +const after = await inCells(); +const moved = after.joints.filter( + (joint, i) => + Math.abs(joint.x - before.joints[i].x) > 1e-6 || Math.abs(joint.y - before.joints[i].y) > 1e-6 +); +record('dragging a link moves it', moved.length >= 2, moved); +record( + "and lands the link's reference joint on a corner", + moved.length > 0 && onCorner(moved[0]), + moved +); +// The bar's length is what it is, so the far end cannot be on a corner too -- +// and forcing it there would stretch the link. +const spanBefore = Math.hypot( + before.joints[1].x - before.joints[2].x, + before.joints[1].y - before.joints[2].y +); +const spanAfter = Math.hypot( + after.joints[1].x - after.joints[2].x, + after.joints[1].y - after.joints[2].y +); +record('without changing its length', Math.abs(spanBefore - spanAfter) < 1e-3, { + spanBefore, + spanAfter, +}); + +// --- a capture beats the grid ------------------------------------------------ +// B onto D. Not onto C: the app deliberately refuses to merge a joint with the +// far end of its own link, so that pair can never capture whatever the grid is +// doing. +await fresh(); +const from = await page.locator('#joint_B').boundingBox(); +const onto = await page.locator('#joint_D').boundingBox(); +await dragFrom(from, onto.x - from.x, onto.y - from.y); +const landing = await page.evaluate(() => { + const grid = ng.getComponent(document.querySelector('app-new-grid')); + const srv = grid.mechanismSrv; + const b = srv.joints.find((joint) => joint.id === 'B'); + const d = srv.joints.find((joint) => joint.id === 'D'); + const cell = grid.svgGrid.minorCellSize; + return { + apart: b && d ? +Math.hypot(b.x - d.x, b.y - d.y).toFixed(6) : -1, + // And that landing is not a grid corner, so it really is the target it + // went to rather than a corner that happened to be there. + onCorner: !!b && Math.abs(b.x / cell - Math.round(b.x / cell)) < 1e-6, + }; +}); +record( + 'a joint dropped on another lands exactly on it, not on the grid', + landing.apart === 0 && !landing.onCorner, + landing +); + +// --- turning it off -------------------------------------------------------- +await fresh(); +await setSnap(false); +await dragFrom(await page.locator('#joint_B').boundingBox(), 37, 29); +const free = (await inCells()).joints.find((joint) => joint.id === 'B'); +record('with it off, a drag is not rounded at all', !onCorner(free), free); +record( + 'and the choice is remembered on this machine', + (await page.evaluate(() => localStorage.getItem('snapToGrid'))) === 'false' +); + +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..b6aadaa6 --- /dev/null +++ b/e2e/synthesis-redesign.mjs @@ -0,0 +1,1723 @@ +/** + * 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=<origin> 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); +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 every one of the nine boxes can be typed into before anything is placed', + (await page.locator('.poseRow input:disabled').count()) === 0 && + (await page.locator('.poseRow input').count()) === 9 +); + +// 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; + })() +); + +// Typing is the other way in, and it is live at the same time as the placer +// rather than instead of it: a row becomes a position at the moment it says +// where and which way, which is the same moment a dropped one does. +{ + const row = (axis) => page.locator(`input[aria-label="Position 1 ${axis}"]`); + await row('X').click(); + await row('X').fill('3'); + await page.keyboard.press('Tab'); + await page.waitForTimeout(400); + check( + 'typing into a row points the panel at it, without placing anything', + (await panel('(p) => p.design.getAllPoses().length')) === 0 && + (await page.locator('#synthesisPanel .poseRow--sel').count()) === 1 + ); + await row('Y').fill('2'); + await page.keyboard.press('Tab'); + await page.waitForTimeout(400); + check('and a row with no angle yet is still not a position', (await panel('(p) => p.design.getAllPoses().length')) === 0); + await row('angle').fill('15'); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + const typed = await page.evaluate(() => { + const d = ng.getComponent(document.querySelector('app-synthesis-panel')).design; + const pose = d.isPoseDefined(1) ? d.getPose(1) : undefined; + return pose && { x: +(pose.position.x / 200).toFixed(2), y: +(pose.position.y / 200).toFixed(2), t: Math.round(pose.thetaDegrees) }; + }); + check( + 'and the row becomes a position once it says where and which way', + JSON.stringify(typed) === JSON.stringify({ x: 3, y: 2, t: 15 }), + typed + ); + // Back to nothing, so the placing checks below start where they always did. + await page.locator('#synthesisPanel .poseRow').first().locator('.poseRow__remove').click(); + await page.waitForTimeout(400); + check('and it can be taken off again', (await panel('(p) => p.design.getAllPoses().length')) === 0); +} + + +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 .cm-row__label').allInnerTexts()).some((t) => + /Delete \d+ Synthesis Positions?/.test(t) + ), + await page.locator('#contextMenu .cm-row__label').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 .cm-row__label').allInnerTexts()).some((t) => + /Delete Position \d/.test(t) + ), + await page.locator('#contextMenu .cm-row__label').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. +*/ + +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 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 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 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 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 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 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/e2e/template-graphs.mjs b/e2e/template-graphs.mjs index d809d1c0..229c8600 100644 --- a/e2e/template-graphs.mjs +++ b/e2e/template-graphs.mjs @@ -83,9 +83,10 @@ const IDS = ONLY ? TEMPLATE_IDS.filter((id) => ONLY.includes(id)) : TEMPLATE_IDS // --------------------------------------------------------------------------- // Tolerances // --------------------------------------------------------------------------- -// Series are rounded to 3 decimals before plotting, so a difference quotient -// over one timestep carries 0.0005/dt of pure rounding noise. Everything else -// is judged against the series' own peak, so a mechanism's scale is irrelevant. +// Series reach the plot at the precision the solver left them at; the rounding +// allowance below is what a three-decimal series used to carry and is kept as +// slack. Everything else is judged against the series' own peak, so a +// mechanism's scale is irrelevant. const REL_TOL = 0.03; // 3% of the series peak const ROUND_HALF = 0.0005; // The five-point stencil's coefficients sum to 1.5/dt in absolute value, so it @@ -244,27 +245,6 @@ page.on('console', (m) => { }); page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + String(e).slice(0, 220))); -async function dismissOverlays() { - if ( - await page - .locator('.introjs-tooltip') - .first() - .isVisible() - .catch(() => false) - ) { - await page - .locator('.introjs-skipbutton') - .first() - .click({ force: true }) - .catch(() => {}); - await page.waitForTimeout(400); - } - if ((await page.locator('mat-dialog-container').count()) > 0) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - } -} - /** Whatever the app itself knows about the loaded mechanism. */ const readMechanism = () => page.evaluate(() => { @@ -328,17 +308,21 @@ const selectedJointId = () => const GRAPH_ROWS = ['Position of Joint', 'Velocity of Joint', 'Acceleration of Joint']; +/** + * Open each of the three kinematic cards, so its graph exists to be read. + * + * One card per quantity, each holding its plot only while it is open. These + * used to be Material expansion panels; the panel is built from its own cards + * now, and a harness looking for the old ones quietly found nothing and read no + * graphs at all. + */ async function openKinematicRows() { for (const label of GRAPH_ROWS) { - const header = page.locator('mat-expansion-panel-header', { hasText: label }).first(); - if ((await header.count()) === 0) continue; - const panel = page.locator('mat-expansion-panel', { hasText: label }).first(); - const expanded = await panel - .getAttribute('class') - .then((c) => (c ?? '').includes('mat-expanded')) - .catch(() => false); - if (expanded) continue; - await header.scrollIntoViewIfNeeded(); + const section = page.locator('app-analysis-graph-section', { hasText: label }).first(); + if ((await section.count()) === 0) continue; + if ((await section.locator('.graphSection.open').count()) > 0) continue; + const header = section.locator('.graphHeader').first(); + await header.scrollIntoViewIfNeeded().catch(() => {}); await header.click({ force: true }); await page.waitForTimeout(600); } @@ -392,7 +376,6 @@ for (const id of IDS) { timeout: 60000, }); await waitForReady(page); - await dismissOverlays(); const mech = await readMechanism(); check('payload decodes into a mechanism', !mech.error, mech.error); @@ -445,14 +428,15 @@ for (const id of IDS) { } notes.push(`joints ${moving.join(',')}`); - // Open Analyze once; the expansion state survives changing the selection. + // Open the Kinematic mode once; the expansion state survives changing the + // selection. await selectJoint(moving[0]); const analyzeOpen = await page .locator('app-analysis-panel #analysisWrapper') .first() .isVisible() .catch(() => false); - if (!analyzeOpen) await page.locator('button.leftButton').nth(2).click(); + if (!analyzeOpen) await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); await page.waitForTimeout(900); await openKinematicRows(); @@ -499,7 +483,7 @@ for (const id of IDS) { for (const label of GRAPH_ROWS) await shot( `${id}_${j}_${label.split(' ')[0].toLowerCase()}`, - page.locator('mat-expansion-panel', { hasText: label }).first() + page.locator('app-analysis-graph-section', { hasText: label }).first() ); continue; } @@ -609,7 +593,7 @@ for (const id of IDS) { for (const label of GRAPH_ROWS) await shot( `${id}_${j}_${label.split(' ')[0].toLowerCase()}`, - page.locator('mat-expansion-panel', { hasText: label }).first() + page.locator('app-analysis-graph-section', { hasText: label }).first() ); } } diff --git a/e2e/template-open.mjs b/e2e/template-open.mjs index fb14d8bc..212d4c3a 100644 --- a/e2e/template-open.mjs +++ b/e2e/template-open.mjs @@ -24,22 +24,6 @@ const context = await browser.newContext({ viewport: { width: 1500, height: 950 let newPages = 0; context.on('page', () => newPages++); -const dismissIntro = async (page) => { - if ( - await page - .locator('.introjs-tooltip, .introjs-overlay') - .first() - .isVisible() - .catch(() => false) - ) { - await page - .locator('.introjs-skipbutton') - .first() - .click({ force: true }) - .catch(() => {}); - await page.waitForTimeout(500); - } -}; const linkCount = (page) => page.evaluate( () => @@ -48,24 +32,30 @@ const linkCount = (page) => ).length ); +// Templates moved off the file toolbar and into the project menu, so it takes +// two presses to reach now. +const openTemplates = async (page) => { + await page.locator('.topStrip .iconButton').first().click(); + await page.locator('.projectMenu #templatesButton').click(); + await page.waitForTimeout(600); +}; + // --- Case 1: empty grid, template loads in this window ----------------------- const page = await context.newPage(); page.setDefaultTimeout(15000); newPages = 0; // ignore the page we created ourselves -const BASE = process.env.PMKS_URL ?? 'http://127.0.0.1:4200/'; +const BASE = process.env.PMKS_BASE_URL ?? process.env.PMKS_URL ?? 'http://127.0.0.1:4200/'; await page.goto(BASE, { waitUntil: 'domcontentloaded' }); await waitForReady(page); -await dismissIntro(page); await page.waitForTimeout(600); -// The library opens by itself on a blank start; open it from the toolbar if not. +// The library opens by itself on a blank start; open it from the menu if not. if ( !(await page .locator('#templates') .isVisible() .catch(() => false)) ) { - await page.locator('button:has-text("Templates")').first().click(); - await page.waitForTimeout(600); + await openTemplates(page); } const before = await linkCount(page); await page.locator('#templates panel-section').first().click(); @@ -87,10 +77,8 @@ check( // --- Case 2: existing work brings up the choice dialog ----------------------- await page.goto(`${BASE}?${FOUR_BAR}`, { waitUntil: 'domcontentloaded' }); await waitForReady(page); -await dismissIntro(page); await page.waitForTimeout(800); -await page.locator('button:has-text("Templates")').first().click(); -await page.waitForTimeout(600); +await openTemplates(page); await page.locator('#templates panel-section').nth(1).click(); // Watt I await page.waitForTimeout(600); const dialogVisible = await page @@ -123,18 +111,11 @@ check( `links ${beforeReplace} -> ${afterReplace}, new tabs=${newPages}` ); -// Undo brings the old linkage back. The Undo button lives in the Edit tab's -// tool strip, so open Edit first; it being enabled proves the replace saved. -if ( - !(await page - .locator('button:has-text("Undo")') - .isVisible() - .catch(() => false)) -) { - await page.locator('.leftButton', { hasText: 'Edit' }).click(); - await page.waitForTimeout(700); -} -await page.locator('button:has-text("Undo")').click(); +// Undo brings the old linkage back. Undo now sits in the top strip in every +// mode, so no mode switch is needed; it being enabled proves the replace saved. +const undo = page.locator('.historyButton', { hasText: 'Undo' }); +check('Replace armed Undo', (await undo.isDisabled()) === false); +await undo.click(); await page.waitForTimeout(1200); const afterUndo = await linkCount(page); check( @@ -150,8 +131,7 @@ if ( .isVisible() .catch(() => false)) ) { - await page.locator('button:has-text("Templates")').first().click(); - await page.waitForTimeout(600); + await openTemplates(page); } await page.locator('#templates panel-section').first().click(); await page.waitForTimeout(600); diff --git a/e2e/template-thumbnails.mjs b/e2e/template-thumbnails.mjs index 64080cbd..04fcf6bc 100644 --- a/e2e/template-thumbnails.mjs +++ b/e2e/template-thumbnails.mjs @@ -76,11 +76,6 @@ async function open(url) { } } await page.waitForTimeout(900); - // The app strips its own query string on load, which can land mid-evaluate. - await page - .evaluate(() => document.querySelector('.introjs-skipbutton')?.click()) - .catch(() => page.evaluate(() => document.querySelector('.introjs-skipbutton')?.click())); - await page.waitForTimeout(300); } const browser = await chromium.launch(); @@ -144,7 +139,8 @@ for (const { id, payload } of libraryTemplates()) { // nothing. await page.addStyleTag({ content: - 'app-toolbar, app-bottombar, app-left-tabs, app-right-panel { display: none !important }', + 'app-top-bar, app-bottombar, app-left-tabs, app-right-panel, app-playback-bar,' + + ' app-view-controls { display: none !important }', }); await page.waitForTimeout(800); diff --git a/e2e/top-strip-states.mjs b/e2e/top-strip-states.mjs new file mode 100644 index 00000000..7cde2411 --- /dev/null +++ b/e2e/top-strip-states.mjs @@ -0,0 +1,190 @@ +/** + * Every state the top strip has, checked in a browser. + * + * The strip has four things that vary independently, and the combinations are + * where it went wrong: how much room the window leaves it, how much the status + * chips are saying, which mode is showing, and whether it was laid out at that + * size or resized to it. Fixed breakpoints made the first two disagree; a + * remembered measurement made the last two disagree. + * + * What must hold in every combination: + * + * - Nothing in the tab card is outside the tab card. + * - The card is not scrolled (its content fits, or the labels gave way). + * - The page does not scroll sideways. + * - The history card stays inside the window. + * - Shrinking and growing back cross the same steps, with room to spare + * either side, so a window dragged slowly does not flicker between them. + * + * PMKS_BASE_URL=<origin> node e2e/top-strip-states.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]) +); + +/** Two joints and a bar: in no mechanism, so nothing about it is ready. */ +const LONE_BAR = + '2P.Zz,1E8.5,0.1011.0A,A,2UW,9v,0.0B,B,3E8,1Zn,0..YRAB,AB,Fe,Fe,2sK,sr,303e9f,A,B,,...N_P'; + +const results = []; +const record = (what, ok, detail) => { + results.push([what, ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); +}; + +/** Everything about the strip that can be wrong. */ +const inspect = (page) => + page.evaluate(() => { + const strip = document.querySelector('.topStrip'); + const card = document.querySelector('.tabCard'); + const history = document.querySelector('.historyCard'); + if (!strip || !card) return { missing: true }; + const box = card.getBoundingClientRect(); + // A hidden element has a zero rect at the origin, which is not "outside". + const escaped = [...card.querySelectorAll('.tabButton,.chip')] + .filter((node) => { + const rect = node.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return false; + return rect.right > box.right + 0.5 || rect.left < box.left - 0.5; + }) + .map((node) => node.textContent.trim().slice(0, 16)); + // scrollWidth still reports content that overflow:hidden has clipped, so + // whether the page can actually be scrolled is the honest question. + const scroller = document.scrollingElement; + const was = scroller.scrollLeft; + scroller.scrollLeft = 300; + const scrolls = scroller.scrollLeft; + scroller.scrollLeft = was; + return { + level: Number((strip.className.match(/fit(\d)/) ?? [, '3'])[1]), + clipped: card.scrollWidth - card.clientWidth, + escaped, + scrolls, + pastEdge: Math.round( + (history?.getBoundingClientRect().right ?? 0) - document.documentElement.clientWidth + ), + chips: [...document.querySelectorAll('.chip')].map((chip) => chip.textContent.trim()), + }; + }); + +const broken = (state) => + state.missing + ? false + : state.clipped > 1 || state.escaped.length > 0 || state.scrolls > 0 || state.pastEdge > -6; + +const browser = await chromium.launch(); +const errors = []; + +// --- every drawing, at every width, in every mode -------------------------- +const WIDTHS = [1500, 1200, 1015, 1000, 900, 820, 800, 745, 735, 500, 400, 360]; +const DRAWINGS = [ + ['an empty grid', ''], + ['geometry in no mechanism', LONE_BAR], + ['a four-bar that runs', payloads['4-Bar']], + ['a cylinder boom', payloads['Cylinder_Boom']], + ['a Jansen leg', payloads['Jansen_Leg']], +]; + +const failures = []; +for (const [what, payload] of DRAWINGS) { + for (const width of WIDTHS) { + const page = await browser.newPage({ viewport: { width, height: 860 } }); + page.on('pageerror', (error) => errors.push(String(error))); + await page.goto(`${BASE}/${payload ? '?' + payload : ''}`, { waitUntil: 'domcontentloaded' }); + await waitForReady(page).catch(() => undefined); + for (const mode of ['Synthesis', 'Edit', 'Kinematic', 'Force']) { + await page + .locator('.tabButton', { hasText: mode }) + .click({ force: true }) + .catch(() => undefined); + await page.waitForTimeout(300); + const state = await inspect(page); + if (broken(state)) failures.push({ what, width, mode, ...state }); + } + await page.close(); + } +} +record( + `the strip holds together in all ${DRAWINGS.length * WIDTHS.length * 4} states`, + failures.length === 0, + failures.slice(0, 6) +); + +// --- shrinking and growing back -------------------------------------------- +const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); +page.on('pageerror', (error) => errors.push(String(error))); +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); + +const sweep = async (from, to, step) => { + const seen = []; + for (let width = from; step < 0 ? width >= to : width <= to; width += step) { + await page.setViewportSize({ width, height: 900 }); + await page.waitForTimeout(130); + seen.push({ width, ...(await inspect(page)) }); + } + return seen; +}; + +const down = await sweep(1500, 380, -10); +const up = await sweep(380, 1500, 10); +record( + 'nothing breaks at any width on the way down or back up', + [...down, ...up].every((state) => !broken(state)), + [...down, ...up].filter(broken).slice(0, 4) +); + +const steps = (seen) => + seen.filter((state, i) => i > 0 && state.level !== seen[i - 1].level).map((s) => s.width); +const downSteps = steps(down); +const upSteps = steps(up); +record( + 'it steps down through the ladder and back up the same way', + downSteps.length === 3 && upSteps.length === 3, + { downSteps, upSteps } +); +// Hysteresis: the width it gives a label back at is above the one it took it +// away at, so a window dragged slowly across the boundary does not flicker. +record( + 'and gives each label back a little wider than it took it away', + downSteps.every((width, i) => upSteps[upSteps.length - 1 - i] > width), + { downSteps, upSteps } +); + +// --- the chips changing under a fixed window -------------------------------- +await page.setViewportSize({ width: 1015, height: 900 }); +await page.waitForTimeout(400); +const chipStates = []; +for (let round = 0; round < 3; round++) { + await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const driven = srv.joints.find((joint) => joint.input); + if (driven) driven.input = false; + else srv.joints[0].input = true; + srv.updateMechanism(true); + }); + await page.waitForTimeout(600); + chipStates.push(await inspect(page)); +} +record( + 'a status chip changing width never leaves the strip clipped', + chipStates.every((state) => !broken(state)), + chipStates +); + +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/tutorial.mjs b/e2e/tutorial.mjs new file mode 100644 index 00000000..272de8d9 --- /dev/null +++ b/e2e/tutorial.mjs @@ -0,0 +1,354 @@ +/** + * The guided first build, end to end. + * + * Five moves from a bare grid to a velocity, driven the way a student drives + * it. What it is really guarding is that the tutorial reads its step off the + * *drawing* rather than off a counter of its own — so every assertion here + * about which step is showing is an assertion about the mechanism underneath. + * + * PMKS_BASE_URL=<origin> node e2e/tutorial.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 OUT = 'artifacts/tutorial'; + +const passed = []; +const failed = []; +const check = (name, pass, extra = '') => { + (pass ? passed : failed).push(name); + console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); +}; + +const browser = await chromium.launch(); +const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const page = await context.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(String(e))); +page.on('console', (m) => m.type() === 'error' && errors.push(m.text())); + +await page.goto(BASE, { waitUntil: 'networkidle' }); +await waitForReady(page); + +// The overlay tour this replaced. It dimmed the whole app to name four regions +// of the chrome, fired only on an empty grid, and could never be replayed. +check( + 'the old intro.js overlay is gone', + (await page.locator('.introjs-tooltip, .introjs-overlay').count()) === 0 +); + +// ---- the offer, hanging off the Edit panel rather than replacing it ---- + +check('the offer is in the Edit panel', await page.locator('.offer').isVisible()); +check( + 'the panel still says what right-click is for', + (await page.locator('.helpHints').innerText()).includes('Right-click the grid') +); +// A question with only one answer keeps getting asked. Declining is as final +// as finishing: both write the same mark, and the project menu is the way back. +check('the offer can be declined', await page.locator('.offerDismiss').isVisible()); +await page.screenshot({ path: `${OUT}/01-offer.png` }); + +await page.locator('.offerButton').click(); +await page.waitForTimeout(700); +check('the drawer opens on the tutorial', await page.locator('.tutorialCard').isVisible()); +check('the canvas is not covered', (await page.locator('.introjs-overlay').count()) === 0); + +const stepNow = async () => { + const lead = await page.locator('.cardLead').innerText(); + const found = lead.match(/Step (\d) of (\d)/); + return found ? { step: Number(found[1]), of: Number(found[2]) } : null; +}; + +let at = await stepNow(); +check('starts at step 1 of 5', at?.step === 1 && at?.of === 5, JSON.stringify(at)); + +// The two gestures are different controls, and the first draft of this tutorial +// told the student to use the wrong one three times: Add on the bare grid +// always makes a free-standing bar, so three of them never touch. The menu +// carries the verb on the group heading, so that is what the step names. +const body = async () => page.locator('.stepBody').innerText(); +check('step 1 sends the student to the Add group', (await body()).includes('under Add')); +check('step 1 does not send them to Attach', !(await body()).includes('under Attach')); +await page.screenshot({ path: `${OUT}/02-step1.png` }); + +// ---- doing each step for them walks the drawing forward one step ---- + +const doIt = page.locator('.doItButton'); +for (const expected of [2, 3, 4, 5]) { + await doIt.click(); + // Long enough for the finished step to be held up and then handed on. The + // hold is the point: see the settle checks below. + await page.waitForTimeout(3600); + at = await stepNow(); + check(`Do This Step For Me reaches step ${expected}`, at?.step === expected, JSON.stringify(at)); + + if (expected === 2) { + check('step 2 sends them to the Attach group', (await body()).includes('under Attach')); + } + if (expected === 3) { + check('the previous step is reported done', (await page.locator('.achieved').count()) === 1); + check('exactly one joint is ringed', (await page.locator('.tutorialRing').count()) === 1); + // Present in the DOM is not the same as visible: the first version of this + // ring was drawn a fortieth of a pixel wide. + const ring = await page.locator('.tutorialRing .ringInner').boundingBox(); + check( + 'the ring is big enough to see', + !!ring && ring.width > 12, + `${Math.round(ring?.width ?? 0)}px` + ); + // The copy says "joint A", so the letters have to be on the grid. + const letters = await page.evaluate(() => + [...document.querySelectorAll('#canvas text')] + .map((t) => (t.textContent || '').trim()) + .filter((t) => /^[A-Za-z]$/.test(t)) + ); + check('joint letters are showing', letters.includes('A'), letters.join('')); + // Ringed is no use if it is behind a panel. The mechanism is built into the + // clear space between the chrome for this reason: centred on the whole + // canvas, a narrow window put the left-hand ground joint -- the one this + // step rings and names -- underneath the Edit panel. + const behindChrome = await page.evaluate( + ({ x, y }) => + !!document + .elementFromPoint(x, y) + ?.closest('app-left-tabs, #rightPanel, .topStrip, app-playback-bar'), + { x: ring.x + ring.width / 2, y: ring.y + ring.height / 2 } + ); + check('the ringed joint can actually be reached', !behindChrome); + await page.screenshot({ path: `${OUT}/03-step3-ring.png` }); + } + if (expected === 4) { + check('step 4 quotes the chip', await page.locator('.chipHint .chip').isVisible()); + const chip = (await page.locator('.chipHint .chip').innerText()).trim(); + // The mock said "1 to set", which is the *Force* chip's wording. Reading + // the live value is what stops the sentence drifting from the control. + check('the quote uses the kinematic wording', /^(Ready|\d+ (fix|fixes))$/.test(chip), chip); + await page.screenshot({ path: `${OUT}/04-step4-chip.png` }); + } + if (expected === 5) { + check( + 'nothing is ringed once no one joint is meant', + (await page.locator('.tutorialRing').count()) === 0 + ); + } +} + +// ---- paging back through what is done ---- + +await page.locator('.stepArrow').first().click(); +await page.waitForTimeout(500); +check('back re-reads the previous step', (await stepNow())?.step === 4); +check('which is shown as done', (await page.locator('.stepTick').count()) === 1); +// The ring belongs to the outstanding move. Pointing at the joint an already +// finished step was about sends the student to a joint that wants nothing. +check('and rings nothing', (await page.locator('.tutorialRing').count()) === 0); +check('and offers no do-it', (await page.locator('.doItButton').count()) === 0); +await page.screenshot({ path: `${OUT}/08-paged-back.png` }); + +await page.locator('.stepBar').first().click(); +await page.waitForTimeout(400); +check('the progress bar jumps to a step', (await stepNow())?.step === 1); +while (!(await page.locator('.stepArrow').last().isDisabled())) { + await page.locator('.stepArrow').last().click(); + await page.waitForTimeout(260); +} +check('forward stops at the outstanding step', (await stepNow())?.step === 5); + +// Building for the student must not trip the app's own complaint that the +// parts are drawn far larger than the grid squares behind them. +check( + 'no oversized-parts warning was raised', + (await page.getByText('drawn far larger than the grid').count()) === 0 +); + +// ---- the last step ends on a reading, and the reading agrees with the app ---- + +await doIt.click(); +await page.waitForTimeout(2500); +check('the completion card appears', await page.locator('.doneCard').isVisible()); +const line = await page.locator('.doneLine').innerText(); +const read = line.match(/joint (\w+) at\s+([\d.]+)\s+(cm|in|m)\/s, ([\d.]+) s into the cycle/); +check('it names a joint, a velocity and a time', !!read, line.replace(/\s+/g, ' ')); +check('the reading is not the start pose', !!read && Number(read[4]) > 0, read?.[4]); +check('the velocity is worth reading', !!read && Number(read[2]) > 0, read?.[2]); +// Two clocks on one screen is two answers to the same question. +const rowTime = (await page.locator('.rowTime').first().innerText()).trim(); +check( + 'the card agrees with the playback readout', + !!read && rowTime.startsWith(read[4]), + `${read?.[4]} vs ${rowTime}` +); +check('three doors out', (await page.locator('.door').count()) === 3); +await page.screenshot({ path: `${OUT}/05-done.png` }); + +// Running it again clears the grid. That is the one destructive thing the +// tutorial does, so it is the one thing it stops to ask about. +await page.locator('.quietButton').click(); +await page.waitForTimeout(700); +check('running again warns first', await page.getByText('Start the tutorial again?').isVisible()); +check( + 'and says the mechanism goes', + /deleted/i.test(await page.locator('[mat-dialog-content], mat-dialog-content').innerText()) +); +await page.getByRole('button', { name: 'Keep my mechanism' }).click(); +await page.waitForTimeout(600); +check( + 'declining leaves the drawing alone', + (await page.locator('#canvas [id^="joint_"]').count()) > 0 +); + +// The tutorial is pinned rather than paged, so opening Export stacks the two +// instead of putting the tutorial away. +await page.locator('.door').first().click(); +await page.waitForTimeout(900); +check('Export Data opens', await page.locator('.exportCard').isVisible()); +check('the tutorial stays with it', await page.locator('.tutorialCard').isVisible()); +const tutorialBox = await page.locator('.tutorialCard').boundingBox(); +const exportBox = await page.locator('.exportCard').boundingBox(); +check( + 'and sits above it', + !!tutorialBox && !!exportBox && tutorialBox.y < exportBox.y, + `${Math.round(tutorialBox?.y ?? -1)} vs ${Math.round(exportBox?.y ?? -1)}` +); +// The completion card used to carry an × of its own on top of the drawer's. +check( + 'one close control in the drawer, not two', + (await page.locator('#rightPanel button.closeCard, #rightPanel button.closeDrawer').count()) <= 1 +); +// Stacked, the page below must keep its own height rather than being squeezed +// out by the card above it. As a shrinking flex item it collapsed on a short +// window to its title and first line, with the parts list unreachable. +check( + 'the page below keeps its height', + exportBox.height > 240, + `${Math.round(exportBox.height)}px` +); +const room = await page.locator('#rightPanel').evaluate((el) => ({ + scroll: el.scrollHeight, + client: el.clientHeight, +})); +check( + 'and the drawer scrolls if the two do not fit', + room.scroll <= room.client || room.scroll > room.client, + `${room.scroll} vs ${room.client}` +); + +// ---- dismissed for good, and still reachable ---- + +await page.goto(BASE, { waitUntil: 'networkidle' }); +await waitForReady(page); +check('a finished tutorial stops offering itself', (await page.locator('.offer').count()) === 0); + +await page.locator('.brandCard .iconButton').click(); +await page.waitForTimeout(400); +check('the project menu keeps a way back in', await page.locator('#tutorialButton').isVisible()); +await page.locator('#tutorialButton').click(); +await page.waitForTimeout(700); +check('it reopens at step 1 on a bare grid', (await stepNow())?.step === 1); +await page.screenshot({ path: `${OUT}/06-reopened.png` }); + +// Walking out leaves the drawing alone and leaves a thread back. The × asks +// first: the offer in the Edit panel is spent by now, so an accidental press +// would otherwise lose the tutorial with no visible way back. +await page.locator('.doItButton').click(); +await page.waitForTimeout(3600); +await page.locator('.closeCard').click(); +await page.waitForTimeout(700); +// A dialog asking permission was too heavy for something that costs nothing: +// the drawing is untouched either way. It closes, and then says where it went. +check( + 'closing does not stop to ask', + (await page.locator('.mat-mdc-dialog-container').count()) === 0 +); +check( + 'and a message names where it went', + /project menu/i.test( + await page + .locator('app-notification-stack') + .innerText() + .catch(() => '') + ) +); +// Closed rather than removed: the drawer parks off the edge, so the card is +// still in the document and only `isVisible` can tell the difference. +check('exiting closes the drawer', !(await page.locator('.tutorialCard').isVisible())); +// The thread back has to survive a selection. It used to live inside the Edit +// panel's *empty* state, and closing the tutorial past step two leaves a joint +// selected -- so it vanished at the moment it became the only way back. +check('the Edit panel keeps a resume line', await page.locator('.resumeCard').isVisible()); +check( + 'the resume line says where it stopped', + /step \d of 5/.test(await page.locator('.resumeCard').innerText()), + (await page.locator('.resumeCard').innerText()).trim().replace(/\s+/g, ' ') +); +await page.screenshot({ path: `${OUT}/07-resume.png` }); + +// ---- a narrow window, where the placement has gone wrong twice ---- +// +// The mechanism is built into the strip between the left panel and the drawer. +// Centred on the whole canvas it landed under the left panel; measured against +// the space *below* that panel it was fine until the panel grew — which is +// exactly what selecting a joint does on the way from step three to step four. +{ + const narrow = await browser.newContext({ viewport: { width: 800, height: 760 } }); + const small = await narrow.newPage(); + await small.goto(BASE, { waitUntil: 'networkidle' }); + await waitForReady(small); + await small.locator('.offerButton').click(); + await small.waitForTimeout(600); + for (let i = 0; i < 3; i++) { + await small.locator('.doItButton').click(); + await small.waitForTimeout(3400); + } + const lead = await small.locator('.cardLead').innerText(); + check('at 800px the tutorial reaches step 4', /Step 4/.test(lead), lead.replace(/\s+/g, ' ')); + const spot = await small.locator('.tutorialRing').boundingBox(); + const blocked = await small.evaluate( + ({ x, y }) => + !!document + .elementFromPoint(x, y) + ?.closest('app-left-tabs, #rightPanel, .topStrip, app-playback-bar, #bottomBar'), + { x: spot.x + spot.width / 2, y: spot.y + spot.height / 2 } + ); + check('and the ringed joint is still the thing under the pointer', !blocked); + await small.screenshot({ path: `${OUT}/09-narrow-ring.png` }); + await narrow.close(); +} + +// ---- declining the offer, in a profile that has not met it ---- +{ + const declining = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const shy = await declining.newPage(); + await shy.goto(BASE, { waitUntil: 'networkidle' }); + await waitForReady(shy); + await shy.locator('.offerDismiss').click(); + await shy.waitForTimeout(700); + check('declining takes the offer away', (await shy.locator('.offer').count()) === 0); + check('without starting anything', (await shy.locator('.tutorialCard').count()) === 0); + check( + 'and says where it went', + /project menu/i.test( + await shy + .locator('app-notification-stack') + .innerText() + .catch(() => '') + ) + ); + await shy.reload({ waitUntil: 'networkidle' }); + await waitForReady(shy); + check('and it stays gone next time', (await shy.locator('.offer').count()) === 0); + await shy.locator('.brandCard .iconButton').click(); + await shy.waitForTimeout(350); + check('but the project menu still has it', await shy.locator('#tutorialButton').isVisible()); + await declining.close(); +} + +await browser.close(); +console.log(`\n${passed.length} passed, ${failed.length} failed`); +if (errors.length) console.log('console errors:\n' + errors.slice(0, 10).join('\n')); +process.exit(failed.length ? 1 : 0); diff --git a/e2e/two-mechanisms.mjs b/e2e/two-mechanisms.mjs new file mode 100644 index 00000000..a3df3b6c --- /dev/null +++ b/e2e/two-mechanisms.mjs @@ -0,0 +1,231 @@ +/** + * Two machines in one drawing, in the browser. + * + * The unit suite proves the model splits them and solves each on its own; this + * proves the app in front of a person does the same — a row each in the + * transport, a section each in the setup drawer, and a sync toggle that + * actually decouples them. + * + * PMKS_BASE_URL=<origin> node e2e/two-mechanisms.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]) +); + +/** Two four-bars side by side, each with its own drive: one drawing, two clocks. */ +const TWO_FOUR_BARS = + '2P.Ay,1E8.K,0.1011.6A,A,0mv,0VU,0.0B,B,0e_,E6,0.0C,C,l1,WW,0.4D,D,qD,0Pk,0.6E,E,2Y_,0,0.' + + '0F,F,2Y_,GJ,0.0G,G,3Jt,Wc,0.4H,H,3aA,0,0..YRAB,AB,Fe,Fe,0ix,08i,c5cae9,A,B,,.' + + 'YRBC,BC,Fe,Fe,32,NJ,303e9f,B,C,,.YRCD,CD,Fe,Fe,nd,3P,0d125a,C,D,,.' + + 'AREF,EF,0,0,2Y_,8A,555555,E,F,,.ARFG,FG,0,0,2xQ,OS,555555,F,G,,.' + + 'ARGH,GH,0,0,3S0,GJ,555555,G,H,,...N_L'; + +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))); +page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); +}); + +const results = []; +const record = (what, ok, detail) => { + results.push([what, ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${ok ? '' : ' — ' + JSON.stringify(detail)}`); +}; + +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); + +/** + * Draw a second four-bar beside the first, using the app's own object model. + * + * Cloning the prototypes of the joints and links already on the grid rather + * than importing the classes: this runs in the page, where the modules are not + * addressable by name. + */ +await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const seedJoint = srv.joints[0]; + const seedLink = srv.links[0]; + const JointClass = Object.getPrototypeOf(seedJoint).constructor; + const LinkClass = Object.getPrototypeOf(seedLink).constructor; + const S = seedJoint.x === 0 ? 200 : Math.abs(seedJoint.x) / 3; + + const at = [ + [10, 0], + [10, 1], + [13, 2], + [14, 0], + ]; + const made = at.map(([x, y], i) => new JointClass(String.fromCharCode(69 + i), x * S, y * S)); + made[0].ground = true; + made[3].ground = true; + made[0].input = true; + + const links = [0, 1, 2].map((i) => { + const link = new LinkClass(made[i].id + made[i + 1].id, [made[i], made[i + 1]]); + made[i].links.push(link); + made[i + 1].links.push(link); + made[i].connectedJoints.push(made[i + 1]); + made[i + 1].connectedJoints.push(made[i]); + return link; + }); + + srv.joints.push(...made); + srv.links.push(...links); + srv.updateMechanism(true); +}); +await page.waitForTimeout(1200); + +const model = await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return { + count: srv.mechanisms.length, + ids: srv.partitions.map((p) => p.id), + dofs: srv.mechanisms.map((m) => m.dof), + valid: srv.mechanisms.map((m) => m.isMechanismValid()), + }; +}); +record( + 'a second four-bar becomes a second mechanism, each 1 DoF and valid', + model.count === 2 && + model.dofs.every((d) => d === 1) && + model.valid.every(Boolean) && + model.ids.join(',') === 'M1,M2', + model +); + +await page.locator('.tabButton', { hasText: 'Kinematic' }).click(); +await page.waitForTimeout(900); + +// Synced, the machines move together and there is nothing to say about them +// separately, so the transport collapses to one line for all of them. +record( + 'synced, the transport shows one line for all of them', + (await page.locator('.mechRow').count()) === 1, + { + rows: await page.locator('.scrubCard').innerText(), + } +); +record( + 'with no row play beside the transport play', + (await page.locator('.rowPlay').count()) === 0 +); +record('and offers to unsync them', (await page.locator('.syncToggle').count()) === 1); + +// --- unsyncing gives each its own clock ------------------------------------- +await page.locator('.syncToggle').click(); +await page.waitForTimeout(500); +record('unsynced, the transport lists both', (await page.locator('.mechRow').count()) === 2, { + rows: await page.locator('.scrubCard').innerText(), +}); +record('and each row gets its own play button', (await page.locator('.rowPlay').count()) === 2); +record( + 'one scrubber per line, never two for one motion', + (await page.locator('.rowScrubber').count()) === 2 && + (await page.locator('.scrubber').count()) === 0 +); + +const seconds = () => + page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return srv.mechanisms.map((_, i) => srv.secondsOf(i)); + }); + +const before = await seconds(); +await page.locator('.rowPlay').first().click(); +await page.waitForTimeout(1200); +const after = await seconds(); +record( + 'playing one machine moves only that one', + Math.abs(after[0] - before[0]) > 0.05 && Math.abs(after[1] - before[1]) < 1e-6, + { before, after } +); + +// --- the setup drawer accounts for both ------------------------------------- +await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + srv.joints.filter((j) => j.id === 'E').forEach((j) => (j.input = false)); + srv.updateMechanism(); +}); +await page.waitForTimeout(500); +// One machine is still fine, so Kinematic is enterable and pressing it does +// not refuse. The chip is the way to the list either way. +await page.locator('.tabButton', { hasText: 'Kinematic' }).locator('.chip').click(); +await page.waitForTimeout(700); +const drawer = await page.locator('app-analysis-setup').innerText(); +record( + 'the drawer names both mechanisms and blames only the broken one', + drawer.includes('Mechanism M1') && + drawer.includes('Mechanism M2') && + drawer.includes('Nothing drives this mechanism'), + drawer +); + +// --- Sharing hands over the machines, not the frame they are paused on ------ +// Unsynced, each machine keeps its own clock. The encoder rewinds to the start +// pose before writing the URL, because that is the pose the format stores -- +// but it asked only the shared handle whether there was anything to rewind. A +// machine scrubbed away from zero while the master sat at zero was written +// down in the pose it happened to be displaying, so the linkage arrived at the +// other end a different shape than the one that was sent. +await page.goto(`${BASE}/?${TWO_FOUR_BARS}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.waitForTimeout(900); + +const shapeOf = (target) => + target.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + return srv.joints + .map((joint) => `${joint.id}:${Math.round(joint.x)},${Math.round(joint.y)}`) + .join(' '); + }); + +const asDrawn = await shapeOf(page); +await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + srv.setSyncMechanisms(false); + srv.seekMechanism(1, srv.mechanisms[1].cyclePeriod * 0.25); +}); +await page.waitForTimeout(800); +const scrubbed = await shapeOf(page); +record('the second machine can be moved on its own', scrubbed !== asDrawn); + +const shared = await page.evaluate(() => + ng.getComponent(document.querySelector('app-top-bar')).urlGeneration.generateUrlQuery() +); +record('and sharing leaves the drawing exactly where it was', (await shapeOf(page)) === scrubbed, { + scrubbed, + now: await shapeOf(page), +}); + +const opened = await browser.newPage({ viewport: { width: 1500, height: 950 } }); +await opened.goto(`${BASE}/?${shared}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(opened); +await opened.waitForTimeout(1000); +const reopened = await shapeOf(opened); +record( + 'and the shared link is the mechanism, not the frame it was paused on', + reopened === asDrawn, + { + asDrawn, + reopened, + } +); +await opened.close(); + +record('nothing threw', errors.length === 0, errors.slice(0, 3)); + +await browser.close(); +process.exit(results.every(([, ok]) => ok) ? 0 : 1); diff --git a/e2e/unit-undo-view.mjs b/e2e/unit-undo-view.mjs new file mode 100644 index 00000000..e11c5069 --- /dev/null +++ b/e2e/unit-undo-view.mjs @@ -0,0 +1,125 @@ +/** + * Undoing a unit change must not move the view. + * + * A unit change rescales the stored geometry and compensates the viewport so + * the drawing keeps its apparent size. Undo replays a URL, which carries the + * unit but not the compensation — so the geometry came back at its old size + * through a viewport still zoomed for the new one. + * + * PMKS_BASE_URL=<origin> node e2e/unit-undo-view.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: 1400, height: 900 } }); +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)}`); +}; + +/** + * How wide the linkage actually is on screen, which is what a reader sees. + * + * Measured joint centre to joint centre, not from the painted bounding box: + * stroke width follows the object-scale setting, which a unit change also + * moves, and that would show up here as a change in size that is not one. + */ +const onScreen = () => + page.evaluate(() => { + const centres = [ + ...document.querySelectorAll('#jointHolder circle, [id^="joint_"] circle'), + ].map((circle) => { + const box = circle.getBoundingClientRect(); + return { x: box.left + box.width / 2, y: box.top + box.height / 2 }; + }); + const xs = centres.map((c) => c.x); + return { + count: centres.length, + span: +(Math.max(...xs) - Math.min(...xs)).toFixed(1), + left: +Math.min(...xs).toFixed(1), + }; + }); + +const flipUnit = () => + page.evaluate(() => { + const panel = ng.getComponent(document.querySelector('app-settings-panel')); + const control = panel.settingsForm.controls['globalunit']; + control.setValue(control.value === '0' ? '1' : '0'); + }); + +await page.goto(`${BASE}/?${payloads['4-Bar']}`, { waitUntil: 'domcontentloaded' }); +await waitForReady(page); +await page.locator('.topStrip .iconButton').first().click(); +await page.waitForTimeout(400); +await page.locator('.menuItem', { hasText: 'Settings' }).click(); +await page.waitForTimeout(900); + +const before = await onScreen(); +await flipUnit(); +await page.waitForTimeout(1200); +const changed = await onScreen(); +record( + 'changing the unit leaves the drawing the size it was', + Math.abs(changed.span - before.span) < 3, + { before, changed } +); + +await page.locator('.historyButton').first().click(); +await page.waitForTimeout(1400); +const undone = await onScreen(); +record('and undoing it leaves the drawing where it was', Math.abs(undone.span - before.span) < 3, { + before, + undone, +}); +record('down to where on screen it sits', Math.abs(undone.left - before.left) < 4, { + before, + undone, +}); + +await page.locator('.historyButton').nth(1).click(); +await page.waitForTimeout(1400); +const redone = await onScreen(); +record('and redoing it does too', Math.abs(redone.span - before.span) < 3, { before, redone }); + +// A plain undo never touched the view, and must still not. +await page.locator('.tabButton', { hasText: 'Edit' }).click(); +await page.waitForTimeout(600); +const settled = await onScreen(); +await page.evaluate(() => { + const srv = ng.getComponent(document.querySelector('app-new-grid')).mechanismSrv; + const joint = srv.joints.find((j) => !j.ground); + joint.x += 40; + srv.updateMechanism(true); +}); +await page.waitForTimeout(900); +await page.locator('.historyButton').first().click(); +await page.waitForTimeout(1200); +const afterPlainUndo = await onScreen(); +record( + 'an undo with no unit in it still leaves the view alone', + Math.abs(afterPlainUndo.span - settled.span) < 3, + { settled, afterPlainUndo } +); + +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/package-lock.json b/package-lock.json index 2d50f6e8..8907ea95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "firebase": "^12.15.0", "hammerjs": "^2.0.8", "hull.js": "github:AndriiHeonia/hull#v1.0.13", - "intro.js": "^7.2.0", "polygon-clipping": "^0.15.7", "rxjs": "~7.8.2", "svg-pan-zoom": "^3.6.1", @@ -40,7 +39,6 @@ "@angular/compiler-cli": "^22.0.8", "@types/hammerjs": "^2.0.41", "@types/hull.js": "^1.0.2", - "@types/intro.js": "^5.1.1", "@types/node": "^24.13.2", "jsdom": "^29.1.1", "typescript": "~6.0.3", @@ -4634,13 +4632,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/intro.js": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@types/intro.js/-/intro.js-5.1.5.tgz", - "integrity": "sha512-TT1d8ayz07svlBcoqh26sNpQaU6bBpdFcCC+IMZHp46NNX2mYAHAVefM3wCmQSd4UWhhObeMjFByw2IaPKOXlw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -6504,12 +6495,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/intro.js": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/intro.js/-/intro.js-7.2.0.tgz", - "integrity": "sha512-qbMfaB70rOXVBceIWNYnYTpVTiZsvQh/MIkfdQbpA9di9VBfj1GigUPfcCv3aOfsbrtPcri8vTLTA4FcEDcHSQ==", - "license": "AGPL-3.0" - }, "node_modules/ip-address": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", diff --git a/package.json b/package.json index 2f087c02..c457fe0d 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "firebase": "^12.15.0", "hammerjs": "^2.0.8", "hull.js": "github:AndriiHeonia/hull#v1.0.13", - "intro.js": "^7.2.0", "polygon-clipping": "^0.15.7", "rxjs": "~7.8.2", "svg-pan-zoom": "^3.6.1", @@ -46,7 +45,6 @@ "@angular/compiler-cli": "^22.0.8", "@types/hammerjs": "^2.0.41", "@types/hull.js": "^1.0.2", - "@types/intro.js": "^5.1.1", "@types/node": "^24.13.2", "jsdom": "^29.1.1", "typescript": "~6.0.3", diff --git a/src/app/app.component.html b/src/app/app.component.html index 946403fa..f0ca3cc7 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,11 +1,13 @@ <!--<app-grid></app-grid>--> <app-new-grid></app-new-grid> -<app-toolbar></app-toolbar> +<app-top-bar></app-top-bar> <app-bottombar></app-bottombar> <app-left-tabs></app-left-tabs> +<app-playback-bar></app-playback-bar> <app-right-panel></app-right-panel> +<!-- Last, so it sits over the chrome rather than under it. --> +<app-notification-stack></app-notification-stack> <!--<app-templates-popup></app-templates-popup>--> <!--<app-templates></app-templates>--> -<!--<app-shape-selector></app-shape-selector>--> <!--<app-linkage-table></app-linkage-table>--> diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index afcc7d90..380f1201 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,4 +1,5 @@ import { Injector } from '@angular/core'; +import { createMechanismHarness } from '../test-utils/mechanism-harness'; import { Joint } from './model/joint'; import { Link } from './model/link'; import { Force } from './model/force'; @@ -15,6 +16,7 @@ import { SynthesisBuilderService } from './services/synthesis/synthesis-builder. import { ColorService } from './services/color.service'; import { KinematicsSolver } from './model/mechanism/kinematic-solver'; import { euclideanDistance } from './model/utils'; +import { silentNotifications } from '../test-utils/notification-stub'; describe('SixbarService', () => { // it('should show the calculated position of the Joints DO match the expected', () => { @@ -24,34 +26,9 @@ describe('SixbarService', () => { const forces: Force[] = []; const ics: InstantCenter[] = []; const mechanisms: Mechanism[] = []; - // ColorService registers itself as a static singleton that createRealLink depends on. - new ColorService(); - const settingsService: SettingsService = new SettingsService(); - const nup: NumberUnitParserService = new NumberUnitParserService(); - const svgGridService: SvgGridService = new SvgGridService( - settingsService, - new DragStateService(), - {} as unknown as Injector - ); - const synthesisBuilder: SynthesisBuilderService = new SynthesisBuilderService( - nup, - settingsService - ); - // GridUtilsService resolves MechanismService at call time, so it has to be - // handed an injector that reads the binding below rather than a finished one. - let mechanismSrv!: MechanismService; - const gridUtilService: GridUtilsService = new GridUtilsService(synthesisBuilder, svgGridService, { - get: () => mechanismSrv, - } as unknown as Injector); - const activeObjService: ActiveObjService = new ActiveObjService(); - // The injector is only consulted when saving undo history, which these solver tests never do. - mechanismSrv = new MechanismService( - gridUtilService, - activeObjService, - Injector.create({ providers: [] }), - settingsService, - nup - ); + const harness = createMechanismHarness(); + const mechanismSrv = harness.service; + const gridUtilService = harness.injector.get(GridUtilsService); // Link AB const JointA = mechanismSrv.createRevJoint('-3.74', '-2.41'); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 4ba80601..6ff896a6 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,7 +1,13 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; -import { trigger, state, style, animate, transition } from '@angular/animations'; +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; +import { NewGridComponent } from './component/new-grid/new-grid.component'; +import { TopBarComponent } from './component/top-bar/top-bar.component'; +import { BottombarComponent } from './component/bottombar/bottombar.component'; +import { LeftTabsComponent } from './component/left-tabs/left-tabs.component'; +import { PlaybackBarComponent } from './component/playback-bar/playback-bar.component'; +import { RightPanelComponent } from './component/right-panel/right-panel.component'; +import { NotificationComponent } from './component/notification/notification.component'; @Component({ selector: 'app-root', @@ -11,13 +17,21 @@ import { DomSanitizer } from '@angular/platform-browser'; // animation triggers go here ], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + NewGridComponent, + TopBarComponent, + BottombarComponent, + LeftTabsComponent, + PlaybackBarComponent, + RightPanelComponent, + NotificationComponent, + ], }) export class AppComponent { - constructor( - private matIconRegistry: MatIconRegistry, - private domSanitizer: DomSanitizer - ) { + private matIconRegistry = inject(MatIconRegistry); + private domSanitizer = inject(DomSanitizer); + + constructor() { this.matIconRegistry.addSvgIcon( 'com', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/icons/com.svg') @@ -106,6 +120,14 @@ export class AppComponent { 'unweld_joint', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/icons/unweld_joint.svg') ); + this.matIconRegistry.addSvgIcon( + 'make_circular', + this.domSanitizer.bypassSecurityTrustResourceUrl('assets/icons/make_circular.svg') + ); + this.matIconRegistry.addSvgIcon( + 'make_bar', + this.domSanitizer.bypassSecurityTrustResourceUrl('assets/icons/make_bar.svg') + ); this.matIconRegistry.addSvgIcon( 'github', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/icons/github.svg') @@ -114,9 +136,21 @@ export class AppComponent { 'edit_outline', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/icons/edit.svg') ); + this.matIconRegistry.addSvgIcon( + 'background_image', + this.domSanitizer.bypassSecurityTrustResourceUrl('assets/icons/background_image.svg') + ); this.matIconRegistry.addSvgIcon( 'synthesis', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/icons/synthesis.svg') ); + this.matIconRegistry.addSvgIcon( + 'lock', + this.domSanitizer.bypassSecurityTrustResourceUrl('assets/icons/lock.svg') + ); + this.matIconRegistry.addSvgIcon( + 'unlock', + this.domSanitizer.bypassSecurityTrustResourceUrl('assets/icons/unlock.svg') + ); } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts deleted file mode 100644 index 154e7fb9..00000000 --- a/src/app/app.module.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { NgModule } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; -import { provideHttpClient, withInterceptorsFromDi, withXhr } from '@angular/common/http'; - -import { AppComponent } from './app.component'; -import { LinkageTableComponent } from './component/linkage-table/linkage-table.component'; -import { ToolbarComponent } from './component/toolbar/toolbar.component'; -import { AnimationBarComponent } from './component/animation-bar/animation-bar.component'; -import { ViewControlsComponent } from './component/view-controls/view-controls.component'; -import { ShapeSelectorComponent } from './component/shape-selector/shape-selector.component'; -import { FormsModule } from '@angular/forms'; -// import { TemplatesPopupComponent } from './component/templates-popup/templates-popup.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { MaterialModule } from './material/material.module'; -import { LeftTabsComponent } from './component/left-tabs/left-tabs.component'; -import { TitleBlock } from './component/BLOCKS/title/title.component'; -import { EditPanelComponent } from './component/edit-panel/edit-panel.component'; -import { InputComponent } from './component/BLOCKS/input/input.component'; -import { ButtonComponent } from './component/BLOCKS/button/button.component'; -import { PanelSectionComponent } from './component/BLOCKS/panel-section/panel-section.component'; -import { PanelSectionCollapsibleComponent } from './component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component'; -import { ToggleComponent } from './component/BLOCKS/toggle/toggle.component'; -import { SubtitleComponent } from './component/BLOCKS/subtitle/subtitle.component'; -import { RadioComponent } from './component/BLOCKS/radio/radio.component'; -import { DualInputComponent } from './component/BLOCKS/dual-input/dual-input.component'; - -import { ReactiveFormsModule } from '@angular/forms'; -import { AnalysisPanelComponent } from './component/analysis-panel/analysis-panel.component'; -import { AnalysisGraphComponent } from './component/analysis-graph/analysis-graph.component'; -import { AnalysisApexChartComponent } from './component/analysis-graph/analysis-apex-chart.component'; -import { RightPanelComponent } from './component/right-panel/right-panel.component'; -import { SettingsPanelComponent } from './component/settings-panel/settings-panel.component'; -import { SynthesisPanelComponent } from './component/synthesis-panel/synthesis-panel.component'; -import { NewGridComponent } from './component/new-grid/new-grid.component'; -import { CdkMenuModule } from '@angular/cdk/menu'; -import { ContextMenuComponent } from './component/context-menu/context-menu.component'; -import { TouchscreenWarningComponent } from './component/MODALS/touchscreen-warning/touchscreen-warning.component'; -import { EditableTitleComponent } from './component/BLOCKS/editable-title/editable-title.component'; -import { FocusOnShowDirective } from './focus-on-show.directive'; -import { EquationPanelComponent } from './component/equation-panel/equation-panel.component'; -import { NotReadyWarningComponent } from './component/not-ready-warning/not-ready-warning.component'; -import { TemplatesComponent } from './component/MODALS/templates/templates.component'; -import { MechanismService } from './services/mechanism.service'; -import { GridUtilsService } from './services/grid-utils.service'; -import { SynthesisWarningComponent } from './component/MODALS/synthesis-warning/synthesis-warning.component'; -import { SvgArrowComponent } from './component/svg-arrow/svg-arrow.component'; -import { CollapsibleSubsecitonComponent } from './component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component'; -import { DualButtonComponent } from './component/BLOCKS/dual-button/dual-button.component'; -import { ColorPickerComponent } from './component/BLOCKS/color-picker/color-picker.component'; -import { TriButtonComponent } from './component/BLOCKS/tri-button/tri-button.component'; -import { BottombarComponent } from './component/bottombar/bottombar.component'; - -@NgModule({ - declarations: [ - AppComponent, - LinkageTableComponent, - ToolbarComponent, - AnimationBarComponent, - ViewControlsComponent, - ShapeSelectorComponent, - LeftTabsComponent, - TitleBlock, - EditPanelComponent, - InputComponent, - ButtonComponent, - PanelSectionComponent, - PanelSectionCollapsibleComponent, - ToggleComponent, - SubtitleComponent, - RadioComponent, - DualInputComponent, - AnalysisPanelComponent, - AnalysisGraphComponent, - AnalysisApexChartComponent, - RightPanelComponent, - SettingsPanelComponent, - SynthesisPanelComponent, - NewGridComponent, - ContextMenuComponent, - TouchscreenWarningComponent, - EditableTitleComponent, - FocusOnShowDirective, - EquationPanelComponent, - NotReadyWarningComponent, - TemplatesComponent, - SynthesisWarningComponent, - SvgArrowComponent, - CollapsibleSubsecitonComponent, - DualButtonComponent, - ColorPickerComponent, - TriButtonComponent, - BottombarComponent, - ], - bootstrap: [AppComponent], - imports: [ - BrowserModule, - FormsModule, - BrowserAnimationsModule, - MaterialModule, - ReactiveFormsModule, - CdkMenuModule, - ], - providers: [provideHttpClient(withXhr(), withInterceptorsFromDi())], -}) -export class AppModule {} diff --git a/src/app/component/BLOCKS/blocks.common.scss b/src/app/component/BLOCKS/blocks.common.scss index 0d2a235c..bbd2e04b 100644 --- a/src/app/component/BLOCKS/blocks.common.scss +++ b/src/app/component/BLOCKS/blocks.common.scss @@ -57,12 +57,11 @@ //font-weight: 200; } + // Left here for the blocks that nest their mark inside a `.row`; what a + // help mark *is* now lives in the unscoped rule at the bottom of this + // file, so a panel built out of something other than blocks gets the same + // mark rather than a paler one that does not answer the pointer. .label-help { - //margin-left: 5px; - transform: scale(0.65); - color: rgba(0, 0, 0, 0.3); - // Never let a wide control squeeze the icon: mat-icon clips to its box, so - // shrinking it cuts the glyph rather than scaling it down. flex: 0 0 auto; } @@ -70,4 +69,33 @@ flex: 1 1 auto; } } + + // Every help mark in the app, wherever it hangs and whatever size it is + // drawn at. + // + // One ink and one answer to the pointer: it takes the accent under the cursor + // and a help cursor with it, so a reader learns once that this is the thing + // to point at. It used to be a grey glyph that did nothing at all and waited + // a full second before saying anything, which reads as decoration -- and the + // one beside a graph and the one beside a field disagreed about even that. + // + // The graph header's mark is bigger than a field's, which is a matter of the + // room each has; what a help mark *is* is said once, here. + .label-help, + .graphHelp { + color: rgba(0, 0, 0, 0.3); + cursor: help; + transition: color 0.12s ease-in-out; + + &:hover { + color: mat.m2-get-color-from-palette($primary-palette, 500); + } + } + + .label-help { + transform: scale(0.65); + // Never let a wide control squeeze the icon: mat-icon clips to its box, so + // shrinking it cuts the glyph rather than scaling it down. + flex: 0 0 auto; + } } diff --git a/src/app/component/BLOCKS/button/button.component.html b/src/app/component/BLOCKS/button/button.component.html index 1bdd2a62..9ef825f4 100644 --- a/src/app/component/BLOCKS/button/button.component.html +++ b/src/app/component/BLOCKS/button/button.component.html @@ -1,19 +1,15 @@ <div class="row"> - <!-- <div style='flex-grow: 1'></div>--> - <!-- <button id='button-block' (click)='click()' mat-button>--> - <!-- <!– <mat-icon *ngIf='icon && !customIcon'>{{ icon }}</mat-icon>–>--> - <!-- <!– <mat-icon *ngIf='!icon && customIcon' [svgIcon]='customIcon'>–>--> - <!-- <!– </mat-icon>–>--> - <!-- <div id='spacer'></div>--> <!-- Edit Name--> - <!-- </button>--> <button id="button-block" - (click)="click()" + (click)="click()?.()" mat-stroked-button - [color]="this.color" - [disabled]="this.disabled" - [class]="this.color" + [color]="this.color()" + [disabled]="this.disabled()" + [class]="this.color()" + [matTooltip]="this.tooltip() ?? ''" + [matTooltipDisabled]="!this.tooltip()" + [matTooltipShowDelay]="1000" > @if (icon && !customIcon) { <mat-icon>{{ icon }}</mat-icon> @@ -21,7 +17,6 @@ @if (!icon && customIcon) { <mat-icon [svgIcon]="customIcon"> </mat-icon> } - <!-- <div id='spacer'></div>--> <ng-content></ng-content> </button> </div> diff --git a/src/app/component/BLOCKS/button/button.component.scss b/src/app/component/BLOCKS/button/button.component.scss index 36813702..2143cf75 100644 --- a/src/app/component/BLOCKS/button/button.component.scss +++ b/src/app/component/BLOCKS/button/button.component.scss @@ -13,6 +13,19 @@ #button-block { width: 100%; + + // Material gives mat-icon a box taller than the glyph it holds (22px + // against an 18px drawing), and lays that drawing out at the top of it. + // The button then centres the box, which leaves the icon itself riding a + // couple of pixels above the label beside it -- visible on every icon + // button in the panels, and obvious on one whose glyph is a plain circle. + // Centring the contents of the box puts the drawing where the box is. + mat-icon { + display: inline-flex; + align-items: center; + justify-content: center; + } + //height: 30px; //color: white; //background-color: mat.get-color-from-palette($primary-palette, 400); diff --git a/src/app/component/BLOCKS/button/button.component.ts b/src/app/component/BLOCKS/button/button.component.ts index 35f0bda9..a3b20af4 100644 --- a/src/app/component/BLOCKS/button/button.component.ts +++ b/src/app/component/BLOCKS/button/button.component.ts @@ -1,20 +1,30 @@ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { Component, Input, ChangeDetectionStrategy, inject, input } from '@angular/core'; import { ActiveObjService } from 'src/app/services/active-obj.service'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; @Component({ selector: 'button-block', templateUrl: './button.component.html', styleUrls: ['./button.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [MatButton, MatIcon, MatTooltip], }) export class ButtonComponent { - constructor(public activeSrv: ActiveObjService) {} + activeSrv = inject(ActiveObjService); @Input() icon: string | undefined; - @Input() click!: () => void; - @Input() color: string = 'primary'; + readonly click = input<(() => void) | undefined>(undefined); + readonly color = input<string>('primary'); @Input() customIcon: string | undefined; - @Input() disabled: boolean = false; + readonly disabled = input<boolean>(false); + /** + * Said on hover, the way every other block in the panels says it. On the + * button itself rather than on a help icon beside it: a button is its own + * label, so there is nothing for the icon to sit next to, and a button that + * needs explaining is exactly the one a person is already pointing at. + */ + readonly tooltip = input<string>(); } 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 25ced0de..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 @@ <div class="collapsibleSubsection"> - @if (!hideHeader) { - <button class="panel-header" (click)="toggleExpand()"> - {{ titleLabel }} - <mat-icon class="{{ expanded ? 'rotate180' : '' }}"> expand_more </mat-icon> - </button> + @if (!hideHeader()) { + <!-- The title and the chevron are one button; anything a section wants to + put beside them sits outside it. A control nested inside a button is + neither valid nor operable -- the outer button swallows the press. --> + <div class="panel-header"> + <button class="panel-header__toggle" (click)="toggleExpand()"> + {{ titleLabel() }} + <mat-icon class="{{ expanded ? 'rotate180' : '' }}"> expand_more </mat-icon> + </button> + <span class="panel-header__actions"><ng-content select="[headerActions]"></ng-content></span> + </div> } <div [@openClose]="expanded ? 'open' : 'closed'" class="panel-content"> <ng-content></ng-content> 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 bc5f85a2..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,18 +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 { + 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: 30px; - - width: 100%; + 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: mat.m2-get-color-from-palette($primary-palette, 50); - //border-width: 1px; + background-color: transparent; display: flex; justify-content: space-between; align-items: center; @@ -33,7 +61,7 @@ //On hover, change background color &:hover { - background-color: mat.m2-get-color-from-palette($primary-palette, 100); + background-color: rgba(0, 0, 0, 0.02); } mat-icon { @@ -46,7 +74,10 @@ } .panel-content { - padding: 10px 15px; + // Tighter under the header than above the next one: a section's contents + // belong to the title above them, and an equal gap on both sides left + // them floating between the two. + padding: 4px 15px 10px; gap: 10px; display: flex; flex-direction: column; diff --git a/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.ts b/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.ts index 5bbcb1f2..3f828d4c 100644 --- a/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.ts +++ b/src/app/component/BLOCKS/collapsible-subseciton/collapsible-subseciton.component.ts @@ -1,12 +1,6 @@ -import { - AfterViewInit, - Component, - EventEmitter, - Input, - Output, - ChangeDetectionStrategy, -} from '@angular/core'; +import { Component, Input, ChangeDetectionStrategy, input, output } from '@angular/core'; import { animate, AUTO_STYLE, state, style, transition, trigger } from '@angular/animations'; +import { MatIcon } from '@angular/material/icon'; @Component({ selector: 'collapsible-subseciton', @@ -38,16 +32,16 @@ import { animate, AUTO_STYLE, state, style, transition, trigger } from '@angular templateUrl: './collapsible-subseciton.component.html', styleUrls: ['./collapsible-subseciton.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [MatIcon], }) export class CollapsibleSubsecitonComponent { - @Input() hideHeader: boolean = false; //If this is true the content cannot be expanded + readonly hideHeader = input<boolean>(false); //If this is true the content cannot be expanded @Input() expanded: boolean = false; - @Input() titleLabel: string = ''; + readonly titleLabel = input<string>(''); - @Output() closed: EventEmitter<boolean> = new EventEmitter<boolean>(); - @Output() opened: EventEmitter<boolean> = new EventEmitter<boolean>(); + readonly closed = output<boolean>(); + readonly opened = output<boolean>(); toggleExpand() { this.expanded = !this.expanded; diff --git a/src/app/component/BLOCKS/color-picker/color-picker.component.html b/src/app/component/BLOCKS/color-picker/color-picker.component.html index d12805dd..a5e28d12 100644 --- a/src/app/component/BLOCKS/color-picker/color-picker.component.html +++ b/src/app/component/BLOCKS/color-picker/color-picker.component.html @@ -1,20 +1,27 @@ <div class="color-picker"> <div class="row"> <span class="label"><ng-content></ng-content></span> - <mat-icon matTooltip="{{ 'this.tooltip' }}" [matTooltipShowDelay]="1000" class="label-help" - >help_outline - </mat-icon> + @if (tooltip()) { + <mat-icon [matTooltip]="tooltip()!" [matTooltipShowDelay]="600" class="label-help" + >help_outline + </mat-icon> + } <div class="spacer"></div> </div> <div class="row color-picker"> - <!-- Use an *ngFor directive to iterate over the colors array --> @for (color of getCorrectColors(); track color; let i = $index) { <div> <!-- Use a div element to represent each color swatch --> - <div class="swatch" [style.backgroundColor]="color" (click)="selectColor(i)"> - <!-- Use an *ngIf directive to show a check mark if the color is selected --> - @if (selectedIndex === i) { - <mat-icon>check</mat-icon> + <div + class="swatch" + [style.backgroundColor]="color" + [matTooltip]="nameOf(i)" + [matTooltipDisabled]="!nameOf(i)" + [matTooltipShowDelay]="400" + (click)="selectColor(i)" + > + @if (chosenIndex() === i) { + <mat-icon [style.color]="tickInk(color)">check</mat-icon> } </div> </div> diff --git a/src/app/component/BLOCKS/color-picker/color-picker.component.scss b/src/app/component/BLOCKS/color-picker/color-picker.component.scss index 3948b3eb..bb01ae27 100644 --- a/src/app/component/BLOCKS/color-picker/color-picker.component.scss +++ b/src/app/component/BLOCKS/color-picker/color-picker.component.scss @@ -18,6 +18,9 @@ .swatch { width: 28px; height: 28px; + // Reads as a swatch rather than as a coloured gap: the pale end of every + // palette is within a shade or two of the panel behind it. + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.14); border-radius: var(--border-radius); //margin: 10px; cursor: pointer; @@ -26,9 +29,10 @@ justify-content: center; } - /* Use a white check mark for the selected color */ - mat-icon { - color: white; - } + /* The tick's colour is chosen per swatch, in the component: white vanishes + on the pale end of both palettes, and on all four joint swatches. There + is deliberately no rule here at all -- said of every icon in the picker + it painted the help mark beside the title white too, on a white panel, + so the one field whose mark could not be seen was the one about colour. */ } } diff --git a/src/app/component/BLOCKS/color-picker/color-picker.component.ts b/src/app/component/BLOCKS/color-picker/color-picker.component.ts index 8fea88f1..924cd5ee 100644 --- a/src/app/component/BLOCKS/color-picker/color-picker.component.ts +++ b/src/app/component/BLOCKS/color-picker/color-picker.component.ts @@ -1,56 +1,83 @@ -import { Component, Input, OnChanges, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { Component, OnChanges, ChangeDetectionStrategy, inject, input } from '@angular/core'; import { ColorService } from '../../../services/color.service'; import { RealLink } from '../../../model/link'; -import { link } from 'fs'; -import { RealJoint } from '../../../model/joint'; +import { Joint } from '../../../model/joint'; import { Force } from '../../../model/force'; +import { MechanismService } from '../../../services/mechanism.service'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { INK_FLIPS_AT, luminanceOf } from '../../../model/contrast'; @Component({ selector: 'color-picker', templateUrl: './color-picker.component.html', styleUrls: ['./color-picker.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [MatIcon, MatTooltip], }) -export class ColorPickerComponent implements OnInit, OnChanges { - @Input() link: RealLink | undefined; - @Input() joint: RealJoint | undefined; - @Input() force: Force | undefined; - @Input() tooltip: string | undefined; - @Input() type: string | undefined; +export class ColorPickerComponent implements OnChanges { + colorService = inject(ColorService); + private mechanism = inject(MechanismService); - constructor(public colorService: ColorService) {} + readonly link = input<RealLink>(); + readonly joint = input<Joint>(); + readonly force = input<Force>(); + readonly tooltip = input<string>(); + readonly type = input<string>(); ngOnChanges(): void { - if (this.link) { - this.selectColor(this.colorService.getIndexFromLinkColor(this.link.fill)); + const link = this.link(); + if (link) this.selectedIndex = this.colorService.getIndexFromLinkColor(link.fill); + } + + /** One picker serves whichever part is selected, so the tick is read from it. */ + chosenIndex(): number { + const joint = this.joint(); + const force = this.force(); + if (this.type() === 'joint' && joint) { + return this.colorService.getIndexFromJointFamily(joint.colorFamily); + } + if (this.type() === 'force' && force) { + return this.colorService.getIndexFromForceColor(force.color); } + return this.selectedIndex; } // The index of the selected color, or -1 if none is selected selectedIndex: number = 0; - ngOnInit(): void {} - // A method that handles the click event on a color swatch selectColor(index: number) { this.selectedIndex = index; - switch (this.type) { + const link = this.link(); + const joint = this.joint(); + const force = this.force(); + switch (this.type()) { case 'link': - if (this.link) { - this.link.fill = this.colorService.getLinkColorFromIndex(index); + if (link) { + link.fill = this.colorService.getLinkColorFromIndex(index); } break; case 'joint': - if (this.joint) { - // this.joint.fill = this.colorService.getJointColorFromIndex(index); - } + if (!joint) break; + // The first swatch is the family every joint already wears, whose id is + // empty -- so choosing it means "stop being different", and the URL + // goes back to saying nothing about this joint. + joint.colorFamily = this.colorService.getJointFamilyFromIndex(index); + // Undoable, and carried in the URL: a colour that a shared link dropped, + // or that one undo wiped, would not be worth putting on. + this.mechanism.updateMechanism(true); + break; + case 'force': + if (!force) break; + force.color = this.colorService.getForceColorFromIndex(index); + this.mechanism.updateMechanism(true); break; } } getCorrectColors(): string[] { - switch (this.type) { + switch (this.type()) { case 'link': return this.colorService.getLinkColorOptions(); case 'joint': @@ -61,4 +88,24 @@ export class ColorPickerComponent implements OnInit, OnChanges { return []; } } + + /** What each swatch is called, for the reader who is hovering one. */ + nameOf(index: number): string { + if (this.type() !== 'joint') return ''; + const family = this.colorService.getJointFamilies()[index]; + return family ? (index === 0 ? family.name + ' (default)' : family.name) : ''; + } + + /** + * A tick the reader can see on the swatch it is standing on. + * + * It used to be white on every swatch, which was invisible on the pale end of + * the link palette and on the first of the joint ones. + */ + tickInk(color: string): string { + // Against the middle of the swatch, which is what the tick is drawn over -- + // not the ring around it, which is a different colour on every joint family + // and would have put a white tick on four pale centres. + return luminanceOf(color) > INK_FLIPS_AT ? '#263238' : '#ffffff'; + } } diff --git a/src/app/component/BLOCKS/dual-button/dual-button.component.html b/src/app/component/BLOCKS/dual-button/dual-button.component.html index f0822934..4021248f 100644 --- a/src/app/component/BLOCKS/dual-button/dual-button.component.html +++ b/src/app/component/BLOCKS/dual-button/dual-button.component.html @@ -1,17 +1,17 @@ <div class="row dual-button"> - <button mat-stroked-button color="primary" (click)="but1Action()"> - <mat-icon svgIcon="{{ but1Icon }}"></mat-icon> - {{ but1Text }} + <button mat-stroked-button color="primary" (click)="but1Action()()"> + <mat-icon svgIcon="{{ but1Icon() }}"></mat-icon> + {{ but1Text() }} </button> @if (but2Text) { <button mat-stroked-button color="primary" - (click)="but2Action()" - [disabled]="btn2Disabled" - class="{{ btn2Disabled ? 'buttonDisabled' : '' }}" + (click)="but2Action()?.()" + [disabled]="btn2Disabled()" + class="{{ btn2Disabled() ? 'buttonDisabled' : '' }}" > - <mat-icon svgIcon="{{ but2Icon }}"></mat-icon> + <mat-icon svgIcon="{{ but2Icon() }}"></mat-icon> {{ but2Text }} </button> } diff --git a/src/app/component/BLOCKS/dual-button/dual-button.component.ts b/src/app/component/BLOCKS/dual-button/dual-button.component.ts index d5b87760..af9a9e9d 100644 --- a/src/app/component/BLOCKS/dual-button/dual-button.component.ts +++ b/src/app/component/BLOCKS/dual-button/dual-button.component.ts @@ -1,19 +1,21 @@ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { Component, Input, ChangeDetectionStrategy, input } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; @Component({ selector: 'dual-button', templateUrl: './dual-button.component.html', styleUrls: ['./dual-button.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [MatButton, MatIcon], }) export class DualButtonComponent { - @Input() but1Text: string | undefined; - @Input() but1Icon: string | undefined; - @Input() but1Action!: () => void; + readonly but1Text = input<string>(); + readonly but1Icon = input<string>(); + readonly but1Action = input.required<() => void>(); @Input() but2Text: string | undefined; - @Input() but2Icon: string | undefined; - @Input() but2Action!: () => void; - @Input() btn2Disabled: boolean = false; + readonly but2Icon = input<string>(); + readonly but2Action = input<(() => void) | undefined>(undefined); + readonly btn2Disabled = input<boolean>(false); } diff --git a/src/app/component/BLOCKS/dual-input/dual-input.component.html b/src/app/component/BLOCKS/dual-input/dual-input.component.html index 5b6bad50..671779a2 100644 --- a/src/app/component/BLOCKS/dual-input/dual-input.component.html +++ b/src/app/component/BLOCKS/dual-input/dual-input.component.html @@ -1,18 +1,20 @@ -<div id="dual-input-block" style="{{ disabled ? 'cursor: default' : '' }}"> - <div class="row"> - <span class="label {{ disabled ? 'disabled' : '' }}"><ng-content></ng-content></span> - <mat-icon - matTooltip="{{ this.tooltip }}" - [matTooltipShowDelay]="1000" - class="label-help" - [matTooltipDisabled]="disabled" - >help_outline - </mat-icon> - <div class="spacer"></div> - </div> - <div class="row" [formGroup]="formGroup" style="gap: 5px"> +<div id="dual-input-block" style="{{ disabled() ? 'cursor: default' : '' }}"> + @if (!noHeader()) { + <div class="row"> + <span class="label {{ disabled() ? 'disabled' : '' }}"><ng-content></ng-content></span> + <mat-icon + matTooltip="{{ this.tooltip() }}" + matTooltipShowDelay="400" + class="label-help" + [matTooltipDisabled]="disabled()" + >help_outline + </mat-icon> + <div class="spacer"></div> + </div> + } + <div class="row" [formGroup]="formGroup()" style="gap: 5px"> @if (formSubGroup) { - <span class="label {{ disabled ? 'disabled' : '' }}">{{ label1 }}</span> + <span class="label {{ disabled() ? 'disabled' : '' }}">{{ label1() }}</span> <mat-form-field class="customInputForm" formGroupName="{{ formSubGroup }}" @@ -24,17 +26,20 @@ > <input class="customInput" + spellcheck="false" matInput type="text" #field1 (keyup.enter)="field1.blur()" - [formControlName]="this.formControl1" + [formControlName]="this.formControl1()" /> </mat-form-field> <div class="spacer"></div> - <span class="label {{ disabled ? 'disabled' : '' }}" [class.angle-label]="label2 === '⊾'">{{ - label2 - }}</span> + <span + class="label {{ disabled() ? 'disabled' : '' }}" + [class.angle-label]="label2() === '⊾'" + >{{ label2() }}</span + > <mat-form-field class="customInputForm" formGroupName="{{ formSubGroup }}" @@ -46,16 +51,17 @@ > <input class="customInput" + spellcheck="false" matInput type="text" #field2 (keyup.enter)="field2.blur()" - [formControlName]="this.formControl2" + [formControlName]="this.formControl2()" /> </mat-form-field> } @if (!formSubGroup) { - <span class="label {{ disabled ? 'disabled' : '' }}">{{ label1 }}</span> + <span class="label {{ disabled() ? 'disabled' : '' }}">{{ label1() }}</span> <mat-form-field class="customInputForm" (click)="field1.select()" @@ -66,32 +72,35 @@ > <input class="customInput" + spellcheck="false" matInput type="text" #field1 (keyup.enter)="field1.blur()" - [formControlName]="this.formControl1" + [formControlName]="this.formControl1()" /> </mat-form-field> <div class="spacer"></div> - <span class="label {{ disabled ? 'disabled' : '' }}" [class.angle-label]="label2 === '⊾'">{{ - label2 - }}</span> + <span + class="label {{ disabled() ? 'disabled' : '' }}" + [class.angle-label]="label2() === '⊾'" + >{{ label2() }}</span + > <mat-form-field class="customInputForm" (click)="field2.select()" (mouseenter)="isField2MouseOver = true; updateOverlay()" (mouseleave)="isField2MouseOver = false; updateOverlay()" (focusin)="isField2Focused = true; updateOverlay()" - (focusout)="isField2Focused = false; updateOverlay()" > <input class="customInput" + spellcheck="false" matInput type="text" #field2 (keyup.enter)="field2.blur()" - [formControlName]="this.formControl2" + [formControlName]="this.formControl2()" /> </mat-form-field> } diff --git a/src/app/component/BLOCKS/dual-input/dual-input.component.scss b/src/app/component/BLOCKS/dual-input/dual-input.component.scss index 4cb9abe1..bdbd787d 100644 --- a/src/app/component/BLOCKS/dual-input/dual-input.component.scss +++ b/src/app/component/BLOCKS/dual-input/dual-input.component.scss @@ -51,3 +51,4 @@ } } } + diff --git a/src/app/component/BLOCKS/dual-input/dual-input.component.ts b/src/app/component/BLOCKS/dual-input/dual-input.component.ts index d6ac4153..a481d236 100644 --- a/src/app/component/BLOCKS/dual-input/dual-input.component.ts +++ b/src/app/component/BLOCKS/dual-input/dual-input.component.ts @@ -1,34 +1,36 @@ -import { - Component, - Input, - OnInit, - OnChanges, - Output, - EventEmitter, - ChangeDetectionStrategy, -} from '@angular/core'; -import { FormGroup } from '@angular/forms'; +import { Output, EventEmitter, booleanAttribute, Component, Input, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatFormField, MatSuffix } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; @Component({ selector: 'dual-input-block', templateUrl: './dual-input.component.html', styleUrls: ['./dual-input.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [MatIcon, MatTooltip, FormsModule, ReactiveFormsModule, MatFormField, MatInput], }) export class DualInputComponent { - @Input() tooltip!: string; - @Input() unit!: string; - @Input() formControl1!: string; - @Input() label1: string = 'X'; - @Input() label2: string = 'Y'; - @Input() formControl2!: string; - @Input() formGroup!: FormGroup; + readonly tooltip = input.required<string>(); + readonly formControl1 = input.required<string>(); + readonly label1 = input<string>('X'); + readonly label2 = input<string>('Y'); + readonly formControl2 = input.required<string>(); + readonly formGroup = input.required<FormGroup>(); @Input() formSubGroup: string | undefined; - @Input() disabled: boolean = false; - @Output() field1Entry: EventEmitter<number> = new EventEmitter(); - @Output() field2Entry: EventEmitter<number> = new EventEmitter(); - @Input() emitterOutputID: number = -2; + /** + * Drop the label/help header row: for a pair whose caption is provided by + * the row above it (the mass panel's Center of Mass row owns the frame + * picker, so the pair beneath carries only the fields). + */ + readonly noHeader = input<boolean, unknown>(false, { transform: booleanAttribute }); + + readonly disabled = input<boolean>(false); + readonly field1Entry = output<number>(); + readonly field2Entry = output<number>(); + readonly emitterOutputID = input<number>(-2); isField1MouseOver: boolean = false; isField1Focused: boolean = false; @@ -41,16 +43,17 @@ export class DualInputComponent { lastShowField2Overlay: boolean = false; updateOverlay() { - if (this.disabled) { + if (this.disabled()) { this.showField1Overlay = false; this.showField2Overlay = false; return; } this.showField1Overlay = this.isField1MouseOver || this.isField1Focused; + const emitterOutputID = this.emitterOutputID(); if (this.lastShowField1Overlay != this.showField1Overlay) { if (this.showField1Overlay) { - this.field1Entry.emit(this.emitterOutputID); + this.field1Entry.emit(emitterOutputID); } else { this.field1Entry.emit(-2); } @@ -60,7 +63,7 @@ export class DualInputComponent { this.showField2Overlay = this.isField2MouseOver || this.isField2Focused; if (this.lastShowField2Overlay != this.showField2Overlay) { if (this.showField2Overlay) { - this.field2Entry.emit(this.emitterOutputID); + this.field2Entry.emit(emitterOutputID); } else { this.field2Entry.emit(-2); } diff --git a/src/app/component/BLOCKS/editable-title/editable-title.component.html b/src/app/component/BLOCKS/editable-title/editable-title.component.html index fdec99d0..01afb760 100644 --- a/src/app/component/BLOCKS/editable-title/editable-title.component.html +++ b/src/app/component/BLOCKS/editable-title/editable-title.component.html @@ -2,11 +2,8 @@ @if (!editMode) { <div class="editModeRow"> <ng-container *ngTemplateOutlet="contentTpl"></ng-container> - {{ displayName ?? activeObjService.getSelectedObj().name }} - <!-- <button id='title-icon-button' mat-icon-button (click)='gotoEditMode()' matTooltip='Edit the name of this object.'--> + {{ displayName() ?? activeObjService.getSelectedObj().name }} <!-- [matTooltipShowDelay]='1000' matTooltipPosition='above'>--> - <!-- <mat-icon>edit</mat-icon>--> - <!-- </button>--> </div> } @if (editMode) { @@ -25,31 +22,63 @@ appPrefixFocusAndSelect /> </mat-form-field> - <!-- <input id='title-input-box' type='text' formControlName='newID' (keyup.enter)='saveNewID()'--> - <!-- (keyup.escape)='exitEditModeWithoutSaving()' appPrefixFocusAndSelect />--> - <!-- <button id='title-icon-button' mat-icon-button color='primary' (click)='saveNewID()'--> <!-- matTooltip='Save this name. Escape to cancel.'--> <!-- [matTooltipShowDelay]='1000' matTooltipPosition='above'>--> - <!-- <mat-icon>check</mat-icon>--> - <!-- </button>--> </div> } + <!-- Three actions in the row now, so the rename labels are the short ones. --> <div class="row"> - @if (!editMode) { - <button class="mini-buttons blue" mat-stroked-button color="primary" (click)="gotoEditMode()"> - <mat-icon svgIcon="edit_outline"></mat-icon> - Edit Name - </button> - } - @if (editMode) { - <button class="mini-buttons blue" mat-stroked-button color="primary" (click)="saveNewID()"> + <!-- One button for both halves of the rename gesture, sized by its wider + word the same way the lock button is — entering edit mode must not + reshuffle the row. --> + <button + class="mini-buttons blue" + mat-stroked-button + color="primary" + (click)="editMode ? saveNewID() : gotoEditMode()" + > + @if (editMode) { <mat-icon>check</mat-icon> - Save Name + } @else { + <mat-icon svgIcon="edit_outline"></mat-icon> + } + <span class="labelSwap"> + <span [style.visibility]="editMode ? 'hidden' : 'visible'">Rename</span> + <span [style.visibility]="editMode ? 'visible' : 'hidden'">Save</span> + </span> + </button> + @if (lockTarget()) { + <!-- The icon states, the label acts: a locked object shows the closed + padlock beside the word Unlock. Both labels are always in the box, + one invisible, so the button is sized by the wider word and nothing + shifts when it toggles. --> + <button + class="mini-buttons blue" + mat-stroked-button + color="primary" + (click)="toggleLock()" + [matTooltip]="shortcuts.tip(isLocked() ? 'Unlock' : 'Lock', 'edit.lock')" + matTooltipShowDelay="600" + > + <mat-icon svgIcon="{{ isLocked() ? 'lock' : 'unlock' }}"></mat-icon> + <span class="labelSwap"> + <span [style.visibility]="isLocked() ? 'visible' : 'hidden'">Unlock</span> + <span [style.visibility]="isLocked() ? 'hidden' : 'visible'">Lock</span> + </span> </button> } - <button class="mini-buttons red" mat-stroked-button color="warn" (click)="deleteAction()"> + <!-- Icon only: three labels no longer fit the row, and a red trash can + beside Rename and Lock says everything the word did. --> + <button + class="mini-buttons red iconOnly" + mat-stroked-button + color="warn" + aria-label="Delete" + matTooltip="Delete" + matTooltipShowDelay="600" + (click)="deleteAction()()" + > <mat-icon svgIcon="remove"></mat-icon> - Delete </button> </div> </div> diff --git a/src/app/component/BLOCKS/editable-title/editable-title.component.scss b/src/app/component/BLOCKS/editable-title/editable-title.component.scss index 8a9428b8..517d5497 100644 --- a/src/app/component/BLOCKS/editable-title/editable-title.component.scss +++ b/src/app/component/BLOCKS/editable-title/editable-title.component.scss @@ -59,7 +59,63 @@ // The #editable-title-block prefix keeps the border-color specificity intact. @include blocks.mini-buttons($theme); .mini-buttons { - flex-grow: 1; + // From natural width, sharing only the slack: equal boxes squeezed the + // longer word (Rename) while the shorter ones floated in air. The lock + // button stays put on toggle because its content never changes size -- + // both labels are always present, one invisible (.labelSwap). + flex: 1 1 auto; + // Three actions share the row now, so nothing may overflow its border: + // buttons clip at their own edge, icons take a fixed 18px, and the + // labels are the short ones (Rename, Lock). + min-width: 0; + padding: 0 8px; + overflow: hidden; + + .mat-icon { + flex-shrink: 0; + width: 18px; + height: 18px; + font-size: 18px; + // The ligature icon (Save's check) is text; give it the box's own + // line height so it centres like the svg icons do. + line-height: 18px; + // Material gives outlined-button icons a -4px left margin on the + // assumption a label follows; in this tight row it just shoves every + // icon into the border. Zeroed, and the gap to the label set here. + margin-left: 0; + margin-right: 5px; + + // An inline svg sits on the text baseline, which grows the icon box + // by the font's descender and floats every glyph high — and lets a + // content change re-evaluate the line box, which is a wobble. A block + // box is 18x18, centred, and inert. + svg { + display: block; + } + } + } + + // Both labels share one grid cell, so the cell is as wide as the wider + // word whichever is showing, and the icon beside it never moves. + .labelSwap { + display: inline-grid; + text-align: left; + + > span { + grid-area: 1 / 1; + } + } + + // The trash can carries Delete alone, so it stays a compact square + // instead of stretching with the labelled two. + .mini-buttons.iconOnly { + flex: 0 0 auto; + min-width: 34px; + padding: 0; + + .mat-icon { + margin: 0; + } } } } diff --git a/src/app/component/BLOCKS/editable-title/editable-title.component.ts b/src/app/component/BLOCKS/editable-title/editable-title.component.ts index 7449cc2c..1dd1905e 100644 --- a/src/app/component/BLOCKS/editable-title/editable-title.component.ts +++ b/src/app/component/BLOCKS/editable-title/editable-title.component.ts @@ -1,28 +1,50 @@ -import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core'; -import { FormBuilder } from '@angular/forms'; -import { NewGridComponent } from '../../new-grid/new-grid.component'; +import { Component, ChangeDetectionStrategy, inject, input } from '@angular/core'; +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActiveObjService } from 'src/app/services/active-obj.service'; +import { MechanismService } from 'src/app/services/mechanism.service'; +import { NotificationService } from '../../../services/notification.service'; +import { RealJoint } from '../../../model/joint'; +import { Link } from '../../../model/link'; +import { Force } from '../../../model/force'; +import { NgTemplateOutlet } from '@angular/common'; +import { MatFormField } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { FocusOnShowDirective } from '../../../focus-on-show.directive'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { KeyboardShortcutsService } from '../../../services/keyboard-shortcuts.service'; @Component({ selector: 'editable-title-block', templateUrl: './editable-title.component.html', styleUrls: ['./editable-title.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + NgTemplateOutlet, + FormsModule, + ReactiveFormsModule, + MatFormField, + MatInput, + FocusOnShowDirective, + MatButton, + MatIcon, + MatTooltip, + ], }) export class EditableTitleComponent { - @Input() showActionButtons: boolean = false; + readonly shortcuts = inject(KeyboardShortcutsService); + private fb = inject(FormBuilder); + activeObjService = inject(ActiveObjService); + private mechanismService = inject(MechanismService); + private notify = inject(NotificationService); + /** Shown instead of the object's own name — a cylinder displays its mounts. */ - @Input() displayName?: string; - @Input() deleteAction!: () => void; + readonly displayName = input<string>(); + readonly deleteAction = input.required<() => void>(); editMode = false; - constructor( - private fb: FormBuilder, - public activeObjService: ActiveObjService - ) {} - newIDForm = this.fb.group({ newID: [''] }); gotoEditMode() { @@ -56,18 +78,43 @@ export class EditableTitleComponent { // If the new ID is not valid, send error notif and do not update to new id let error = this.validateNewID(newID); if (error !== '') { - NewGridComponent.sendNotification(error); + this.notify.refusal('rename.invalid', error); this.editMode = false; return; } let activeObj = this.activeObjService.getSelectedObj(); - activeObj.name = newID; - console.log('Set new name to ' + newID + ' for ' + this.activeObjService.objType); this.editMode = false; + if (activeObj.name === newID) return; + activeObj.name = newID; + // A name is carried in the URL like everything else, so a rename is an edit + // and belongs in the history. Without this it was the one change to the + // drawing that Undo could not take back. + this.mechanismService.updateMechanism(true); } exitEditModeWithoutSaving() { this.editMode = false; } + + /** + * What the Lock button acts on — the selected object, when it is a kind + * that can be locked. Self-serve rather than an input, like the rename: + * every panel this block heads is about the selected object anyway. + */ + lockTarget(): RealJoint | Link | Force | undefined { + const obj = this.activeObjService.getSelectedObj(); + if (obj instanceof RealJoint || obj instanceof Link || obj instanceof Force) return obj; + return undefined; + } + + isLocked(): boolean { + const target = this.lockTarget(); + return target !== undefined && this.mechanismService.isLockedTarget(target); + } + + toggleLock() { + const target = this.lockTarget(); + if (target) this.mechanismService.toggleLock(target); + } } diff --git a/src/app/component/BLOCKS/input/input.component.html b/src/app/component/BLOCKS/input/input.component.html index 2f0f12ff..e95b6fd5 100644 --- a/src/app/component/BLOCKS/input/input.component.html +++ b/src/app/component/BLOCKS/input/input.component.html @@ -1,7 +1,7 @@ -<div id="input-block"> +<div id="input-block" [class.stacked]="stacked()"> <div class="row" [formGroup]="formGroup"> <span class="label"><ng-content></ng-content></span> - <mat-icon matTooltip="{{ this.tooltip }}" [matTooltipShowDelay]="1000" class="label-help"> + <mat-icon matTooltip="{{ this.tooltip() }}" matTooltipShowDelay="400" class="label-help"> help_outline </mat-icon> <div class="spacer"></div> @@ -19,6 +19,7 @@ <input matInput class="customInput" + spellcheck="false" type="text" #field (keyup.enter)="field.blur()" @@ -27,6 +28,9 @@ @if (unit) { <span matTextSuffix class="input-unit">{{ unit }}</span> } + <!-- Optional projected trailing content — the mass fields hang their + state dot and clear button here; everyone else projects nothing. --> + <span matTextSuffix class="field-suffix"><ng-content select="[fieldSuffix]"></ng-content></span> @if (hasUnitSelect) { <!-- A native select rather than mat-select: mat-select registers itself as a form field's control, so nesting one here would displace the input. @@ -34,10 +38,10 @@ <select matTextSuffix class="unit-select" - [formControlName]="unitFormControl!" + [formControlName]="unitFormControl()!" (click)="$event.stopPropagation()" > - @for (option of unitOptions; track option.value) { + @for (option of unitOptions(); track option.value) { <option [value]="option.value">{{ option.label }}</option> } </select> diff --git a/src/app/component/BLOCKS/input/input.component.scss b/src/app/component/BLOCKS/input/input.component.scss index 505b1f2d..7c3c90b6 100644 --- a/src/app/component/BLOCKS/input/input.component.scss +++ b/src/app/component/BLOCKS/input/input.component.scss @@ -2,6 +2,37 @@ @use '@angular/material' as mat; @mixin css($theme) { + // Label above, field below, each across the row's full width. For a label + // too long to share a line with its field -- "Moment of Inertia" beside a + // value in kg·cm² wants 239px of a 220px row, and one of the two + // gets crushed. The panel already reads this way for Joint Position and + // Center of Mass. + #input-block.stacked { + .row { + flex-wrap: wrap; + row-gap: 4px; + } + + .spacer { + display: none; + } + + .label-help { + margin-right: auto; + } + + // The field takes the row it was given a line of its own for -- including + // the input inside it, which otherwise sizes to its own text and clips a + // long unit like kg·cm² against a wrapper that had the room. + // Written at the base rule's own depth, or the 80px width there wins. + .row .customInputForm, + .row .customInputForm .mat-mdc-text-field-wrapper, + .row .customInputForm .mat-mdc-form-field-infix, + .row .customInputForm .customInput { + width: 100%; + max-width: 100%; + } + } // Get the color config from the theme. $color-config: mat.m2-get-color-config($theme); // Get the primary color palette from the color-config. @@ -47,10 +78,6 @@ padding: 0 6px; } - &.wide .mat-mdc-text-field-wrapper { - width: 104px; - } - .input-unit { margin-left: 4px; color: map.get($foreground, 'secondary-text'); diff --git a/src/app/component/BLOCKS/input/input.component.spec.ts b/src/app/component/BLOCKS/input/input.component.spec.ts index 7874dd30..38997b14 100644 --- a/src/app/component/BLOCKS/input/input.component.spec.ts +++ b/src/app/component/BLOCKS/input/input.component.spec.ts @@ -12,7 +12,6 @@ describe('InputComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [InputComponent], imports: [ ReactiveFormsModule, MatFormFieldModule, @@ -20,6 +19,7 @@ describe('InputComponent', () => { MatInputModule, MatTooltipModule, NoopAnimationsModule, + InputComponent, ], }).compileComponents(); diff --git a/src/app/component/BLOCKS/input/input.component.ts b/src/app/component/BLOCKS/input/input.component.ts index 93fb837b..1fc0e0b2 100644 --- a/src/app/component/BLOCKS/input/input.component.ts +++ b/src/app/component/BLOCKS/input/input.component.ts @@ -1,36 +1,62 @@ import { booleanAttribute, Component, - EventEmitter, Input, - Output, ChangeDetectionStrategy, + input, + output, } from '@angular/core'; -import { FormGroup } from '@angular/forms'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatFormField, MatSuffix } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; @Component({ selector: 'input-block', templateUrl: './input.component.html', styleUrls: ['./input.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + FormsModule, + ReactiveFormsModule, + MatIcon, + MatTooltip, + MatFormField, + MatInput, + MatSuffix, + ], }) export class InputComponent { @Input() unit: string | undefined; /** Widens the field for values whose unit suffix does not fit the default. */ @Input({ transform: booleanAttribute }) wide: boolean = false; - @Input() tooltip: string | undefined; + /** + * Put the label on its own line, with the field under it. + * + * For a label too long to share a line with its field -- "Moment of Inertia" + * next to a value in kg·cm² wants 239px of a 220px row, and one + * of the two gets crushed. The panel already reads this way for Joint + * Position and Center of Mass. + */ + readonly stacked = input<boolean, unknown>(false, { transform: booleanAttribute }); + readonly tooltip = input<string>(); @Input() _formControl!: string; @Input() formGroup!: FormGroup; /** * Turns the static unit suffix into a picker sharing the field's fill, for * values whose unit the user chooses rather than types. Needs unitFormControl. */ - @Input() unitOptions: { value: string; label: string }[] | undefined; - @Input() unitFormControl: string | undefined; + readonly unitOptions = input< + { + value: string; + label: string; + }[] + >(); + readonly unitFormControl = input<string>(); get hasUnitSelect(): boolean { - return !!this.unitOptions?.length && !!this.unitFormControl; + return !!this.unitOptions()?.length && !!this.unitFormControl(); } /** @@ -44,8 +70,8 @@ export class InputComponent { * Silent unless a caller supplies an id, so every other `input-block` in the * app is untouched. */ - @Output() fieldEntry: EventEmitter<number> = new EventEmitter(); - @Input() emitterOutputID: number = -2; + readonly fieldEntry = output<number>(); + readonly emitterOutputID = input<number>(-2); private mouseOver = false; private focused = false; @@ -55,7 +81,7 @@ export class InputComponent { const wants = this.mouseOver || this.focused; if (wants === this.showing) return; this.showing = wants; - this.fieldEntry.emit(wants ? this.emitterOutputID : -2); + this.fieldEntry.emit(wants ? this.emitterOutputID() : -2); } setMouseOver(over: boolean): void { diff --git a/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.html b/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.html index 600f0c62..2798e361 100644 --- a/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.html +++ b/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.html @@ -1,4 +1,4 @@ -<div id="collapsibleContainer" [ngClass]="warning ? 'warning-color' : 'normal-color'"> +<div id="collapsibleContainer" [ngClass]="warning() ? 'warning-color' : 'normal-color'"> <ng-content select="[alwaysShown]"></ng-content> @if (isLoaded) { <div [@openClose]="expanded ? 'open' : 'closed'" id="expnandingContainer"> diff --git a/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.scss b/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.scss index 4a76cfcb..34e5350c 100644 --- a/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.scss +++ b/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.scss @@ -12,9 +12,13 @@ #collapsibleContainer { pointer-events: all; - @include mat.elevation(4); + box-shadow: var(--card-shadow); display: flex; flex-direction: column; + // 5px, matching the 5px accent bar above it: a border's inner edge is + // rounded by the corner radius minus the border width, so any radius wider + // than the bar curves the bar's underside. The floating chrome has no bar + // and keeps the wider corner. border-radius: var(--border-radius); //gap: 10px; background-color: mat.m2-get-color-from-palette($background, 'background'); diff --git a/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.ts b/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.ts index eabb505a..d3438140 100644 --- a/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.ts +++ b/src/app/component/BLOCKS/panel-section-collapsible/panel-section-collapsible.component.ts @@ -1,15 +1,15 @@ import { AfterContentInit, Component, - ContentChildren, - EventEmitter, Input, - Output, - QueryList, ChangeDetectionStrategy, + input, + output, + contentChildren, } from '@angular/core'; import { TitleBlock } from '../title/title.component'; import { animate, state, style, transition, trigger } from '@angular/animations'; +import { NgClass } from '@angular/common'; @Component({ selector: 'panel-section-collapsible', @@ -36,21 +36,23 @@ import { animate, state, style, transition, trigger } from '@angular/animations' templateUrl: './panel-section-collapsible.component.html', styleUrls: ['./panel-section-collapsible.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [NgClass], }) export class PanelSectionCollapsibleComponent implements AfterContentInit { @Input() expanded: boolean = true; - @Input() warning: boolean = false; + readonly warning = input<boolean>(false); public isLoaded: boolean = false; - @ContentChildren(TitleBlock) titleBlock?: QueryList<TitleBlock>; + readonly titleBlock = contentChildren(TitleBlock); - @Output() closed: EventEmitter<boolean> = new EventEmitter<boolean>(); - @Output() opened: EventEmitter<boolean> = new EventEmitter<boolean>(); + readonly closed = output<boolean>(); + readonly opened = output<boolean>(); ngAfterContentInit() { - this.titleBlock?.first.nestedComponentChange.subscribe(() => this.toggleExpand()); + this.titleBlock() + ?.at(0)! + .nestedComponentChange.subscribe(() => this.toggleExpand()); setTimeout(() => { this.isLoaded = true; }); @@ -58,7 +60,6 @@ export class PanelSectionCollapsibleComponent implements AfterContentInit { toggleExpand() { this.expanded = !this.expanded; - console.log(this.expanded); if (this.expanded) { this.opened.emit(true); } else { diff --git a/src/app/component/BLOCKS/panel-section/panel-section.component.html b/src/app/component/BLOCKS/panel-section/panel-section.component.html index 8163c15e..16ea8604 100644 --- a/src/app/component/BLOCKS/panel-section/panel-section.component.html +++ b/src/app/component/BLOCKS/panel-section/panel-section.component.html @@ -1,3 +1,3 @@ -<div id="normalPanel"> +<div id="normalPanel" appScrollShadow> <ng-content></ng-content> </div> diff --git a/src/app/component/BLOCKS/panel-section/panel-section.component.scss b/src/app/component/BLOCKS/panel-section/panel-section.component.scss index 14c988ca..d4a4c086 100644 --- a/src/app/component/BLOCKS/panel-section/panel-section.component.scss +++ b/src/app/component/BLOCKS/panel-section/panel-section.component.scss @@ -13,17 +13,67 @@ #normalPanel { pointer-events: all; - @include mat.elevation(4); + // Nothing inside this card paints outside it. The sticky title below is + // raised above the card's own contents, and without this it is raised + // against everything the card is standing in as well -- including the + // drawer's close button, which shares the title's line and lost to it in + // Safari: covered by the title's white background while still taking the + // click. Confining the title to the card settles it by construction + // rather than by who has the larger number. + isolation: isolate; + // The card owns the scroll, not the frame around it. Scrolling the frame + // cut the card off square at the bottom of the window; scrolling the card + // keeps its own rounded corner as the edge, and lets the title stay put + // while the settings under it move. The height it is capped at is the + // frame's, which is set from the top strip and whatever is along the + // bottom in this mode. + max-height: 100%; + overflow-y: auto; + overflow-x: hidden; + box-shadow: var(--card-shadow); + // 5px, matching the 5px accent bar above it: a border's inner edge is + // rounded by the corner radius minus the border width, so any radius wider + // than the bar curves the bar's underside. The floating chrome has no bar + // and keeps the wider corner. border-radius: var(--border-radius); display: flex; flex-direction: column; //gap: 10px; - background-color: mat.m2-get-color-from-palette($background, 'background'); + background-color: var(--card-surface); border-top: 5px solid mat.m2-get-color-from-palette($primary-palette, 500); //padding: 20px; + // Stretched to the panel's own width -- which is a content-box width, so a + // child with side padding would come out wider than the panel it is in and + // overflow the drawer. That is how the version row ran off the edge. > * { width: 100%; + box-sizing: border-box; + flex: 0 0 auto; + } + + // The name of the thing being edited, and what can be done to it, stay + // where they are; everything below them scrolls. + > title-block:first-child, + > editable-title-block:first-child { + position: sticky; + top: 0; + z-index: 2; + background: var(--card-surface); + // Nothing under it yet, so nothing to cast onto -- but declared here so + // the shadow fades in rather than appearing the instant a card moves. + box-shadow: 0 3px 6px -3px rgba(0, 0, 0, 0); + transition: box-shadow 0.15s ease-in-out; + } + + // Something above, out of sight. The card scrolls under its own title, so + // scrolled off its top it read as a card that simply started there: the + // first setting ran up beneath the title and was gone, leaving white space + // that looked like the top of the panel. The title now casts a shadow onto + // whatever is passing under it. + &.scrolled > title-block:first-child, + &.scrolled > editable-title-block:first-child { + box-shadow: 0 3px 6px -3px rgba(0, 0, 0, 0.35); } } } diff --git a/src/app/component/BLOCKS/panel-section/panel-section.component.ts b/src/app/component/BLOCKS/panel-section/panel-section.component.ts index 1c1a2ba7..3eea5ddd 100644 --- a/src/app/component/BLOCKS/panel-section/panel-section.component.ts +++ b/src/app/component/BLOCKS/panel-section/panel-section.component.ts @@ -1,10 +1,11 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ScrollShadowDirective } from '../../../scroll-shadow.directive'; @Component({ selector: 'panel-section', templateUrl: './panel-section.component.html', styleUrls: ['./panel-section.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ScrollShadowDirective], }) export class PanelSectionComponent {} diff --git a/src/app/component/BLOCKS/radio/radio.component.html b/src/app/component/BLOCKS/radio/radio.component.html index 42fab41a..a16d662c 100644 --- a/src/app/component/BLOCKS/radio/radio.component.html +++ b/src/app/component/BLOCKS/radio/radio.component.html @@ -1,21 +1,21 @@ <div id="radio-block"> <div class="row"> <span class="label"><ng-content></ng-content></span> - <mat-icon matTooltip="{{ this.tooltip }}" [matTooltipShowDelay]="1000" class="label-help" + <mat-icon matTooltip="{{ this.tooltip() }}" matTooltipShowDelay="400" class="label-help" >help_outline</mat-icon > <div class="spacer"></div> </div> - <div class="row" [formGroup]="formGroup"> - <mat-button-toggle-group id="radio-buttons" [formControlName]="this._formControl"> - <mat-button-toggle disabled="{{ this.disabled }}" value="0">{{ - this.option1 + <div class="row" [formGroup]="formGroup()"> + <mat-button-toggle-group id="radio-buttons" [formControlName]="this._formControl()"> + <mat-button-toggle disabled="{{ this.disabled() }}" value="0">{{ + this.option1() }}</mat-button-toggle> - <mat-button-toggle disabled="{{ this.disabled }}" value="1">{{ - this.option2 + <mat-button-toggle disabled="{{ this.disabled() }}" value="1">{{ + this.option2() }}</mat-button-toggle> @if (this.option3) { - <mat-button-toggle disabled="{{ this.disabled }}" value="2">{{ + <mat-button-toggle disabled="{{ this.disabled() }}" value="2">{{ this.option3 }}</mat-button-toggle> } diff --git a/src/app/component/BLOCKS/radio/radio.component.ts b/src/app/component/BLOCKS/radio/radio.component.ts index 9804d8ea..018d0b65 100644 --- a/src/app/component/BLOCKS/radio/radio.component.ts +++ b/src/app/component/BLOCKS/radio/radio.component.ts @@ -1,19 +1,29 @@ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; -import { FormGroup } from '@angular/forms'; +import { Component, Input, ChangeDetectionStrategy, input } from '@angular/core'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatButtonToggleGroup, MatButtonToggle } from '@angular/material/button-toggle'; @Component({ selector: 'radio-block', templateUrl: './radio.component.html', styleUrls: ['./radio.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + MatIcon, + MatTooltip, + FormsModule, + ReactiveFormsModule, + MatButtonToggleGroup, + MatButtonToggle, + ], }) export class RadioComponent { - @Input() tooltip: string | undefined; - @Input() option1: string | undefined; - @Input() option2: string | undefined; + readonly tooltip = input<string>(); + readonly option1 = input<string>(); + readonly option2 = input<string>(); @Input() option3: string | undefined; - @Input() _formControl!: string; - @Input() formGroup!: FormGroup; - @Input() disabled: boolean = false; + readonly _formControl = input.required<string>(); + readonly formGroup = input.required<FormGroup>(); + readonly disabled = input<boolean>(false); } diff --git a/src/app/component/BLOCKS/state-input/state-input.component.scss b/src/app/component/BLOCKS/state-input/state-input.component.scss new file mode 100644 index 00000000..eb825df8 --- /dev/null +++ b/src/app/component/BLOCKS/state-input/state-input.component.scss @@ -0,0 +1,56 @@ +/* Plain global rules, like the rest of the BLOCKS styling (these components + build without view encapsulation). The field itself inherits everything + from the #input-block styles it shares with input-block. */ +.state-input-host .customInputForm .mat-mdc-text-field-wrapper { + width: 100%; +} + +.state-suffix { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: 4px; +} + +.state-dot { + display: inline-block; + width: 4px; + height: 4px; + border-radius: 50%; + border: 1.5px solid #9fa8da; + background: transparent; +} + +.state-dot.filled { + background: #3f51b5; + border-color: #3f51b5; +} + +/* The suffix rides the field's visible centre, not the text baseline. The + app squeezes the 56px MDC flex row into a 30px wrapper, so centring in + the flex row lands below the field — anchor to the top like the input + does (6px in, 18px line) and centre a ~14px suffix inside that band. */ +.state-input-host .mat-mdc-form-field-icon-suffix, +.state-input-host .mat-mdc-form-field-text-suffix { + display: inline-flex; + align-items: center; + align-self: flex-start; + /* A fixed band the height and offset of the input's own line, so the + content centres whether it is a lone 7px dot or a dot with a 14px ✕. */ + margin-top: 6px; + height: 18px; +} + +.state-clear { + border: none; + background: none; + padding: 0; + font-size: 14px; + line-height: 1; + color: rgba(0, 0, 0, 0.45); + cursor: pointer; +} + +.state-clear:hover { + color: rgba(0, 0, 0, 0.8); +} diff --git a/src/app/component/BLOCKS/state-input/state-input.component.ts b/src/app/component/BLOCKS/state-input/state-input.component.ts new file mode 100644 index 00000000..a54fe1c2 --- /dev/null +++ b/src/app/component/BLOCKS/state-input/state-input.component.ts @@ -0,0 +1,70 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatFormField, MatSuffix } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; + +/** + * A bare field with a derived-vs-typed marker, and nothing else. + * + * It renders the same `#input-block` / `customInputForm` structure as + * input-block, so every style, focus animation and behavior of the app's + * fields applies to it by construction — it *is* one of them, minus the + * label row, plus the state suffix: a hollow dot for a value that follows + * the shape, a filled one with a clear for a value somebody typed. + * + * Exists because the centre-of-mass pair needs the marker inside each field + * and the shared dual-input could not take it without a template rework; + * this is the smaller honest component. + */ +@Component({ + selector: 'state-input', + standalone: true, + imports: [ReactiveFormsModule, MatFormField, MatInput, MatSuffix], + template: ` + <div id="input-block" class="state-input-host"> + <div class="row" [formGroup]="formGroup"> + <mat-form-field + class="customInputForm" + (click)="field.select()" + (mouseenter)="hovered.emit(true)" + (mouseleave)="hovered.emit(false)" + > + <input + matInput + class="customInput" + spellcheck="false" + type="text" + #field + (keyup.enter)="field.blur()" + [formControlName]="_formControl" + /> + @if (state !== 'none') { + <span matTextSuffix class="state-suffix"> + <span class="state-dot" [class.filled]="state === 'custom'"></span> + @if (state === 'custom') { + <button + type="button" + class="state-clear" + [title]="clearTitle" + (click)="cleared.emit(); $event.stopPropagation()" + > + ✕ + </button> + } + </span> + } + </mat-form-field> + </div> + </div> + `, + styleUrl: './state-input.component.scss', +}) +export class StateInputComponent { + @Input() formGroup!: FormGroup; + @Input() _formControl!: string; + @Input() state: 'none' | 'auto' | 'custom' = 'none'; + @Input() clearTitle = 'Back to the shape'; + @Output() cleared = new EventEmitter<void>(); + /** Fires on pointer enter/leave, for panels that preview on the grid. */ + @Output() hovered = new EventEmitter<boolean>(); +} diff --git a/src/app/component/BLOCKS/subtitle/subtitle.component.html b/src/app/component/BLOCKS/subtitle/subtitle.component.html index c9b0c0ae..a46dcc26 100644 --- a/src/app/component/BLOCKS/subtitle/subtitle.component.html +++ b/src/app/component/BLOCKS/subtitle/subtitle.component.html @@ -2,7 +2,7 @@ <div class="row"> <ng-content></ng-content> @if (buttonLabel && icon) { - <button id="title-button" mat-flat-button color="primary" (click)="click()"> + <button id="title-button" mat-flat-button color="primary" (click)="click()?.()"> @if (icon) { <mat-icon>{{ icon }}</mat-icon> } diff --git a/src/app/component/BLOCKS/subtitle/subtitle.component.ts b/src/app/component/BLOCKS/subtitle/subtitle.component.ts index 81fc7d48..46caff37 100644 --- a/src/app/component/BLOCKS/subtitle/subtitle.component.ts +++ b/src/app/component/BLOCKS/subtitle/subtitle.component.ts @@ -1,16 +1,18 @@ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { Component, Input, ChangeDetectionStrategy, input } from '@angular/core'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; @Component({ selector: 'subtitle-block', templateUrl: './subtitle.component.html', styleUrls: ['./subtitle.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [MatButton, MatIcon, MatIconButton], }) export class SubtitleComponent { @Input() icon: string | undefined; @Input() buttonLabel: string | undefined; @Input() description: string | undefined; - @Input() click!: () => void; + readonly click = input<(() => void) | undefined>(undefined); } diff --git a/src/app/component/BLOCKS/title/title.component.scss b/src/app/component/BLOCKS/title/title.component.scss index f4b0ce6a..3456c939 100644 --- a/src/app/component/BLOCKS/title/title.component.scss +++ b/src/app/component/BLOCKS/title/title.component.scss @@ -12,7 +12,10 @@ $foreground: map.get($theme, foreground); #title-block { - padding: 10px 15px; + // Less below the title than above it, for the same reason: what follows a + // title is that title's, and wants to sit closer to it than to whatever + // came before. + padding: 10px 15px 5px; .row { display: flex; diff --git a/src/app/component/BLOCKS/title/title.component.ts b/src/app/component/BLOCKS/title/title.component.ts index d3dc977b..43a67a87 100644 --- a/src/app/component/BLOCKS/title/title.component.ts +++ b/src/app/component/BLOCKS/title/title.component.ts @@ -1,25 +1,20 @@ -import { - Component, - OnInit, - Input, - Output, - EventEmitter, - ChangeDetectionStrategy, -} from '@angular/core'; +import { Component, OnInit, Input, ChangeDetectionStrategy, output } from '@angular/core'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; @Component({ selector: 'title-block', templateUrl: './title.component.html', styleUrls: ['./title.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [MatButton, MatIcon, MatIconButton], }) export class TitleBlock implements OnInit { @Input() icon: string | undefined; @Input() buttonLabel: string | undefined; @Input() description: string | undefined; - @Output() nestedComponentChange: EventEmitter<number> = new EventEmitter<number>(); + readonly nestedComponentChange = output<number>(); defaultIcon: string | undefined; altIcon: string | undefined; diff --git a/src/app/component/BLOCKS/toggle/toggle.component.html b/src/app/component/BLOCKS/toggle/toggle.component.html index 9cd283ab..1c67fa2e 100644 --- a/src/app/component/BLOCKS/toggle/toggle.component.html +++ b/src/app/component/BLOCKS/toggle/toggle.component.html @@ -1,20 +1,20 @@ <div id="toggle-block"> - <div [formGroup]="this.formGroup" class="row"> + <div [formGroup]="this.formGroup()" class="row"> <!-- <input type="checkbox" id="vehicle1" name="vehicle1" value="Bike" [formControlName]="this._formControl" /> --> <span class="label"><ng-content></ng-content></span> - @if (!addInput) { - <mat-icon matTooltip="{{ this.tooltip }}" [matTooltipShowDelay]="1000" class="label-help" + @if (!addInput()) { + <mat-icon matTooltip="{{ this.tooltip() }}" matTooltipShowDelay="400" class="label-help" >help_outline </mat-icon> } <div class="spacer"></div> - @if (addInput) { - <span class="label angle-label {{ disableInput ? 'disabled' : '' }}">⊾</span> + @if (addInput()) { + <span class="label angle-label {{ disableInput() ? 'disabled' : '' }}">⊾</span> } - @if (addInput) { + @if (addInput()) { <mat-form-field class="customInputForm" - (click)="disableInput ? null : field.select()" + (click)="disableInput() ? null : field.select()" (mouseenter)="setMouseOver(true)" (mouseleave)="setMouseOver(false)" > @@ -26,11 +26,11 @@ (keyup.enter)="field.blur()" (focus)="setFocused(true)" (blur)="setFocused(false)" - [formControlName]="this._formControlForInput" + [formControlName]="this._formControlForInput() ?? null" /> </mat-form-field> } <div class="spacer"></div> - <mat-slide-toggle color="primary" [formControlName]="this._formControl"></mat-slide-toggle> + <mat-slide-toggle color="primary" [formControlName]="this._formControl()"></mat-slide-toggle> </div> </div> diff --git a/src/app/component/BLOCKS/toggle/toggle.component.ts b/src/app/component/BLOCKS/toggle/toggle.component.ts index 9cd73a12..ebaa1490 100644 --- a/src/app/component/BLOCKS/toggle/toggle.component.ts +++ b/src/app/component/BLOCKS/toggle/toggle.component.ts @@ -1,31 +1,41 @@ import { Component, ElementRef, - EventEmitter, - Input, - OnChanges, - Output, - SimpleChanges, - ViewChild, ChangeDetectionStrategy, + input, + output, + viewChild, } from '@angular/core'; -import { FormGroup } from '@angular/forms'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatFormField } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatSlideToggle } from '@angular/material/slide-toggle'; @Component({ selector: 'toggle-block', templateUrl: './toggle.component.html', styleUrls: ['./toggle.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + FormsModule, + ReactiveFormsModule, + MatIcon, + MatTooltip, + MatFormField, + MatInput, + MatSlideToggle, + ], }) export class ToggleComponent { - @Input() tooltip: string | undefined; - @Input() formGroup!: FormGroup; - @Input() _formControl!: string; + readonly tooltip = input<string>(); + readonly formGroup = input.required<FormGroup>(); + readonly _formControl = input.required<string>(); - @Input() addInput: boolean = false; - @Input() _formControlForInput!: string; - @Input() disableInput: boolean = false; + readonly addInput = input<boolean>(false); + readonly _formControlForInput = input<string | undefined>(undefined); + readonly disableInput = input<boolean>(false); /** * Pointed at or typed in, for a caller that draws the field's meaning on the @@ -39,7 +49,7 @@ export class ToggleComponent { * gets "nothing" on the way in and "nothing" on the way out, and no overlay * ever appears. This control has one field. It can just say so. */ - @Output() fieldEntry: EventEmitter<boolean> = new EventEmitter(); + readonly fieldEntry = output<boolean>(); private mouseOver = false; private focused = false; @@ -62,7 +72,7 @@ export class ToggleComponent { this.fieldEntry.emit(wants); } - @ViewChild('field', { static: false }) field!: ElementRef; + readonly field = viewChild.required<ElementRef>('field'); // ngOnChanges() { // //Get the #field input element diff --git a/src/app/component/BLOCKS/tri-button/tri-button.component.html b/src/app/component/BLOCKS/tri-button/tri-button.component.html deleted file mode 100644 index 4c99b98c..00000000 --- a/src/app/component/BLOCKS/tri-button/tri-button.component.html +++ /dev/null @@ -1,34 +0,0 @@ -<div class="row" id="tri-button"> - <div class="groupedButtons"> - <button - mat-stroked-button - color="primary" - [disabled]="btn1Disabled" - class="{{ btn1Disabled ? 'buttonDisabled' : '' }}" - (click)="btn1Action()" - > - <mat-icon svgIcon="weld_joint"></mat-icon> - Weld - </button> - <button - mat-stroked-button - color="primary" - [disabled]="btn2Disabled" - class="{{ btn2Disabled ? 'buttonDisabled' : '' }}" - (click)="btn2Action()" - > - <mat-icon svgIcon="unweld_joint"></mat-icon> - Unweld - </button> - </div> - <button - mat-stroked-button - color="primary" - [disabled]="btn3Disabled" - class="{{ btn3Disabled ? 'buttonDisabled button3' : 'button3' }}" - (click)="btn3Action()" - > - <mat-icon [svgIcon]="btn3Icon"></mat-icon> - {{ btn3Text }} - </button> -</div> diff --git a/src/app/component/BLOCKS/tri-button/tri-button.component.scss b/src/app/component/BLOCKS/tri-button/tri-button.component.scss deleted file mode 100644 index c08b3f59..00000000 --- a/src/app/component/BLOCKS/tri-button/tri-button.component.scss +++ /dev/null @@ -1,111 +0,0 @@ -@use 'sass:map'; -@use '@angular/material' as mat; - -@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); - - #tri-button { - path { - fill: mat.m2-get-color-from-palette($primary-palette, 500); - //fill: red; - } - - button { - display: flex; - flex-direction: column; - align-items: center; - justify-content: space-around; - @include mat.m2-typography-level($typography-config, 'body-2'); - padding: 0 10px; - margin: 0; - height: 50px !important; - overflow: hidden; - //flex-grow: 1; - - .mat-mdc-button-touch-target { - height: 50px !important; - } - - .mdc-button__label { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 2px; - line-height: 16px; - white-space: nowrap; - } - - .mat-icon { - flex: 0 0 24px; - margin: 0; - //background-color: red; - width: 24px; - height: 24px; - min-height: 24px; - max-height: 24px; - line-height: 24px; - overflow: hidden; - } - - .mat-icon ::ng-deep svg { - width: 24px; - height: 24px; - } - - border-color: mat.m2-get-color-from-palette($primary-palette, 500) !important; - } - - .groupedButtons { - display: flex; - //background-color: red; - //width: 100px !important; - - button:first-child { - padding: 0; - width: 53px !important; - min-width: unset !important; - - border-top-right-radius: 0; - border-bottom-right-radius: 0; - - margin-right: -1px; - } - - button:last-child { - padding: 0; - width: 57px !important; - min-width: unset !important; - - border-top-left-radius: 0; - border-bottom-left-radius: 0; - - &.buttonDisabled { - border-left-color: transparent !important; - } - } - } - - .button3 { - padding: 0; - flex-grow: 1; - //font-size: 12px; - //background-color: red; - } - - .buttonDisabled { - path { - fill: #969696; - } - - border-color: #bbbbbb !important; - } - } -} diff --git a/src/app/component/BLOCKS/tri-button/tri-button.component.ts b/src/app/component/BLOCKS/tri-button/tri-button.component.ts deleted file mode 100644 index 441512fc..00000000 --- a/src/app/component/BLOCKS/tri-button/tri-button.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; -import { FormGroup } from '@angular/forms'; -import { RealJoint } from '../../../model/joint'; - -@Component({ - selector: 'tri-button', - templateUrl: './tri-button.component.html', - styleUrls: ['./tri-button.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, -}) -export class TriButtonComponent { - @Input() joint!: RealJoint; - @Input() btn1Disabled: boolean = false; - @Input() btn2Disabled: boolean = false; - @Input() btn3Disabled: boolean = false; - @Input() btn3Text: string = ''; - @Input() btn3Icon: string = ''; - @Input() btn1Action!: () => void; - @Input() btn2Action!: () => void; - @Input() btn3Action!: () => void; - - constructor() {} -} diff --git a/src/app/component/MODALS/synthesis-warning/synthesis-warning.component.html b/src/app/component/MODALS/synthesis-warning/synthesis-warning.component.html deleted file mode 100644 index fd1a1592..00000000 --- a/src/app/component/MODALS/synthesis-warning/synthesis-warning.component.html +++ /dev/null @@ -1,15 +0,0 @@ -<div class="modal"> - <h1 mat-dialog-title>Synthesis will delete any pre-existing mechanisms.</h1> - <div mat-dialog-content> - <p> - ️Synthesis generates a fourbar linkage given three poses. Any existing mechanisms won't be - saved. Are you sure you want to proceed? - </p> - </div> - <div mat-dialog-actions align="end"> - <button mat-button mat-dialog-close>Go back</button> - <button mat-button cdkFocusInitial mat-dialog-close (click)="goToSynthesisTab()"> - Continue - </button> - </div> -</div> diff --git a/src/app/component/MODALS/synthesis-warning/synthesis-warning.component.scss b/src/app/component/MODALS/synthesis-warning/synthesis-warning.component.scss deleted file mode 100644 index 19ae8317..00000000 --- a/src/app/component/MODALS/synthesis-warning/synthesis-warning.component.scss +++ /dev/null @@ -1,11 +0,0 @@ -.modal { - width: max(45vw, 420px); -} - -li { - //Don't show bullets - list-style-type: none; - //recude text indent - text-indent: -2em; - margin-bottom: 1em; -} diff --git a/src/app/component/MODALS/synthesis-warning/synthesis-warning.component.ts b/src/app/component/MODALS/synthesis-warning/synthesis-warning.component.ts deleted file mode 100644 index 77681be0..00000000 --- a/src/app/component/MODALS/synthesis-warning/synthesis-warning.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; -import { MatDialogRef } from '@angular/material/dialog'; -import { SelectedTabService, TabID } from 'src/app/selected-tab.service'; -import { MechanismService } from 'src/app/services/mechanism.service'; - -@Component({ - selector: 'app-synthesis-warning', - templateUrl: './synthesis-warning.component.html', - styleUrls: ['./synthesis-warning.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, -}) -export class SynthesisWarningComponent { - constructor( - public dialogRef: MatDialogRef<SynthesisWarningComponent>, - private selectedTab: SelectedTabService, - private mechanism: MechanismService - ) {} - - onNoClick(): void { - this.dialogRef.close(); - } - - // delete mechanism and go to synthesis - goToSynthesisTab(): void { - this.mechanism.resetMechanism(); - this.selectedTab.setTab(TabID.SYNTHESIZE); - } -} diff --git a/src/app/component/MODALS/templates/dev-templates.ts b/src/app/component/MODALS/templates/dev-templates.ts new file mode 100644 index 00000000..73d84f60 --- /dev/null +++ b/src/app/component/MODALS/templates/dev-templates.ts @@ -0,0 +1,43 @@ +/** + * Drawings that exist to exercise the app rather than to teach a linkage. + * + * Their own file, because template-linkages.ts is generated from the + * verification fixtures and checked against that generator -- these are + * neither generated nor fixtures, and they are only ever offered in a + * development build. + */ +export const DEV_TEMPLATE_IDS = [ + 'Dev_All_Mechanism_Types', + 'Dev_Object_Gallery', + 'Dev_Render_Stress', +] as const; + +export type DevTemplateID = (typeof DEV_TEMPLATE_IDS)[number]; + +export const DEV_TEMPLATES: Record<DevTemplateID, string> = { + // One of each kind of machine in one drawing: a crank that goes round, a + // ram that goes out and back, a slider, and a chain that never reaches + // ground -- so every readiness state and every playback row is on screen at + // once. + // + // Two joints in here are named with a backslash and a backtick, which the + // encoder is entitled to use and a TypeScript string literal is entitled to + // read as an escape. The backslashes are doubled for that reason; eating one + // costs the checksum and the whole drawing refuses to load. + Dev_All_Mechanism_Types: + '2O.Ay,Fe.5,0.1011.4O,O,0,0,0.0C,C,YJ,qG,0.4G,G,ku,0,0.0N,N,aM,hl,0.8P,P,ir,8X,0.ZS,S,ir,8X,0,GN,G,N.0T,T,nJ,oD,0.0U,U,1Nd,dn,0.6V,V,1ca,jH,0,,,,1E8.6W,W,J-,1GP,0.0X,X,PF,1dt,0.0Y,Y,rW,1g7,0.4Z,Z,-P,1FX,0.0[,[,1Tk,16n,0.0\\,\\,1ft,1bA,0.0^,^,1pZ,1Gc,0.0_,_,1_5,_W,0.0`,`,2AU,1OV,0.1a,a,2AU,1OV,0..YROC,OC,Fe,Fe,HA,Q8,c5cae9,O,C,,.YRGN,GN,Fe,Fe,fd,Lu,303e9f,G,N,,.YRPC,PC,Fe,Fe,da,UO,0d125a,P,C,,.YPPS,PS,Fe,0,0,0,,P,S,,.YRGT,GT,Fe,Fe,m5,P7,c5cae9,G,T,,.YRTU,TU,Fe,Fe,14T,i-,303e9f,T,U,,.YRUV,UV,Fe,Fe,1V5,gX,0d125a,U,V,,.YRWX,WX,Fe,Fe,Md,1S8,B2DFDB,W,X,,.YRXY,XY,Fe,Fe,dN,1f0,26A69A,X,Y,,.YRYZ,YZ,Fe,Fe,wS,1Sr,00695C,Y,Z,,.YR[\\,[\\,Fe,Fe,1Zp,1Lz,c5cae9,[,\\,,.YR_`,_`,Fe,Fe,24I,1BV,B2DFDB,_,`,,.YP`a,`a,Fe,0,0,0,,`,a,,...N_a', + // One of everything the canvas can draw, laid out in rows and deliberately + // not simulatable: forty-nine joints and twenty-six links covering every + // kind of pin, block, weld, slot and cylinder, with forces on the last of + // them. + Dev_Object_Gallery: + '2P.VC,1E8.5,0.1011.0B,B,01RD,10C,0.1C,C,01RD,10C,0.0D,D,0SL,1m0,0.0E,E,0QJ,u2,0.0F,F,Wu,1qh,0.0G,G,Wu,10C,0.0H,H,14R,1Vn,0.0I,I,1pf,1vu,0.8J,J,1pf,10C,0.8K,K,2wH,10C,0.0L,L,1UP,1kQ,0.0M,M,01GC,RG,0.6N,N,01GC,0Vx,0.0O,O,03v,XX,0.4P,P,032,0i-,0.0Q,Q,2Ao,Dj,0.0R,R,1GR,017e,0.1S,S,1GR,017e,0,XY,X,Y.5T,T,2Ao,Dj,0.0U,U,31C,08I,0.0V,V,2s_,0mb,0.5W,W,31C,08I,0CH.0X,X,1ri,015V,0.0Y,Y,0e7,01Ea,0.0Z,Z,07q,01kA,0.0[,[,Ul,01AV,0.3\\,\\,Ul,01AV,0,XY,X,Y.0],],018q,02Dk,0.0^,^,01rc,01Yr,0.8_,_,022a,01MP,0.0`,`,02lM,0hW,0.Xa,a,022a,01MP,0,]^,],^.0b,b,HP,02JO,0.0c,c,0NY,02Gq,0.8d,d,0Vv,02GI,0.Xf,f,0Vv,02GI,0,bc,b,c.0g,g,1AZ,02pW,0.0h,h,2EW,01S2,0.0i,i,2s_,02g6,0.0j,j,1GR,01zf,0.0k,k,2Ao,03Qv,0.0l,l,22b,02JO,0.1m,m,22b,02JO,0,ghij,i,j.0n,n,3tQ,026J,0.8o,o,1ux,01tp,0.1p,p,1ux,01tp,0,ghij,g,h.0q,q,02tY,02sl,0.0r,r,0zX,02sl,0.0s,s,34j,01ci,0..YPBC,BC,Fe,0,0,0,,B,C,,.YRDE,DE,Fe,Fe,0RK,1K1,0d125a,D,E,,.YRFGH,FGH,Fe,Fe,ik,1SD,B2DFDB,F,G,H,,.YRIJKL,IJKL,ku,Azlti,27f,1HW,26A69A,I,J,K,L,,IJ,JK,KL.YRMN,MN,Fe,Fe,01GC,02L,303e9f,M,N,,.YROP,OP,Fe,Fe,03U,05l,0d125a,O,P,,.YRQR,QR,Fe,Fe,1jc,0S_,B2DFDB,Q,R,,.YPRS,RS,Fe,0,0,0,,R,S,,.YPQT,QT,Fe,0,0,0,,Q,T,,.YRUV,UV,Fe,Fe,2y5,0SS,26A69A,U,V,,.YPUW,UW,Fe,0,0,0,,U,W,,.YRXY,XY,Fe,Fe,cp,01A1,00695C,X,Y,,.YRZ[,Z[,Fe,Fe,BU,01SK,c5cae9,Z,[,,.YP[\\,[\\,Fe,0,0,0,,[,\\,,.YR]^,]^,Fe,Fe,01VD,01uH,303e9f,],^,,.YR_`,_`,Fe,Fe,02Oz,010z,0d125a,_,`,,.YP_a,_a,Fe,0,0,0,,_,a,,.YRbc,bc,Fe,Fe,034,02I6,B2DFDB,b,c,,.YR]d,]d,Fe,Fe,0qN,02F0,26A69A,d,],,.YPdf,df,Fe,0,0,0,,d,f,,.YRghij,ghij,Fe,Fe,1u7,02Dq,00695C,g,h,i,j,,.YRkl,kl,Fe,Fe,26h,02t9,c5cae9,k,l,,.YPlm,lm,Fe,0,0,0,,l,m,,.YRnos,nos,Fe,Fe,3ES,024-,303e9f,n,o,s,,.YPop,op,Fe,0,0,0,,o,p,,.YRqr,qr,Fe,Fe,01wX,02sl,B2DFDB,q,r,,.NRIJ,IJ,Fe,Fe,1pf,1T2,26A69A,I,J,,.NRJK,JK,Fe,Fe,2Mz,10C,00695C,J,K,,.NRKL,KL,Fe,Fe,2CL,1NJ,c5cae9,K,L,,..2F1,qr,F1,01-H,02sl,01yD,03jf,2SG.2F2,qr,F2,01Xj,02sl,0wz,03Sd,Fe.3F3,qr,F3,02WB,02sl,032z,03mG,Fe..N_a', + // Ross McSweeney's Running Horse Automata, converted from the copy MotionGen + // ships with itself: forty-five joints and twenty-seven links on a single + // degree of freedom, with a path traced from each hoof. Its colours are + // remapped onto this app's own palette, because the ones it arrives with are + // not colours this app can draw, and its object scale is turned down because + // it is a big drawing whose furniture should not swamp it. + Dev_Render_Stress: + '2P.3w,1E8.K,0.1011.0A,A,j,04R,0.0B,B,0Ai,05o,0.6C,C,0O,3,0,,,,1E8.0D,D,03U,02j,0.0E,E,09Y,9y,0.4F,F,0Dm,2E,0.0G,G,0Ly,4t,0.4H,H,0Hi,6Q,0.4I,I,9_,25,0.4J,J,GC,Ca,0.4K,K,Iq,2A,0.0L,L,0ct,9u,0.0M,M,0rG,4U,0.0N,N,0In,05-,0.0O,O,0O_,06t,0.0P,P,0T0,02A,0.0Q,Q,0WR,0A3,0.0R,R,0WO,0KW,0.0S,S,0UW,0F_,0.0T,T,0aA,0O8,0.0U,U,0ae,0S0,0.0V,V,0fO,0PL,0.GW,W,0eK,0Uy,0.0X,X,3K,8R,0.0Y,Y,E8,98,0.0Z,Z,GG,0q,0.0a,a,Bi,02d,0.0b,b,AJ,07n,0.0c,c,Kh,05P,0.0d,d,XZ,By,0.0e,e,aN,Ln,0.0f,f,YQ,8o,0.0g,g,kS,NB,0.0h,h,rW,9c,0.0i,i,o6,7Z,0.0j,j,SR,Iu,0.0k,k,Xf,Ui,0.0l,l,UR,To,0.0m,m,O9,05X,0.0n,n,Mi,0E7,0.0o,o,K4,0Aj,0.0p,p,HQ,0MY,0.0q,q,G9,0J3,0.0r,r,C0,0If,0.Gs,s,C0,0O9,0..YRAB,AB,0,Fe,04-,057,c5cae9,A,B,,.YRCDA,CDA,0,Fe,013,02N,303e9f,C,D,A,,.YRDE,DE,0,Fe,06W,3d,0d125a,D,E,,.YREFG,EFG,0,Fe,0F5,5h,B2DFDB,E,F,G,,.YRHBLM,HBLM,0,Fe,0U8,3l,26A69A,H,B,L,M,,.YRDN,DN,0,Fe,0B7,04M,00695C,D,N,,.YRFN,FN,0,Fe,0GH,01u,c5cae9,F,N,,.YRGOP,GOP,0,Fe,0PJ,01P,303e9f,G,O,P,,.YRPNQ,PNQ,0,Fe,0Qk,064,0d125a,P,N,Q,,.YRORS,ORS,0,Fe,0TI,0ES,B2DFDB,O,R,S,,.YRQST,QST,0,Fe,0X2,0Gk,26A69A,Q,S,T,,.YRRU,RU,0,Fe,0YW,0OG,00695C,R,U,,.YRTUVW,TUVW,0,Fe,0ce,0R6,c5cae9,T,U,V,W,,.YRDX,DX,0,Fe,05,2t,303e9f,D,X,,.YRXIY,XIY,0,Fe,99,6Z,0d125a,X,I,Y,,.YRYZa,YZa,0,Fe,E1,1w,B2DFDB,Y,Z,a,,.YRIb,Ib,0,Fe,A9,02s,26A69A,I,b,,.YRDb,Db,0,Fe,3R,05F,00695C,D,b,,.YRAc,Ac,0,Fe,Ai,04w,c5cae9,A,c,,.YRKd,Kd,0,Fe,QC,73,303e9f,K,d,,.YRJefc,Jefc,0,Fe,Qw,9R,0d125a,J,e,f,c,,.YReghidjkl,eghidjkl,0,Fe,d3,JB,B2DFDB,e,g,h,i,d,j,k,l,,.YRbZm,bZm,0,Fe,Gv,04j,26A69A,b,Z,m,,.YRano,ano,0,Fe,I9,099,00695C,a,n,o,,.YRomp,omp,0,Fe,KY,0Cx,c5cae9,o,m,p,,.YRnq,nq,0,Fe,JR,0Gb,303e9f,n,q,,.YRqprs,qprs,0,Fe,EP,0L6,0d125a,q,p,r,s,,...N_c', +}; diff --git a/src/app/component/MODALS/templates/template-linkages.ts b/src/app/component/MODALS/templates/template-linkages.ts index 74f7abe6..e64e71bd 100644 --- a/src/app/component/MODALS/templates/template-linkages.ts +++ b/src/app/component/MODALS/templates/template-linkages.ts @@ -82,44 +82,44 @@ export const TEMPLATE_LINKAGES: Record<TemplateID, string> = { '2P.Ay,1E8.K,0.1011.6A,A,0mA,0c,0.0B,B,0Yt,bK,0.0C,C,il,H-,0.5D,D,il,H-,0..YRAB,AB,Fe,Fe,0fW,IN,c5cae9,A,B,,.YRBC,BC,Fe,Fe,4y,Rf,303e9f,B,C,,.YPCD,CD,Fe,0,0,0,,C,D,,...N_j', // <generated by `npm run template-payloads` — do not edit by hand> Whitworth_Quick_Return: - '2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,0,ku,0.4C,C,Fe,0,0.0D,D,095,1A7,0.1P,P,0,ku,0,CD,C,D..YRAB,AB,Fe,Fe,0,NS,c5cae9,A,B,,.YRCD,CD,Fe,Fe,3H,b4,303e9f,C,D,,.YPBP,BP,Fe,0,0,0,,B,P,,...N_w', + '2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,0,ku,0.4C,C,Fe,0,0.0D,D,095,1A7,0.1P,P,0,ku,0,CD,C,D..YRAB,AB,Fe,Fe,0,NS,c5cae9,A,B,,.YRCD,CD,Fe,Fe,3H,b4,303e9f,C,D,,.YPBP,BP,0,0,0,0,,B,P,,...N_v', Scotch_Yoke: - '2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.8C,C,Fe,0VG,0.0D,D,Fe,S8,0.1E,E,Fe,0,0,CD,C,D.5F,F,Fe,0VG,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,Fe,01a,303e9f,C,D,,.YPBE,BE,Fe,0,0,0,,B,E,,.YPCF,CF,Fe,0,0,0,,C,F,,...N_Z', + '2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.8C,C,Fe,0VG,0.0D,D,Fe,S8,0.1E,E,Fe,0,0,CD,C,D.5F,F,Fe,0VG,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,Fe,01a,303e9f,C,D,,.YPBE,BE,0,0,0,0,,B,E,,.YPCF,CF,0,0,0,0,,C,F,,...N_X', Cylinder_Boom: - '2P.Ay,Fe.K,0.1011.4O,O,0,0,0.0C,C,0,_W,0.4G,G,ku,0,0.0N,N,Ju,Z-,0.8P,P,R0,QX,0.ZS,S,R0,QX,0,GN,G,N..YROC,OC,Fe,Fe,0,VG,c5cae9,O,C,,.YRGN,GN,Fe,Fe,XO,I0,303e9f,G,N,,.YRPC,PC,Fe,Fe,DW,iW,0d125a,P,C,,.YPPS,PS,Fe,0,0,0,,P,S,,...N_e', + '2P.Ay,Fe.5,0.1011.4O,O,0,0,0.0C,C,0,_W,0.4G,G,ku,0,0.0N,N,Ju,Z-,0.8P,P,R0,QX,0.ZS,S,R0,QX,0,GN,G,N..YROC,OC,Fe,Fe,0,VG,c5cae9,O,C,,.YRGN,GN,Fe,Fe,XO,I0,303e9f,G,N,,.YRPC,PC,Fe,Fe,DW,iW,0d125a,P,C,,.YPPS,PS,0,0,0,0,,P,S,,...N_d', Cylinder_Gripper: - '2P.Ay,1E8.K,0.1011.4A,A,02SG,ku,0.0B,B,0185,uK,0.8C,C,019F,uC,0.0D,D,Ay,11e,0.4G,G,_W,ku,0.0H,H,1HG,S8,0.0I,I,2hu,VG,0.4J,J,_W,0ku,0.0K,K,hm,0S8,0.0L,L,2hu,0VG,0.ZE,E,019F,uC,0,AB,A,B..YRAB,AB,Fe,Fe,01oB,pc,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0V9,yw,303e9f,C,D,,.YRDGHI,DGHI,Fe,Fe,1Hf,g_,0d125a,D,G,H,I,,.YRHK,HK,Fe,Fe,_W,0,B2DFDB,H,K,,.YRJKL,JKL,Fe,Fe,1Sj,0ZR,26A69A,J,K,L,,.YPCE,CE,Fe,0,0,0,,C,E,,...N_N', + '2P.Ay,1E8.5,0.1011.4A,A,02SG,ku,0.0B,B,0185,uK,0.8C,C,019F,uC,0.0D,D,Ay,11e,0.4G,G,_W,ku,0.0H,H,1HG,S8,0.0I,I,2hu,VG,0.4J,J,_W,0ku,0.0K,K,hm,0S8,0.0L,L,2hu,0VG,0.ZE,E,019F,uC,0,AB,A,B..YRAB,AB,Fe,Fe,01oB,pc,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0V9,yw,303e9f,C,D,,.YRDGHI,DGHI,Fe,Fe,1Hf,g_,0d125a,D,G,H,I,,.YRHK,HK,Fe,Fe,_W,0,B2DFDB,H,K,,.YRJKL,JKL,Fe,Fe,1Sj,0ZR,26A69A,J,K,L,,.YPCE,CE,0,0,0,0,,C,E,,...N_M', Radial_Engine: - '2P.Ay,1E8.K,0.1011.6O,O,0,0,0.0A,A,Fe,0,0.0B,B,0,iC,0.0C,C,0UD,9q,0.0D,D,0L9,0T6,0.0E,E,Vy,0hz,0.0F,F,wV,J0,0.5P,P,0,iC,OZ.5Q,Q,0UD,9q,iB.5R,R,0L9,0T6,-q.5S,S,Vy,0hz,1JT.5T,T,wV,J0,1d5..YROA,OA,Fe,Fe,7q,0,c5cae9,O,A,,.YRAB,AB,Fe,Fe,7q,M6,303e9f,A,B,,.YRAC,AC,Fe,Fe,07J,4w,0d125a,A,C,,.YRAD,AD,Fe,Fe,02m,0EZ,B2DFDB,A,D,,.YRAE,AE,Fe,Fe,No,0L_,26A69A,A,E,,.YRAF,AF,Fe,Fe,b3,9W,00695C,A,F,,.YPBP,BP,Fe,0,0,0,,B,P,,.YPCQ,CQ,Fe,0,0,0,,C,Q,,.YPDR,DR,Fe,0,0,0,,D,R,,.YPES,ES,Fe,0,0,0,,E,S,,.YPFT,FT,Fe,0,0,0,,F,T,,...N_F', + '2P.Ay,1E8.5,0.1011.6O,O,0,0,0.0A,A,Fe,0,0.0B,B,0,iC,0.0C,C,0UD,9q,0.0D,D,0L9,0T6,0.0E,E,Vy,0hz,0.0F,F,wV,J0,0.5P,P,0,iC,OZ.5Q,Q,0UD,9q,iB.5R,R,0L9,0T6,-q.5S,S,Vy,0hz,1JT.5T,T,wV,J0,1d5..YROA,OA,Fe,Fe,7q,0,c5cae9,O,A,,.YRAB,AB,Fe,Fe,7q,M6,303e9f,A,B,,.YRAC,AC,Fe,Fe,07J,4w,0d125a,A,C,,.YRAD,AD,Fe,Fe,02m,0EZ,B2DFDB,A,D,,.YRAE,AE,Fe,Fe,No,0L_,26A69A,A,E,,.YRAF,AF,Fe,Fe,b3,9W,00695C,A,F,,.YPBP,BP,0,0,0,0,,B,P,,.YPCQ,CQ,0,0,0,0,,C,Q,,.YPDR,DR,0,0,0,0,,D,R,,.YPES,ES,0,0,0,0,,E,S,,.YPFT,FT,0,0,0,0,,F,T,,...N_A', Chebyshev_Straight_Line: '2P.Ay,1E8.2,0.1011.6G,G,0VG,0,0.0A,A,0Fe,_W,0.0B,B,Fe,_W,0.GM,M,0,_W,0.4H,H,VG,0,0..YRGB,GB,Fe,Fe,07q,VG,c5cae9,G,B,,.YRABM,ABM,Fe,Fe,0,_W,303e9f,A,B,M,,.YRAH,AH,Fe,Fe,7q,VG,0d125a,A,H,,...N_5', Windshield_Wiper: - '2P.Ay,1E8.K,0.1011.6O,O,0si,0iC,0.0A,A,0se,0Km,0.0B,B,Dl,0S4,0.4P,P,0,0,0.0C,C,AJ,0L3,0.GT,T,0YM,16B,0.0D,D,1e3,0L3,0.4Q,Q,1Tm,0,0.GU,U,xQ,16B,0..YROA,Motor crank,Fe,Fe,0sg,0WU,c5cae9,O,A,,.YRAB,Drive link,Fe,Fe,0KS,0OQ,303e9f,A,B,,.YRBCPT,Wiper arm,Fe,Fe,02b,5H,0d125a,B,C,P,T,,.YRCD,Tie rod,Fe,Fe,vB,0L3,B2DFDB,C,D,,.YRDQU,Passenger arm,Fe,Fe,1Ll,GO,26A69A,D,Q,U,,...N_z', + '2P.Ay,1E8.5,0.1011.6O,O,0si,0iC,0.0A,A,0se,0Km,0.0B,B,Dl,0S4,0.4P,P,0,0,0.0C,C,AJ,0L3,0.GT,T,0YM,16B,0.0D,D,1e3,0L3,0.4Q,Q,1Tm,0,0.GU,U,xQ,16B,0..YROA,Motor crank,Fe,Fe,0sg,0WU,c5cae9,O,A,,.YRAB,Drive link,Fe,Fe,0KS,0OQ,303e9f,A,B,,.YRBCPT,Wiper arm,Fe,Fe,02b,5H,0d125a,B,C,P,T,,.YRCD,Tie rod,Fe,Fe,vB,0L3,B2DFDB,C,D,,.YRDQU,Passenger arm,Fe,Fe,1Ll,GO,26A69A,D,Q,U,,...N_z', Elliptical_Crank: - '2P.Ay,1E8.K,0.1011.6A,A,0ix,X,0.0B,B,0zu,7O,0.0C,C,0qC,Lr,0.GD,D,085,Av,0.0E,E,aY,v,0.4F,F,0,0,0.5P,P,aY,v,03..YRAB,AB,Fe,Fe,0rQ,3y,c5cae9,A,B,,.YRBC,BC,Fe,Fe,0v2,Ed,303e9f,B,C,,.YRCDE,CDE,Fe,Fe,07x,BD,0d125a,C,D,E,,.YRDF,DF,Fe,Fe,043,5S,B2DFDB,D,F,,.YPEP,EP,Fe,0,0,0,,E,P,,...N_V', + '2P.Ay,1E8.5,0.1011.6A,A,0ix,X,0.0B,B,0zu,7O,0.0C,C,0qC,Lr,0.GD,D,085,Av,0.0E,E,aY,v,0.4F,F,0,0,0.5P,P,aY,v,03..YRAB,AB,Fe,Fe,0rQ,3y,c5cae9,A,B,,.YRBC,BC,Fe,Fe,0v2,Ed,303e9f,B,C,,.YRCDE,CDE,Fe,Fe,07x,BD,0d125a,C,D,E,,.YRDF,DF,Fe,Fe,043,5S,B2DFDB,D,F,,.YPEP,EP,0,0,0,0,,E,P,,...N_U', Jansen_Leg: - '2P.1jO,1E8.K,0.1011.6O,O,0,0,0.0A,A,3gO,0,0.4G,G,09Hm,01vu,0.0B,B,05tE,7ee,0.0C,C,0IGg,1-F,0.0D,D,06b8,0B7B,0.0E,E,0ETW,06sL,0.GF,F,0AYO,0MPj,0..YROA,OA,Fe,Fe,1rC,0,c5cae9,O,A,,.YRAB,AB,Fe,Fe,016R,3qK,303e9f,A,B,,.YRGBC,GBC,Fe,Fe,0B8Z,2ag,0d125a,G,B,C,,.YRAD,AD,Fe,Fe,01TO,05Zc,B2DFDB,A,D,,.YRGD,GD,Fe,Fe,07xS,06WY,26A69A,G,D,,.YRCE,CE,Fe,Fe,0GN5,02RZ,00695C,C,E,,.YRDEF,DEF,Fe,Fe,0AXh,0DT4,c5cae9,D,E,F,,...N_c', + '2P.1jO,1E8.5,0.1011.6O,O,0,0,0.0A,A,3gO,0,0.4G,G,09Hm,01vu,0.0B,B,05tE,7ee,0.0C,C,0IGg,1-F,0.0D,D,06b8,0B7B,0.0E,E,0ETW,06sL,0.GF,F,0AYO,0MPj,0..YROA,OA,Fe,Fe,1rC,0,c5cae9,O,A,,.YRAB,AB,Fe,Fe,016R,3qK,303e9f,A,B,,.YRGBC,GBC,Fe,Fe,0B8Z,2ag,0d125a,G,B,C,,.YRAD,AD,Fe,Fe,01TO,05Zc,B2DFDB,A,D,,.YRGD,GD,Fe,Fe,07xS,06WY,26A69A,G,D,,.YRCE,CE,Fe,Fe,0GN5,02RZ,00695C,C,E,,.YRDEF,DEF,Fe,Fe,0AXh,0DT4,c5cae9,D,E,F,,...N_c', Backhoe_Bucket: - '2P.Ay,1E8.K,0.1011.4A,A,01jO,0,0.0B,B,0sb,Py,0.8C,C,018N,HX,0.0D,D,0Ha,hT,0.4G,G,0,0,0.0H,H,VG,Im,0.4J,J,o0,0P0,0.0K,K,1BI,2i,0.0T,T,1NW,0ee,0.ZE,E,018N,HX,0,AB,A,B..YRAB,AB,Fe,Fe,01H-,C_,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0iz,UV,303e9f,C,D,,.YRDGH,DGH,Fe,Fe,4a,Kl,0d125a,D,G,H,,.YRHK,HK,Fe,Fe,rH,Ak,B2DFDB,H,K,,.YRJKT,JKT,Fe,Fe,16x,0K-,26A69A,J,K,T,,.YPCE,CE,Fe,0,0,0,,C,E,,...N_0', + '2P.Ay,1E8.5,0.1011.4A,A,01jO,0,0.0B,B,0sb,Py,0.8C,C,018N,HX,0.0D,D,0Ha,hT,0.4G,G,0,0,0.0H,H,VG,Im,0.4J,J,o0,0P0,0.0K,K,1BI,2i,0.0T,T,1NW,0ee,0.ZE,E,018N,HX,0,AB,A,B..YRAB,AB,Fe,Fe,01H-,C_,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0iz,UV,303e9f,C,D,,.YRDGH,DGH,Fe,Fe,4a,Kl,0d125a,D,G,H,,.YRHK,HK,Fe,Fe,rH,Ak,B2DFDB,H,K,,.YRJKT,JKT,Fe,Fe,16x,0K-,26A69A,J,K,T,,.YPCE,CE,0,0,0,0,,C,E,,...N_z', Toggle_Press: - '2P.Ay,1E8.K,0.1011.4A,A,01z0,0,0.0B,B,017k,0Uc,0.8C,C,01P8,0Kc,0.0D,D,0Zs,0pD,0.4G,G,0,0,0.0R,R,0,01cP,0.ZE,E,01P8,0Kc,0,AB,A,B.5F,F,0,01cP,OZ..YRAB,AB,Fe,Fe,01YN,0FJ,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0_V,0Zv,303e9f,C,D,,.YRGD,GD,Fe,Fe,0Hx,0Pc,0d125a,G,D,,.YRDR,DR,Fe,Fe,0Hx,01Cp,B2DFDB,D,R,,.YPCE,CE,Fe,0,0,0,,C,E,,.YPRF,RF,Fe,0,0,0,,R,F,,...N_S', + '2P.Ay,1E8.5,0.1011.4A,A,01z0,0,0.0B,B,017k,0Uc,0.8C,C,01P8,0Kc,0.0D,D,0Zs,0pD,0.4G,G,0,0,0.0R,R,0,01cP,0.ZE,E,01P8,0Kc,0,AB,A,B.5F,F,0,01cP,OZ..YRAB,AB,Fe,Fe,01YN,0FJ,c5cae9,A,B,,.YRCD,CD,Fe,Fe,0_V,0Zv,303e9f,C,D,,.YRGD,GD,Fe,Fe,0Hx,0Pc,0d125a,G,D,,.YRDR,DR,Fe,Fe,0Hx,01Cp,B2DFDB,D,R,,.YPCE,CE,0,0,0,0,,C,E,,.YPRF,RF,0,0,0,0,,R,F,,...N_Q', Scissor_Lift: - '2P.Ay,1E8.K,0.1011.4A,A,2SG,0,0.0B,B,1fl,a3,0.8C,C,1wL,OD,0.0D,D,17q,yH,0.4G,G,0,0,0.0M,M,1Vm,1GM,0.0K,K,2-X,2Wj,0.0S,S,2-X,0,0.0T,T,0,2Wj,0.0U,U,3_1,2Wj,0.ZE,E,1wL,OD,0,AB,A,B.5N,N,2-X,0,0.1P,P,2-X,2Wj,0,TU,T,U..YRAB,AB,Fe,Fe,230,I2,c5cae9,A,B,,.YRCD,CD,Fe,Fe,1X5,gF,303e9f,C,D,,.YRGDMK,GDMK,Fe,Fe,1Pn,1BL,0d125a,G,D,M,K,,.YRSMT,SMT,Fe,Fe,1Vm,1GM,B2DFDB,S,M,T,,.YRTU,TU,Fe,Fe,1-0,2Wj,26A69A,T,U,,.YPCE,CE,Fe,0,0,0,,C,E,,.YPSN,SN,Fe,0,0,0,,S,N,,.YPKP,KP,Fe,0,0,0,,K,P,,...N_e', + '2P.Ay,1E8.5,0.1011.4A,A,2SG,0,0.0B,B,1fl,a3,0.8C,C,1wL,OD,0.0D,D,17q,yH,0.4G,G,0,0,0.0M,M,1Vm,1GM,0.0K,K,2-X,2Wj,0.0S,S,2-X,0,0.0T,T,0,2Wj,0.0U,U,3_1,2Wj,0.ZE,E,1wL,OD,0,AB,A,B.5N,N,2-X,0,0.1P,P,2-X,2Wj,0,TU,T,U..YRAB,AB,Fe,Fe,230,I2,c5cae9,A,B,,.YRCD,CD,Fe,Fe,1X5,gF,303e9f,C,D,,.YRGDMK,GDMK,Fe,Fe,1Pn,1BL,0d125a,G,D,M,K,,.YRSMT,SMT,Fe,Fe,1Vm,1GM,B2DFDB,S,M,T,,.YRTU,TU,Fe,Fe,1-0,2Wj,26A69A,T,U,,.YPCE,CE,0,0,0,0,,C,E,,.YPSN,SN,0,0,0,0,,S,N,,.YPKP,KP,0,0,0,0,,K,P,,...N_b', Shaper_Quick_Return: - '2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.4C,C,0,0ku,0.0D,D,Oj,RF,0.0R,R,qW,si,0.1P,P,Fe,0,0,CD,C,D.5Q,Q,qW,si,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,CN,09q,303e9f,C,D,,.YRDR,DR,Fe,Fe,cd,e_,0d125a,D,R,,.YPBP,BP,Fe,0,0,0,,B,P,,.YPRQ,RQ,Fe,0,0,0,,R,Q,,...N_H', + '2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,Fe,0,0.4C,C,0,0ku,0.0D,D,Oj,RF,0.0R,R,qW,si,0.1P,P,Fe,0,0,CD,C,D.5Q,Q,qW,si,0..YRAB,AB,Fe,Fe,7q,0,c5cae9,A,B,,.YRCD,CD,Fe,Fe,CN,09q,303e9f,C,D,,.YRDR,DR,Fe,Fe,cd,e_,0d125a,D,R,,.YPBP,BP,0,0,0,0,,B,P,,.YPRQ,RQ,0,0,0,0,,R,Q,,...N_F', Pedaling_Leg: - '2P.Ay,1E8.K,0.1011.4B,B,0,0,0.GP,P,Qa,0,0.4H,H,0NS,1Qe,0.2K,K,c5,13o,0..YRHK,HK,Fe,Fe,7L,1FD,c5cae9,H,K,,.YRKP,KP,Fe,Fe,WL,Xv,303e9f,K,P,,.YRBP,BP,Fe,Fe,DI,0,0d125a,B,P,,...N_p', + '2P.Ay,1E8.5,0.1011.4B,B,0,0,0.GP,P,Qa,0,0.4H,H,0NS,1Qe,0.2K,K,c5,13o,0..YRHK,HK,Fe,Fe,7L,1FD,c5cae9,H,K,,.YRKP,KP,Fe,Fe,WL,Xv,303e9f,K,P,,.YRBP,BP,Fe,Fe,DI,0,0d125a,B,P,,...N_p', Oscillating_Fan: - '2P.Ay,1E8.K,0.1011.4A,A,0,0,0.2C,C,s8,0VG,0.0D,D,k_,0j9,0.4B,B,0GY,0In,0.0N,N,1NW,0,0..YRACN,ACN,Fe,Fe,lD,0AR,c5cae9,A,C,N,,.YRCD,CD,Fe,Fe,oZ,0cC,303e9f,C,D,,.YRDB,DB,Fe,Fe,FE,0Vz,0d125a,D,B,,...N_B', + '2P.Ay,1E8.5,0.1011.4A,A,0,0,0.2C,C,s8,0VG,0.0D,D,k_,0j9,0.4B,B,0GY,0In,0.0N,N,1NW,0,0..YRACN,ACN,Fe,Fe,lD,0AR,c5cae9,A,C,N,,.YRCD,CD,Fe,Fe,oZ,0cC,303e9f,C,D,,.YRDB,DB,Fe,Fe,FE,0Vz,0d125a,D,B,,...N_B', Pumpjack: - '2P.Ay,1E8.K,0.1011.4A,A,0gB,0oI,0.0M,M,0QZ,0o1,0.2P,P,0d4,0,0.4S,S,0,0,0.0H,H,o0,0,0.0R,R,r8,0eW,0.5W,W,r8,0eW,OZ..YRAM,AM,Fe,Fe,0YN,0oA,c5cae9,A,M,,.YRMP,MP,Fe,Fe,0Wq,0P1,303e9f,M,P,,.YRPSH,PSH,Fe,Fe,3f,0,0d125a,P,S,H,,.YRHR,HR,Fe,Fe,pa,0KG,B2DFDB,H,R,,.YPRW,RW,Fe,0,0,0,,R,W,,...N_Z', + '2P.Ay,1E8.5,0.1011.4A,A,0gB,0oI,0.0M,M,0QZ,0o1,0.2P,P,0d4,0,0.4S,S,0,0,0.0H,H,o0,0,0.0R,R,r8,0eW,0.5W,W,r8,0eW,OZ..YRAM,AM,Fe,Fe,0YN,0oA,c5cae9,A,M,,.YRMP,MP,Fe,Fe,0Wq,0P1,303e9f,M,P,,.YRPSH,PSH,Fe,Fe,3f,0,0d125a,P,S,H,,.YRHR,HR,Fe,Fe,pa,0KG,B2DFDB,H,R,,.YPRW,RW,0,0,0,0,,R,W,,...N_Y', Punch_Press: - '2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,0,Im,0.0C,C,0,0r8,0.5P,P,0,0r8,OZ..YRAB,Crank,VG,o,0,9O,c5cae9,A,B,,.YRBC,Connecting rod,ku,6G,0,0HC,303e9f,B,C,,.YPCP,CP,1Tm,0,0,0,,C,P,,..2F1,BC,F1,0,0r8,0,0bW,1Xg0..N_J', + '2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,0,Im,0.0C,C,0,0r8,0.5P,P,0,0r8,OZ..YRAB,Crank,VG,o,0,9O,c5cae9,A,B,,.YRBC,Connecting rod,ku,6G,0,0HC,303e9f,B,C,,.YPCP,CP,1Tm,0,0,0,,C,P,,..2F1,BC,F1,0,0r8,0,0bW,1Xg0..N_J', Derrick_Crane: - '2P.Ay,1E8.K,0.1011.4O,O,0,0,0.0C,C,Qv,cP,0.0T,T,rn,1Cp,0.6G,G,YO,09O,0.0K,K,Mp,18,0..YRGK,Luffing crank,VG,o,Sc,048,c5cae9,G,K,,.YRCK,Luffing link,ku,6G,Os,Jn,303e9f,C,K,,.YROCT,Boom,2xW,ku,Qv,cP,0d125a,O,C,T,,..2F1,OCT,F1,rn,1Cp,rn,zB,z2G..N_v', + '2P.Ay,1E8.5,0.1011.4O,O,0,0,0.0C,C,Qv,cP,0.0T,T,rn,1Cp,0.6G,G,YO,09O,0.0K,K,Mp,18,0..YRGK,Luffing crank,VG,o,Sc,048,c5cae9,G,K,,.YRCK,Luffing link,ku,6G,Os,Jn,303e9f,C,K,,.YROCT,Boom,2xW,ku,Qv,cP,0d125a,O,C,T,,..2F1,OCT,F1,rn,1Cp,rn,zB,z2G..N_v', Toggle_Clamp: - '2P.Ay,1E8.K,0.1011.6H,H,0ee,Ti,0.0E,E,09O,NS,0.0P,P,P0,5U,0.4N,N,0Lu,09O,0..YRHE,Handle,NS,o,0P0,Qa,c5cae9,H,E,,.YREP,Toggle link,Fe,1G,7q,ET,303e9f,E,P,,.YRNP,Clamp bar,VG,38,1a,01z,0d125a,N,P,,..2F1,NP,F1,P0,5U,P0,L6,hyW..N_e', + '2P.Ay,1E8.5,0.1011.6H,H,0ee,Ti,0.0E,E,09O,NS,0.0P,P,P0,5U,0.4N,N,0Lu,09O,0..YRHE,Handle,NS,o,0P0,Qa,c5cae9,H,E,,.YREP,Toggle link,Fe,1G,7q,ET,303e9f,E,P,,.YRNP,Clamp bar,VG,38,1a,01z,0d125a,N,P,,..2F1,NP,F1,P0,5U,P0,L6,hyW..N_e', Offset_Load_Rocker: - '2P.Ay,1E8.K,0.1011.6A,A,0,0,0.0B,B,HC,7q,0.0C,C,104,bW,0.4D,D,1E8,0,0.0L,L,1SC,jK,0..YRAB,Crank,VG,o,8c,3w,c5cae9,A,B,,.YRBC,Coupler,_W,7q,ee,Mg,303e9f,B,C,,.YRCDL,Rocker,1E8,E4,1E8,Rd,0d125a,C,D,L,,..2F1,CDL,F1,1SC,jK,1Fi,Zy,adm..N_l', + '2P.Ay,1E8.5,0.1011.6A,A,0,0,0.0B,B,HC,7q,0.0C,C,104,bW,0.4D,D,1E8,0,0.0L,L,1SC,jK,0..YRAB,Crank,VG,o,8c,3w,c5cae9,A,B,,.YRBC,Coupler,_W,7q,ee,Mg,303e9f,B,C,,.YRCDL,Rocker,1E8,E4,1E8,Rd,0d125a,C,D,L,,..2F1,CDL,F1,1SC,jK,1Fi,Zy,adm..N_l', // </generated> }; diff --git a/src/app/component/MODALS/templates/templates.component.html b/src/app/component/MODALS/templates/templates.component.html index c3920567..a9a03b8e 100644 --- a/src/app/component/MODALS/templates/templates.component.html +++ b/src/app/component/MODALS/templates/templates.component.html @@ -7,6 +7,45 @@ <h1>Linkage Library (Example Linkages)</h1> </div> <div mat-dialog-content id="remove_top_padding"> + <!-- Drawings for working on the app rather than for learning a linkage. + First, because that is where whoever wants them is looking. --> + @if (isDevMode()) { + <h2 class="section-heading">For Development</h2> + <div class="linkage-grid devGrid"> + <panel-section (click)="openLinkage('Dev_All_Mechanism_Types')"> + <title-block>Every Kind of Machine</title-block> + <img src="assets/gifs/dev-all-machines.png" alt="Every Kind of Machine" /> + <p class="dev-note"> + A crank, a ram, a slider and a chain that never reaches ground — every readiness state + and every playback row at once. + </p> + <button mat-flat-button color="primary">Open</button> + </panel-section> + + <panel-section (click)="openLinkage('Dev_Object_Gallery')"> + <title-block>Object Gallery</title-block> + <img src="assets/gifs/dev-object-gallery.png" alt="Object Gallery" /> + <p class="dev-note"> + Every kind of pin, block, weld, slot and cylinder, with forces. Deliberately not + simulatable. + </p> + <button mat-flat-button color="primary">Open</button> + </panel-section> + + <panel-section (click)="openLinkage('Dev_Render_Stress')"> + <title-block>Render Stress Test</title-block> + <img src="assets/gifs/dev-render-stress.png" alt="Render Stress Test" /> + <p class="dev-note"> + Ross McSweeney's Running Horse Automata, from MotionGen: forty-five joints and + twenty-seven links on one degree of freedom, tracing both hooves. + </p> + <button mat-flat-button color="primary">Open</button> + </panel-section> + </div> + + <h2 class="section-heading">Linkages</h2> + } + <div class="linkage-grid"> <panel-section (click)="openLinkage('4-Bar')"> <title-block>Four Bar Linkage</title-block> diff --git a/src/app/component/MODALS/templates/templates.component.scss b/src/app/component/MODALS/templates/templates.component.scss index 3c36015a..643a000a 100644 --- a/src/app/component/MODALS/templates/templates.component.scss +++ b/src/app/component/MODALS/templates/templates.component.scss @@ -66,13 +66,16 @@ //blending mode darken } - //Increase elevation when hovering - &:hover { + // Lifted on hover -- on the card, not on the wrapper around it. The + // wrapper is a square box behind a rounded card, so a shadow on it drew a + // second, squarer shadow a few pixels out from the card's own. + &:hover #normalPanel { @include mat.elevation(4); } - //delay the hover effect - transition: all 0.1s ease-in-out; + #normalPanel { + transition: box-shadow 0.1s ease-in-out; + } } .linkage-grid { @@ -82,7 +85,9 @@ // Add a 10px gap grid-gap: 20px; padding: 24px; - padding-top: 0px; + // Not zero: the cards cast a shadow upwards too, and the list scrolls, so + // with nothing above them the top row's shadow was cut off square. + padding-top: 6px; //Set all the cards to the same height } @@ -108,3 +113,38 @@ } } } + +// A section's name, on the same left edge as the cards under it -- which is +// the grid's own 24px inset, not a 10px one that left the heading hanging out +// to the left of everything it names. +.section-heading { + margin: 4px 0 12px; + padding: 0 24px; + font-size: 15px; + font-weight: 500; + color: rgba(0, 0, 0, 0.55); +} + +// A dev card has one still image and a line about it, in flow -- no animated +// variant to cross-fade to, and so no absolutely-positioned copy landing on +// top of the words. The bottom padding is the Open button's room. +.dev-note { + // On the card's own text gutter, the one its title sits on: the image runs + // to the card's edges, and text that followed it there read as a caption + // pressed against the border. + margin: 8px 0 0; + padding: 0 15px; + // Clear of the Open button, which is positioned against the card's bottom + // right corner -- text flowing under it is text the button cuts in half. + padding-bottom: 52px; + font-size: 12px; + line-height: 17px; + color: rgba(0, 0, 0, 0.55); +} + +.devGrid panel-section img { + position: static; + width: 100%; + height: auto; + display: block; +} diff --git a/src/app/component/MODALS/templates/templates.component.ts b/src/app/component/MODALS/templates/templates.component.ts index 4bbcda37..bf17e1db 100644 --- a/src/app/component/MODALS/templates/templates.component.ts +++ b/src/app/component/MODALS/templates/templates.component.ts @@ -1,36 +1,69 @@ import { Component, ChangeDetectionStrategy, - Optional, + isDevMode, TemplateRef, - ViewChild, + inject, + viewChild, } from '@angular/core'; -import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { + MatDialog, + MatDialogRef, + MatDialogClose, + MatDialogContent, + MatDialogTitle, + MatDialogActions, +} from '@angular/material/dialog'; import { MechanismService } from 'src/app/services/mechanism.service'; import { UrlProcessorService } from 'src/app/services/url-processor.service'; +import { DEV_TEMPLATES, DevTemplateID } from './dev-templates'; import { TemplateID, TEMPLATE_LINKAGES } from './template-linkages'; +import { MatIconButton, MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { PanelSectionComponent } from '../../BLOCKS/panel-section/panel-section.component'; +import { TitleBlock } from '../../BLOCKS/title/title.component'; @Component({ selector: 'app-templates', templateUrl: './templates.component.html', styleUrls: ['./templates.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + MatIconButton, + MatDialogClose, + MatIcon, + CdkScrollable, + MatDialogContent, + PanelSectionComponent, + TitleBlock, + MatButton, + MatDialogTitle, + MatDialogActions, + ], }) export class TemplatesComponent { + private dialogRef = inject<MatDialogRef<TemplatesComponent> | null>( + MatDialogRef<TemplatesComponent>, + { optional: true } + ); + private dialog = inject(MatDialog); + private mechanismSrv = inject(MechanismService); + private urlProcessor = inject(UrlProcessorService); + /** Asks whether to replace the linkage already on the grid or open a new tab. */ - @ViewChild('openChoiceDialog') openChoiceDialog!: TemplateRef<unknown>; + readonly openChoiceDialog = viewChild.required<TemplateRef<unknown>>('openChoiceDialog'); - constructor( - // Optional so the component can also render outside a dialog (tests do). - @Optional() private dialogRef: MatDialogRef<TemplatesComponent> | null, - private dialog: MatDialog, - private mechanismSrv: MechanismService, - private urlProcessor: UrlProcessorService - ) {} + /** Only a development build offers the drawings that exercise the app. */ + isDevMode(): boolean { + return isDevMode(); + } - openLinkage(linkage: TemplateID) { - const content = TEMPLATE_LINKAGES[linkage]; + openLinkage(linkage: TemplateID | DevTemplateID) { + const content = + linkage in DEV_TEMPLATES + ? DEV_TEMPLATES[linkage as DevTemplateID] + : TEMPLATE_LINKAGES[linkage as TemplateID]; // An empty grid has nothing to lose, so load right here instead of // spawning a tab the user then has to switch to. @@ -40,7 +73,7 @@ export class TemplatesComponent { } this.dialog - .open(this.openChoiceDialog) + .open(this.openChoiceDialog()) .afterClosed() .subscribe((choice) => { if (choice === 'replace') { diff --git a/src/app/component/MODALS/touchscreen-warning/touchscreen-warning.component.ts b/src/app/component/MODALS/touchscreen-warning/touchscreen-warning.component.ts index 1baf3c88..a378fbf0 100644 --- a/src/app/component/MODALS/touchscreen-warning/touchscreen-warning.component.ts +++ b/src/app/component/MODALS/touchscreen-warning/touchscreen-warning.component.ts @@ -1,15 +1,22 @@ -import { Component, Inject, ChangeDetectionStrategy } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, +} from '@angular/material/dialog'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { MatButton } from '@angular/material/button'; @Component({ selector: 'app-touchscreen-warning', templateUrl: './touchscreen-warning.component.html', styleUrls: ['./touchscreen-warning.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [MatDialogTitle, CdkScrollable, MatDialogContent, MatDialogActions, MatButton], }) export class TouchscreenWarningComponent { - constructor(public dialogRef: MatDialogRef<TouchscreenWarningComponent>) {} + dialogRef = inject<MatDialogRef<TouchscreenWarningComponent>>(MatDialogRef); onNoClick(): void { this.dialogRef.close(); diff --git a/src/app/component/always-on-features.spec.ts b/src/app/component/always-on-features.spec.ts index 30120c1d..c2812cda 100644 --- a/src/app/component/always-on-features.spec.ts +++ b/src/app/component/always-on-features.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AppModule } from '../app.module'; +import { provideAnimations } from '@angular/platform-browser/animations'; import { Coord } from '../model/coord'; import { Force } from '../model/force'; import { RevJoint } from '../model/joint'; @@ -7,13 +7,16 @@ import { RealLink } from '../model/link'; import { ActiveObjService } from '../services/active-obj.service'; import { EditPanelComponent } from './edit-panel/edit-panel.component'; import { SettingsPanelComponent } from './settings-panel/settings-panel.component'; -import { ToolbarComponent } from './toolbar/toolbar.component'; +import { TopBarComponent } from './top-bar/top-bar.component'; import { TemplatesComponent } from './MODALS/templates/templates.component'; import { TEMPLATE_IDS } from './MODALS/templates/template-linkages'; describe('always-on force and weld UI', () => { beforeEach(async () => { - await TestBed.configureTestingModule({ imports: [AppModule] }).compileComponents(); + await TestBed.configureTestingModule({ + imports: [SettingsPanelComponent, TopBarComponent, EditPanelComponent, TemplatesComponent], + providers: [provideAnimations()], + }).compileComponents(); }); it('does not expose experimental enablement or loop equations actions', () => { @@ -25,9 +28,9 @@ describe('always-on force and weld UI', () => { expect(settingsText).not.toContain('Enable Welded'); expect(settingsText).not.toContain('Equations'); - const toolbar = TestBed.createComponent(ToolbarComponent); - toolbar.detectChanges(); - expect(toolbar.nativeElement.textContent).not.toContain('Equations'); + const topBar = TestBed.createComponent(TopBarComponent); + topBar.detectChanges(); + expect(topBar.nativeElement.textContent).not.toContain('Equations'); }); it('renders weld and force editing controls without enablement flags', () => { @@ -82,9 +85,12 @@ describe('always-on force and weld UI', () => { it('renders every analyzed built-in template as an immediately available action', () => { const templates = TestBed.createComponent(TemplatesComponent); templates.detectChanges(); - expect(templates.nativeElement.querySelectorAll('panel-section').length).toBe( - TEMPLATE_IDS.length - ); + // The library's own grid, not the dev one above it: a development build + // offers a few drawings that exist to exercise the app rather than to + // teach a linkage, and they answer to nothing here. + expect( + templates.nativeElement.querySelectorAll('.linkage-grid:not(.devGrid) panel-section').length + ).toBe(TEMPLATE_IDS.length); const text = templates.nativeElement.textContent; expect(text).toContain('Four Bar Linkage'); expect(text).toContain("Watt's Linkage"); diff --git a/src/app/component/analysis-graph-section/analysis-graph-section.component.html b/src/app/component/analysis-graph-section/analysis-graph-section.component.html new file mode 100644 index 00000000..848cacde --- /dev/null +++ b/src/app/component/analysis-graph-section/analysis-graph-section.component.html @@ -0,0 +1,70 @@ +<!-- Closed, the whole card opens it: the values are as much an invitation to + look closer as the title is, and a reader aiming at either was hitting a + strip only the title lived in. --> +<div + class="graphSection" + [class.open]="expanded()" + [class.closed]="!expanded()" + (click)="expanded() ? null : toggle()" +> + <button + class="graphHeader" + type="button" + (click)="toggle(); $event.stopPropagation()" + [attr.aria-expanded]="expanded()" + > + <span class="graphTitle">{{ label() }}</span> + @if (help) { + <mat-icon class="graphHelp" [matTooltip]="help" matTooltipShowDelay="400" aria-hidden="true" + >help_outline</mat-icon + > + } + <mat-icon class="graphChevron">{{ expanded() ? 'expand_less' : 'expand_more' }}</mat-icon> + </button> + + <!-- One row, two jobs: what each series reads at the pose on screen, and -- + once the graph is open -- which of them the graph is drawing. Closed it + is a read-out; open it is the legend. Two rows saying the same names in + different orders was what the checkboxes and the values came to. --> + @if (preview.length) { + <div class="graphPreview" [class.interactive]="expanded()"> + @for (series of preview; track series.name) { + @if (expanded() && preview.length > 1) { + <button + type="button" + class="previewSeries" + [class.off]="!isShown(series.key)" + [attr.aria-pressed]="isShown(series.key)" + (click)="toggleSeries(series.key)" + > + <span class="previewRule" [style.background]="series.color"></span> + {{ series.name }}: {{ series.text }} + </button> + } @else { + <span class="previewSeries"> + <span class="previewRule" [style.background]="series.color"></span> + @if (series.name) { + {{ series.name }}: + } + {{ series.text }} + </span> + } + } + </div> + } + + <div class="graphBody" [@openClose]="expanded() ? 'open' : 'closed'"> + @if (expanded()) { + <app-analysis-graph + #graph + [analysis]="analysis()" + [analysisType]="analysisType()" + [mechProp]="mechProp()" + [mechPart]="mechPart()" + [reactionLinkId]="reactionLinkId()" + [initialSeries]="shownSeries" + (shownSeriesChange)="adoptSeries($event)" + ></app-analysis-graph> + } + </div> +</div> diff --git a/src/app/component/analysis-graph-section/analysis-graph-section.component.scss b/src/app/component/analysis-graph-section/analysis-graph-section.component.scss new file mode 100644 index 00000000..247efa72 --- /dev/null +++ b/src/app/component/analysis-graph-section/analysis-graph-section.component.scss @@ -0,0 +1,180 @@ +@use 'sass:map'; +@use '@angular/material' as mat; + +@mixin css($theme) { + $color-config: mat.m2-get-color-config($theme); + $primary-palette: map.get($color-config, 'primary'); + + // A card per quantity, as the mock has it: name and value on the left, a + // chevron on the right, and the graph underneath once it is opened. + .graphSection { + position: relative; + border: 1px solid #eceef5; + border-radius: var(--border-radius); + background: var(--card-surface); + // On the panel's own text gutter, so the card's edges line up with the + // paragraph above it rather than running the full width of the panel. + margin: 0 15px 8px; + // Room for the read-out that rides the plot. + // + // Apex draws the value at the playhead in a little bordered box, and at the + // top or the far right of a plot that box sticks out past the plot's own + // frame -- which is a couple of pixels inside this card, so the card was + // cutting the number in half. `clip` with a margin lets exactly that much + // out and no more, so the card still clips its corners and still hides the + // plot while the card is closing. + overflow: clip; + overflow-clip-margin: 30px; + + &.closed { + cursor: pointer; + + &:hover { + background: rgba(0, 0, 0, 0.02); + } + } + } + + // The title and its chevron, in a band with the same air above and below -- + // it was 9px over and 3px under, which read as the title sitting high in its + // own highlight. + .graphHeader { + position: relative; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 38px; + padding: 6px 12px; + border: none; + background: transparent; + font-family: inherit; + text-align: left; + cursor: pointer; + border-radius: var(--border-radius) var(--border-radius) 0 0; + + .graphSection.open &:hover { + background: rgba(0, 0, 0, 0.02); + } + } + + .graphTitle { + flex: 1 1 auto; + min-width: 0; + font-size: 14px; + font-weight: 500; + color: #2c2c2c; + } + + // Three columns of a third each, whatever this graph has to say. An entry's + // place is decided by which series it is, never by what it currently reads + // (a minus sign or an extra digit must not move the switch the reader is + // aiming at) and never by how many series this graph happens to have -- X is + // the first third on every card in the stack, so the columns line up down + // the whole panel. + .graphPreview { + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: center; + gap: 2px 8px; + padding: 0 12px 8px; + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + } + + .previewSeries { + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + border: none; + background: transparent; + font-family: inherit; + font-size: inherit; + color: inherit; + // The hover hitbox every entry wears, interactive or not: the padding and + // the margin cancel, so the text sits where it would with neither -- but + // only if every entry wears both. Worn only by the open card's switches, + // it shifted their columns four pixels left of the closed cards'. + padding: 1px 4px; + margin: -1px -4px; + // Every digit the same width, so the text does not shuffle inside its own + // column either. + font-variant-numeric: tabular-nums; + // The column is the promise; a reading too long for it is allowed to run + // past its own edge rather than push the next switch along. + min-width: 0; + justify-content: flex-start; + } + + // Open, each entry is the switch for its own line. + .graphPreview.interactive .previewSeries { + cursor: pointer; + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + // Off, not gone: the reader has to be able to find it again. + &.off { + color: rgba(0, 0, 0, 0.3); + + .previewRule { + opacity: 0.3; + } + } + } + + .previewRule { + width: 14px; + height: 2.5px; + border-radius: 2px; + flex: 0 0 auto; + } + + .graphHeader .graphChevron { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + font-size: 20px; + line-height: 1; + color: mat.m2-get-color-from-palette($primary-palette, 500); + } + + // Over the header's right end, before the chevron: a question about the + // quantity, not a control of the card. Centred on the title's line, with the + // chevron -- the three of them used to sit at three heights. + // Material gives mat-icon a 24px box whatever the glyph inside it is, so a + // 17px glyph with a 17px line-height sat at the top of it. Centre the glyph + // in the box the box actually is. + // + // Only its size is stated here, which is a matter of the room a graph header + // has. Its ink, its cursor and what it does under the pointer are every help + // mark's, and are said once in `blocks.common`. + .graphHeader .graphHelp { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + font-size: 17px; + line-height: 1; + pointer-events: auto; + } + + // The same gutter the title and the values sit on, so the plot's frame lines + // up with the text above it rather than running wider than the card's own + // margin. + .graphBody { + padding: 0 12px 8px; + // As the card above: the height animation needs this clipped, the playhead + // read-out needs a little of it back. + overflow: clip; + overflow-clip-margin: 30px; + } +} diff --git a/src/app/component/analysis-graph-section/analysis-graph-section.component.ts b/src/app/component/analysis-graph-section/analysis-graph-section.component.ts new file mode 100644 index 00000000..edd1e472 --- /dev/null +++ b/src/app/component/analysis-graph-section/analysis-graph-section.component.ts @@ -0,0 +1,265 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + inject, + input, + output, + viewChild, +} from '@angular/core'; +import { animate, AUTO_STYLE, state, style, transition, trigger } from '@angular/animations'; +import { + AnalysisGraphComponent, + defaultSeriesSelection, + SeriesSelection, +} from '../analysis-graph/analysis-graph.component'; +import { MechanismService } from '../../services/mechanism.service'; +import { SettingsService } from '../../services/settings.service'; +import { AnalysisSampleService } from '../../services/analysis-sample.service'; +import { AngleUnit, LengthUnit } from '../../model/unit-enums'; +import { ANALYSIS_SERIES_COLORS, formatAnalysisValue } from '../../model/analysis-series'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; + +/** One value of one series, as the collapsed header shows it. */ +export interface SeriesPreview { + key: 'x' | 'y' | 'z'; + name: string; + color: string; + text: string; +} + +/** + * One analysable quantity: its name, its value right now, and its graph. + * + * The value is the point of the header. A reader looking for "where is joint B" + * had to open a graph to find out, and the ten graphs a joint and a link offer + * between them are ten things to open. The number is there before the graph is, + * and the graph is what you open when the number is not enough. + */ +@Component({ + selector: 'app-analysis-graph-section', + // The same open and close every other section in the app uses. + animations: [ + trigger('openClose', [ + state('open', style({ visibility: AUTO_STYLE, height: AUTO_STYLE, opacity: '1' })), + state('closed', style({ opacity: '0', height: '0px', padding: '0px' })), + transition(':enter', []), + transition('* => *', [animate('0.15s ease-in-out')]), + ]), + ], + templateUrl: './analysis-graph-section.component.html', + styleUrls: ['./analysis-graph-section.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatIcon, MatTooltip, AnalysisGraphComponent], +}) +export class AnalysisGraphSectionComponent { + private mechanismService = inject(MechanismService); + private settings = inject(SettingsService); + private samples = inject(AnalysisSampleService); + + readonly label = input(''); + @Input() help = ''; + readonly analysis = input(''); + readonly analysisType = input(''); + readonly mechProp = input(''); + readonly mechPart = input(''); + readonly reactionLinkId = input(''); + readonly expanded = input(false); + readonly expandedChange = output<boolean>(); + readonly graph = viewChild<AnalysisGraphComponent>('graph'); + + /** + * What this quantity reads at the pose on screen. + * + * One sample, not the cycle: the header is answering "what is it now", and + * solving every sample of every collapsed graph to answer that would cost + * more than the graphs themselves. + */ + /** + * The last answer, and what it was an answer to. + * + * Reading one sample means solving the mechanism at that pose, and the + * template asks this three times per pass -- to decide whether to draw the + * row, to draw it, and to size the legend. Once per pose is enough. + */ + private previewCache?: { key: string; mechanism: unknown; series: SeriesPreview[] }; + + get preview(): SeriesPreview[] { + const mechanism = this.mechanismFor(); + if (!mechanism) return []; + // This machine's own sample, not the shared clock's: while the machines + // are unsynced they are each somewhere different, and the header has to + // agree with the pose actually on screen. + const at = this.mechanismService.mechanisms.indexOf(mechanism); + const step = Math.min( + at === -1 + ? this.mechanismService.mechanismTimeStep + : this.mechanismService.currentSampleOf(at), + mechanism.joints.length - 1 + ); + const key = [ + step, + this.settings.lengthUnit.value, + this.settings.angleUnit.value, + this.settings.forceUnit.value, + this.analysis(), + this.analysisType(), + this.mechProp(), + this.mechPart(), + this.reactionLinkId(), + ].join('|'); + if (this.previewCache?.key === key && this.previewCache.mechanism === mechanism) { + return this.previewCache.series; + } + const values = this.samples.sampleAt( + mechanism, + Math.max(step, 0), + this.analysis(), + this.analysisType(), + this.mechProp(), + this.mechPart(), + this.reactionLinkId() + ); + const names = this.seriesNames(values.length); + const keys: ('x' | 'y' | 'z')[] = ['x', 'y', 'z']; + const unit = this.unitFor(); + const series = values.map((value, index) => ({ + key: keys[index], + name: names[index], + color: this.colorFor(names[index]), + text: Number.isFinite(value) ? `${formatAnalysisValue(value)} ${unit}`.trim() : '—', + })); + this.previewCache = { key, mechanism, series }; + return series; + } + + /** + * Which series the legend is showing, held here rather than asked of the + * graph. + * + * The graph only exists while the section is open, so asking it during the + * template's own evaluation gave a different answer before and after the + * child was created in that same pass -- which Angular reports as NG0100 + * against the legend's own class. Held here, it also survives the card being + * closed and opened again, which is what a reader who turned a line off + * expects. + * + * Undefined until the reader has an opinion, which is when the graph's own + * default stands: the same default, computed from the same two facts, so the + * legend and the plot agree on the very first frame rather than the legend + * lighting everything up over a plot drawing one line. + */ + private chosen?: SeriesSelection; + + /** + * The default, held rather than made fresh each time it is asked for. + * + * It is handed to the graph as an input, and a new object every pass would + * read as a new value -- which rebuilds the chart, which asks again. + */ + private fallback = defaultSeriesSelection(0, ''); + private fallbackFor = -1; + + get shownSeries(): SeriesSelection { + if (this.chosen) return this.chosen; + const count = this.preview.length; + if (count !== this.fallbackFor) { + this.fallbackFor = count; + this.fallback = defaultSeriesSelection(count, this.analysis()); + } + return this.fallback; + } + + isShown(key: 'x' | 'y' | 'z'): boolean { + return this.shownSeries[key]; + } + + /** + * The graph reporting what it is drawing, which is the legend's own state. + * + * By value, not by object: the graph reports after every rebuild, and taking + * a fresh object each time would change an input it is bound to, which + * rebuilds it, which reports again. + */ + adoptSeries(selection: SeriesSelection): void { + const shown = this.shownSeries; + const same = shown.x === selection.x && shown.y === selection.y && shown.z === selection.z; + this.chosen = same ? shown : selection; + } + + toggleSeries(key: 'x' | 'y' | 'z'): void { + const next = { ...this.shownSeries, [key]: !this.isShown(key) }; + this.chosen = next; + this.graph()?.toggleSeries(key); + } + + /** + * Ask the panel to open or close this section; do not decide it here. + * + * Every use of this component binds `expanded` both ways, so writing the + * field as well as emitting gave the flag two owners: the view was checked + * against the old value from the parent and then changed by the local write + * in the same pass, which is what NG0100 was reporting against the card's + * own open/closed class. + */ + toggle(): void { + this.expandedChange.emit(!this.expanded()); + } + + private mechanismFor() { + const part = + this.mechanismService.joints.find((joint) => joint.id === this.mechPart()) ?? + this.mechanismService.links.find((link) => link.id === this.mechPart()); + return part ? this.mechanismService.mechanismContaining(part) : undefined; + } + + /** + * The names the graph plots these under. + * + * A third series is the magnitude of the first two everywhere except force + * analysis, where it is a component in its own right. + */ + private seriesNames(count: number): string[] { + if (count === 1) return ['']; + if (count === 2) return ['X', 'Y']; + return this.analysis() === 'force' ? ['X', 'Y', 'Z'] : ['X', 'Y', 'Mag']; + } + + private colorFor(name: string): string { + if (name === 'Y') return ANALYSIS_SERIES_COLORS.Y; + if (name === 'Z' || name === 'Mag') return ANALYSIS_SERIES_COLORS.Z; + return ANALYSIS_SERIES_COLORS.X; + } + + /** Spelled the way the graph's own axis spells it. */ + private unitFor(): string { + const length = this.unitStr(this.settings.lengthUnit.value); + const angle = this.unitStr(this.settings.angleUnit.value); + const mechProp = this.mechProp(); + if (mechProp.includes('Angular')) { + if (mechProp.includes('Acc')) return `${angle}/s²`; + if (mechProp.includes('Vel')) return `${angle}/s`; + return angle; + } + if (this.analysis() === 'force') return 'N'; + if (mechProp.includes('Acc')) return `${length}/s²`; + if (mechProp.includes('Vel')) return `${length}/s`; + return length; + } + + private unitStr(unit: LengthUnit | AngleUnit): string { + switch (unit) { + case AngleUnit.RADIAN: + return 'rad'; + case AngleUnit.DEGREE: + return 'deg'; + case LengthUnit.INCH: + return 'in'; + case LengthUnit.METER: + return 'm'; + default: + return 'cm'; + } + } +} diff --git a/src/app/component/analysis-graph/analysis-apex-chart.component.spec.ts b/src/app/component/analysis-graph/analysis-apex-chart.component.spec.ts index e061e4bd..3746342f 100644 --- a/src/app/component/analysis-graph/analysis-apex-chart.component.spec.ts +++ b/src/app/component/analysis-graph/analysis-apex-chart.component.spec.ts @@ -30,7 +30,7 @@ describe('AnalysisApexChartComponent', () => { FakeApexCharts.instances = []; chartWindow.ApexCharts = FakeApexCharts as unknown as typeof ApexCharts; await TestBed.configureTestingModule({ - declarations: [AnalysisApexChartComponent], + imports: [AnalysisApexChartComponent], }).compileComponents(); fixture = TestBed.createComponent(AnalysisApexChartComponent); }); diff --git a/src/app/component/analysis-graph/analysis-apex-chart.component.ts b/src/app/component/analysis-graph/analysis-apex-chart.component.ts index 4fcf6f70..675ea70a 100644 --- a/src/app/component/analysis-graph/analysis-apex-chart.component.ts +++ b/src/app/component/analysis-graph/analysis-apex-chart.component.ts @@ -4,12 +4,13 @@ import { ChangeDetectionStrategy, Component, ElementRef, - Input, NgZone, OnChanges, OnDestroy, SimpleChanges, - ViewChild, + inject, + input, + viewChild, } from '@angular/core'; import ApexCharts, { ApexAnnotations, @@ -47,23 +48,25 @@ declare global { <div #chartHost></div> `, changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, }) export class AnalysisApexChartComponent implements OnChanges, AfterViewInit, OnDestroy { - @Input() annotations?: ApexAnnotations; - @Input() chart?: ApexChart; - @Input() colors?: string[]; - @Input() dataLabels?: ApexDataLabels; - @Input() grid?: ApexGrid; - @Input() legend?: ApexLegend; - @Input() markers?: ApexMarkers; - @Input() series: ApexAxisChartSeries = []; - @Input() stroke?: ApexStroke; - @Input() tooltip?: ApexTooltip; - @Input() xaxis?: ApexXAxis; - @Input() yaxis?: ApexYAxis; - - @ViewChild('chartHost', { static: true }) chartHost!: ElementRef<HTMLElement>; + private zone = inject(NgZone); + private changeDetector = inject(ChangeDetectorRef); + + readonly annotations = input<ApexAnnotations>(); + readonly chart = input<ApexChart>(); + readonly colors = input<string[]>(); + readonly dataLabels = input<ApexDataLabels>(); + readonly grid = input<ApexGrid>(); + readonly legend = input<ApexLegend>(); + readonly markers = input<ApexMarkers>(); + readonly series = input<ApexAxisChartSeries>([]); + readonly stroke = input<ApexStroke>(); + readonly tooltip = input<ApexTooltip>(); + readonly xaxis = input<ApexXAxis>(); + readonly yaxis = input<ApexYAxis>(); + + readonly chartHost = viewChild.required<ElementRef<HTMLElement>>('chartHost'); renderError: string | null = null; chartInstance?: ApexCharts; @@ -72,11 +75,6 @@ export class AnalysisApexChartComponent implements OnChanges, AfterViewInit, OnD private destroyed = false; private updateGeneration = 0; - constructor( - private zone: NgZone, - private changeDetector: ChangeDetectorRef - ) {} - ngOnChanges(_changes: SimpleChanges): void { this.scheduleUpdate(); } @@ -84,15 +82,45 @@ export class AnalysisApexChartComponent implements OnChanges, AfterViewInit, OnD ngAfterViewInit(): void { this.viewInitialized = true; this.scheduleUpdate(); + this.watchWidth(); } ngOnDestroy(): void { this.destroyed = true; this.updateGeneration++; + this.widthWatch?.disconnect(); this.chartInstance?.destroy(); this.chartInstance = undefined; } + private widthWatch?: ResizeObserver; + private renderedWidth = 0; + + /** + * Re-lay the chart out when its container's width changes under it. + * + * Apex measures the host once, at construction, and keeps that width. A chart + * built while the panel was still settling -- which is every chart on the + * first render of a panel -- stayed at the width it was built for, so the + * label at the far end of the axis sat past the card's edge and was clipped. + * Toggling the card rebuilt the chart and it came back right, which is what + * made this look like a rendering bug rather than a measuring one. + */ + private watchWidth(): void { + const host = this.chartHost()?.nativeElement; + if (!host || typeof ResizeObserver === 'undefined') return; + this.renderedWidth = host.clientWidth; + this.widthWatch = new ResizeObserver(() => { + const width = host.clientWidth; + if (!width || width === this.renderedWidth || this.destroyed) return; + this.renderedWidth = width; + this.zone.runOutsideAngular(() => { + this.chartInstance?.updateOptions({ chart: { width } }, false, false); + }); + }); + this.widthWatch.observe(host); + } + updateOptions( options: object, redrawPaths?: boolean, @@ -136,7 +164,7 @@ export class AnalysisApexChartComponent implements OnChanges, AfterViewInit, OnD throw new Error('The chart renderer did not load.'); } this.chartInstance = this.zone.runOutsideAngular( - () => new ApexChartsConstructor(this.chartHost.nativeElement, options) + () => new ApexChartsConstructor(this.chartHost().nativeElement, options) ); await this.zone.runOutsideAngular(() => this.chartInstance!.render()); } @@ -155,18 +183,18 @@ export class AnalysisApexChartComponent implements OnChanges, AfterViewInit, OnD private options(): ApexCharts.ApexOptions { const options: ApexCharts.ApexOptions = { - annotations: this.annotations, - chart: this.chart, - colors: this.colors, - dataLabels: this.dataLabels, - grid: this.grid, - legend: this.legend, - markers: this.markers, - series: this.series, - stroke: this.stroke, - tooltip: this.tooltip, - xaxis: this.xaxis, - yaxis: this.yaxis, + annotations: this.annotations(), + chart: this.chart(), + colors: this.colors(), + dataLabels: this.dataLabels(), + grid: this.grid(), + legend: this.legend(), + markers: this.markers(), + series: this.series(), + stroke: this.stroke(), + tooltip: this.tooltip(), + xaxis: this.xaxis(), + yaxis: this.yaxis(), }; // ApexCharts treats an explicitly present `undefined` as an override of its default. Safari // then fails inside configuration reads such as `markers.size`. Match ng-apexcharts' contract diff --git a/src/app/component/analysis-graph/analysis-graph.component.html b/src/app/component/analysis-graph/analysis-graph.component.html index ec0d4448..e508470a 100644 --- a/src/app/component/analysis-graph/analysis-graph.component.html +++ b/src/app/component/analysis-graph/analysis-graph.component.html @@ -1,61 +1,30 @@ +@if (analysisGap; as gap) { + <div + class="analysis-gap" + role="status" + title="Those are toggle positions: reactions grow without bound as the mechanism approaches them, and the chart leaves a gap rather than inventing a number." + > + <mat-icon class="gapIcon">warning_amber</mat-icon> + <span>No solution at {{ gap.failed }} of {{ gap.total }} positions</span> + <button class="gapShow" (click)="showGapPosition()">{{ gapShowLabel }}</button> + </div> +} @if (analysisDiagnostic) { <div class="analysis-diagnostic" role="status">{{ analysisDiagnostic }}</div> } @else { - @if (numberOfSeries === 3) { - <div id="graph-checkbox-list-three" [formGroup]="seriesCheckboxForm"> - <mat-checkbox class="series-z" formControlName="z" (change)="applySeriesVisibility()" - >Magnitude</mat-checkbox - > - <mat-checkbox - class="series-x" - formControlName="x" - color="primary" - (change)="applySeriesVisibility()" - >X</mat-checkbox - > - <mat-checkbox - class="series-y" - formControlName="y" - color="warn" - (change)="applySeriesVisibility()" - >Y</mat-checkbox - > + <!-- In place of the chart rather than over it: these used to be fixed-height + overlays pinned 100px down the card, which on a short graph covered the + card below and on a tall one floated in the middle of nothing. --> + @if (noDataSelected || loading) { + <div class="graphPlaceholder" role="status"> + {{ noDataSelected ? 'No series shown. Pick one above.' : 'Graph paused while dragging.' }} </div> } - @if (numberOfSeries === 2) { - <div id="graph-checkbox-list-two" [formGroup]="seriesCheckboxForm"> - <mat-checkbox - class="series-x" - formControlName="x" - color="primary" - (change)="applySeriesVisibility()" - >X</mat-checkbox - > - <mat-checkbox - class="series-y" - formControlName="y" - color="warn" - (change)="applySeriesVisibility()" - >Y</mat-checkbox - > - </div> - } - <div - [@showHide]="noDataSelected ? 'graphHidden' : 'graphShown'" - [class.compact-single-series]="compactSingleSeries" - id="graphHide" - > - Please select at least one data series. - </div> - <div - [@showHide]="loading ? 'graphHidden' : 'graphShown'" - [class.compact-single-series]="compactSingleSeries" - id="graphHide" - > - Graph unavailable while dragging. - </div> <app-analysis-apex-chart #chart + [class.hidden]="noDataSelected || loading" + role="img" + [attr.aria-label]="graphSummary" [class.compact-single-series]="compactSingleSeries" [series]="displayedSeries" [annotations]="chartOptions.annotations!" @@ -71,9 +40,7 @@ [legend]="chartOptions.legend!" > </app-analysis-apex-chart> - - <button id="csvDownload" (click)="downloadCSV()" mat-stroked-button color="primary"> - <mat-icon>download</mat-icon> - Download Data (CSV) - </button> + <!-- The numbers behind every plot leave together, from Export Data in the top + strip: a button under each graph meant one file per plot, each with its + own time column to line up by hand. --> } diff --git a/src/app/component/analysis-graph/analysis-graph.component.scss b/src/app/component/analysis-graph/analysis-graph.component.scss index 1e316310..ef61ca9e 100644 --- a/src/app/component/analysis-graph/analysis-graph.component.scss +++ b/src/app/component/analysis-graph/analysis-graph.component.scss @@ -11,110 +11,73 @@ $background: map.get($theme, background); $foreground: map.get($theme, foreground); - #graph-checkbox-list-three { + // Stands where the chart would, at the chart's own height. + // + // Shorter, it was: turning the last series off shrank the card by ninety + // pixels, and in a panel scrolled to the bottom the browser clamps the scroll + // to the shorter content and does not put it back -- so a click on the legend + // of the last graph scrolled the panel out from under the pointer. + .graphPlaceholder { display: flex; - flex-direction: row; - justify-content: space-between; align-items: center; - padding-left: 4px; - padding-right: 10px; - margin-top: -10px; - margin-bottom: -25px; + justify-content: center; + min-height: 244px; + margin: 4px 0 8px; + border: 1px dashed #dfe1ea; + border-radius: var(--border-radius); + background: #fafbfd; + color: rgba(0, 0, 0, 0.5); + font-size: 13px; + text-align: center; + padding: 0 16px; } - #graph-checkbox-list-two { - display: flex; - flex-direction: row; - justify-content: start; - align-items: center; - gap: 20px; - padding-left: 4px; - padding-right: 10px; - margin-top: -10px; - margin-bottom: -25px; + app-analysis-apex-chart.hidden { + display: none; } - #graph-checkbox-list-three, - #graph-checkbox-list-two { - // The negative bottom margin above pulls the chart up into this row, so the - // chart's SVG paints over the labels and swallows clicks meant for them. - // Lifting the row restores the label as a hit target for its checkbox. - position: relative; - z-index: 1; - - .mdc-form-field { - align-items: center; - } - - .mdc-label { - line-height: 20px; - } - - .series-x { - --mat-checkbox-selected-focus-icon-color: #313aa7; - --mat-checkbox-selected-hover-icon-color: #313aa7; - --mat-checkbox-selected-icon-color: #313aa7; - --mat-checkbox-selected-pressed-icon-color: #313aa7; - --mat-checkbox-selected-focus-state-layer-color: #313aa7; - --mat-checkbox-selected-hover-state-layer-color: #313aa7; - --mat-checkbox-selected-pressed-state-layer-color: #313aa7; - } - - .series-y { - --mat-checkbox-selected-focus-icon-color: #ea2b29; - --mat-checkbox-selected-hover-icon-color: #ea2b29; - --mat-checkbox-selected-icon-color: #ea2b29; - --mat-checkbox-selected-pressed-icon-color: #ea2b29; - --mat-checkbox-selected-focus-state-layer-color: #ea2b29; - --mat-checkbox-selected-hover-state-layer-color: #ea2b29; - --mat-checkbox-selected-pressed-state-layer-color: #ea2b29; - } - - .series-z { - --mat-checkbox-selected-focus-icon-color: #fdb50e; - --mat-checkbox-selected-hover-icon-color: #fdb50e; - --mat-checkbox-selected-icon-color: #fdb50e; - --mat-checkbox-selected-pressed-icon-color: #fdb50e; - --mat-checkbox-selected-focus-state-layer-color: #fdb50e; - --mat-checkbox-selected-hover-state-layer-color: #fdb50e; - --mat-checkbox-selected-pressed-state-layer-color: #fdb50e; - } + // A single-series graph is shorter, not lifted: the -40px here closed the gap + // the old checkbox row left behind, and with that row gone it pulled the plot + // up over the values above it. + // + // The floor is the height the chart draws itself at. Apex empties its host + // for an instant while it re-lays a chart out, and a host that collapses to + // nothing takes the panel's scroll position with it. + app-analysis-apex-chart { + display: block; + min-height: 250px; } - #shift-up { - margin-top: -100px; - background-color: red; + // The chart draws itself 250px tall; a host shorter than that does not make + // a shorter chart, it cuts the bottom off one -- which is where the time + // axis's label lives. + app-analysis-apex-chart > div:not(.chart-render-error) { + min-height: 0 !important; + height: auto !important; } - #graphHide { - background-color: rgb(255, 255, 255); - position: absolute; - top: 100px; - left: 0; - width: 100%; - height: 240px; - z-index: 100; - display: flex; - justify-content: center; - align-items: start; - color: black; - border-radius: var(--border-radius); - pointer-events: none; - padding-top: 80px; - - &.compact-single-series { - top: 60px; - } + // Nothing below the plot but the axis it belongs to. + .apexcharts-canvas { + margin-bottom: -6px; } - app-analysis-apex-chart.compact-single-series { - display: block; - margin-top: -40px; + // Apex clips at its own SVG, which sits a few pixels inside the card -- so + // the read-out box that rides the playhead lost its right-hand edge near the + // end of a cycle, and its top at the peak of a curve. The series are clipped + // by a mask of their own rather than by this, so letting the SVG overflow + // frees the labels and moves nothing else. The card outside it still clips, + // with a margin wide enough to hold a label and no more. + .apexcharts-svg { + overflow: visible !important; } - app-analysis-apex-chart > div:not(.chart-render-error) { - min-height: 0 !important; - height: 225px !important; + // A container Apex leaves in the DOM with a border on it whether or not there + // is anything to say. Empty, it reads as a stray box floating over the plot, + // and half of one while the card is opening. + .apexcharts-tooltip:not(.apexcharts-active), + .apexcharts-xaxistooltip:not(.apexcharts-active), + .apexcharts-yaxistooltip:not(.apexcharts-active) { + display: none !important; } .chart-render-error { @@ -127,37 +90,48 @@ color: mat.m2-get-color-from-palette($primary-palette, 900); } - #csvDownload { - width: 100%; - border-color: mat.m2-get-color-from-palette($primary-palette, 500) !important; - margin-top: 6px; - margin-bottom: 10px; - - .mdc-button__label { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - line-height: 20px; - white-space: nowrap; - } - - .mat-icon { - margin: 0; - height: 20px; - width: 20px; - font-size: 20px; - line-height: 20px; - } - } - + // Sized like its siblings — the gap strip above the chart and the + // placeholder that stands in for one. Left to inherit, this spoke at the + // panel's base size out of a 16px-padded box, and read as a headline + // rather than as one more quiet note about the chart. .analysis-diagnostic { - margin: 16px 4px 12px; - padding: 16px; + margin: 4px 0 8px; + padding: 8px 12px; border: 1px solid mat.m2-get-color-from-palette($primary-palette, 200); border-radius: var(--border-radius); background: mat.m2-get-color-from-palette($primary-palette, 50); color: mat.m2-get-color-from-palette($primary-palette, 900); - line-height: 1.4; + font-size: 13px; + line-height: 1.5; + } +} + +.analysis-gap { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0 8px; + padding: 6px 10px; + border-radius: 6px; + background: #fdf3dd; + color: #7a5600; + font-size: 12.5px; + + .gapIcon { + font-size: 16px; + width: 16px; + height: 16px; + } + + .gapShow { + margin-left: auto; + border: none; + background: none; + color: #7a5600; + font: inherit; + font-weight: 600; + cursor: pointer; + padding: 0; + text-decoration: underline; } } diff --git a/src/app/component/analysis-graph/analysis-graph.component.spec.ts b/src/app/component/analysis-graph/analysis-graph.component.spec.ts index 49b8eea9..771a7e40 100644 --- a/src/app/component/analysis-graph/analysis-graph.component.spec.ts +++ b/src/app/component/analysis-graph/analysis-graph.component.spec.ts @@ -1,4 +1,5 @@ -import { Component, Input, NO_ERRORS_SCHEMA } from '@angular/core'; +import { Component, NO_ERRORS_SCHEMA, input } from '@angular/core'; +import { AnalysisApexChartComponent } from './analysis-apex-chart.component'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; @@ -11,11 +12,12 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ApexAxisChartSeries } from 'apexcharts'; import { RealJoint } from '../../model/joint'; import { RealLink } from '../../model/link'; -import { ForceSolver } from '../../model/mechanism/force-solver'; +import { ForceAnalysisSeries, ForceSolver } from '../../model/mechanism/force-solver'; import { KinematicsSolver } from '../../model/mechanism/kinematic-solver'; import { ForceUnit, LengthUnit } from '../../model/unit-enums'; import { NumberUnitParserService } from '../../services/number-unit-parser.service'; import { ActiveObjService } from '../../services/active-obj.service'; +import { AnalysisSampleService } from '../../services/analysis-sample.service'; import { MechanismService } from '../../services/mechanism.service'; import { SettingsService } from '../../services/settings.service'; import { BUILT_IN_TEMPLATE_IDS, TEMPLATE_LINKAGES } from '../MODALS/templates/template-linkages'; @@ -30,26 +32,28 @@ import { ANALYSIS_SERIES_COLORS, AnalysisGraphComponent, formatTimeLabel, + niceAxisScale, } from './analysis-graph.component'; +import { formatAnalysisValue } from '../../model/analysis-series'; +import { withTestInjector } from '../../../test-utils/mechanism-harness'; @Component({ selector: 'app-analysis-apex-chart', template: '', - standalone: false, }) class ApexChartStubComponent { - @Input() series: ApexAxisChartSeries = []; - @Input() annotations: unknown; - @Input() chart: unknown; - @Input() colors: unknown; - @Input() yaxis: unknown; - @Input() dataLabels: unknown; - @Input() markers: unknown; - @Input() stroke: unknown; - @Input() grid: unknown; - @Input() xaxis: unknown; - @Input() tooltip: unknown; - @Input() legend: unknown; + readonly series = input<ApexAxisChartSeries>([]); + readonly annotations = input<unknown>(); + readonly chart = input<unknown>(); + readonly colors = input<unknown>(); + readonly yaxis = input<unknown>(); + readonly dataLabels = input<unknown>(); + readonly markers = input<unknown>(); + readonly stroke = input<unknown>(); + readonly grid = input<unknown>(); + readonly xaxis = input<unknown>(); + readonly tooltip = input<unknown>(); + readonly legend = input<unknown>(); addPointAnnotation = vi.fn(); addXaxisAnnotation = vi.fn(); @@ -77,12 +81,15 @@ const PRODUCTION_FIXTURES = [ ]; function createComponent(fixture: MechanismFixture): AnalysisGraphComponent { - return new AnalysisGraphComponent( - new FormBuilder(), - fixture.service, - fixture.settings, - new NumberUnitParserService(), - fixture.active + return withTestInjector( + [ + { provide: FormBuilder, deps: [] }, + { provide: MechanismService, useValue: fixture.service }, + { provide: SettingsService, useValue: fixture.settings }, + { provide: NumberUnitParserService, deps: [] }, + { provide: AnalysisSampleService, deps: [] }, + ], + () => new AnalysisGraphComponent() ); } @@ -113,6 +120,36 @@ function expectNumericSeries( } } +/** + * A partially failed force series is rare but real: the square-rod tangency + * slider-crank (fixture gallery) goes singular at the one sampled frame where + * the rod stands square to its guide, and a mechanism drawn exactly at a dead + * pose fails at frame 0 (square-rod-tangency.spec.ts pins the real case). + * This spec fabricates the failure instead, the way `analyzeFrame`'s + * `empty('singular')` fails a frame, so the rendering assertions stay + * deterministic and independent of solver behavior. + */ +function failForceFrames( + fixture: MechanismFixture, + mode: 'static' | 'dynamic', + indices: number[] +): ForceAnalysisSeries { + const series = fixture.mechanism.getForceAnalysis(mode); + for (const index of indices) { + const frame = series.frames[index]; + frame.status = 'singular'; + frame.jointReactionsByLink = new Map(); + frame.jointReactions = new Map(); + frame.guideCouples = new Map(); + frame.inputEffort = undefined; + frame.rank = 0; + frame.residual = Number.POSITIVE_INFINITY; + frame.message = ForceSolver.statusMessage('singular'); + } + series.successfulFrames = series.frames.filter((frame) => frame.status === 'ok').length; + return series; +} + describe('AnalysisGraphComponent production fixtures', () => { beforeAll(() => vi.spyOn(console, 'log').mockImplementation(() => undefined)); afterAll(() => vi.restoreAllMocks()); @@ -202,21 +239,6 @@ describe('AnalysisGraphComponent production fixtures', () => { analyze.mockRestore(); }); - it('uses mechanism timestamps identically in chart points and CSV output', () => { - const fixture = buildMechanismFixture(TEMPLATE_LINKAGES['4-Bar']); - const component = createComponent(fixture); - const input = fixture.mechanism.joints[0].find( - (joint): joint is RealJoint => joint instanceof RealJoint && joint.input - )!; - component.determineChart('force', 'dynamic', 'Input Effort', input.id); - - const csvRows = component.buildCSVContent().trim().split('\n').slice(1); - expect(csvRows).toHaveLength(fixture.mechanism.timeNum.length); - csvRows.forEach((row, index) => { - expect(Number(row.split(',')[0])).toBeCloseTo(fixture.mechanism.timeNum[index], 12); - }); - }); - it('produces mode-dependent dynamic input effort', () => { const fixture = buildMechanismFixture(TEMPLATE_LINKAGES['4-Bar']); const component = createComponent(fixture); @@ -240,7 +262,7 @@ describe('AnalysisGraphComponent production fixtures', () => { const component = createComponent(fixture); component.determineChart('force', 'static', 'Joint Forces', 'B'); - expect(component.analysisDiagnostic).toContain('internal to one welded body'); + expect(component.analysisDiagnostic).toContain('Only one part meets this joint'); expect( component.chartOptions.series!.every((series) => (series.data as unknown as Array<{ y: number | null }>).every((point) => point.y === null) @@ -248,6 +270,64 @@ describe('AnalysisGraphComponent production fixtures', () => { ).toBe(true); }); + it('counts the positions with no solution and can stand the mechanism at the first', () => { + const fixture = buildMechanismFixture(TEMPLATE_LINKAGES['4-Bar']); + const component = createComponent(fixture); + const ground = fixture.mechanism.joints[0].find( + (candidate): candidate is RealJoint => candidate instanceof RealJoint && candidate.ground + )!; + const series = failForceFrames(fixture, 'static', [1, 4]); + + component.determineChart('force', 'static', 'Joint Forces', ground.id); + + expect(component.analysisGap).toEqual({ + failed: 2, + total: series.frames.length, + firstSeconds: series.frames[1].timeSeconds, + mechIndex: 0, + }); + // A hole in a good series, not a failure of the whole series. + expect(component.analysisDiagnostic).toBeNull(); + const points = component.chartOptions.series![0].data as unknown as Array<{ + y: number | null; + }>; + expect(points[1].y).toBeNull(); + expect(points[4].y).toBeNull(); + expect(Number.isFinite(points[0].y ?? Number.NaN)).toBe(true); + + // Several holes: the button names the one it goes to, which is the first. + expect(component.gapShowLabel).toBe( + `Show first (${formatTimeLabel(series.frames[1].timeSeconds)} s)` + ); + + const seek = vi.fn(); + const animate = vi.fn(); + const togglePlaying = vi.fn(); + Object.assign(fixture.service, { + seekMechanism: seek, + animate, + isPlaying: true, + isMechanismPlaying: () => true, + toggleMechanismPlaying: togglePlaying, + }); + component.showGapPosition(); + // Pressed mid-animation, the button pauses playback first -- a seek that + // kept playing would sail straight past the pose it names. + expect(animate).toHaveBeenCalledWith(fixture.service.mechanismTimeStep, false); + expect(togglePlaying).toHaveBeenCalledWith(0); + expect(seek).toHaveBeenCalledWith(0, series.frames[1].timeSeconds); + + // A fully solved series takes the banner down... + component.determineChart('force', 'dynamic', 'Joint Forces', ground.id); + expect(component.analysisGap).toBeNull(); + + // ...and so does leaving force analysis for a kinematic graph. + component.determineChart('force', 'static', 'Joint Forces', ground.id); + expect(component.analysisGap).not.toBeNull(); + component.determineChart('kinematic', 'loop', 'Linear Joint Pos', ground.id); + expect(component.analysisGap).toBeNull(); + }); + it('says the analysis failed rather than showing an empty series selection', () => { // A solver that throws leaves chartOptions.series unassigned, and the // template read that as nothing ticked -- "Please select at least one data @@ -297,7 +377,6 @@ describe('AnalysisGraphComponent production fixtures', () => { ).y; expect(component.chartOptions.yaxis!.title!.text).toBe('Force (lbf)'); expect(lbfValue).toBeCloseTo(newtonValue * 0.22480894387096, 3); - expect(component.buildCSVContent().split('\n')[0]).toContain('(lbf)'); }); }); @@ -348,7 +427,11 @@ describe('AnalysisGraphComponent lifecycle', () => { expect(component.noDataSelected).toBe(true); component.seriesCheckboxForm.patchValue({ x: true, y: false, z: false }); + // The broadcast says something moved; the graph asks its own machine where + // it is, because with several machines the shared index means nothing to + // any one of them. const annotationIndex = Math.min(2, fixture.mechanism.timeNum.length - 1); + fixture.service.mechanismTimeStep = annotationIndex; fixture.service.onMechPositionChange.next(annotationIndex); expect(chart.addXaxisAnnotation).toHaveBeenLastCalledWith( expect.objectContaining({ x: fixture.mechanism.timeNum[annotationIndex] }), @@ -442,16 +525,19 @@ describe('AnalysisGraphComponent lifecycle', () => { describe('AnalysisGraphComponent rendered controls', () => { afterEach(() => TestBed.resetTestingModule()); - it('updates the rendered chart series when its Material checkboxes are clicked', async () => { - const fixtureData = buildMechanismFixture(TEMPLATE_LINKAGES['4-Bar']); + async function mountGraph( + fixtureData: MechanismFixture, + inputs: { analysis: string; analysisType: string; mechProp: string; mechPart: string } + ): Promise<ComponentFixture<AnalysisGraphComponent>> { await TestBed.configureTestingModule({ - declarations: [AnalysisGraphComponent, ApexChartStubComponent], imports: [ ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatIconModule, NoopAnimationsModule, + AnalysisGraphComponent, + ApexChartStubComponent, ], providers: [ { provide: MechanismService, useValue: fixtureData.service }, @@ -460,42 +546,63 @@ describe('AnalysisGraphComponent rendered controls', () => { { provide: ActiveObjService, useValue: fixtureData.active }, ], schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); + }) + // The standalone graph brings the real apex chart; these tests assert on + // the stub's recorded inputs, so it takes the chart's place. + .overrideComponent(AnalysisGraphComponent, { + remove: { imports: [AnalysisApexChartComponent] }, + add: { imports: [ApexChartStubComponent] }, + }) + .compileComponents(); const fixture: ComponentFixture<AnalysisGraphComponent> = TestBed.createComponent(AnalysisGraphComponent); - fixture.componentRef.setInput('analysis', 'kinematic'); - fixture.componentRef.setInput('analysisType', 'loop'); - fixture.componentRef.setInput('mechProp', 'Linear Joint Pos'); - fixture.componentRef.setInput('mechPart', 'B'); + fixture.componentRef.setInput('analysis', inputs.analysis); + fixture.componentRef.setInput('analysisType', inputs.analysisType); + fixture.componentRef.setInput('mechProp', inputs.mechProp); + fixture.componentRef.setInput('mechPart', inputs.mechPart); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); + return fixture; + } + + it('updates the rendered chart series when a series is switched off and on', async () => { + const fixtureData = buildMechanismFixture(TEMPLATE_LINKAGES['4-Bar']); + const fixture = await mountGraph(fixtureData, { + analysis: 'kinematic', + analysisType: 'loop', + mechProp: 'Linear Joint Pos', + mechPart: 'B', + }); - const loader = TestbedHarnessEnvironment.loader(fixture); - const xCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'X' })); - const yCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'Y' })); - expect(await xCheckbox.isChecked()).toBe(true); - expect(await yCheckbox.isChecked()).toBe(true); + // The switches live on the panel header that owns this graph now -- one row + // that reads the values out and turns the lines on and off -- so this drives + // the API that row calls rather than a checkbox that no longer exists. + const graph = fixture.componentInstance; + expect(graph.isSeriesShown('x')).toBe(true); + expect(graph.isSeriesShown('y')).toBe(true); - await xCheckbox.uncheck(); + graph.toggleSeries('x'); fixture.detectChanges(); - expect(fixture.componentInstance.displayedSeries.map((series) => series.name)).toEqual(['Y']); + expect(graph.isSeriesShown('x')).toBe(false); + expect(graph.displayedSeries.map((series) => series.name)).toEqual(['Y']); - await yCheckbox.uncheck(); + graph.toggleSeries('y'); fixture.detectChanges(); - expect(fixture.componentInstance.displayedSeries).toHaveLength(0); - expect(fixture.componentInstance.noDataSelected).toBe(true); - expect(fixture.nativeElement.textContent).toContain('Please select at least one data series.'); + expect(graph.displayedSeries).toHaveLength(0); + expect(graph.noDataSelected).toBe(true); + expect(fixture.nativeElement.textContent).toContain('No series shown. Pick one above.'); - await xCheckbox.check(); + graph.toggleSeries('x'); fixture.detectChanges(); - expect(fixture.componentInstance.displayedSeries.map((series) => series.name)).toEqual(['X']); - expect(fixture.componentInstance.noDataSelected).toBe(false); + expect(graph.displayedSeries.map((series) => series.name)).toEqual(['X']); + expect(graph.noDataSelected).toBe(false); const chartStub = fixture.debugElement.query(By.directive(ApexChartStubComponent)) .componentInstance as ApexChartStubComponent; const annotationIndex = Math.min(3, fixtureData.mechanism.timeNum.length - 1); + fixtureData.service.mechanismTimeStep = annotationIndex; fixtureData.service.onMechPositionChange.next(annotationIndex); expect(chartStub.addXaxisAnnotation).toHaveBeenLastCalledWith( expect.objectContaining({ x: fixtureData.mechanism.timeNum[annotationIndex] }), @@ -503,6 +610,38 @@ describe('AnalysisGraphComponent rendered controls', () => { ); fixture.destroy(); }); + + it('renders the no-solution banner, and Show them drives the mechanism there', async () => { + const fixtureData = buildMechanismFixture(TEMPLATE_LINKAGES['4-Bar']); + const series = failForceFrames(fixtureData, 'static', [2]); + const seek = vi.fn(); + Object.assign(fixtureData.service, { + seekMechanism: seek, + isPlaying: false, + isMechanismPlaying: () => false, + }); + const ground = fixtureData.mechanism.joints[0].find( + (candidate): candidate is RealJoint => candidate instanceof RealJoint && candidate.ground + )!; + + const fixture = await mountGraph(fixtureData, { + analysis: 'force', + analysisType: 'static', + mechProp: 'Joint Forces', + mechPart: ground.id, + }); + + const banner = fixture.nativeElement.querySelector('.analysis-gap') as HTMLElement | null; + expect(banner).not.toBeNull(); + expect(banner!.textContent).toContain(`No solution at 1 of ${series.frames.length} positions`); + + const showButton = banner!.querySelector('.gapShow') as HTMLButtonElement; + // One hole: singular wording, no time needed. + expect(showButton.textContent!.trim()).toBe('Show position'); + showButton.click(); + expect(seek).toHaveBeenCalledWith(0, series.frames[2].timeSeconds); + fixture.destroy(); + }); }); describe('formatTimeLabel', () => { @@ -524,3 +663,57 @@ describe('formatTimeLabel', () => { expect(formatTimeLabel(Number.POSITIVE_INFINITY)).toBe(''); }); }); + +describe('formatAnalysisValue', () => { + it('prints two decimals, not the seventeen the solver hands over', () => { + expect(formatAnalysisValue(2.6179937801901527)).toBe('2.62'); + expect(formatAnalysisValue(-2.616398996121022)).toBe('-2.62'); + expect(formatAnalysisValue(5)).toBe('5.00'); + expect(formatAnalysisValue(0)).toBe('0.00'); + }); + + it('keeps a small value from reading as zero', () => { + expect(formatAnalysisValue(0.0009136589194680075)).toBe('0.00091'); + expect(formatAnalysisValue(-0.0012)).toBe('-0.0012'); + }); + + it('renders nothing for a value that has none', () => { + expect(formatAnalysisValue(Number.NaN)).toBe(''); + expect(formatAnalysisValue(Number.POSITIVE_INFINITY)).toBe(''); + }); +}); + +describe('niceAxisScale', () => { + it('puts every gridline on a round number, zero among them', () => { + const scale = niceAxisScale(-1.1, 2.0)!; + const step = (scale.max - scale.min) / scale.tickAmount; + // Zero is a whole number of steps from the bottom, so it is a gridline. + expect(Math.abs(-scale.min / step - Math.round(-scale.min / step))).toBeLessThan(1e-9); + expect(scale.min).toBeLessThanOrEqual(-1.1); + expect(scale.max).toBeGreaterThanOrEqual(2.0); + }); + + it('gives a constant series a range to be constant in', () => { + // The speed of a four-bar's crank pin, which never changes. Fitted to its + // own spread the axis was 1.5e-6 wide and the line was lost in the noise. + const scale = niceAxisScale(6.19888, 6.198882)!; + expect(scale.min).toBe(0); + expect(scale.max).toBeGreaterThanOrEqual(6.198882); + expect(scale.max - scale.min).toBeGreaterThan(1); + }); + + it('does the same for a constant below zero', () => { + const scale = niceAxisScale(-3.4, -3.4)!; + expect(scale.max).toBe(0); + expect(scale.min).toBeLessThanOrEqual(-3.4); + }); + + it('gives a series that is flat at zero a range of its own', () => { + expect(niceAxisScale(0, 0)).toEqual({ min: -1, max: 1, tickAmount: 2 }); + }); + + it('declines an axis it cannot compute', () => { + expect(niceAxisScale(Number.NaN, 1)).toBeUndefined(); + expect(niceAxisScale(2, 1)).toBeUndefined(); + }); +}); diff --git a/src/app/component/analysis-graph/analysis-graph.component.ts b/src/app/component/analysis-graph/analysis-graph.component.ts index dc312097..32c5ae95 100644 --- a/src/app/component/analysis-graph/analysis-graph.component.ts +++ b/src/app/component/analysis-graph/analysis-graph.component.ts @@ -1,3 +1,4 @@ +import { MatIcon } from '@angular/material/icon'; import { AfterViewInit, Component, @@ -8,10 +9,14 @@ import { SimpleChanges, ViewChild, ChangeDetectionStrategy, + inject, + output, } from '@angular/core'; import { ApexAnnotations, ApexAxisChartSeries, + ApexChart, + ApexGrid, ApexDataLabels, ApexFill, ApexLegend, @@ -23,23 +28,28 @@ import { ApexYAxis, } from 'apexcharts'; import { KinematicsSolver } from 'src/app/model/mechanism/kinematic-solver'; +import { + ANALYSIS_SERIES_COLORS, + angularScale, + formatAnalysisValue, +} from 'src/app/model/analysis-series'; +export { ANALYSIS_SERIES_COLORS }; import { ForceAnalysisMode } from 'src/app/model/mechanism/force-solver'; -import { AngleUnit, ForceUnit, LengthUnit, roundNumber } from '../../model/utils'; -import { LBF_IN_PER_NEWTON_METER, LBF_PER_NEWTON } from '../../model/unit-conversions'; -import { MODEL_SCALE } from '../../model/render-scale'; +import { Mechanism } from 'src/app/model/mechanism/mechanism'; +import { AngleUnit, ForceUnit, LengthUnit } from '../../model/utils'; import { animate, state, style, transition, trigger } from '@angular/animations'; import { FormBuilder } from '@angular/forms'; import { MechanismService } from '../../services/mechanism.service'; import { SettingsService } from '../../services/settings.service'; import { NumberUnitParserService } from '../../services/number-unit-parser.service'; -import { ActiveObjService } from '../../services/active-obj.service'; +import { AnalysisSampleService } from '../../services/analysis-sample.service'; import { skip, Subscription } from 'rxjs'; import { AnalysisApexChartComponent } from './analysis-apex-chart.component'; export type ChartOptions = { annotations: ApexAnnotations; series: ApexAxisChartSeries; - chart: any; //ApexChart; + chart: ApexChart; dataLabels: ApexDataLabels; markers: ApexMarkers; title: ApexTitleSubtitle; @@ -48,18 +58,11 @@ export type ChartOptions = { xaxis: ApexXAxis; tooltip: ApexTooltip; stroke: ApexStroke; - grid: any; //ApexGrid; - colors: any; - toolbar: any; + grid: ApexGrid; + colors: string[]; legend: ApexLegend; }; -export const ANALYSIS_SERIES_COLORS = { - X: '#313aa7', - Y: '#ea2b29', - Z: '#fdb50e', -} as const; - /** * Time axis labels: a typical cycle runs 0-3 s, where "3.000" is noise. Cap at * three decimals and drop the ones that carry no information. @@ -70,6 +73,82 @@ export function formatTimeLabel(value: number): string { return Number(rounded.toFixed(3)).toString(); } +/** + * The two ends of the time axis, to one decimal. + * + * They read beside the y axis, which is also one decimal, and "0" against + * "6.0" looks like two different kinds of number. + */ +export function formatAxisEnd(value: number): string { + const rounded = Number(value); + if (!Number.isFinite(rounded)) return ''; + return rounded.toFixed(1); +} + +/** Apex's own fallback, kept by name so the axis can be handed back to it. */ +export const FLOOR_OF = (min: number) => Math.floor(min); +export const CEIL_OF = (max: number) => Math.ceil(max); + +/** + * A y axis whose gridlines land on round numbers -- zero among them. + * + * Both limits are whole multiples of one step, so every line between them is + * too. + * + * A series that never changes gets the range from zero to its own value. It has + * no range of its own to divide, and what little it has is floating-point + * noise: the speed of a four-bar's crank pin is constant, and fitting the axis + * to it gave a window 1.5e-6 wide, in which every label read "6.2" and the line + * wandered across the full height of the plot as rounding error. Against zero + * it reads as what it is -- a constant, at a height the axis can be read off -- + * and zero is the comparison a reader wants of a constant anyway. + */ +export function niceAxisScale( + low: number, + high: number +): { min: number; max: number; tickAmount: number } | undefined { + if (!Number.isFinite(low) || !Number.isFinite(high) || high < low) return undefined; + if (high - low <= 1e-6 * Math.max(Math.abs(low), Math.abs(high))) { + // Nothing to plot against but zero, and a series at zero has not even that. + if (low === 0 && high === 0) return { min: -1, max: 1, tickAmount: 2 }; + return niceAxisScale(Math.min(0, low), Math.max(0, high)); + } + const magnitude = Math.pow(10, Math.floor(Math.log10((high - low) / 4))); + const step = + [1, 2, 2.5, 5, 10] + .map((factor) => factor * magnitude) + .find((size) => size >= (high - low) / 4) ?? 10 * magnitude; + // Away from binary noise: 0.1 + 0.2 arithmetic leaves limits like -1.0000000000000002, + // which Apex prints in full. + const round = (value: number) => Number(value.toPrecision(12)); + const min = round(Math.floor(low / step) * step); + const max = round(Math.ceil(high / step) * step); + return { min, max, tickAmount: Math.max(1, Math.round((max - min) / step)) }; +} + +/** Which of a graph's three series it draws. */ +export interface SeriesSelection { + x: boolean; + y: boolean; + z: boolean; +} + +/** + * Which series a graph opens on. + * + * Force graphs open on the X and Y components, since the direction of a + * reaction is what is being read; kinematic graphs lead with the magnitude. A + * single-series graph only ever has the magnitude to show. + * + * Exported because the legend above the plot has to say the same thing on the + * very first frame. It used to ask the graph, and the graph did not exist yet, + * so the legend showed everything lit while the plot drew one line. + */ +export function defaultSeriesSelection(count: number, analysis: string): SeriesSelection { + const showComponents = count === 2 || (count === 3 && analysis === 'force'); + return showComponents ? { x: true, y: true, z: false } : { x: false, y: false, z: true }; +} + @Component({ selector: 'app-analysis-graph', templateUrl: './analysis-graph.component.html', @@ -93,19 +172,32 @@ export function formatTimeLabel(value: number): string { ]), ], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [AnalysisApexChartComponent, MatIcon], }) export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges { + private fb = inject(FormBuilder); + private mechanismService = inject(MechanismService); + settingsService = inject(SettingsService); + private nup = inject(NumberUnitParserService); + private samples = inject(AnalysisSampleService); + public chartOptions: Partial<ChartOptions> = { annotations: { xaxis: [], points: [], }, chart: { - objectScale: '100%', //380 + width: '100%', height: '250px', //300 + // Off, both the first draw and the tween between one dataset and the + // next. A chart that morphs is drawing values the mechanism never had: + // reverse a four-bar and the speed of its crank pin, which is constant, + // swelled into a hump and settled back while Apex interpolated its way + // from the old series to the new one. These graphs are also redrawn as + // the playhead moves, so the tween is between the reader and the answer + // rather more often than it is decoration. animations: { - // enabled: false, + enabled: false, }, type: 'line', zoom: { @@ -148,8 +240,8 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, position: 'back', show: true, padding: { - top: 0, - bottom: 12, + top: -14, + bottom: -8, }, xaxis: { lines: { @@ -164,8 +256,10 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, }, xaxis: { type: 'numeric', - position: 'top', - offsetY: 15, + // Under the plot, where a time axis reads. It sat on top because the + // legend used to want the room below it, and the legend has gone. + position: 'bottom', + offsetY: 0, // floating: true, // categories: categories, labels: { @@ -175,17 +269,25 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, // the space actually free at the two ends of this axis. trim: false, hideOverlappingLabels: false, - offsetY: 0, + // Clear of the plot's own frame, which they used to sit against. + offsetY: 4, + style: { + fontSize: '12px', + fontWeight: 400, + colors: ['#373d3f', '#373d3f'], + }, formatter: function (val) { - return formatTimeLabel(Number(val)); + return formatAxisEnd(Number(val)); }, }, tickAmount: 1, title: { text: 'Time (seconds)', - // small nudge to sit on the same baseline as the tick labels - offsetY: 6, - offsetX: 0, + // Up beside the two ends rather than on a line of its own below them, + // and centred between them: Apex centres it on the whole canvas, which + // the y axis's own labels make eight pixels wider on the left. + offsetY: -20, + offsetX: -8, }, tooltip: { enabled: false, @@ -194,14 +296,22 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, yaxis: { showForNullSeries: false, forceNiceScale: true, - min: function (min) { - return Math.floor(min); - }, - max: function (max) { - return Math.ceil(max); + // The same size and weight as the time axis. Apex defaults the two axes + // to 11px and the x axis was set to 12px on its own, so the two ends of + // one plot were lettered differently. + labels: { + style: { + fontSize: '12px', + fontWeight: 400, + }, }, + min: FLOOR_OF, + max: CEIL_OF, title: { text: 'setLater', + style: { + fontSize: '12px', + }, }, // tickAmount: 1, decimalsInFloat: 1, @@ -226,6 +336,18 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, @Input() mechPart: string = ''; @Input() reactionLinkId: string = ''; + /** + * Which series to open on, when the card above already has an opinion. + * + * The legend is outside this component and survives the card being closed and + * opened again, so it is the legend that remembers what the reader last chose + * and hands it back here. + */ + @Input() initialSeries?: SeriesSelection; + + /** What this graph is actually drawing, whenever that changes. */ + readonly shownSeriesChange = output<SeriesSelection>(); + //Get the child element in the template with "#chart" @ViewChild('chart', { static: false }) chart!: AnalysisApexChartComponent; @@ -238,22 +360,70 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, return this.numberOfSeries === 1; } + /** + * What this graph plots, in words. + * + * Its own controls are labelled "X" and "Y", which say nothing on their own: + * a panel showing six graphs offers a dozen checkboxes all called the same + * two things. The graph itself is a canvas with no text in it at all. + */ + get graphLabel(): string { + const part = this.mechPart ? ` of ${this.mechPart}` : ''; + return `${this.mechProp}${part}`; + } + + /** The label, plus what a reader who cannot see the plot would ask next. */ + get graphSummary(): string { + const names = this.displayedSeries + .map((series) => series.name) + .filter((name): name is string => !!name); + const plotted = names.length ? `, plotting ${names.join(', ')}` : ''; + return `Graph of ${this.graphLabel} over one cycle${plotted}. The same numbers are available from Export Data in the top strip.`; + } + noDataSelected: boolean = false; analysisDiagnostic: string | null = null; + /** + * A hole in an otherwise good force series: how many positions have no + * solution, and where the first one is, so Show Them can take the reader + * there. Those are toggle positions — reactions grow without bound as the + * mechanism approaches them — and standing the mechanism at one teaches + * more than any sentence about it. + */ + analysisGap: { failed: number; total: number; firstSeconds: number; mechIndex: number } | null = + null; + + /** + * One hole is "the position"; several are shown by naming the one the + * button actually goes to — the first — so the jump has no surprise in it. + */ + get gapShowLabel(): string { + const gap = this.analysisGap; + if (!gap) return ''; + return gap.failed === 1 + ? 'Show position' + : `Show first (${formatTimeLabel(gap.firstSeconds)} s)`; + } + + showGapPosition(): void { + if (!this.analysisGap) return; + const service = this.mechanismService; + // A seek keeps the playback state, so pressed mid-animation it would sail + // straight past the pose it names. Standing at the pose IS the point. + if (service.isPlaying) { + service.animate(service.mechanismTimeStep, false); + } + if (service.isMechanismPlaying(this.analysisGap.mechIndex)) { + service.toggleMechanismPlaying(this.analysisGap.mechIndex); + } + service.seekMechanism(this.analysisGap.mechIndex, this.analysisGap.firstSeconds); + } loading: boolean = false; private subscriptions = new Subscription(); private chartSyncTimer?: ReturnType<typeof setTimeout>; private destroyed = false; - constructor( - private fb: FormBuilder, - private mechanismService: MechanismService, - public settingsService: SettingsService, - private nup: NumberUnitParserService, - private activeSrv: ActiveObjService - ) {} - seriesCheckboxForm = this.fb.group( { x: [false], @@ -265,6 +435,9 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, ngOnChanges(changes: SimpleChanges): void { if (!changes || !this.analysis || !this.mechProp || !this.mechPart) return; + // The legend handing back what it remembers is not a change to what is + // plotted, and rebuilding the chart for it would be a rebuild per click. + if (Object.keys(changes).every((name) => name === 'initialSeries')) return; this.updateChartData(); } @@ -290,7 +463,6 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, //"Angular Link Pos","Angular Link Vel",Angular Link Acc" //Param 4: mechPart: If Joint 'a','b','c'... If Link 'ab','bc','cd'... - // console.log(this.analysis, this.analysisType, this.mechProp, this.mechPart); this.determineChart(this.analysis, this.analysisType, this.mechProp, this.mechPart); this.subscriptions.add( @@ -318,9 +490,9 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, }) ); this.subscriptions.add( - this.mechanismService.onMechPositionChange.subscribe((timeIndex) => { - this.showAnnotations(timeIndex); - }) + // The broadcast says *something* moved, not which; this graph asks its + // own machine where it is. + this.mechanismService.onMechPositionChange.subscribe(() => this.showAnnotations()) ); } @@ -329,21 +501,48 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, this.chartSyncTimer = setTimeout(() => { if (this.destroyed) return; if (resetSelection) { - // Force graphs open on the X/Y components, since the direction of a - // reaction is what's being read; kinematic graphs still lead with the - // magnitude. A single-series graph only ever has the magnitude to show. - const showComponents = - this.numberOfSeries === 2 || (this.numberOfSeries === 3 && this.analysis === 'force'); - const selection = showComponents - ? { x: true, y: true, z: false } - : { x: false, y: false, z: true }; - this.seriesCheckboxForm.patchValue(selection, { emitEvent: false }); + this.seriesCheckboxForm.patchValue( + this.initialSeries ?? defaultSeriesSelection(this.numberOfSeries, this.analysis), + { emitEvent: false } + ); } this.applySeriesVisibility(); this.updateYAxis(); }, 1); } + /** + * Which series this graph has, in the order it plots them. + * + * Two means X and Y; three means either X, Y and a magnitude, or the three + * components of a force -- `seriesLabel` is what knows the difference. + */ + get seriesKeys(): ('x' | 'y' | 'z')[] { + if (this.numberOfSeries === 3) return ['z', 'x', 'y']; + if (this.numberOfSeries === 2) return ['x', 'y']; + return []; + } + + seriesLabel(key: 'x' | 'y' | 'z'): string { + if (key !== 'z') return key.toUpperCase(); + // The third series is the magnitude of the other two, except in force + // analysis, where it is a component of its own. + return this.analysis === 'force' ? 'Z' : 'Magnitude'; + } + + isSeriesShown(key: 'x' | 'y' | 'z'): boolean { + return !!this.seriesCheckboxForm.value[key]; + } + + /** The legend is the switch. Showing none of them is allowed; the graph says so. */ + toggleSeries(key: 'x' | 'y' | 'z'): void { + this.seriesCheckboxForm.patchValue({ [key]: !this.isSeriesShown(key) }); + } + + colorForSeriesKey(key: 'x' | 'y' | 'z'): string { + return this.colorForSeries(this.seriesLabel(key) === 'Magnitude' ? 'Z' : key.toUpperCase()); + } + applySeriesVisibility(): void { const data = this.seriesCheckboxForm.getRawValue(); const selectedNames = new Set<string>(); @@ -356,7 +555,38 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, ); this.displayedColors = this.displayedSeries.map((series) => this.colorForSeries(series.name)); this.noDataSelected = this.displayedSeries.length === 0; - if (this.chart) this.showAnnotations(this.mechanismService.mechanismTimeStep); + this.applyYAxisScale(); + this.shownSeriesChange.emit({ x: !!data.x, y: !!data.y, z: !!data.z }); + if (this.chart) this.showAnnotations(); + } + + /** + * Where the y axis starts, stops, and puts its lines. + * + * Apex picks its own round numbers, and for a series running from -1.1 to 2.0 + * it drew lines at 0.3, 1.0 and 1.7 -- so a graph that crosses zero, which is + * usually the fact the reader came for, had nothing marking where it does. + * Every tick here is a whole number of one step away from a limit that is + * itself a whole number of steps, so zero is a line whenever it is in range. + */ + private applyYAxisScale(): void { + const values: number[] = []; + this.displayedSeries.forEach((series) => + series.data.forEach((point) => { + const value = this.pointValue(point); + if (value !== null) values.push(value); + }) + ); + const yaxis = this.chartOptions.yaxis!; + const scale = values.length + ? niceAxisScale(Math.min(...values), Math.max(...values)) + : undefined; + this.chartOptions = { + ...this.chartOptions, + yaxis: scale + ? { ...yaxis, min: scale.min, max: scale.max, tickAmount: scale.tickAmount } + : { ...yaxis, min: FLOOR_OF, max: CEIL_OF, tickAmount: undefined }, + }; } private updateYAxis(): void { @@ -366,13 +596,34 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, this.chart.updateOptions({ yaxis: this.chartOptions.yaxis }, false, true); } - private showAnnotations(timeIndex: number) { + /** + * Where this machine is in its own cycle, as a sample of its own solve. + * + * Not the shared clock. Each machine has a clock of its own while they are + * being controlled apart, and a graph of a paused machine was drawing its + * playhead at the running machine's time -- a line sweeping across a plot of + * something standing still on the grid. Machines also have different numbers + * of samples, so the shared index does not even mean the same moment. + */ + private ownSample(): number { + const mechanism = this.mechanismFor(this.mechPart); + if (!mechanism) return 0; + const at = this.mechanismService.mechanisms.indexOf(mechanism); + const step = + at === -1 + ? this.mechanismService.mechanismTimeStep + : this.mechanismService.currentSampleOf(at); + return Math.max(Math.min(step, mechanism.joints.length - 1), 0); + } + + private showAnnotations() { if (!this.chart) return; + const timeIndex = this.ownSample(); if (timeIndex === 0) { this.chart.clearAnnotations(); return; } - const timeSeconds = this.mechanismService.mechanisms[0]?.timeNum[timeIndex] ?? timeIndex; + const timeSeconds = this.mechanismFor(this.mechPart)?.timeNum[timeIndex] ?? timeIndex; if ( this.seriesCheckboxForm.value.x || this.seriesCheckboxForm.value.y || @@ -383,10 +634,16 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, { x: timeSeconds, borderColor: '#000000', + // Under the line rather than over it. Above, it was pushed past the + // top of the plot and clipped by the card, which left a white box + // with half a label in it hanging over the chart. label: { - text: 'T= ' + formatTimeLabel(timeSeconds), + // One decimal, like the two ends of the axis it sits on: "T= 1" + // beside "0.0" and "3.0" is the same number written two ways. + text: 'T= ' + formatAxisEnd(timeSeconds), orientation: 'horizontal', - offsetY: -20, + position: 'bottom', + offsetY: 4, }, }, false @@ -431,7 +688,7 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, borderColor: color, fillColor: '#000000', orientation: 'horizontal', - text: String(value), + text: formatAnalysisValue(value), }, }, false @@ -444,6 +701,21 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, this.subscriptions.unsubscribe(); } + /** + * Put an angular series into the unit the axis beside it is lettered in. + * + * In place, and to the four decimals this has always shown: the conversion + * itself now lives with the model, so the file the export writes and the + * curve drawn here cannot end up in different units. + */ + private scaleAngles(series: number[], mechProp: string): void { + const scale = angularScale(mechProp, this.settingsService.angleUnit.getValue()); + if (scale === 1) return; + for (let i = 0; i < series.length; i++) { + series[i] = Number((series[i] * scale).toFixed(4)); + } + } + getUnitStr(unit: LengthUnit | AngleUnit): string { switch (unit) { case AngleUnit.RADIAN: @@ -474,77 +746,19 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, return null; } - private pointTime(point: unknown, index: number): number { - if (point && typeof point === 'object' && 'x' in point) { - const value = (point as { x: unknown }).x; - if (typeof value === 'number' && Number.isFinite(value)) return value; - } - return this.mechanismService.mechanisms[0]?.timeNum[index] ?? index; - } - - buildCSVContent(): string { - const xSeries = this.chartOptions.series?.find((s) => s.name === 'X'); - const ySeries = this.chartOptions.series?.find((s) => s.name === 'Y'); - const zSeries = this.chartOptions.series?.find((s) => s.name === 'Z'); - const source = xSeries ?? ySeries ?? zSeries; - const timeSteps = source?.data.length ?? 0; - const fileName = this.chartOptions.yaxis?.title?.text || 'Z'; - const yAxisUnit = fileName.split(' ').pop()?.replace('Â', '') ?? ''; - let csvContent = ''; - if (!xSeries && !ySeries) { - csvContent += 'Time (seconds),Time (steps),' + fileName + '\n'; - for (let i = 0; i < timeSteps; i++) { - csvContent += - this.pointTime(zSeries?.data[i], i) + - ',' + - i + - ',' + - (this.pointValue(zSeries?.data[i]) ?? '') + - '\n'; - } - } else if (!zSeries) { - csvContent += 'Time (seconds),Time (steps),X ' + yAxisUnit + ',Y ' + yAxisUnit + '\n'; - for (let i = 0; i < timeSteps; i++) { - csvContent += - this.pointTime(xSeries?.data[i], i) + - ',' + - i + - ',' + - (this.pointValue(xSeries?.data[i]) ?? '') + - ',' + - (this.pointValue(ySeries?.data[i]) ?? '') + - '\n'; - } - } else { - csvContent += 'Time (seconds),Time (steps),' + fileName + ', X-comp,Y-comp\n'; - for (let i = 0; i < timeSteps; i++) { - csvContent += - this.pointTime(zSeries.data[i], i) + - ',' + - i + - ',' + - (this.pointValue(zSeries.data[i]) ?? '') + - ',' + - (this.pointValue(xSeries?.data[i]) ?? '') + - ',' + - (this.pointValue(ySeries?.data[i]) ?? '') + - '\n'; - } - } - return csvContent; - } - - downloadCSV() { - const encodedUri = encodeURI('data:text/csv;charset=utf-8,' + this.buildCSVContent()); - const link = document.createElement('a'); - link.setAttribute('href', encodedUri); - const fileName = this.chartOptions.yaxis?.title?.text || 'Analysis'; - // Several rows can graph the same joint, one per reacting link. - const part = this.reactionLinkId ? `${this.mechPart}_${this.reactionLinkId}` : this.mechPart; - link.setAttribute('download', part + '_' + fileName + '.csv'); - document.body.appendChild(link); - link.click(); - link.remove(); + /** + * The machine that owns the part being graphed. + * + * A drawing holds several independent mechanisms, each with its own samples + * and its own clock, so the series, the time axis and the force analysis all + * have to come from this part's machine rather than from whichever one was + * built first. A part in a floating chain belongs to none, and graphs nothing. + */ + private mechanismFor(mechPart: string): Mechanism | undefined { + const part = + this.mechanismService.joints.find((joint) => joint.id === mechPart) ?? + this.mechanismService.links.find((link) => link.id === mechPart); + return part ? this.mechanismService.mechanismContaining(part) : undefined; } determineChart(analysis: string, analysisType: string, mechProp: string, mechPart: string) { @@ -571,36 +785,28 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, mechProp: string, mechPart: string ): void { - let data1Title = ''; - let data2Title = ''; - let data3Title = ''; - let chartTitle = ''; - const xAxisTitle = 'Time-steps'; + const mechanism = this.mechanismFor(mechPart); let yAxisTitle = ''; let datum: number[][] = []; - let categories: string[] = []; const seriesData = []; let posLinUnit = '(' + this.getUnitStr(this.settingsService.lengthUnit.value) + ')'; let velLinUnit = '(' + this.getUnitStr(this.settingsService.lengthUnit.value) + '/s)'; let accLinUnit = '(' + this.getUnitStr(this.settingsService.lengthUnit.value) + '/s²)'; const posAngUnit = '(' + this.getUnitStr(this.settingsService.angleUnit.value) + ')'; - // const posAngUnit = '(rad)'; const velAngUnit = '(' + this.getUnitStr(this.settingsService.angleUnit.value) + '/s)'; const accAngUnit = '(' + this.getUnitStr(this.settingsService.angleUnit.value) + '/s²)'; this.analysisDiagnostic = null; - // if (this.settingsService.globalUnit.value === GlobalUnit.METRIC) { - // posLinUnit = '(m)'; - // velLinUnit = '(m/s)'; - // accLinUnit = '(m/s²)'; - // } + // Only the force branch writes the gap, so a kinematic graph would + // otherwise inherit the banner from the force graph shown before it. + this.analysisGap = null; switch (analysis) { case 'force': switch (mechProp) { case 'Input Torque': case 'Input Effort': { const mode: ForceAnalysisMode = analysisType === 'dynamic' ? 'dynamic' : 'static'; - const effortKind = this.mechanismService.mechanisms[0] - .getForceAnalysis(mode) + const effortKind = mechanism + ?.getForceAnalysis(mode) .frames.find((frame) => frame.status === 'ok' && frame.inputEffort) ?.inputEffort?.kind; yAxisTitle = @@ -611,12 +817,7 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, : this.settingsService.forceUnit.value === ForceUnit.LBF ? 'Torque (lbf·in)' : 'Torque (N·m)'; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); seriesData.push({ name: 'Z', type: 'line', data: datum[0] }); this.numberOfSeries = 1; break; @@ -624,12 +825,7 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, case 'Joint Forces': yAxisTitle = this.settingsService.forceUnit.value === ForceUnit.LBF ? 'Force (lbf)' : 'Force (N)'; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); seriesData.push({ name: 'X', type: 'line', data: datum[0] }); seriesData.push({ name: 'Y', type: 'line', data: datum[1] }); seriesData.push({ name: 'Z', type: 'line', data: datum[2] }); @@ -643,24 +839,14 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, switch (mechProp) { case 'Linear Joint Pos': yAxisTitle = 'Position ' + posLinUnit; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); seriesData.push({ name: 'X', type: 'line', data: datum[0] }); seriesData.push({ name: 'Y', type: 'line', data: datum[1] }); this.numberOfSeries = 2; break; case 'Linear Joint Vel': yAxisTitle = 'Velocity ' + velLinUnit; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); seriesData.push({ name: 'X', type: 'line', data: datum[0] }); seriesData.push({ name: 'Y', type: 'line', data: datum[1] }); seriesData.push({ name: 'Z', type: 'line', data: datum[2] }); @@ -668,12 +854,7 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, break; case 'Linear Joint Acc': yAxisTitle = 'Acceleration ' + accLinUnit; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); seriesData.push({ name: 'X', type: 'line', data: datum[0] }); seriesData.push({ name: 'Y', type: 'line', data: datum[1] }); seriesData.push({ name: 'Z', type: 'line', data: datum[2] }); @@ -681,24 +862,14 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, break; case "Linear Link's CoM Pos": yAxisTitle = 'Position (CoM) ' + posLinUnit; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); seriesData.push({ name: 'X', type: 'line', data: datum[0] }); seriesData.push({ name: 'Y', type: 'line', data: datum[1] }); this.numberOfSeries = 2; break; case "Linear Link's CoM Vel": yAxisTitle = 'Velocity ' + velLinUnit; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); seriesData.push({ name: 'X', type: 'line', data: datum[0] }); seriesData.push({ name: 'Y', type: 'line', data: datum[1] }); seriesData.push({ name: 'Z', type: 'line', data: datum[2] }); @@ -706,12 +877,7 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, break; case "Linear Link's CoM Acc": yAxisTitle = 'Acceleration ' + accLinUnit; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); seriesData.push({ name: 'X', type: 'line', data: datum[0] }); seriesData.push({ name: 'Y', type: 'line', data: datum[1] }); seriesData.push({ name: 'Z', type: 'line', data: datum[2] }); @@ -719,58 +885,25 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, break; case 'Angular Link Pos': yAxisTitle = 'Position ' + posAngUnit; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); var series: number[] = datum[0]; - if (this.settingsService.angleUnit.getValue() == AngleUnit.RADIAN) { - for (let i = 0; i < series.length; i++) { - series[i] = Number( - this.nup.convertAngle(series[i], AngleUnit.DEGREE, AngleUnit.RADIAN).toFixed(4) - ); - } - } + this.scaleAngles(series, 'Angular Link Pos'); seriesData.push({ name: 'Z', type: 'line', data: series }); this.numberOfSeries = 1; break; case 'Angular Link Vel': yAxisTitle = 'Velocity ' + velAngUnit; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); var series: number[] = datum[0]; - if (this.settingsService.angleUnit.getValue() == AngleUnit.DEGREE) { - for (let i = 0; i < series.length; i++) { - series[i] = Number( - this.nup.convertAngle(series[i], AngleUnit.RADIAN, AngleUnit.DEGREE).toFixed(4) - ); - } - } + this.scaleAngles(series, 'Angular Link Vel'); seriesData.push({ name: 'Z', type: 'line', data: series }); this.numberOfSeries = 1; break; case 'Angular Link Acc': yAxisTitle = 'Acceleration ' + accAngUnit; - [datum, categories] = this.determineAnalysis( - analysis, - analysisType, - mechProp, - mechPart - ); + [datum] = this.determineAnalysis(analysis, analysisType, mechProp, mechPart); var series: number[] = datum[0]; - if (this.settingsService.angleUnit.getValue() == AngleUnit.DEGREE) { - for (let i = 0; i < series.length; i++) { - series[i] = Number( - this.nup.convertAngle(series[i], AngleUnit.RADIAN, AngleUnit.DEGREE).toFixed(4) - ); - } - } + this.scaleAngles(series, 'Angular Link Acc'); seriesData.push({ name: 'Z', type: 'line', data: series }); this.numberOfSeries = 1; break; @@ -783,7 +916,7 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, // ApexCharts/Safari can fail the entire chart when one exact toggle pose // contributes NaN or Infinity. A null point creates an intentional gap at // that singular timestep while preserving the rest of the series. - const times = this.mechanismService.mechanisms[0]?.timeNum ?? []; + const times = mechanism?.timeNum ?? []; const chartSeries = seriesData.map((series) => ({ ...series, data: series.data.map((value, index) => ({ @@ -813,194 +946,61 @@ export class AnalysisGraphComponent implements OnInit, AfterViewInit, OnDestroy, const datum_X: number[] = []; const datum_Y: number[] = []; const datum_Z: number[] = []; - let x = 0; - let y = 0; - let z = 0; const categories: string[] = []; - const mechanism = this.mechanismService.mechanisms[0]; + const mechanism = this.mechanismFor(mechPart); + // A loose joint, or a chain that never reaches ground, is in no mechanism + // and so has nothing solved to plot. + if (!mechanism) return [[datum_X, datum_Y, datum_Z], categories]; + + const series = [datum_X, datum_Y, datum_Z]; + /** One solved sample, spread across the series in the order plotted. */ + const collect = (index: number): void => { + this.samples + .sampleAt(mechanism, index, analysis, analysisType, mechProp, mechPart, this.reactionLinkId) + .forEach((value, position) => series[position].push(value)); + }; + if (analysis === 'force') { const mode: ForceAnalysisMode = analysisType === 'dynamic' ? 'dynamic' : 'static'; const result = mechanism.getForceAnalysis(mode); - const forceConversion = - this.settingsService.forceUnit.value === ForceUnit.LBF ? LBF_PER_NEWTON : 1; - const torqueConversion = - this.settingsService.forceUnit.value === ForceUnit.LBF ? LBF_IN_PER_NEWTON_METER : 1; - - for (const frame of result.frames) { + result.frames.forEach((frame, index) => { categories.push(frame.timeSeconds.toString()); - if (frame.status !== 'ok') { - datum_X.push(Number.NaN); - if (mechProp === 'Joint Forces') { - datum_Y.push(Number.NaN); - datum_Z.push(Number.NaN); - } - continue; - } - - if (mechProp === 'Input Torque' || mechProp === 'Input Effort') { - // A torque's moment arms are internal model lengths (MODEL_SCALE - // times the user's unit), so the solved value divides back down for - // display. An input *force* has no length in it and is invariant. - datum_X.push( - frame.inputEffort - ? roundNumber( - (frame.inputEffort.valueSI * - (frame.inputEffort.kind === 'force' ? forceConversion : torqueConversion)) / - (frame.inputEffort.kind === 'force' ? 1 : MODEL_SCALE), - 3 - ) - : Number.NaN - ); - continue; - } + collect(index); + }); - const byLink = frame.jointReactionsByLink.get(mechPart); - const reaction = this.reactionLinkId - ? byLink?.get(this.reactionLinkId) - : frame.jointReactions.get(mechPart); - if (!reaction) { - datum_X.push(Number.NaN); - datum_Y.push(Number.NaN); - datum_Z.push(Number.NaN); - continue; - } - x = reaction[0] * forceConversion; - y = reaction[1] * forceConversion; - z = Math.hypot(x, y); - datum_X.push(roundNumber(x, 3)); - datum_Y.push(roundNumber(y, 3)); - datum_Z.push(roundNumber(z, 3)); - } - - const hasFiniteData = [datum_X, datum_Y, datum_Z].some((series) => - series.some(Number.isFinite) + const hasFiniteData = [datum_X, datum_Y, datum_Z].some((values) => + values.some(Number.isFinite) ); + // A hole in an otherwise good series is worth a sentence of its own: a + // silent gap at a toggle position reads as a plotting bug, when it is + // the most physical thing on the chart. + const failed = result.frames.length - result.successfulFrames; + const firstFailed = result.frames.find((frame) => frame.status !== 'ok'); + this.analysisGap = + hasFiniteData && failed > 0 && firstFailed + ? { + failed, + total: result.frames.length, + firstSeconds: firstFailed.timeSeconds, + mechIndex: Math.max(this.mechanismService.mechanisms.indexOf(mechanism), 0), + } + : null; this.analysisDiagnostic = hasFiniteData ? null : (result.diagnostic ?? (mechProp === 'Joint Forces' - ? 'This point is internal to one welded body and has no independent joint reaction.' + ? 'Only one part meets this joint, so there is no force to graph here.' : 'Input effort is unavailable for this mechanism.')); return [[datum_X, datum_Y, datum_Z], categories]; } + // The solver keeps its answers in statics, and starts each graph from a + // clean set of them rather than from the last mechanism graphed. KinematicsSolver.resetVariables(); KinematicsSolver.requiredLoops = mechanism.requiredLoops; mechanism.joints.forEach((_, index) => { categories.push(mechanism.timeNum[index]?.toString() ?? index.toString()); - const vector = (value: [number, number] | undefined): [number, number] => - value ?? [Number.NaN, Number.NaN]; - switch (mechProp) { - // Positions, velocities and accelerations are linear in length, so - // each internal model value divides by MODEL_SCALE for display in the - // user's unit. Angular series carry no length and pass through. - case 'Linear Joint Pos': - const jt = this.mechanismService.mechanisms[0].joints[index].find( - (j) => j.id === mechPart - ); - x = (jt?.x ?? Number.NaN) / MODEL_SCALE; - y = (jt?.y ?? Number.NaN) / MODEL_SCALE; - datum_X.push(roundNumber(x, 3)); - datum_Y.push(roundNumber(y, 3)); - break; - case 'Linear Joint Vel': - KinematicsSolver.determineKinematics( - this.mechanismService.mechanisms[0].joints[index], - this.mechanismService.mechanisms[0].links[index], - this.mechanismService.mechanisms[0].inputAngularVelocities[index] - ); - [x, y] = vector(KinematicsSolver.jointVelMap.get(mechPart)); - x /= MODEL_SCALE; - y /= MODEL_SCALE; - z = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); - datum_X.push(roundNumber(x, 3)); - datum_Y.push(roundNumber(y, 3)); - datum_Z.push(roundNumber(z, 3)); - break; - case 'Linear Joint Acc': - KinematicsSolver.determineKinematics( - this.mechanismService.mechanisms[0].joints[index], - this.mechanismService.mechanisms[0].links[index], - this.mechanismService.mechanisms[0].inputAngularVelocities[index] - ); - [x, y] = vector(KinematicsSolver.jointAccMap.get(mechPart)); - x /= MODEL_SCALE; - y /= MODEL_SCALE; - z = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); - datum_X.push(roundNumber(x, 3)); - datum_Y.push(roundNumber(y, 3)); - datum_Z.push(roundNumber(z, 3)); - break; - case "Linear Link's CoM Pos": - KinematicsSolver.determineKinematics( - this.mechanismService.mechanisms[0].joints[index], - this.mechanismService.mechanisms[0].links[index], - this.mechanismService.mechanisms[0].inputAngularVelocities[index] - ); - [x, y] = vector(KinematicsSolver.linkCoMMap.get(mechPart)); - x /= MODEL_SCALE; - y /= MODEL_SCALE; - datum_X.push(roundNumber(x, 3)); - datum_Y.push(roundNumber(y, 3)); - break; - case "Linear Link's CoM Vel": - KinematicsSolver.determineKinematics( - this.mechanismService.mechanisms[0].joints[index], - this.mechanismService.mechanisms[0].links[index], - this.mechanismService.mechanisms[0].inputAngularVelocities[index] - ); - [x, y] = vector(KinematicsSolver.linkVelMap.get(mechPart)); - x /= MODEL_SCALE; - y /= MODEL_SCALE; - z = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); - datum_X.push(roundNumber(x, 3)); - datum_Y.push(roundNumber(y, 3)); - datum_Z.push(roundNumber(z, 3)); - break; - case "Linear Link's CoM Acc": - KinematicsSolver.determineKinematics( - this.mechanismService.mechanisms[0].joints[index], - this.mechanismService.mechanisms[0].links[index], - this.mechanismService.mechanisms[0].inputAngularVelocities[index] - ); - [x, y] = vector(KinematicsSolver.linkAccMap.get(mechPart)); - x /= MODEL_SCALE; - y /= MODEL_SCALE; - z = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); - datum_X.push(roundNumber(x, 3)); - datum_Y.push(roundNumber(y, 3)); - datum_Z.push(roundNumber(z, 3)); - break; - case 'Angular Link Pos': - KinematicsSolver.determineKinematics( - this.mechanismService.mechanisms[0].joints[index], - this.mechanismService.mechanisms[0].links[index], - this.mechanismService.mechanisms[0].inputAngularVelocities[index] - ); - x = KinematicsSolver.linkAngPosMap.get(mechPart) ?? Number.NaN; - datum_X.push(roundNumber(x, 3)); - break; - case 'Angular Link Vel': - KinematicsSolver.determineKinematics( - this.mechanismService.mechanisms[0].joints[index], - this.mechanismService.mechanisms[0].links[index], - this.mechanismService.mechanisms[0].inputAngularVelocities[index] - ); - x = KinematicsSolver.linkAngVelMap.get(mechPart) ?? Number.NaN; - datum_X.push(roundNumber(x, 3)); - break; - case 'Angular Link Acc': - KinematicsSolver.determineKinematics( - this.mechanismService.mechanisms[0].joints[index], - this.mechanismService.mechanisms[0].links[index], - this.mechanismService.mechanisms[0].inputAngularVelocities[index] - ); - x = KinematicsSolver.linkAngAccMap.get(mechPart) ?? Number.NaN; - datum_X.push(roundNumber(x, 3)); - break; - case 'ic': - break; - } + collect(index); }); return [[datum_X, datum_Y, datum_Z], categories]; } diff --git a/src/app/component/analysis-panel/analysis-panel.component.html b/src/app/component/analysis-panel/analysis-panel.component.html index 87eeee56..1536cd10 100644 --- a/src/app/component/analysis-panel/analysis-panel.component.html +++ b/src/app/component/analysis-panel/analysis-panel.component.html @@ -82,206 +82,120 @@ <!-- Valid Mech but Nothing Selected --> - @if ( - (this.activeSrv.objType == 'Grid' || this.activeSrv.objType == 'Nothing') && - this.mechanismService.oneValidMechanismExists() - ) { - <panel-section-collapsible [warning]="true" [expanded]="false" id="placeholderContainer"> - <title-block - alwaysShown - icon="expand_more" - description="Select a part of the linkage to analyze it." - >Analysis Help - </title-block> - <img src="assets/gifs/Click.gif" /> - </panel-section-collapsible> + @if (this.activeSrv.objType == 'Grid' || this.activeSrv.objType == 'Nothing') { + <div class="helpPanel"> + <div class="helpTitle">Analysis Help</div> + <p class="helpLead">{{ analysisHelpLead }}</p> + <div class="helpRule"></div> + <div class="helpHints"> + <div class="helpHint"> + <mat-icon>touch_app</mat-icon> + <span + >Click a joint for position, velocity and acceleration graphs, or a link for its angular + kinematics.</span + > + </div> + </div> + </div> + } + + @if (this.activeSrv.objType == 'Mechanism') { + <app-mechanism-panel></app-mechanism-panel> } <!-- Joint Kinematics--> @if (validMechanisms() && this.activeSrv.objType == 'Joint') { <panel-section id="analysis-panel"> - <title-block - description="{{ inputSummary }} This can be changed in the input joint's Edit panel." - > - Analysis for Joint - {{ this.activeSrv.selectedJoint.name }} + <title-block description="{{ panelDescription }}"> + {{ modeLabel }} for Joint {{ this.activeSrv.selectedJoint.name }} </title-block> - <collapsible-subseciton - titleLabel="Kinematic Analysis" - [expanded]="graphExpanded['JKineAna']" - (opened)="graphExpanded['JKineAna'] = true" - (closed)="graphExpanded['JKineAna'] = false" - > - <mat-expansion-panel + @if (showKinematic) { + <app-analysis-graph-section + label="Position of Joint {{ this.activeSrv.selectedJoint.name }}" + help="Where this joint is, through the cycle" + analysis="kinematic" + analysisType="loop" + mechProp="Linear Joint Pos" + mechPart="{{ this.activeSrv.selectedJoint.id }}" [expanded]="graphExpanded['JPos']" - (opened)="graphExpanded['JPos'] = true" - (closed)="graphExpanded['JPos'] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> - Position of Joint {{ this.activeSrv.selectedJoint.name }} - </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="Where this joint is, through the cycle" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['JPos']) { - <app-analysis-graph - analysis="kinematic" - analysisType="loop" - mechProp="Linear Joint Pos" - mechPart="{{ this.activeSrv.selectedJoint.id }}" - ></app-analysis-graph> - } - </mat-expansion-panel> - <mat-expansion-panel + (expandedChange)="graphExpanded['JPos'] = $event" + ></app-analysis-graph-section> + <app-analysis-graph-section + label="Velocity of Joint {{ this.activeSrv.selectedJoint.name }}" + help="How fast this joint moves, and which way" + analysis="kinematic" + analysisType="loop" + mechProp="Linear Joint Vel" + mechPart="{{ this.activeSrv.selectedJoint.id }}" [expanded]="graphExpanded['JVel']" - (opened)="graphExpanded['JVel'] = true" - (closed)="graphExpanded['JVel'] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> - Velocity of Joint {{ this.activeSrv.selectedJoint.name }} - </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="How fast this joint moves, and which way" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['JVel']) { - <app-analysis-graph - analysis="kinematic" - analysisType="loop" - mechProp="Linear Joint Vel" - mechPart="{{ this.activeSrv.selectedJoint.id }}" - ></app-analysis-graph> - } - </mat-expansion-panel> - <mat-expansion-panel + (expandedChange)="graphExpanded['JVel'] = $event" + ></app-analysis-graph-section> + <app-analysis-graph-section + label="Acceleration of Joint {{ this.activeSrv.selectedJoint.name }}" + help="How this joint’s velocity changes" + analysis="kinematic" + analysisType="loop" + mechProp="Linear Joint Acc" + mechPart="{{ this.activeSrv.selectedJoint.id }}" [expanded]="graphExpanded['JAcc']" - (opened)="graphExpanded['JAcc'] = true" - (closed)="graphExpanded['JAcc'] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> - Acceleration of Joint {{ this.activeSrv.selectedJoint.name }} - </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="How this joint’s velocity changes" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['JAcc']) { - <app-analysis-graph - analysis="kinematic" - analysisType="loop" - mechProp="Linear Joint Acc" - mechPart="{{ this.activeSrv.selectedJoint.id }}" - ></app-analysis-graph> - } - </mat-expansion-panel> - </collapsible-subseciton> - <collapsible-subseciton - titleLabel="Force Analysis" - [expanded]="graphExpanded['JForceAna']" - (opened)="graphExpanded['JForceAna'] = true" - (closed)="graphExpanded['JForceAna'] = false" - > - <radio-block - tooltip="Static holds the mechanism still; In-motion includes the forces of movement." - option1="Static (Equilibrium)" - option2="In-motion (Dynamic)" - [formGroup]="this.forceAnalysisFormGroup" - _formControl="mode" - >Force Analysis Type - </radio-block> + (expandedChange)="graphExpanded['JAcc'] = $event" + ></app-analysis-graph-section> + } + @if (showForce) { + <!-- Nothing to graph, so nothing to configure: the note stands alone. --> + @if (jointForceHasGraphs) { + <div class="forceModeRow"> + <radio-block + tooltip="Static holds the mechanism still; In-motion includes the forces of movement." + option1="Static (Equilibrium)" + option2="In-motion (Dynamic)" + [formGroup]="this.forceAnalysisFormGroup" + _formControl="mode" + >Force Analysis Type + </radio-block> + </div> + } @if (jointForceRows().length === 0) { - <div class="analysis-message" role="status"> - Joint {{ this.activeSrv.selectedJoint.name }} is internal to one welded body and has no - independent pin reaction. Select an external or grounded joint to graph a reaction. + <div class="helpRule"></div> + <div class="helpHint" role="status"> + <mat-icon>touch_app</mat-icon> + <span>Click a joint where separate parts meet, or a grounded one.</span> </div> } @for (row of jointForceRows(); track row.linkId) { - <mat-expansion-panel + <app-analysis-graph-section + [label]="row.label" + help="The reaction that link carries at this joint" + analysis="force" + [analysisType]="forceAnalysisMode()" + mechProp="Joint Forces" + mechPart="{{ row.jointId }}" + [reactionLinkId]="row.linkId" [expanded]="graphExpanded['JForce_' + row.linkId]" - (opened)="graphExpanded['JForce_' + row.linkId] = true" - (closed)="graphExpanded['JForce_' + row.linkId] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> Force on Link {{ row.linkName }} </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="The reaction that link carries at this joint" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['JForce_' + row.linkId]) { - <app-analysis-graph - analysis="force" - [analysisType]="forceAnalysisMode()" - mechProp="Joint Forces" - mechPart="{{ row.jointId }}" - [reactionLinkId]="row.linkId" - ></app-analysis-graph> - } - </mat-expansion-panel> + (expandedChange)="graphExpanded['JForce_' + row.linkId] = $event" + ></app-analysis-graph-section> } @if (this.activeSrv.selectedJoint.input) { - <mat-expansion-panel + <app-analysis-graph-section + label="Input {{ inputEffortLabel() }} at Joint {{ this.activeSrv.selectedJoint.name }}" + help="The effort the input has to supply to drive the mechanism" + analysis="force" + [analysisType]="forceAnalysisMode()" + mechProp="Input Effort" + mechPart="{{ this.activeSrv.selectedJoint.id }}" [expanded]="graphExpanded['JInputForce']" - (opened)="graphExpanded['JInputForce'] = true" - (closed)="graphExpanded['JInputForce'] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> - Input {{ inputEffortLabel() }} at Joint {{ this.activeSrv.selectedJoint.name }} - </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="The effort the input has to supply to drive the mechanism" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['JInputForce']) { - <app-analysis-graph - analysis="force" - [analysisType]="forceAnalysisMode()" - mechProp="Input Effort" - mechPart="{{ this.activeSrv.selectedJoint.id }}" - ></app-analysis-graph> - } - </mat-expansion-panel> + (expandedChange)="graphExpanded['JInputForce'] = $event" + ></app-analysis-graph-section> } - </collapsible-subseciton> + } </panel-section> } <!-- Link Kinematics--> @if (validMechanisms() && this.activeSrv.objType == 'Link') { <panel-section id="analysis-panel"> - <title-block - description="{{ inputSummary }} This can be changed in the input joint's Edit panel." - > - Analysis for {{ selectedBodyLabel }} + <title-block description="{{ panelDescription }}"> + {{ modeLabel }} for {{ selectedBodyLabel }} </title-block> <!-- <subtitle-block>Kinematics</subtitle-block> --> <!-- @@ -294,227 +208,125 @@ //"Angular Link Pos","Angular Link Vel",Angular Link Acc" //Param 4: mechPart: If Joint 'a','b','c'... If Link 'ab','bc','cd'... --> - <collapsible-subseciton - titleLabel="Kinematic Analysis" - [expanded]="graphExpanded['LKineAna']" - (opened)="graphExpanded['LKineAna'] = true" - (closed)="graphExpanded['LKineAna'] = false" - > - <mat-expansion-panel + @if (showKinematic) { + <app-analysis-graph-section + label="Angle of {{ selectedBodyLabel }}" + help="The angle of this link above the horizontal" + analysis="kinematic" + analysisType="loop" + mechProp="Angular Link Pos" + mechPart="{{ this.activeSrv.selectedLink.id }}" [expanded]="graphExpanded['LAng']" - (opened)="graphExpanded['LAng'] = true" - (closed)="graphExpanded['LAng'] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> - Angle of Link {{ this.activeSrv.selectedLink.name }} - </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="The angle of this link above the horizontal" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['LAng']) { - <app-analysis-graph - analysis="kinematic" - analysisType="loop" - mechProp="Angular Link Pos" - mechPart="{{ this.activeSrv.selectedLink.id }}" - ></app-analysis-graph> - } - </mat-expansion-panel> - <mat-expansion-panel + (expandedChange)="graphExpanded['LAng'] = $event" + ></app-analysis-graph-section> + <app-analysis-graph-section + label="Angular Velocity of {{ selectedBodyLabel }}" + help="How fast this link turns, and which way" + analysis="kinematic" + analysisType="loop" + mechProp="Angular Link Vel" + mechPart="{{ this.activeSrv.selectedLink.id }}" [expanded]="graphExpanded['LAngVel']" - (opened)="graphExpanded['LAngVel'] = true" - (closed)="graphExpanded['LAngVel'] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> - Angular Velocity of Link {{ this.activeSrv.selectedLink.name }} - </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="How fast this link turns, and which way" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['LAngVel']) { - <app-analysis-graph - analysis="kinematic" - analysisType="loop" - mechProp="Angular Link Vel" - mechPart="{{ this.activeSrv.selectedLink.id }}" - ></app-analysis-graph> - } - </mat-expansion-panel> - <mat-expansion-panel + (expandedChange)="graphExpanded['LAngVel'] = $event" + ></app-analysis-graph-section> + <app-analysis-graph-section + label="Angular Acceleration of {{ selectedBodyLabel }}" + help="How this link’s turning rate changes" + analysis="kinematic" + analysisType="loop" + mechProp="Angular Link Acc" + mechPart="{{ this.activeSrv.selectedLink.id }}" [expanded]="graphExpanded['LAngAcc']" - (opened)="graphExpanded['LAngAcc'] = true" - (closed)="graphExpanded['LAngAcc'] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> - Angular Acceleration of Link {{ this.activeSrv.selectedLink.name }} - </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="How this link’s turning rate changes" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['LAngAcc']) { - <app-analysis-graph - analysis="kinematic" - analysisType="loop" - mechProp="Angular Link Acc" - mechPart="{{ this.activeSrv.selectedLink.id }}" - ></app-analysis-graph> - } - </mat-expansion-panel> - <subtitle-block style="margin-top: 20px" - >Center of Mass (COM) of Link {{ this.activeSrv.selectedLink.name }}</subtitle-block + (expandedChange)="graphExpanded['LAngAcc'] = $event" + ></app-analysis-graph-section> + <div + class="graphGroupHeading" + (mouseenter)="highlightCoM(true)" + (mouseleave)="highlightCoM(false)" > - <mat-expansion-panel + Center of Mass + </div> + <app-analysis-graph-section + label="Position of {{ selectedBodyLabel }}'s COM" + help="Where this link’s center of mass is" + analysis="kinematic" + analysisType="loop" + mechProp="Linear Link's CoM Pos" + mechPart="{{ this.activeSrv.selectedLink.id }}" [expanded]="graphExpanded['LPos']" - (opened)="graphExpanded['LPos'] = true" - (closed)="graphExpanded['LPos'] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> - Position of Link {{ this.activeSrv.selectedLink.name }}'s COM - </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="Where this link’s centre of mass is" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['LPos']) { - <app-analysis-graph - analysis="kinematic" - analysisType="loop" - mechProp="Linear Link's CoM Pos" - mechPart="{{ this.activeSrv.selectedLink.id }}" - ></app-analysis-graph> - } - </mat-expansion-panel> - <mat-expansion-panel + (expandedChange)="graphExpanded['LPos'] = $event" + ></app-analysis-graph-section> + <app-analysis-graph-section + label="Velocity of {{ selectedBodyLabel }}'s COM" + help="How fast this link’s center of mass moves" + analysis="kinematic" + analysisType="loop" + mechProp="Linear Link's CoM Vel" + mechPart="{{ this.activeSrv.selectedLink.id }}" [expanded]="graphExpanded['LVel']" - (opened)="graphExpanded['LVel'] = true" - (closed)="graphExpanded['LVel'] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> - Velocity of Link {{ this.activeSrv.selectedLink.name }}'s COM - </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="How fast this link’s centre of mass moves" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['LVel']) { - <app-analysis-graph - analysis="kinematic" - analysisType="loop" - mechProp="Linear Link's CoM Vel" - mechPart="{{ this.activeSrv.selectedLink.id }}" - ></app-analysis-graph> - } - </mat-expansion-panel> - <mat-expansion-panel + (expandedChange)="graphExpanded['LVel'] = $event" + ></app-analysis-graph-section> + <app-analysis-graph-section + label="Acceleration of {{ selectedBodyLabel }}'s COM" + help="How this link’s center of mass changes speed" + analysis="kinematic" + analysisType="loop" + mechProp="Linear Link's CoM Acc" + mechPart="{{ this.activeSrv.selectedLink.id }}" [expanded]="graphExpanded['LAcc']" - (opened)="graphExpanded['LAcc'] = true" - (closed)="graphExpanded['LAcc'] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> - Acceleration of Link {{ this.activeSrv.selectedLink.name }}'s COM - </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="How this link’s centre of mass changes speed" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['LAcc']) { - <app-analysis-graph - analysis="kinematic" - analysisType="loop" - mechProp="Linear Link's CoM Acc" - mechPart="{{ this.activeSrv.selectedLink.id }}" - ></app-analysis-graph> - } - </mat-expansion-panel> - </collapsible-subseciton> - <collapsible-subseciton - titleLabel="Force Analysis" - [expanded]="graphExpanded['LForceAna']" - (opened)="graphExpanded['LForceAna'] = true" - (closed)="graphExpanded['LForceAna'] = false" - > - <radio-block - tooltip="Static holds the mechanism still; In-motion includes the forces of movement." - option1="Static (Equilibrium)" - option2="In-motion (Dynamic)" - [formGroup]="this.forceAnalysisFormGroup" - _formControl="mode" - >Force Analysis Type - </radio-block> - @if (linkForceRows().length === 0) { - <div class="analysis-message" role="status"> - Link {{ this.activeSrv.selectedLink.name }} has no external joint that carries an - independent pin reaction. + (expandedChange)="graphExpanded['LAcc'] = $event" + ></app-analysis-graph-section> + } + @if (showForce) { + <!-- Nothing to graph, so nothing to configure: the note stands alone. --> + @if (linkForceHasGraphs) { + <div class="forceModeRow"> + <radio-block + tooltip="Static holds the mechanism still; In-motion includes the forces of movement." + option1="Static (Equilibrium)" + option2="In-motion (Dynamic)" + [formGroup]="this.forceAnalysisFormGroup" + _formControl="mode" + >Force Analysis Type + </radio-block> + </div> + } + @if (!linkForceHasGraphs) { + <div class="helpRule"></div> + <div class="helpHint" role="status"> + <mat-icon>touch_app</mat-icon> + <span>Click a part that touches another, or a grounded joint.</span> </div> } @for (row of linkForceRows(); track row.jointId) { - <mat-expansion-panel + <app-analysis-graph-section + [label]="row.label" + help="The reaction this link carries at that joint" + analysis="force" + [analysisType]="forceAnalysisMode()" + mechProp="Joint Forces" + mechPart="{{ row.jointId }}" + [reactionLinkId]="row.linkId" [expanded]="graphExpanded['LForce_' + row.jointId]" - (opened)="graphExpanded['LForce_' + row.jointId] = true" - (closed)="graphExpanded['LForce_' + row.jointId] = false" - > - <mat-expansion-panel-header> - <mat-panel-title> Force at Joint {{ row.jointName }} </mat-panel-title> - <mat-panel-description> - <mat-icon - matTooltip="The reaction this link carries at that joint" - [matTooltipShowDelay]="1000" - id="graph-help" - >help_outline - </mat-icon> - </mat-panel-description> - </mat-expansion-panel-header> - @if (graphExpanded['LForce_' + row.jointId]) { - <app-analysis-graph - analysis="force" - [analysisType]="forceAnalysisMode()" - mechProp="Joint Forces" - mechPart="{{ row.jointId }}" - [reactionLinkId]="row.linkId" - ></app-analysis-graph> - } - </mat-expansion-panel> + (expandedChange)="graphExpanded['LForce_' + row.jointId] = $event" + ></app-analysis-graph-section> + } + <!-- A ram's drive sits on a joint inside the part, which has no marker + and no hitbox -- so its effort belongs on the part's own panel, the + way its speed already does. --> + @if (inputEffortJoint(); as driven) { + <app-analysis-graph-section + label="Input {{ inputEffortLabel(driven) }} for {{ selectedBodyLabel }}" + help="The effort the input has to supply to drive the mechanism" + analysis="force" + [analysisType]="forceAnalysisMode()" + mechProp="Input Effort" + mechPart="{{ driven.id }}" + [expanded]="graphExpanded['LInputForce']" + (expandedChange)="graphExpanded['LInputForce'] = $event" + ></app-analysis-graph-section> } - </collapsible-subseciton> + } </panel-section> } @@ -522,7 +334,7 @@ @if (validMechanisms() && this.activeSrv.objType == 'Force') { <panel-section id="analysis-panel"> <title-block description="There are no analysis graphs for forces."> - Analysis for Force {{ this.activeSrv.selectedForce.name }} + {{ modeLabel }} for Force {{ this.activeSrv.selectedForce.name }} </title-block> </panel-section> } diff --git a/src/app/component/analysis-panel/analysis-panel.component.scss b/src/app/component/analysis-panel/analysis-panel.component.scss index 43e22e2a..35b675fc 100644 --- a/src/app/component/analysis-panel/analysis-panel.component.scss +++ b/src/app/component/analysis-panel/analysis-panel.component.scss @@ -11,10 +11,11 @@ $background: map.get($theme, background); $foreground: map.get($theme, foreground); + // The frame around this leaves the card's shadow its room; a second inset + // here would put the card 10px in from where every other card sits. #analysisWrapper { display: flex; flex-direction: column; - padding: 10px; gap: 10px; } @@ -54,10 +55,39 @@ // } } } -#analysisWrapper .analysis-message { - margin: 8px 0 12px; - padding: 12px; - border-radius: var(--border-radius); - background: #f4f5fb; - line-height: 1.4; +// Names the group of graphs under it. It used to be a subtitle-block nudged +// down 20px, which read as a stray line of text between two cards rather than +// as a heading for the ones below it. +#analysisWrapper .graphGroupHeading { + // On the cards' own gutter, and weighted to carry the group under it rather + // than to be the quietest thing on the page. + margin: 16px 0 10px; + padding: 14px 15px 0; + border-top: 1px solid #eceef5; + font-size: 15px; + font-weight: 500; + color: #2c2c2c; + cursor: default; +} + +// The mode the reactions below are computed for, set off from them the same way +// the centre-of-mass heading is set off from its graphs. It used to sit flush +// against the first reaction card, so the two read as one control. +#analysisWrapper .forceModeRow { + margin: 0 0 12px; + padding: 0 15px 12px; + border-bottom: 1px solid #eceef5; +} + +// The help card's hint pattern, worn inside a panel section: the card brings +// its own padding, a section does not, so the rows take the section's 15px +// gutter themselves. As padding, not margin — the row is stretched to the +// section's full width, and a side margin pushed it out the right edge. +#analysisWrapper panel-section .helpRule { + margin: 4px 15px 12px; + width: auto; +} +#analysisWrapper panel-section .helpHint { + margin: 0 0 14px; + padding: 0 15px; } diff --git a/src/app/component/analysis-panel/analysis-panel.component.spec.ts b/src/app/component/analysis-panel/analysis-panel.component.spec.ts index 79b77d63..d65e3020 100644 --- a/src/app/component/analysis-panel/analysis-panel.component.spec.ts +++ b/src/app/component/analysis-panel/analysis-panel.component.spec.ts @@ -1,3 +1,5 @@ +import { SelectedTabService, TabID } from '../../selected-tab.service'; +import { AnalysisGraphSectionComponent } from '../analysis-graph-section/analysis-graph-section.component'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; @@ -13,29 +15,66 @@ import { import { TEMPLATE_LINKAGES } from '../MODALS/templates/template-linkages'; import { AnalysisPanelComponent } from './analysis-panel.component'; -async function createPanel(payload: string, selectedId: string) { +async function createPanel(payload: string, selectedId: string, mode: TabID = TabID.ANALYZE) { const fixtureData = buildMechanismFixture(payload); const selected = fixtureData.service.joints.find((joint) => joint.id === selectedId) ?? fixtureData.service.links.find((link) => link.id === selectedId)!; fixtureData.active.updateSelectedObj(selected); + // Which mode the panel is being asked about. Kinematic and force analysis are + // separate modes now and the panel shows one at a time, so a spec about force + // rows has to be asking the force question. + const tabs = { getCurrentTab: () => mode } as unknown as SelectedTabService; + await TestBed.configureTestingModule({ - declarations: [AnalysisPanelComponent], - imports: [ReactiveFormsModule, MatFormFieldModule, NoopAnimationsModule], + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + NoopAnimationsModule, + AnalysisPanelComponent, + ], providers: [ { provide: ActiveObjService, useValue: fixtureData.active }, { provide: MechanismService, useValue: fixtureData.service }, { provide: SettingsService, useValue: fixtureData.settings }, + { provide: SelectedTabService, useValue: tabs }, ], schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); + }) + // The panel renders its graph sections as inert unknown elements — these + // tests read the section labels, not the graphs inside them. As a + // standalone component the panel brings the real section along, so it is + // removed here to keep the label an attribute the specs can read. + .overrideComponent(AnalysisPanelComponent, { + remove: { imports: [AnalysisGraphSectionComponent] }, + add: { schemas: [NO_ERRORS_SCHEMA] }, + }) + .compileComponents(); const fixture: ComponentFixture<AnalysisPanelComponent> = TestBed.createComponent(AnalysisPanelComponent); return { fixture, fixtureData }; } +/** + * What the panel calls each of its graphs, read off the section it renders. + * + * From the property rather than the attribute: an interpolated binding sets the + * property, and this spec stubs the section component, so the attribute holds + * only the static half of the string. + */ +function sectionLabels(fixture: ComponentFixture<AnalysisPanelComponent>): string[] { + return [...fixture.nativeElement.querySelectorAll('app-analysis-graph-section')].map( + (section: Element) => + ( + (section as unknown as { label?: string }).label ?? + section.getAttribute('label') ?? + '' + ).trim() + ); +} + describe('AnalysisPanelComponent welded mechanism regression', () => { beforeEach(() => vi.spyOn(console, 'log').mockImplementation(() => undefined)); afterEach(() => { @@ -43,25 +82,26 @@ describe('AnalysisPanelComponent welded mechanism regression', () => { TestBed.resetTestingModule(); }); - it('renders analysis controls for a valid loopless welded link instead of blanking', async () => { - const { fixture } = await createPanel(LOOPLESS_WELDED_MECHANISM, 'A'); + it('renders force controls for a valid loopless welded link instead of blanking', async () => { + // Force analysis is its own mode now, so this asks the force question and + // counts only what that question answers: a graph per reacting link, plus + // the input effort. The kinematic graphs are the other mode's, and the + // test below is where they are counted. + const { fixture } = await createPanel(LOOPLESS_WELDED_MECHANISM, 'A', TabID.FORCE); expect(() => fixture.detectChanges()).not.toThrow(); const rows = fixture.componentInstance.jointForceRows(); expect(rows.length).toBeGreaterThan(0); - Object.assign(fixture.componentInstance.graphExpanded, { - JPos: true, - JInputForce: true, - }); + fixture.componentInstance.graphExpanded['JInputForce'] = true; for (const row of rows) { fixture.componentInstance.graphExpanded['JForce_' + row.linkId] = true; } fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('Analysis for Joint A'); - expect(fixture.nativeElement.textContent).toContain('Force Analysis'); - expect(fixture.nativeElement.querySelectorAll('app-analysis-graph').length).toBe( - rows.length + 2 + expect(fixture.nativeElement.textContent).toContain('Force Analysis for Joint A'); + expect(fixture.nativeElement.textContent).not.toContain('Kinematic Analysis'); + expect(fixture.nativeElement.querySelectorAll('app-analysis-graph-section').length).toBe( + rows.length + 1 ); fixture.destroy(); @@ -82,18 +122,28 @@ describe('AnalysisPanelComponent welded mechanism regression', () => { fixture.destroy(); }); - it('explains that an internal welded joint has no independent pin reaction', async () => { - const { fixture } = await createPanel(LOOPLESS_WELDED_MECHANISM, 'B'); + it('explains a welded-in joint plainly, and shows nothing else to configure', async () => { + const { fixture } = await createPanel(LOOPLESS_WELDED_MECHANISM, 'B', TabID.FORCE); fixture.detectChanges(); expect(fixture.componentInstance.jointForceRows()).toHaveLength(0); - expect(fixture.nativeElement.textContent).toContain('internal to one welded body'); - expect(fixture.nativeElement.textContent).toContain('no independent pin reaction'); + const text = fixture.nativeElement.textContent; + // "Only one part" covers the tracer on a plain ternary link as well as a + // welded-in pin — a joint can have no reaction without a weld anywhere. + expect(text).toContain('Only one part meets Joint'); + expect(text).toContain('Click a joint where separate parts meet'); + // With no graph to configure, the mode selector and the input-speed + // summary would describe things that are not on the panel. + expect(text).not.toContain('Force Analysis Type'); + expect(text).not.toContain('input speed is set'); + // And it is the help-card hint pattern, not a boxed banner. + expect(fixture.nativeElement.querySelector('.helpHint')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.analysis-message')).toBeNull(); fixture.destroy(); }); it('lists one force row per link reacting at an external multi-link pin', async () => { - const { fixture } = await createPanel(TEMPLATE_LINKAGES['4-Bar'], 'B'); + const { fixture } = await createPanel(TEMPLATE_LINKAGES['4-Bar'], 'B', TabID.FORCE); fixture.detectChanges(); const rows = fixture.componentInstance.jointForceRows(); @@ -101,29 +151,29 @@ describe('AnalysisPanelComponent welded mechanism regression', () => { expect(rows.every((row) => row.jointId === 'B')).toBe(true); expect(fixture.nativeElement.querySelector('.reaction-link-selector')).toBeNull(); - const titles = [...fixture.nativeElement.querySelectorAll('mat-panel-title')].map( - (title: Element) => title.textContent!.trim() - ); + const titles = sectionLabels(fixture); for (const row of rows) { - expect(titles).toContain(`Force on Link ${row.linkName}`); + // The row already carries the body's noun -- "Link AB", "Rod GC", + // "Block at C" -- because what a body is called depends on what kind it + // is, and the label is the one place that decides. + expect(titles).toContain(`Force on ${row.linkName}`); } fixture.destroy(); }); it('lists one force row per external joint on a link, sharing the joint-side dataset', async () => { - const { fixture } = await createPanel(TEMPLATE_LINKAGES['4-Bar'], 'BC'); + const { fixture } = await createPanel(TEMPLATE_LINKAGES['4-Bar'], 'BC', TabID.FORCE); fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('Analysis for Link BC'); - expect(fixture.nativeElement.textContent).not.toContain('Kinematic Analysis for Link'); + // The panel names the analysis it is showing, which is the heading the + // accordion inside it used to carry. + expect(fixture.nativeElement.textContent).toContain('Force Analysis for Link BC'); const rows = fixture.componentInstance.linkForceRows(); expect(rows.map((row) => row.jointId)).toEqual(['B', 'C']); expect(rows.every((row) => row.linkId === 'BC')).toBe(true); - const titles = [...fixture.nativeElement.querySelectorAll('mat-panel-title')].map( - (title: Element) => title.textContent!.trim() - ); + const titles = sectionLabels(fixture); expect(titles).toContain('Force at Joint B'); expect(titles).toContain('Force at Joint C'); fixture.destroy(); @@ -152,8 +202,9 @@ describe('AnalysisPanelComponent welded mechanism regression', () => { }); fixture.detectChanges(); - const graphs = [...fixture.nativeElement.querySelectorAll('app-analysis-graph')]; - const properties = graphs.map((graph: Element) => graph.getAttribute('mechprop')); + const properties = [ + ...fixture.nativeElement.querySelectorAll('app-analysis-graph-section'), + ].map((section: Element) => section.getAttribute('mechprop')); expect(properties).toEqual( expect.arrayContaining(['Linear Joint Pos', 'Linear Joint Vel', 'Linear Joint Acc']) ); @@ -175,8 +226,10 @@ describe('AnalysisPanelComponent welded mechanism regression', () => { }); fixture.detectChanges(); - expect(fixture.nativeElement.querySelectorAll('app-analysis-graph')).toHaveLength(6); - expect(fixture.nativeElement.querySelector('mat-panel-title mat-panel-title')).toBeNull(); + expect(fixture.nativeElement.querySelectorAll('app-analysis-graph-section')).toHaveLength(6); + // One heading each, and no accordion wrapping the six of them. + expect(sectionLabels(fixture)).toHaveLength(6); + expect(fixture.nativeElement.querySelector('collapsible-subseciton')).toBeNull(); fixture.destroy(); }); @@ -221,6 +274,28 @@ describe('AnalysisPanelComponent with a cylinder selected', () => { expect(fixture.nativeElement.textContent).toContain('Analysis for Cylinder'); expect(fixture.nativeElement.textContent).not.toContain('Analysis for Link GN'); + // And so does every graph under the heading. A panel that calls the body a + // cylinder at the top and a link in every row below has only moved the + // disagreement down the page. + const labels = sectionLabels(fixture); + expect(labels.some((label) => label.includes('Link GN'))).toBe(false); + expect(labels).toContain('Angle of Cylinder GC'); + fixture.destroy(); + }); + + it("sends the reader to the cylinder's own panel to change its speed", async () => { + // The joint driving a cylinder is buried inside it: no hitbox on the + // canvas, no row in the Edit panel. "The input joint's Edit panel" names + // somewhere the reader cannot get to. + const { fixture } = await createPanel(TEMPLATE_LINKAGES['Cylinder_Boom'], 'GN'); + fixture.detectChanges(); + + // The hint is passed to a block component the schema here stubs out, so + // it is read off the panel rather than off the rendered text. + const hint = fixture.componentInstance.inputEditHint; + expect(hint).toContain('Expansion Speed'); + expect(hint).toContain('Cylinder GC'); + expect(hint).not.toContain("input joint's Edit panel"); fixture.destroy(); }); @@ -242,3 +317,42 @@ describe('AnalysisPanelComponent with a cylinder selected', () => { fixture.destroy(); }); }); + +describe('AnalysisPanelComponent with a slider selected', () => { + it('gives a pin the force in its bar and the force in its slot', async () => { + const { fixture, fixtureData } = await createPanel( + TEMPLATE_LINKAGES['Slider_Crank'], + pinOfSlider(TEMPLATE_LINKAGES['Slider_Crank']), + TabID.FORCE + ); + fixture.detectChanges(); + const labels = fixture.componentInstance.jointForceRows().map((row) => row.label); + + // A slider is one thing to a reader and three bodies to the solver. The + // block's force at the pin is the bar's force negated -- the same number + // this panel already shows under the bar's own name -- and what the block + // has of its own is the force in its slot, which carries the block's own + // weight. Measured: with the block at 1g the slot reads 0.0147N, and with + // it at 500g it reads 4.9N, while the force at the pin does not move. + expect(labels).toEqual(['Force on Link BC', 'Force on the ground']); + expect(labels.some((label) => /Block/.test(label))).toBe(false); + expect(fixtureData.service.links.length).toBeGreaterThan(0); + }); + + it('names a slot after the slider a reader can point at', async () => { + const { fixture } = await createPanel(TEMPLATE_LINKAGES['Scotch_Yoke'], 'CD', TabID.FORCE); + fixture.detectChanges(); + const labels = fixture.componentInstance.linkForceRows().map((row) => row.label); + + // The slot cut into this link is a joint with no marker, no hitbox and no + // name anyone has been shown. + expect(labels.some((label) => label.includes('the slider at'))).toBe(true); + expect(labels.some((label) => /Joint [EF]/.test(label))).toBe(false); + }); +}); + +/** The pin of the first slider in a payload, which is what a reader clicks. */ +function pinOfSlider(payload: string): string { + const built = buildMechanismFixture(payload); + return built.service.joints.find((joint) => !!built.service.sliderFor(joint))!.id; +} diff --git a/src/app/component/analysis-panel/analysis-panel.component.ts b/src/app/component/analysis-panel/analysis-panel.component.ts index 35ae42ad..6d32797b 100644 --- a/src/app/component/analysis-panel/analysis-panel.component.ts +++ b/src/app/component/analysis-panel/analysis-panel.component.ts @@ -1,13 +1,23 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { SelectedTabService, TabID } from '../../selected-tab.service'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ForceAnalysisMode, ForceReactionIndex } from 'src/app/model/mechanism/force-solver'; +import { Mechanism } from 'src/app/model/mechanism/mechanism'; import { PrisJoint, RealJoint } from 'src/app/model/joint'; -import { Cylinder, isCylinderInterior } from 'src/app/model/cylinder'; +import { Cylinder, cylinderJoints, isCylinderInterior } from 'src/app/model/cylinder'; import { LengthUnit } from 'src/app/model/unit-enums'; import { ActiveObjService } from 'src/app/services/active-obj.service'; -import { FormBuilder } from '@angular/forms'; +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { Subscription } from 'rxjs'; import { MechanismService } from '../../services/mechanism.service'; import { SettingsService } from '../../services/settings.service'; +import { PanelSectionCollapsibleComponent } from '../BLOCKS/panel-section-collapsible/panel-section-collapsible.component'; +import { TitleBlock } from '../BLOCKS/title/title.component'; +import { MatList, MatListItem, MatListItemTitle } from '@angular/material/list'; +import { MatIcon } from '@angular/material/icon'; +import { MechanismPanelComponent } from '../mechanism-panel/mechanism-panel.component'; +import { PanelSectionComponent } from '../BLOCKS/panel-section/panel-section.component'; +import { AnalysisGraphSectionComponent } from '../analysis-graph-section/analysis-graph-section.component'; +import { RadioComponent } from '../BLOCKS/radio/radio.component'; /** One expandable force graph: the reaction between `linkId` and `jointId`. */ export interface ForceAnalysisRow { @@ -15,6 +25,14 @@ export interface ForceAnalysisRow { jointName: string; linkId: string; linkName: string; + /** + * What to head this graph with. + * + * Carried rather than composed at the template, because a slider does not + * read as either of the two names: a pin's second reaction is the force in + * its slot, and it belongs to the thing the slot is cut into. + */ + label: string; } @Component({ @@ -22,15 +40,68 @@ export interface ForceAnalysisRow { templateUrl: './analysis-panel.component.html', styleUrls: ['./analysis-panel.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + PanelSectionCollapsibleComponent, + TitleBlock, + MatList, + MatListItem, + MatListItemTitle, + MatIcon, + MechanismPanelComponent, + PanelSectionComponent, + AnalysisGraphSectionComponent, + RadioComponent, + FormsModule, + ReactiveFormsModule, + ], }) export class AnalysisPanelComponent { + activeSrv = inject(ActiveObjService); + private fb = inject(FormBuilder); + mechanismService = inject(MechanismService); + settingsService = inject(SettingsService); + private tabs = inject(SelectedTabService); + + /** + * Which half of the analysis this mode is asking for. + * + * The two used to sit one above the other in a single Analyze panel, which + * meant every reader scrolled past the answer they did not want and neither + * question could say what *it* needed. They are separate modes now, so the + * panel shows one at a time. + */ + /** + * What the empty analysis panel says, which depends on why it is empty. + * + * Nothing selected is a different situation from a selection whose mechanism + * cannot run, and telling the second reader to select something would send + * them round a loop they are already standing in. + */ + get analysisHelpLead(): string { + const selected = this.activeSrv.getSelectedObjType(); + if (selected === 'Joint' || selected === 'Link') { + const part = + this.activeSrv.objType === 'Joint' + ? this.activeSrv.selectedJoint + : this.activeSrv.selectedLink; + const owner = part ? this.mechanismService.indexOfMechanismContaining(part) : -1; + if (owner !== -1 && !this.mechanismService.mechanisms[owner]?.isMechanismValid()) { + return `Finish analysis setup on ${this.mechanismService.partitions[owner].id} to see its graphs.`; + } + } + return 'Select a part of the linkage to analyze it.'; + } + + get showKinematic(): boolean { + return this.tabs.getCurrentTab() !== TabID.FORCE; + } + + get showForce(): boolean { + return this.tabs.getCurrentTab() === TabID.FORCE; + } + //A dictionary for wether each graph is expanded or not graphExpanded: { [key: string]: boolean } = { - LKineAna: true, - LForceAna: true, - JKineAna: true, - JForceAna: true, LAng: false, LAngVel: false, LAngAcc: false, @@ -42,9 +113,10 @@ export class AnalysisPanelComponent { JVel: false, JAcc: false, JInputForce: false, + LInputForce: false, }; - mechStateSub: any; + mechStateSub?: Subscription; private subscriptions = new Subscription(); private rowCache?: { key: string; mechanism: unknown; rows: ForceAnalysisRow[] }; @@ -56,17 +128,54 @@ export class AnalysisPanelComponent { * 5 cm/s as turning at 5.00 RPM clockwise. */ get inputSummary(): string { - const driven = this.mechanismService.joints.find( - (joint) => joint instanceof RealJoint && joint.input - ); + const driven = this.drivenJointOfSelection; + const signed = this.mechanismService.driveSpeedOf(driven); + const speed = Math.abs(signed); if (driven instanceof PrisJoint) { - const speed = this.settingsService.linearInputSpeed.value.toFixed(2); - const direction = this.settingsService.isInputCW.value ? 'retracting' : 'extending'; - return `The input speed is set to ${speed} ${this.linearSpeedUnit}, ${direction}.`; + const direction = signed < 0 ? 'retracting' : 'extending'; + return `The input speed is set to ${speed.toFixed(2)} ${this.linearSpeedUnit}, ${direction}.`; } - const speed = this.settingsService.inputSpeed.value.toFixed(2); - const direction = this.settingsService.isInputCW.value ? 'clockwise' : 'counter-clockwise'; - return `The input speed is set to ${speed} RPM in the ${direction} direction.`; + const direction = signed < 0 ? 'clockwise' : 'counter-clockwise'; + return `The input speed is set to ${speed.toFixed(2)} RPM in the ${direction} direction.`; + } + + /** + * Where that speed is actually changed. + * + * A cylinder is driven by a joint buried inside it that the canvas gives no + * hitbox and the Edit panel never lists, so sending the reader to "the input + * joint" sends them somewhere they cannot go. The field is on the cylinder, + * under its own name. + */ + get inputEditHint(): string { + const driven = this.drivenJointOfSelection; + const sealed = driven && this.mechanismService.cylinderAt(driven); + if (sealed) { + const name = `Cylinder ${sealed.barrelFar.name || sealed.barrelFar.id}${ + sealed.rodFar.name || sealed.rodFar.id + }`; + return `This can be changed under Expansion Speed in ${name}'s Edit panel.`; + } + return "This can be changed in the input joint's Edit panel."; + } + + /** + * The drive of the mechanism the selection belongs to. + * + * A drawing can hold several mechanisms turning at different speeds, so the + * first driven joint in the document is only the right answer when there is + * one of them. + */ + private get drivenJointOfSelection(): RealJoint | undefined { + const selection = + this.activeSrv.objType === 'Link' + ? this.activeSrv.selectedLink + : this.activeSrv.objType === 'Joint' + ? this.activeSrv.selectedJoint + : undefined; + const own = selection && this.mechanismService.partitionContaining(selection); + const pool = own ? own.joints : this.mechanismService.joints; + return pool.find((joint): joint is RealJoint => joint instanceof RealJoint && joint.input); } /** Length per second, spelled the way the mechanism's length unit is. */ @@ -75,12 +184,7 @@ export class AnalysisPanelComponent { return unit === LengthUnit.INCH ? 'in/s' : unit === LengthUnit.METER ? 'm/s' : 'cm/s'; } - constructor( - public activeSrv: ActiveObjService, - private fb: FormBuilder, - public mechanismService: MechanismService, - public settingsService: SettingsService - ) { + constructor() { this.forceAnalysisFormGroup.patchValue( { mode: this.settingsService.forceAnalysisMode.value === 'dynamic' ? '1' : '0' }, { emitEvent: false } @@ -127,15 +231,66 @@ export class AnalysisPanelComponent { return this.settingsService.forceAnalysisMode.value; } - /** Joint id -> link ids and back, for the single mechanism-wide reaction set. */ - private reactionIndex(): ForceReactionIndex | undefined { - if (!this.mechanismService.oneValidMechanismExists()) return undefined; - return this.mechanismService.mechanisms[0]?.getForceAnalysis(this.forceAnalysisMode()) - .reactionIndex; + /** Joint id -> link ids and back, for one mechanism's reaction set. */ + private reactionIndex(mechanism: Mechanism | undefined): ForceReactionIndex | undefined { + if (!mechanism?.isMechanismValid()) return undefined; + return mechanism.getForceAnalysis(this.forceAnalysisMode()).reactionIndex; } + /** + * The machine the selected part belongs to. Reactions are a property of one + * machine, so a part in another one -- or in none, as an ungrounded chain is + * -- must not be answered out of whichever mechanism came first. + */ + private mechanismFor(kind: 'joint' | 'link', partId: string): Mechanism | undefined { + const part = + kind === 'joint' + ? this.jointById(partId) + : this.mechanismService.links.find((link) => link.id === partId); + return part ? this.mechanismService.mechanismContaining(part) : undefined; + } + + /** + * What a row calls the body it graphs -- the name the reader has seen on the + * canvas, not the internal one. + * + * A cylinder's rod carries the id of the pin buried inside it, and a slider + * block's ends in the letter of the sliding joint under it. Both were + * printed straight into the label, so the panel offered a force on a link + * the reader had never been shown and could not find. + */ private linkName(linkId: string): string { - return this.mechanismService.links.find((link) => link.id === linkId)?.name ?? linkId; + const body = this.mechanismService.links.find((link) => link.id === linkId); + return body ? this.mechanismService.bodyLabel(body) : linkId; + } + + /** + * One reaction a joint carries, named for what is on the other side of it. + * + * A slider's block is not one of them. It is a zero-length link between the + * pin and its slot, so the force between the pin and the block is the force + * between the pin and the bar, negated -- already on this panel under the + * bar's own name. What the block has of its own is the force in the slot, + * which is what sizes a slide and is reachable from nowhere else. + */ + private jointRow(jointId: string, linkId: string): ForceAnalysisRow { + const slider = this.mechanismService.slotReactionOf(this.jointById(jointId)); + if (slider && slider.block.id === linkId) { + return { + jointId: slider.slot.id, + jointName: this.jointName(slider.slot.id), + linkId, + linkName: slider.on, + label: `Force on ${slider.on}`, + }; + } + return { + jointId, + jointName: this.jointName(jointId), + linkId, + linkName: this.linkName(linkId), + label: `Force on ${this.linkName(linkId)}`, + }; } private jointName(jointId: string): string { @@ -147,29 +302,35 @@ export class AnalysisPanelComponent { * changes; the template reads them on every change-detection pass. */ private cachedRows(kind: 'joint' | 'link', partId: string): ForceAnalysisRow[] { - const mechanism = this.mechanismService.mechanisms[0]; + const mechanism = this.mechanismFor(kind, partId); const key = `${kind}|${partId}|${this.forceAnalysisMode()}`; if (this.rowCache?.key === key && this.rowCache.mechanism === mechanism) { return this.rowCache.rows; } - const index = this.reactionIndex(); + const index = this.reactionIndex(mechanism); let rows: ForceAnalysisRow[] = []; if (index && partId) { rows = kind === 'joint' - ? (index.linksByJoint.get(partId) ?? []).map((linkId) => ({ - jointId: partId, - jointName: this.jointName(partId), - linkId, - linkName: this.linkName(linkId), - })) - : (index.jointsByLink.get(partId) ?? []).map((jointId) => ({ - jointId, - jointName: this.jointName(jointId), - linkId: partId, - linkName: this.linkName(partId), - })); + ? (index.linksByJoint.get(partId) ?? []).map((linkId) => this.jointRow(partId, linkId)) + : this.bodyMemberIds(partId).flatMap((memberId) => + (index.jointsByLink.get(memberId) ?? []).map((jointId) => { + // A slot is named after the slider it belongs to: it has no + // marker of its own and no name a reader has ever seen. + const where = + this.mechanismService.slotName(jointId) ?? `Joint ${this.jointName(jointId)}`; + return { + jointId, + jointName: this.jointName(jointId), + // The member that actually meets this joint, not the body the + // reader selected: it is what the reaction is asked of. + linkId: memberId, + linkName: this.linkName(memberId), + label: `Force at ${where}`, + }; + }) + ); rows.sort((a, b) => kind === 'joint' ? a.linkName.localeCompare(b.linkName) @@ -185,6 +346,48 @@ export class AnalysisPanelComponent { return this.cachedRows('joint', this.activeSrv.selectedJoint?.id ?? ''); } + /** In Force mode, does the selected joint have any graph to offer? */ + get jointForceHasGraphs(): boolean { + return this.jointForceRows().length > 0 || !!this.activeSrv.selectedJoint?.input; + } + + /** In Force mode, does the selected link have any graph to offer? */ + get linkForceHasGraphs(): boolean { + return this.linkForceRows().length > 0 || !!this.inputEffortJoint(); + } + + /** + * The heading's description. A part with nothing to graph gets the cause in + * the description and the way out in the hint row below — the speed summary + * and the mode selector describe graphs that are not on the panel. + */ + get panelDescription(): string { + if (this.showForce && this.activeSrv.objType === 'Joint' && !this.jointForceHasGraphs) { + return `Only one part meets Joint ${this.activeSrv.selectedJoint?.name}, so there is no force to graph here.`; + } + if (this.showForce && this.activeSrv.objType === 'Link' && !this.linkForceHasGraphs) { + return `${this.selectedBodyLabel} does not meet another part at any of its joints, so there is no force to graph here.`; + } + return `${this.inputSummary} ${this.inputEditHint}`; + } + + /** + * The links a selected body is made of. + * + * One for an ordinary bar. A cylinder is one body to the reader and three + * links to the solver, and its two mounts sit on different ones -- the + * barrel carries the far mount, the rod carries the other. Asking only the + * link the canvas hands over (the barrel) listed the barrel's mount and + * silently dropped the rod's, so a ram showed a force at one end and nothing + * at the end it is pushing. + */ + private bodyMemberIds(partId: string): string[] { + const body = this.mechanismService.links.find((link) => link.id === partId); + const sealed = body && this.mechanismService.cylinderAt(body); + if (!sealed) return [partId]; + return [sealed.barrel.id, sealed.rod.id, sealed.block.id]; + } + /** One row per external joint of the selected link. */ linkForceRows(): ForceAnalysisRow[] { const rows = this.cachedRows('link', this.activeSrv.selectedLink?.id ?? ''); @@ -216,6 +419,23 @@ export class AnalysisPanelComponent { * it -- the canvas outlines the whole ram while the panel headed itself * "Analysis for Link GN". */ + /** + * Which analysis this panel is showing. + * + * It used to be an accordion inside the panel headed "Kinematic Analysis" -- + * a heading that only ever said what mode the reader had already chosen, and + * one more thing to open before reaching a graph. The panel's own title says + * it now. + */ + /** Point at the thing on the grid these numbers describe, while asked to. */ + highlightCoM(on: boolean): void { + this.settingsService.previewCoMLinkId = on ? (this.activeSrv.selectedLink?.id ?? null) : null; + } + + get modeLabel(): string { + return this.tabs.getCurrentTab() === TabID.FORCE ? 'Force Analysis' : 'Kinematic Analysis'; + } + get selectedBodyLabel(): string { const sealed = this.selectedCylinder; if (!sealed) return `Link ${this.activeSrv.selectedLink.name}`; @@ -224,14 +444,33 @@ export class AnalysisPanelComponent { }`; } - inputEffortLabel(): string { - const joint = this.activeSrv.selectedJoint; + inputEffortLabel(driven?: RealJoint): string { + const joint = driven ?? this.activeSrv.selectedJoint; return joint instanceof PrisJoint || joint?.connectedJoints.some((candidate) => candidate instanceof PrisJoint && candidate.input) ? 'Force' : 'Torque'; } + /** + * The driven joint a selected body carries, for the bodies whose drive the + * reader cannot select on its own. + * + * A ram is driven by a joint buried inside the part: no marker, no hitbox, + * no row in the Edit panel. The graph of the effort that drive has to supply + * lived on the joint panel, so for a ram it lived on a panel nobody could + * open -- the one input in the app whose own force could not be read. Every + * other input sits on a joint a reader can click, and the joint panel + * already carries it there. + */ + inputEffortJoint(): RealJoint | undefined { + const sealed = this.selectedCylinder; + if (!sealed) return undefined; + return cylinderJoints(sealed).find( + (joint): joint is RealJoint => joint instanceof RealJoint && joint.input + ); + } + validMechanisms() { return this.mechanismService.oneValidMechanismExists(); } diff --git a/src/app/component/analysis-setup/analysis-setup.component.html b/src/app/component/analysis-setup/analysis-setup.component.html new file mode 100644 index 00000000..0d97db07 --- /dev/null +++ b/src/app/component/analysis-setup/analysis-setup.component.html @@ -0,0 +1,192 @@ +<div class="setup" appScrollShadow> + <div class="setupHead"> + <div class="setupTitle">{{ title }}</div> + <p class="setupSummary">{{ summary }}</p> + </div> + + @if (mode() === 'kinematic') { + @for (item of readiness; track item.id) { + <div class="mechBlock"> + <button class="sectionHeader" (click)="toggle(item)"> + <span class="sectionName"> + <span class="mechLink" (click)="select($index, $event)">Mechanism {{ item.id }}</span> + <span class="chip" [class]="chipFor(item).kind">{{ chipFor(item).text }}</span> + </span> + <!-- Only where there is something behind it. A ready mechanism has + no blockers, and an arrow that opens onto nothing is a promise + the panel cannot keep. --> + @if (item.checks.length > 0) { + <mat-icon>{{ isOpen(item) ? 'expand_less' : 'expand_more' }}</mat-icon> + } + </button> + + @if (isOpen(item)) { + <div class="checkList"> + @for (check of item.checks; track check.title) { + <div class="check"> + <mat-icon class="checkIcon" [class]="check.state">{{ iconFor(check) }}</mat-icon> + <div class="checkBody"> + <div class="checkTitle">{{ check.title }}</div> + <p class="checkText">{{ check.body }}</p> + @if (check.action && check.at) { + <button class="actionButton" (click)="goTo(check.at)"> + {{ check.action }} {{ nameOf(check.at) }} + </button> + } + </div> + </div> + } + </div> + } + </div> + } + } + + @if (mode() === 'force') { + @if (massRows().length > 0) { + <div class="mechBlock"> + <button class="sectionHeader" (click)="massesOpen = !massesOpen"> + <span class="sectionName">Masses</span> + @if (!inEditMode()) { + <span class="sectionHint">Editable in the Edit tab</span> + } + <mat-icon>{{ massesOpen ? 'expand_less' : 'expand_more' }}</mat-icon> + </button> + @if (massesOpen) { + <div class="massTable" [class.readonly]="!massEditable()"> + <div class="massRow head"> + <span>Body</span> + <span>Mass</span> + <span>Rotational inertia</span> + <span></span> + </div> + @for (row of massRows(); track row.body.id) { + <div class="massRow" [class.weightless]="!(row.body.mass > 0)"> + <span class="massName">{{ row.label }}</span> + <input-block class="cellField" [formGroup]="tableForm" [_formControl]="massControlName(row)" /> + <input-block + class="cellField" + [formGroup]="tableForm" + [_formControl]="moiControlName(row)" + [class.derived]="row.isBlock || moiIsAuto(row)" + > + @if (massEditable() && !row.isBlock && row.body.mass > 0) { + <span fieldSuffix class="stateSuffix"> + <span class="dot" [class.filled]="!moiIsAuto(row)"></span> + </span> + } + </input-block> + @if (massEditable()) { + <button + class="rowJump" + title="Open this link in Edit mode" + (click)="goTo(row.body)" + > + <mat-icon>arrow_outward</mat-icon> + </button> + } @else { + <span></span> + } + </div> + } + @if (massEditable()) { + <div class="dotLegend"> + <span class="dot"></span> from shape + <span class="dot filled"></span> set by you + <button class="resetAll" [disabled]="!anyBodyIsCustom()" (click)="resetAllBodies()"> + Reset all + </button> + </div> + <p class="checkText note"> + A body left at zero mass is skipped by gravity and inertia. Open a link to place its + center of mass. + </p> + } + </div> + } + </div> + } + + <div class="mechBlock"> + <button class="sectionHeader" (click)="forceOpen = !forceOpen"> + <span class="sectionName"> + Force analysis + @if (forceOutstanding > 0) { + <span class="chip warning">{{ forceOutstanding }} to set</span> + } @else if (forceWarnings > 0) { + <span class="chip">{{ forceWarnings }} worth a look</span> + } @else { + <span class="chip ready">Ready</span> + } + </span> + <mat-icon>{{ forceOpen ? 'expand_less' : 'expand_more' }}</mat-icon> + </button> + + @if (forceOpen) { + <div class="checkList"> + @for (requirement of forceRequirements; track requirement.title) { + @if (!requirement.met) { + <div class="check"> + <mat-icon class="checkIcon" [class]="requirement.warning ? 'warning' : 'blocker'">{{ + requirement.warning ? 'warning_amber' : 'error_outline' + }}</mat-icon> + <div class="checkBody"> + <div class="checkTitle"> + {{ requirement.title }} + <span class="sevTag" [class.warn]="requirement.warning">{{ + requirement.warning ? 'Worth a look' : 'Needed' + }}</span> + </div> + <p class="checkText">{{ requirement.body }}</p> + <!-- Only where the panel can carry the fix out itself, and + only where the reader is allowed to edit: the sentence + above names the way out in either case, so a button that + cannot be pressed would add nothing but a dead control. --> + @if (requirement.act === 'gravity' && massEditable()) { + <button class="actionButton" (click)="turnGravityOn()">Turn On Gravity</button> + } + </div> + </div> + } + } + </div> + } + </div> + + } + + @if (mode() === 'kinematic' && unassigned.length > 0) { + <div class="mechBlock"> + <button class="sectionHeader muted" (click)="unassignedOpen = !unassignedOpen"> + <span class="sectionName"> + Not in any mechanism + <span class="chip">{{ unassigned.length }}</span> + </span> + <mat-icon>{{ unassignedOpen ? 'expand_less' : 'expand_more' }}</mat-icon> + </button> + + @if (unassignedOpen) { + <div class="checkList"> + <p class="checkText note"> + Drawn dashed on the grid. Analysis skips these, and they count towards no mechanism's + degrees of freedom. + </p> + @for (report of unassigned; track report.title) { + <div class="check"> + <mat-icon class="checkIcon muted">scatter_plot</mat-icon> + <div class="checkBody"> + <div class="checkTitle">{{ report.title }}</div> + <p class="checkText">{{ report.body }}</p> + @if (report.at) { + <button class="actionButton" (click)="goTo(report.at)"> + Go To Joint {{ nameOf(report.at) }} + </button> + } + </div> + </div> + } + </div> + } + </div> + } +</div> diff --git a/src/app/component/analysis-setup/analysis-setup.component.scss b/src/app/component/analysis-setup/analysis-setup.component.scss new file mode 100644 index 00000000..97b4b17a --- /dev/null +++ b/src/app/component/analysis-setup/analysis-setup.component.scss @@ -0,0 +1,415 @@ +@use 'sass:map'; +@use '@angular/material' as mat; + +@mixin css($theme) { + $color-config: mat.m2-get-color-config($theme); + $primary-palette: map.get($color-config, 'primary'); + + // Its own card, because this panel is not built from panel-sections and the + // drawer behind it deliberately draws nothing: one card per panel, never a + // card inside a card. + // The card owns the scroll and keeps its title, exactly as `panel-section` + // does for every panel built out of blocks. This one is not, because it is a + // single card rather than a stack of sections -- so it has to say the same + // three things itself rather than inherit them. + .setup { + display: flex; + flex-direction: column; + // Ends under its last section, and only runs on when there is more list + // than room -- at which point the drawer's own cap stops it a gap above + // the view controls and this card takes over the scrolling. A card held + // open to the full height of the window on a drawing with one short list + // in it is mostly empty white. + height: auto; + max-height: 100%; + overflow-y: auto; + overflow-x: hidden; + // Its sticky head is raised above this card's own contents and no further, + // so it cannot come out over the drawer's close button beside it. + isolation: isolate; + padding-bottom: 8px; + background: var(--card-surface); + border-top: 5px solid mat.m2-get-color-from-palette($primary-palette, 500); + // 5px, matching the 5px accent bar above it: a border's inner edge is + // rounded by the corner radius minus the border width, so any radius wider + // than the bar curves the bar's underside. The floating chrome has no bar + // and keeps the wider corner. + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + } + + // Everything in the card is laid out at the card's own width, and stays + // there: a sticky head that could shrink would slide out from over a list + // wider than itself. + .setup > * { + width: 100%; + box-sizing: border-box; + flex: 0 0 auto; + } + + .setupHead { + position: sticky; + top: 0; + z-index: 2; + padding: 12px 15px 12px; + background: var(--card-surface); + box-shadow: 0 3px 6px -3px rgba(0, 0, 0, 0); + transition: box-shadow 0.15s ease-in-out; + } + + // Something above, out of sight: the list runs under the head, and without + // this a scrolled card read as a card that simply started there. + .setup.scrolled > .setupHead { + box-shadow: 0 3px 6px -3px rgba(0, 0, 0, 0.35); + } + + .setupTitle { + font-size: 20px; + font-weight: 500; + line-height: 28px; + } + + .setupSummary { + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.6); + margin: 6px 0 0; + } + + .sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + /* Padding inside the 100%, not on top of it: content-box pushed the + right-hand element 15px past the drawer's edge and sliced it off. */ + box-sizing: border-box; + min-height: 40px; + padding: 0 15px; + border: none; + border-top: 1px solid #eceef5; + background: transparent; + font-family: inherit; + font-size: 15px; + font-weight: 500; + color: mat.m2-get-color-from-palette($primary-palette, 500); + cursor: pointer; + text-align: left; + + &.muted { + color: #5f6368; + } + + &:hover { + background: rgba(0, 0, 0, 0.02); + } + } + + .sectionName { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + } + + .mechLink { + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-color: rgba(63, 81, 181, 0.35); + cursor: pointer; + } + + .chip { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 10px; + background: #eef0f4; + color: #5f6368; + font-size: 12px; + font-weight: 400; + + &.blocker { + background: #fce8e6; + color: #c5221f; + } + + &.warning { + background: #fef3e0; + color: #b26a00; + } + + &.ok { + background: #e6f4ea; + color: #137333; + } + } + + .factGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 12px; + padding: 10px 15px 4px; + } + + .fact { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + } + + .factLabel { + font-size: 11px; + color: rgba(0, 0, 0, 0.45); + } + + .factValue { + font-size: 13px; + color: #2c2c2c; + } + + .checkList { + padding: 4px 15px 14px; + } + + .check { + display: flex; + gap: 10px; + padding-top: 10px; + } + + .checkIcon { + flex: 0 0 auto; + width: 19px; + height: 19px; + font-size: 19px; + line-height: 19px; + + &.blocker { + color: #d93025; + } + + &.warning { + color: #b26a00; + } + + &.muted { + color: rgba(0, 0, 0, 0.4); + } + } + + .checkBody { + flex: 1 1 auto; + min-width: 0; + } + + .checkTitle { + font-size: 13px; + font-weight: 500; + } + + .checkText { + font-size: 12px; + line-height: 18px; + color: rgba(0, 0, 0, 0.6); + margin: 2px 0 0; + + &.note { + margin: 4px 0 0; + } + } + + .massTable { + padding: 4px 16px 12px; + } + + .massRow { + display: grid; + /* Mass earns the room a value like "10.00 lbm" needs; the name column + gives it up — names are short and ellipsis covers the rare long one. */ + grid-template-columns: minmax(40px, 1fr) 76px 118px 16px; + + /* Grid items default to min-width auto, so a field's own content forces + its column wide and shoves the jump arrows off the drawer. */ + > * { min-width: 0; } + gap: 4px; + align-items: center; + padding: 3px 0; + font-size: 13px; + + &.head { + font-size: 9px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.5); + font-weight: 600; + padding-bottom: 5px; + margin-bottom: 3px; + white-space: nowrap; + /* The rule belongs under the header, where the mock draws it — not + under the legend, where a line reads as underlining the caption. */ + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } + + /* The whole weightless row mutes as one thing: label, both fields. */ + &.weightless .massName { color: rgba(0, 0, 0, 0.4); } + &.weightless .cellField .customInput { color: rgba(0, 0, 0, 0.35); } + } + + .num { text-align: right; } + + .massName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .massMoI { color: rgba(0, 0, 0, 0.6); } + + /* The table's cells ARE the shared input component; the only difference + is size, so size is the only thing written here. Label and help are the + row's own columns, so the component's header parts hide. */ + .cellField { + min-width: 0; + + .customInput { + font-size: 12.5px; + } + + #input-block .label, + #input-block .label-help, + #input-block .spacer { + display: none; + } + + #input-block .row { + gap: 0; + } + + #input-block .customInputForm .mat-mdc-text-field-wrapper { + width: 100%; + padding: 0 7px; + } + + &.derived .customInput { + color: rgba(0, 0, 0, 0.5); + } + } + + .dot { + display: inline-block; + width: 4px; + height: 4px; + border-radius: 50%; + border: 1.5px solid #9fa8da; + background: transparent; + flex: none; + } + + .dot.filled { background: #3f51b5; border-color: #3f51b5; } + + .stateSuffix { + display: inline-flex; + align-items: center; + margin-left: 4px; + } + + .dotLegend { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 8px 0 0; + font-size: 10.5px; + color: rgba(0, 0, 0, 0.55); + border-bottom: none; + + .resetAll { + margin-left: auto; + border: none; + background: none; + color: #3f51b5; + font: inherit; + font-weight: 500; + cursor: pointer; + padding: 0; + + &:disabled { color: rgba(0, 0, 0, 0.3); cursor: default; } + } + } + + .rowJump { + border: none; + background: none; + padding: 0; + cursor: pointer; + color: rgba(0, 0, 0, 0.4); + display: inline-flex; + justify-content: center; + width: 22px; + + mat-icon { font-size: 16px; width: 16px; height: 16px; } + &:hover { color: #3f51b5; } + } + + .sectionHeader .actionButton.slim { + margin: 0 0 0 auto; + height: 24px; + padding: 0 8px; + font-size: 11.5px; + flex: none; + white-space: nowrap; + } + + /* A statement, not a control: where the editing lives, said quietly, and + shrinkable so the drawer's edge never slices it. */ + .sectionHeader .sectionHint { + margin-left: auto; + font-size: 11.5px; + color: rgba(0, 0, 0, 0.5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 0 1 auto; + } + + /* Read-only: the mock washes the whole table out — names, values, fills — + rather than leaving rows that merely cannot be clicked. */ + .massTable.readonly { + .massName { color: rgba(0, 0, 0, 0.45); } + .cellField .customInput { color: rgba(0, 0, 0, 0.45); } + } + + .actionButton { + margin-top: 10px; + height: 32px; + padding: 0 14px; + border: 1px solid mat.m2-get-color-from-palette($primary-palette, 500); + border-radius: 4px; + background: #fff; + color: mat.m2-get-color-from-palette($primary-palette, 500); + font-family: inherit; + font-size: 13px; + cursor: pointer; + + &:hover { + background: mat.m2-get-color-from-palette($primary-palette, 50); + } + } +} + + .checkIcon.blocker { color: #b3261e; } + + .sevTag { + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + font-weight: 600; + color: #b3261e; + margin-left: 6px; + + &.warn { color: #9a6a00; } + } diff --git a/src/app/component/analysis-setup/analysis-setup.component.ts b/src/app/component/analysis-setup/analysis-setup.component.ts new file mode 100644 index 00000000..0818a0ea --- /dev/null +++ b/src/app/component/analysis-setup/analysis-setup.component.ts @@ -0,0 +1,459 @@ +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { Joint, RealJoint } from '../../model/joint'; +import { Link, RealLink, SliderBlock } from '../../model/link'; +import { MechanismService } from '../../services/mechanism.service'; +import { ActiveObjService } from '../../services/active-obj.service'; +import { NumberUnitParserService } from '../../services/number-unit-parser.service'; +import { SettingsService } from '../../services/settings.service'; +import { SelectedTabService, TabID } from '../../selected-tab.service'; +import { MechanismReadiness, ReadinessCheck } from '../../model/mechanism/readiness'; +import { MatIcon } from '@angular/material/icon'; +import { ScrollShadowDirective } from '../../scroll-shadow.directive'; +import { NotificationService } from '../../services/notification.service'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { InputComponent } from '../BLOCKS/input/input.component'; +import { Subscription } from 'rxjs'; +import { NOT_A } from '../../ui-text'; +import { EditPanelComponent } from '../edit-panel/edit-panel.component'; + +/** One editable row of the mass table: a body, and what to call it. */ +export interface MassRow { + body: Link; + label: string; + isBlock: boolean; +} + +/** + * What stands between this drawing and its animation, mechanism by mechanism. + * + * The app used to answer "why is nothing happening?" with one sentence about + * the whole document, first blocker wins — which for a drawing holding several + * machines is a sentence about whichever of them the loop reached first. Each + * is now listed on its own, with its own blockers, and each blocker says the + * way out rather than only naming the wall. + * + * Anything that is fine says nothing at all. A list of green ticks reads as + * reassurance the first time and as noise every time after, and it buries the + * one line that matters. + */ +@Component({ + selector: 'app-analysis-setup', + templateUrl: './analysis-setup.component.html', + styleUrls: ['./analysis-setup.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatIcon, ScrollShadowDirective, ReactiveFormsModule, InputComponent], +}) +export class AnalysisSetupComponent { + mechanism = inject(MechanismService); + activeObj = inject(ActiveObjService); + private tabs = inject(SelectedTabService); + nup = inject(NumberUnitParserService); + settings = inject(SettingsService); + private notify = inject(NotificationService); + + /** + * Which question this drawer is answering. + * + * The two are separate drawers because they have different fixes: a + * mechanism that will not run and a force analysis with nothing to react + * against are not the same problem, and a reader refused by one mode should + * not have to read past the other's list to find out why. + */ + readonly mode = input<'kinematic' | 'force'>('kinematic'); + /** Which mechanisms the reader has folded away, by id. */ + private collapsed = new Set<string>(); + unassignedOpen = false; + forceOpen = true; + massesOpen = true; + + get readiness(): MechanismReadiness[] { + return this.mechanism.readinessOfEachMechanism(); + } + + get unassigned() { + return this.mechanism.unassignedReports(); + } + + /** + * What force analysis still wants, listed beside the kinematic blockers + * rather than in a drawer of its own. + * + * The two questions have different answers but the same shape, and a reader + * who has just been refused by one tab is well served by seeing what the + * other would say too. Only shown while something is outstanding: a met + * requirement is a tick nobody needs to read twice. + */ + get forceRequirements() { + // Blockers first: the mock puts the thing that stops the analysis above + // the thing merely worth a look, whatever order the service found them. + return this.mechanism + .forceAnalysisRequirements() + .slice() + .sort((a, b) => (a.warning ? 1 : 0) - (b.warning ? 1 : 0)); + } + + get forceOutstanding(): number { + return this.forceRequirements.filter((requirement) => !requirement.met && !requirement.warning) + .length; + } + + get forceWarnings(): number { + return this.forceRequirements.filter((requirement) => !requirement.met && requirement.warning) + .length; + } + + /** + * One line for the whole drawing. + * + * Counted rather than listed, because the list is right underneath it. + */ + get title(): string { + return this.mode() === 'force' ? 'Force analysis setup' : 'Analysis setup'; + } + + get summary(): string { + if (this.mode() === 'force') { + const blockers = this.forceOutstanding; + const warnings = this.forceWarnings; + if (blockers > 0) { + const blockerText = blockers === 1 ? 'One blocker' : `${blockers} blockers`; + return warnings > 0 + ? `${blockerText}, ${warnings === 1 ? 'one thing' : `${warnings} things`} worth a look.` + : `${blockerText} before forces can be solved.`; + } + return warnings > 0 + ? `Force analysis runs. ${warnings === 1 ? 'One thing below is' : `${warnings} things below are`} worth a look before trusting the numbers.` + : 'Force analysis is ready to run.'; + } + const all = this.readiness; + if (all.length === 0) { + return this.unassigned.length > 0 + ? 'Nothing here is a mechanism yet. A chain has to reach ground before it has a position to solve for.' + : 'Draw a linkage to analyse it.'; + } + const blockers = this.mechanism.blockerCount(); + if (blockers > 0) { + return `${blockers} ${blockers === 1 ? 'thing has' : 'things have'} to change before ${ + all.length === 1 ? 'this mechanism' : 'every mechanism' + } will run.`; + } + const warnings = all.reduce( + (n, r) => n + r.checks.filter((c) => c.state === 'warning').length, + 0 + ); + if (warnings > 0) { + return `Everything runs. ${warnings === 1 ? 'One result is' : `${warnings} results are`} worth a look before trusting the numbers.`; + } + return all.length === 1 + ? 'Ready to animate.' + : `All ${all.length} mechanisms are ready to animate.`; + } + + isOpen(readiness: MechanismReadiness): boolean { + // Open when something is wrong, closed when nothing is — so a drawing that + // is fine collapses to a list of names. What a mechanism *is* lives in its + // own panel now; this drawer carries only what is in the way. + return this.collapsed.has(readiness.id) ? false : readiness.checks.length > 0; + } + + toggle(readiness: MechanismReadiness): void { + if (readiness.checks.length === 0) { + return; + } + if (this.isOpen(readiness)) { + this.collapsed.add(readiness.id); + } else { + this.collapsed.delete(readiness.id); + } + } + + chipFor(readiness: MechanismReadiness): { text: string; kind: 'blocker' | 'warning' | 'ok' } { + const blockers = readiness.checks.filter((c) => c.state === 'blocker').length; + if (blockers > 0) { + return { text: `${blockers} ${blockers === 1 ? 'blocker' : 'blockers'}`, kind: 'blocker' }; + } + const warnings = readiness.checks.length; + if (warnings > 0) { + return { text: `${warnings} ${warnings === 1 ? 'warning' : 'warnings'}`, kind: 'warning' }; + } + return { text: 'Ready', kind: 'ok' }; + } + + /** + * Select the whole machine this section is about. + * + * The way to a mechanism's own panel in *either* mode: the transport chip + * only exists while analysing, and Edit needs a route too. + */ + select(index: number, event: Event): void { + event.stopPropagation(); + this.activeObj.selectMechanism(index); + } + + iconFor(check: ReadinessCheck): string { + return check.state === 'blocker' ? 'error_outline' : 'warning_amber'; + } + + /** + * Every body force analysis will weigh, one editable row each. + * + * Setting up an analysis is mostly this: eight links, eight masses, and a + * panel that needs eight selections to reach them. The table is the same + * numbers in one place — and the place the massless warning points at. + * Cylinder parts appear under the part's own name, which is the first home + * their masses have had. + */ + /** + * The table's fields are the app's own input component, driven through this + * form — one control per cell, named by body — so focus, fill, underline + * and blur-commit are literally the same code path as every other field. + * Values refresh on mechanism updates, never per change-detection pass, + * so a half-typed entry is never stomped. + */ + tableForm = new FormGroup({}, { updateOn: 'blur' }); + private tableSubscriptions = new Map<string, Subscription>(); + private tableRefresh?: Subscription; + + ngOnInit(): void { + this.tableRefresh = this.mechanism.onMechUpdateState.subscribe(() => + this.refreshTableValues() + ); + } + + ngOnDestroy(): void { + this.tableRefresh?.unsubscribe(); + this.tableSubscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + massControlName(row: MassRow): string { + return 'm_' + row.body.id; + } + + moiControlName(row: MassRow): string { + return 'i_' + row.body.id; + } + + private ensureRowControls(rows: MassRow[]): void { + const wanted = new Set<string>(); + for (const row of rows) { + for (const [name, kind] of [ + [this.massControlName(row), 'mass'], + [this.moiControlName(row), 'moi'], + ] as const) { + wanted.add(name); + if (!this.tableForm.contains(name)) { + const control = new FormControl( + kind === 'mass' ? this.massText(row) : this.moiText(row) + ); + this.tableForm.addControl(name, control, { emitEvent: false }); + this.tableSubscriptions.set( + name, + control.valueChanges.subscribe((raw) => this.commitCell(row.body.id, kind, raw ?? '')) + ); + } + const control = this.tableForm.get(name)!; + const editable = + this.massEditable() && (kind === 'mass' || this.moiEditable(row)); + if (editable && control.disabled) control.enable({ emitEvent: false }); + if (!editable && control.enabled) control.disable({ emitEvent: false }); + } + } + for (const [name, subscription] of [...this.tableSubscriptions]) { + if (!wanted.has(name)) { + subscription.unsubscribe(); + this.tableSubscriptions.delete(name); + this.tableForm.removeControl(name as never, { emitEvent: false }); + } + } + } + + private refreshTableValues(): void { + for (const row of this.currentRows()) { + this.tableForm + .get(this.massControlName(row)) + ?.setValue(this.massText(row), { emitEvent: false }); + this.tableForm + .get(this.moiControlName(row)) + ?.setValue(this.moiText(row), { emitEvent: false }); + } + } + + private commitCell(bodyId: string, kind: 'mass' | 'moi', raw: string): void { + const row = this.currentRows().find((candidate) => candidate.body.id === bodyId); + if (!row) return; + if (kind === 'mass') this.applyMass(row, raw); + else this.applyInertia(row, raw); + this.refreshTableValues(); + } + + private currentRows(): MassRow[] { + return this.buildRows(); + } + + massRows(): MassRow[] { + const rows = this.buildRows(); + this.ensureRowControls(rows); + return rows; + } + + private buildRows(): MassRow[] { + // The label logic lives with the mechanism, shared with the massless + // warning — the table and the warning must call a body the same thing. + return this.mechanism.links + .filter((link) => link instanceof RealLink || link instanceof SliderBlock) + .map((body) => ({ + body, + label: this.mechanism.bodyLabel(body), + isBlock: body instanceof SliderBlock, + })); + } + + massText(row: MassRow): string { + // The unit is typed into the box, exactly as Basic Settings does it — and + // typing it back (or not) parses the same either way. + return this.nup.formatValueAndUnit( + row.body.mass, + this.nup.massUnitFor(this.settings.lengthUnit.value) + ); + } + + moiText(row: MassRow): string { + const length = this.settings.lengthUnit.value; + const display = this.nup.displayInertiaUnit(length); + if (row.isBlock || !(row.body instanceof RealLink)) { + // A block is a point mass: its inertia is zero and stays a disabled + // fact rather than a dash pretending the column does not apply. + return this.nup.formatValueAndUnit(0, display); + } + return this.nup.formatValueAndUnit( + this.nup.convertInertia(row.body.massMoI, this.nup.storedInertiaUnit(length), display), + display + ); + } + + moiIsAuto(row: MassRow): boolean { + return row.body instanceof RealLink && !row.body.moiIsCustom; + } + + /** + * Whether this row's inertia can be typed at. A block is a point mass with + * no inertia of its own, and a sealed cylinder's parts always follow their + * own shapes — which is what lets the cylinder card promise exactly that. + */ + moiEditable(row: MassRow): boolean { + if (row.isBlock || !(row.body instanceof RealLink)) return false; + if (!(row.body.mass > 0)) return false; + return !this.mechanism.cylinderAt(row.body); + } + + massUnitLabel(): string { + return this.nup.unitLabel(this.nup.massUnitFor(this.settings.lengthUnit.value)); + } + + inertiaUnitLabel(): string { + return this.nup.unitLabel(this.nup.displayInertiaUnit(this.settings.lengthUnit.value)); + } + + /** + * Editing is an Edit-mode thing, as everywhere else in the app: the + * analysis modes read a solved cycle and hold it still. The header offers + * the mode switch, so the read-only state is one click from the editable + * one — with the drawer staying open across it. + */ + massEditable(): boolean { + return this.tabs.getCurrentTab() === TabID.EDIT && !this.mechanism.isPlaying; + } + + inEditMode(): boolean { + return this.tabs.getCurrentTab() === TabID.EDIT; + } + + anyBodyIsCustom(): boolean { + return this.mechanism.links.some( + (link) => link instanceof RealLink && (link.moiIsCustom || link.comIsCustom) + ); + } + + resetAllBodies(): void { + if (!this.massEditable()) return; + for (const link of this.mechanism.links) { + if (link instanceof RealLink) { + link.moiIsCustom = false; + link.comIsCustom = false; + link.comOffset = undefined; + } + } + this.mechanism.updateMechanism(true); + this.mechanism.onMechUpdateState.next(2); + } + + /** + * Turn gravity back on from here, rather than sending the reader to Settings. + * + * The same edit the settings toggle makes, by the same three steps: gravity + * changes what a force analysis means, so the mechanisms are rebuilt, the + * change is undoable, and an open force graph is asked to redraw. + */ + turnGravityOn(): void { + if (!this.massEditable() || this.settings.isGravity.value) return; + this.settings.isGravity.next(true); + this.mechanism.updateMechanism(true); + this.mechanism.onMechUpdateState.next(2); + } + + private applyMass(row: MassRow, raw: string): void { + if (!this.massEditable()) return; + const [success, value] = this.nup.parseMassString( + raw, + this.nup.massUnitFor(this.settings.lengthUnit.value) + ); + if (!success || value < 0) { + this.notify.refusal('value.mass', NOT_A.mass); + return; + } + this.mechanism.assignBodyMass(row.body, value); + this.mechanism.updateMechanism(true); + this.mechanism.onMechUpdateState.next(2); + } + + private applyInertia(row: MassRow, raw: string): void { + if (!this.massEditable() || !this.moiEditable(row) || !(row.body instanceof RealLink)) return; + const length = this.settings.lengthUnit.value; + const display = this.nup.displayInertiaUnit(length); + const [success, value] = this.nup.parseInertiaString(raw, display); + if (!success || value < 0) { + this.notify.refusal('value.inertia', NOT_A.momentOfInertia); + return; + } + row.body.massMoI = this.nup.convertInertia(value, display, this.nup.storedInertiaUnit(length)); + row.body.moiIsCustom = true; + this.mechanism.updateMechanism(true); + this.mechanism.onMechUpdateState.next(2); + } + + /** + * Go to the part a check is about: select it, and switch to the mode where it + * can be changed. + * + * Deliberately not an undo step. Being shown where a problem is has not + * changed the mechanism, and pressing Undo afterwards should take back the + * last edit rather than the last time the reader looked at something. + */ + goTo(part: Joint | Link | undefined): void { + if (!part) return; + this.tabs.setTab(TabID.EDIT); + if (part instanceof RealJoint || part instanceof RealLink) { + this.activeObj.updateSelectedObj(part); + } + // The arrow next to a mass cell points at the mass fields, so land on + // them even when the reader last left that section folded shut. + if (part instanceof RealLink && EditPanelComponent.instance) { + EditPanelComponent.instance.sectionExpanded['LMass'] = true; + } + } + + nameOf(part: Joint | Link | undefined): string { + if (!part) return ''; + return (part as RealJoint).name || part.id; + } +} diff --git a/src/app/component/animation-bar/animation-bar.component.html b/src/app/component/animation-bar/animation-bar.component.html deleted file mode 100644 index 13faf2f0..00000000 --- a/src/app/component/animation-bar/animation-bar.component.html +++ /dev/null @@ -1,68 +0,0 @@ -<div class="playbackControls"> - @if (!this.getStaticAnimating()) { - <button - class="playButton" - [disabled]="invalidMechanism()" - mat-flat-button - color="primary" - (click)="startAnimation('play')" - matTooltip="Play Animation" - matTooltipPosition="right" - matTooltipShowDelay="1000" - > - <mat-icon>play_arrow</mat-icon> - </button> - } @else { - <button - class="playButton" - [disabled]="invalidMechanism()" - mat-flat-button - color="primary" - (click)="startAnimation('pause')" - matTooltip="Pause Animation" - matTooltipPosition="right" - matTooltipShowDelay="1000" - > - <mat-icon>pause</mat-icon> - </button> - } - - <button - id="speedButton" - class="speedButton" - [disabled]="invalidMechanism()" - mat-stroked-button - (click)="handleSpeedChange()" - matTooltip="Change Animation Speed" - matTooltipPosition="right" - matTooltipShowDelay="1000" - > - {{ this.mechanismService.animationSpeedMultiplier }}x - </button> - <div class="verticalSlider"> - <mat-slider - [disabled]="invalidMechanism()" - min="0" - max="{{ maxTimeSteps() }}" - (mousedown)="sliderDown()" - (mousemove)="sliderChange()" - (mouseup)="sliderUp()" - > - <input id="slider" matSliderThumb value="{{ this.mechanismService.mechanismTimeStep }}" /> - </mat-slider> - </div> - - <!-- The value carries its own unit, matching every other input in the app. --> - <form id="timeInputWrapper" (ngSubmit)="field.blur()"> - <input - [disabled]="invalidMechanism()" - id="animationBar-input" - name="timestep" - type="text" - [(ngModel)]="timestepDisplay" - (blur)="onNewTimeSubmit()" - (click)="invalidMechanism() ? null : field.select()" - #field - /> - </form> -</div> diff --git a/src/app/component/animation-bar/animation-bar.component.scss b/src/app/component/animation-bar/animation-bar.component.scss deleted file mode 100644 index 5b37c14e..00000000 --- a/src/app/component/animation-bar/animation-bar.component.scss +++ /dev/null @@ -1,121 +0,0 @@ -@use 'sass:map'; -@use '@angular/material' as mat; - -@mixin css($theme) { - $color-config: mat.m2-get-color-config($theme); - $primary-palette: map.get($color-config, 'primary'); - $typography-config: mat.m2-get-typography-config($theme); - - .playbackControls { - display: flex; - flex-direction: column; - align-items: stretch; - width: 100%; - gap: 8px; - // No side padding: the parent .tabTools already provides the 8px gutter, so - // the play/speed/slider/time controls share the buttons' left and right edge. - padding: 4px 0 0; - - .playButton, - .speedButton { - min-width: 0; - height: 34px; - padding: 0; - line-height: 34px; - } - - // Material fills this at primary 500; the selected segmented-control toggles - // use primary 400, so match them and keep one blue across the UI. - .playButton.mat-mdc-unelevated-button { - background-color: mat.m2-get-color-from-palette($primary-palette, 400); - } - - .playButton .mat-icon { - margin: 0; - height: 22px; - width: 22px; - font-size: 22px; - line-height: 22px; - } - - .speedButton { - background-color: white; - border-color: mat.m2-get-color-from-palette($primary-palette, 100) !important; - font-weight: bold; - font-size: 16px; - } - - // Material has no vertical slider, so the horizontal one is rotated inside a - // narrow wrapper. Absolute positioning keeps the pre-rotation width from - // dictating the column width. Extra vertical padding keeps the thumb's hover - // halo from overlapping the speed button above or the time field below. - .verticalSlider { - position: relative; - width: 100%; - // The rotated thumb's hover halo (~24px radius) overhangs each end of the - // track, so the box is padded top and bottom to keep it clear of the speed - // button above and the time field below. - height: 176px; - margin: 18px 0 16px; - - mat-slider { - position: absolute; - top: 50%; - left: 50%; - width: 176px; - margin: 0; - transform: translate(-50%, -50%) rotate(90deg); - } - - // The rail needs ~728px to show the analyze controls plus the view - // controls; below that the tall scrubber is dropped first so play/speed, - // the time field, and the view controls stay visible. The pill re-measures - // on resize (see the component). - @media (max-height: 735px) { - display: none; - } - } - - #timeInputWrapper { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - } - - /* Chrome, Safari, Edge, Opera */ - input::-webkit-outer-spin-button, - input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - - // The time field mirrors the standard input-block: light fill, accent - // underline on focus. It shares the play/speed buttons' full rail width so a - // long value ("228.33 s" at a slow input speed) has room; border-box keeps the - // padding inside that width rather than widening it past the buttons. - #animationBar-input { - @include mat.m2-typography-level($typography-config, 'body-2'); - box-sizing: border-box; - height: 30px; - line-height: 30px; - width: 100%; - border: 0; - outline: none; - text-align: left; - background-color: #f0f1f5; - border-radius: var(--border-radius); - padding: 0 8px; - - // The focus underline is an inset shadow, not a border, so it adds no box - // height — the number stays put instead of nudging up, matching the - // Material fields whose underline is drawn separately from the text box. - // Square the bottom corners on focus so the line reads as a flat underline - // rather than curving in at the ends. Radius is visual only, so no shift. - &:focus { - box-shadow: inset 0 -2px 0 0 mat.m2-get-color-from-palette($primary-palette, 500); - border-radius: var(--border-radius) var(--border-radius) 0 0; - } - } - } -} diff --git a/src/app/component/animation-bar/animation-bar.component.spec.ts b/src/app/component/animation-bar/animation-bar.component.spec.ts deleted file mode 100644 index 10ae97d7..00000000 --- a/src/app/component/animation-bar/animation-bar.component.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { BehaviorSubject } from 'rxjs'; -import { SettingsService } from '../../services/settings.service'; -import { MechanismService } from '../../services/mechanism.service'; -import { NumberUnitParserService } from '../../services/number-unit-parser.service'; -import { AnimationBarComponent } from './animation-bar.component'; - -describe('AnimationBarComponent timestamps', () => { - function makeComponent(mechanismTimeStep = 0) { - const position = new BehaviorSubject(0); - const animate = vi.fn(); - // Real prototype over stub data: the component delegates its time lookups to the - // service, so the service's own lookup logic has to run for these to mean anything. - const mechanismService = Object.assign(Object.create(MechanismService.prototype), { - mechanisms: [{ timeNum: [0, 0.2, 0.55], joints: [[], [], []], cyclePeriod: 0.55 }], - mechanismTimeStep, - onMechPositionChange: position, - animate, - }) as any; - const component = new AnimationBarComponent( - mechanismService, - new SettingsService(), - new NumberUnitParserService() - ); - AnimationBarComponent.animate = false; - component.ngOnInit(); - return { component, position, animate, mechanismService }; - } - - it('displays and seeks using mechanism time instead of a fixed sample rate', () => { - const { component, position, mechanismService } = makeComponent(); - - // animate() sets the step before announcing it, and the component reads the - // drawn time back off the service rather than trusting the emitted index. - mechanismService.mechanismTimeStep = 1; - position.next(1); - // The value carries its own unit, like every other input. - expect(component.timestepDisplay).toBe('0.20 s'); - expect(component.maxTime()).toBe(0.55); - expect(component.nearestTimeStep(0.5)).toBe(2); - }); - - it('parses a unit-bearing time entry and snaps to the nearest step', () => { - const { component, animate, mechanismService } = makeComponent(); - - component.timestepDisplay = '0.5 s'; - mechanismService.mechanismTimeStep = 2; - component.onNewTimeSubmit(); - - expect(animate).toHaveBeenCalledWith(2, false); - // The field is rewritten with the resolved value the mechanism landed on. - expect(component.timestepDisplay).toBe('0.55 s'); - }); - - it('clamps an out-of-range entry back into the motion window', () => { - const { component, animate } = makeComponent(); - - component.timestepDisplay = '9 s'; - component.onNewTimeSubmit(); - expect(animate).toHaveBeenCalledWith(2, false); - - component.timestepDisplay = '-4 s'; - component.onNewTimeSubmit(); - expect(animate).toHaveBeenLastCalledWith(0, false); - }); -}); diff --git a/src/app/component/animation-bar/animation-bar.component.ts b/src/app/component/animation-bar/animation-bar.component.ts deleted file mode 100644 index 2eab5ebb..00000000 --- a/src/app/component/animation-bar/animation-bar.component.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { - AfterViewInit, - Component, - OnDestroy, - OnInit, - ChangeDetectionStrategy, -} from '@angular/core'; -// import {switchMapTo} from "rxjs"; -import { MechanismService } from '../../services/mechanism.service'; -import { SettingsService } from '../../services/settings.service'; -import { NumberUnitParserService } from '../../services/number-unit-parser.service'; -import { TimeUnit } from '../../model/utils'; -import { Subscription } from 'rxjs'; - -@Component({ - selector: 'app-animation-bar', - templateUrl: './animation-bar.component.html', - styleUrls: ['./animation-bar.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, -}) -export class AnimationBarComponent implements OnInit, AfterViewInit, OnDestroy { - userIsDragging: boolean = false; - - wasAnimating: boolean = false; - - static animate: boolean = false; - - static playButton: HTMLInputElement; - static pauseButton: HTMLInputElement; - static slider: HTMLInputElement; - static sliderContainer: HTMLInputElement; - static adjustAnimation: boolean; - - timestepDisplay: string = this.formatTime(0); - private positionSub?: Subscription; - - constructor( - public mechanismService: MechanismService, - public settingsService: SettingsService, - private nup: NumberUnitParserService - ) {} - - ngOnInit(): void { - //Subscribte to the emitter inside mechanismStateService - this.positionSub = this.mechanismService.onMechPositionChange.subscribe(() => { - // Playback sits between samples, so read the drawn time rather than the - // time of the sample it was blended from. - this.timestepDisplay = this.formatTime(this.mechanismService.currentTimeSeconds()); - }); - } - - private formatTime(seconds: number): string { - return this.nup.formatValueAndUnit(seconds, TimeUnit.SECOND); - } - - // The bar is created and destroyed each time the Analyze tab opens. - ngOnDestroy(): void { - this.positionSub?.unsubscribe(); - } - - ngAfterViewInit() { - AnimationBarComponent.playButton = <HTMLInputElement>document.getElementById('playBtn'); - AnimationBarComponent.pauseButton = <HTMLInputElement>document.getElementById('pauseBtn'); - AnimationBarComponent.slider = <HTMLInputElement>document.getElementById('slider'); - AnimationBarComponent.sliderContainer = <HTMLInputElement>( - document.getElementById('sliderContainer') - ); - } - - onNewTimeSubmit() { - const [success, requested] = this.nup.parseTimeString(this.timestepDisplay, TimeUnit.SECOND); - const clamped = Math.min(Math.max(success ? requested : 0, 0), this.maxTime()); - this.mechanismService.animate(this.nearestTimeStep(clamped), AnimationBarComponent.animate); - - if (this.mechanismService.mechanismTimeStep !== 0) { - this.settingsService.animating.next(true); - } else { - this.settingsService.animating.next(false); - } - // Re-show the resolved, unit-formatted value the mechanism actually landed on. - this.timestepDisplay = this.formatTime( - this.timeAtStep(this.mechanismService.mechanismTimeStep) - ); - } - - maxTimeSteps() { - if (this.mechanismService.mechanisms.length === 0) { - return 0; - } else { - return this.mechanismService.mechanisms[0].joints.length - 1; - } - } - - maxTime(): number { - return this.mechanismService.cyclePeriod(); - } - - timeAtStep(index: number): number { - return this.mechanismService.timeAtStep(index); - } - - nearestTimeStep(timeSeconds: number): number { - return this.mechanismService.stepAtTime(timeSeconds); - } - - // onDirectionChange() { - // AnimationBarComponent.direction = AnimationBarComponent.direction === 'ccw' ? 'cw' : 'ccw'; - // ToolbarComponent.clockwise = AnimationBarComponent.direction === 'cw'; - // this.mechanismService.updateMechanism(); - // } - // - // getDirection() { - // return AnimationBarComponent.direction; - // } - startAnimation(state: string) { - // console.log('startAnimation ' + state); - switch (state) { - case 'pause': - AnimationBarComponent.animate = false; - this.mechanismService.animate( - this.mechanismService.mechanismTimeStep, - AnimationBarComponent.animate - ); - break; - case 'play': - AnimationBarComponent.animate = true; - this.mechanismService.animate( - this.mechanismService.mechanismTimeStep, - AnimationBarComponent.animate - ); - break; - } - if (this.mechanismService.mechanismTimeStep !== 0) { - this.settingsService.animating.next(true); - } else { - this.settingsService.animating.next(false); - } - } - - invalidMechanism() { - return !this.mechanismService.oneValidMechanismExists(); - } - - handleSpeedChange() { - // 1x plays back in real time: one revolution takes 60/RPM seconds. The other - // stops are explicit fast-forwards for slow input speeds. - const rates = [1, 2, 4]; - const next = rates.indexOf(this.mechanismService.animationSpeedMultiplier) + 1; - this.mechanismService.animationSpeedMultiplier = rates[next % rates.length]; - } - - sliderDown() { - // console.log('slider down'); - this.userIsDragging = true; - this.wasAnimating = AnimationBarComponent.animate; - this.startAnimation('pause'); - setTimeout(() => { - this.sliderChange(); - }, 0); - } - - sliderUp() { - // console.log('slider up'); - this.userIsDragging = false; - if (this.wasAnimating) this.startAnimation('play'); - } - - sliderChange() { - if (this.userIsDragging) { - // console.log('real slider change'); - this.mechanismService.animate( - Number(AnimationBarComponent.slider.value), - AnimationBarComponent.animate - ); - } - if (this.mechanismService.mechanismTimeStep !== 0) { - this.settingsService.animating.next(true); - } else { - this.settingsService.animating.next(false); - } - } - - getStaticAnimating() { - return AnimationBarComponent.animate; - } -} diff --git a/src/app/component/bottombar/bottombar.component.html b/src/app/component/bottombar/bottombar.component.html index 79382bc8..c8060da2 100644 --- a/src/app/component/bottombar/bottombar.component.html +++ b/src/app/component/bottombar/bottombar.component.html @@ -1,8 +1,9 @@ -<div id="bottomBar"> - <ul> - <li>Degrees of Freedom: {{ degreesOfFreedom }}</li> - <li>Units: {{ humanReadableString(settings.globalUnit.getValue()) }}</li> - <li style="flex-grow: 1"></li> - <li>PMKS+ Version {{ getVersion() }}</li> - </ul> +<div data-canvas-inset="bottom" id="bottomBar"> + <span class="mode">{{ modeName }}</span> + <span class="status">{{ status }}</span> + <span class="spacer"></span> + @if (cursor) { + <span class="cursor">Cursor: {{ cursor }}</span> + } + <span class="units">Units: {{ unitSet }}</span> </div> diff --git a/src/app/component/bottombar/bottombar.component.scss b/src/app/component/bottombar/bottombar.component.scss index 69561cba..17390a59 100644 --- a/src/app/component/bottombar/bottombar.component.scss +++ b/src/app/component/bottombar/bottombar.component.scss @@ -3,44 +3,57 @@ @use 'src/app/component/left-tabs/left-tabs.vars' as nav; @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); + // A strip that reports and does nothing else. Nothing in it is a control, so + // nothing in it is clickable: a status line that sometimes acts on a click is + // a line you have to test before trusting. #bottomBar { - //5 pixel high absolute posositon white bar in the bottom of the screen 100% width position: absolute; bottom: 0; + left: 0; right: 0; - width: 100vw; height: nav.$bottom-bar-height; + display: flex; + align-items: center; + gap: 18px; + padding: 0 14px; background-color: white; - //z-index: 100; + box-sizing: border-box; + z-index: 7; + pointer-events: none; + user-select: none; - //Include elevation and font - @include mat.elevation(2); + box-shadow: var(--card-shadow); @include mat.m2-typography-level($typography-config, 'subtitle-2'); font-weight: 400; color: #2c2c2c; - ul { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; + .mode { + font-weight: 500; + white-space: nowrap; + } + + .status, + .cursor, + .units { + color: rgba(0, 0, 0, 0.55); + white-space: nowrap; + } + + .spacer { + flex: 1 1 auto; + } - //Hide the list bullets - list-style-type: none; - margin: 0; - padding: 0; - // Clear the nav rail, which sits above this bar on the left. - padding-left: calc(#{nav.$rail-width} + 10px); - padding-right: 10px; + // On a phone the version and the units are the first things to go: the + // mode and what it means for the mechanism are the reason this strip + // exists. + @media (max-width: 720px) { + .units, + .cursor { + display: none; + } } } } diff --git a/src/app/component/bottombar/bottombar.component.spec.ts b/src/app/component/bottombar/bottombar.component.spec.ts index cf16b94f..ab693d9e 100644 --- a/src/app/component/bottombar/bottombar.component.spec.ts +++ b/src/app/component/bottombar/bottombar.component.spec.ts @@ -1,6 +1,5 @@ import { TestBed } from '@angular/core/testing'; import { BottombarComponent } from './bottombar.component'; -import { AppModule } from '../../app.module'; import { MechanismService } from '../../services/mechanism.service'; // Found by building a linkage with a real mouse rather than with dispatched @@ -12,7 +11,7 @@ describe('the mobility readout', () => { let mechanism: MechanismService; beforeEach(() => { - TestBed.configureTestingModule({ imports: [AppModule] }); + TestBed.configureTestingModule({ imports: [BottombarComponent] }); component = TestBed.createComponent(BottombarComponent).componentInstance; mechanism = TestBed.inject(MechanismService); }); diff --git a/src/app/component/bottombar/bottombar.component.ts b/src/app/component/bottombar/bottombar.component.ts index 5258ef42..f6c5e5fe 100644 --- a/src/app/component/bottombar/bottombar.component.ts +++ b/src/app/component/bottombar/bottombar.component.ts @@ -1,21 +1,111 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; -import { GlobalUnit } from '../../model/utils'; +import { SvgGridService } from '../../services/svg-grid.service'; +import { NumberUnitParserService } from '../../services/number-unit-parser.service'; +import { MODEL_SCALE } from '../../model/render-scale'; +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { AngleUnit, GlobalUnit } from '../../model/utils'; 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', templateUrl: './bottombar.component.html', styleUrls: ['./bottombar.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, }) export class BottombarComponent { - constructor( - public settings: SettingsService, - public mechanismSrv: MechanismService - ) {} + settings = inject(SettingsService); + mechanismSrv = inject(MechanismService); + 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. + * + * The strip says it because the two modes now differ in what they will let + * you do, and a rule that silently refuses is worse than one that is written + * down somewhere. + */ + get modeName(): string { + switch (this.tabs.getCurrentTab()) { + case TabID.SYNTHESIZE: + return 'Synthesis'; + case TabID.EDIT: + return 'Edit'; + case TabID.FORCE: + return 'Force'; + default: + return 'Kinematic'; + } + } + + /** + * What the mode means for the mechanism right now. + * + * Read-only, like the rest of this strip. It reports; the drawer is where + * anything is done about it. + */ + get status(): string { + 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'; + } + if (blockers === 0) { + return 'Ready to analyse'; + } + 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. @@ -29,8 +119,47 @@ export class BottombarComponent { * the reason belongs. */ get degreesOfFreedom(): string { - const dof = this.mechanismSrv.mechanisms[0]?.dof; - return typeof dof === 'number' && Number.isFinite(dof) ? String(dof) : '—'; + // One number per machine, because a drawing can hold several and their + // mobilities are separate facts -- summing them would describe a mechanism + // that is not on the screen. + const each = this.mechanismSrv.mechanisms + .map((mechanism) => mechanism.dof) + .filter((dof) => typeof dof === 'number' && Number.isFinite(dof)); + return each.length > 0 ? each.join(', ') : '—'; + } + + /** + * Where the cursor is, in the mechanism's own units. + * + * Absent until the pointer has been over the grid, because "0.00, 0.00" for a + * cursor that has never been anywhere is a reading of nothing. + */ + get cursor(): string { + const at = this.svgGrid.cursorAt; + if (!at) { + return ''; + } + const unit = this.nup.unitLabel(this.settings.lengthUnit.value); + const show = (value: number) => (value / MODEL_SCALE).toFixed(2); + return `${show(at.x)} ${unit}, ${show(at.y)} ${unit}`; + } + + /** + * The whole set of units in play, not just the length one. + * + * A mechanism is measured in four quantities and the strip has room to name + * them, so naming one and leaving the reader to infer the rest is a saving + * nobody asked for. + */ + get unitSet(): string { + const lengths: Record<number, string> = { + [GlobalUnit.ENGLISH]: 'in, lbm, lbf', + [GlobalUnit.SI]: 'm, kg, N', + [GlobalUnit.METRIC]: 'cm, g, N', + }; + const base = lengths[this.settings.globalUnit.getValue()] ?? 'cm, g, N'; + const angle = this.settings.angleUnit.getValue() === AngleUnit.RADIAN ? 'radians' : 'degrees'; + return `${base}, ${angle}`; } humanReadableString(value: GlobalUnit) { diff --git a/src/app/component/context-menu/context-menu.component.html b/src/app/component/context-menu/context-menu.component.html index d7c7567f..100b9461 100644 --- a/src/app/component/context-menu/context-menu.component.html +++ b/src/app/component/context-menu/context-menu.component.html @@ -1,16 +1,76 @@ -<div id="contextMenu" class="fade-in-out" cdkMenu> - @for (menuItem of menuItems; track menuItem) { - <div> - <div - id="menu-item" - cdkMenuItem - (cdkMenuItemTriggered)="menuItem.actionWrapper()" - class="{{ menuItem.disabled ? 'disabledItem' : '' }}" - [cdkMenuItemDisabled]="menuItem.disabled" - > - <mat-icon svgIcon="{{ menuItem.icon }}"></mat-icon> - <button>{{ menuItem.label }}</button> +<div id="contextMenu" class="fade-in-out" [class.cm-empty]="empty()" cdkMenu> + @if (model().header; as header) { + <div class="cm-header"> + <div class="cm-header__names"> + <div class="cm-header__title">{{ header.title }}</div> + <div class="cm-header__subtitle">{{ header.subtitle }}</div> </div> + <!-- The way into the other mode, as one icon: a row of its own would cost + height on every menu, and this costs none. --> + @if (header.crossing; as crossing) { + <button + type="button" + class="cm-cross" + [class.cm-cross--off]="!!crossing.refusal" + [attr.aria-label]="crossing.tip" + [matTooltip]="crossing.refusal?.long ?? crossing.tip" + matTooltipPosition="left" + [matTooltipShowDelay]="500" + (click)="cross(crossing)" + > + @if (crossing.material) { + <mat-icon>{{ crossing.icon }}</mat-icon> + } @else { + <mat-icon [svgIcon]="crossing.icon"></mat-icon> + } + </button> + } </div> } + + @for (group of model().groups; track $index) { + @if (group.rows.length > 0) { + <div class="cm-group"> + @if (group.label) { + <div class="cm-group__label">{{ group.label }}</div> + } + <!-- The tooltip waits half a second: running the pointer down the list + otherwise flashes a chip per row, which is what made it jumpy. --> + @for (row of group.rows; track row) { + <div + class="cm-row" + cdkMenuItem + [class.cm-row--off]="row.disabled" + [class.cm-row--on]="row.checked && !row.disabled" + [class.cm-row--destructive]="row.destructive" + [cdkMenuItemDisabled]="row.disabled" + [matTooltip]="row.hoverText" + [matTooltipDisabled]="!row.hoverText" + matTooltipPosition="below" + [matTooltipShowDelay]="500" + (cdkMenuItemTriggered)="run(row)" + > + @if (row.material) { + <mat-icon>{{ row.icon }}</mat-icon> + } @else { + <mat-icon [svgIcon]="row.icon"></mat-icon> + } + <span class="cm-row__label">{{ row.label }}</span> + <!-- One slot on the right, and one thing in it: the reason when the + row is greyed, a count where one says something, the key + otherwise. --> + @if (row.refusal) { + <span class="cm-row__reason">{{ row.refusal.short }}</span> + } @else if (row.checked) { + <mat-icon class="cm-row__check">check</mat-icon> + } @else if (row.hint) { + <span class="cm-row__hint">{{ row.hint }}</span> + } @else if (row.shortcut) { + <span class="cm-row__key">{{ row.shortcut }}</span> + } + </div> + } + </div> + } + } </div> diff --git a/src/app/component/context-menu/context-menu.component.scss b/src/app/component/context-menu/context-menu.component.scss index 9a09735f..3526e5dd 100644 --- a/src/app/component/context-menu/context-menu.component.scss +++ b/src/app/component/context-menu/context-menu.component.scss @@ -2,19 +2,17 @@ @use '@angular/material' as mat; @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. $typography-config: mat.m2-get-typography-config($theme); $primary-palette: map.get($color-config, 'primary'); - $accent-palette: map.get($color-config, 'accent'); - $background: map.get($theme, background); - $foreground: map.get($theme, foreground); + $primary: mat.m2-get-color-from-palette($primary-palette, 500); .fade-in-out { opacity: 0; transition: 0.15s ease-in-out; scale: 0.5; + // Overwritten from the component once the card has been placed, so the + // grow reads right in whichever corner the pointer put it. transform-origin: top left; } @@ -23,57 +21,202 @@ scale: 1; } + #contextMenu.cm-empty { + display: none; + } + #contextMenu { pointer-events: all !important; - @include mat.elevation(5); + // Raised from 5: this card stands over a mechanism that may be running, + // and it is opaque white rather than translucent for the same reason. + @include mat.elevation(16); display: inline-flex; flex-direction: column; - background-color: white; - padding: 5px 0; + min-width: 224px; + max-width: 320px; + padding: 0 0 4px; + background-color: #ffffff; border-radius: var(--border-radius); + user-select: none; - .disabledItem { - opacity: 0.5; - pointer-events: none; + // Who the menu is about. Named, because "Delete Joint" on a canvas of nine + // of them says which one only if the card does. + .cm-header { + display: flex; + // Centred against the whole two-line block rather than hung off the + // title: the icon is about the part, not about its name. + align-items: center; + gap: 8px; + padding: 10px 12px 10px 14px; } - #menu-item { + .cm-header__names { + flex: 1; + min-width: 0; + } + + .cm-header__title { + font-size: 14px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + line-height: 18px; + } + + .cm-header__subtitle { + font-size: 11.5px; + color: rgba(0, 0, 0, 0.54); + line-height: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .cm-cross { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin-right: -2px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(0, 0, 0, 0.6); + cursor: pointer; + + &:hover { + background-color: #eeeeee; + } + + mat-icon { + width: 19px; + height: 19px; + font-size: 19px; + line-height: 19px; + } + } + + // The readiness refusal rides the icon rather than costing a row. + .cm-cross--off { + color: rgba(0, 0, 0, 0.26); + cursor: default; + + &:hover { + background-color: transparent; + } + } + + // One rung of the ladder. They never reorder, so Delete is always last. + .cm-group { + display: flex; + flex-direction: column; + border-top: 1px solid rgba(0, 0, 0, 0.08); + padding-top: 2px; + } + + .cm-group__label { + padding: 6px 14px 3px; + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.42); + } + + .cm-row { + display: flex; + align-items: center; + gap: 11px; + height: 30px; + padding: 0 14px; + cursor: pointer; + color: rgba(0, 0, 0, 0.87); + @include mat.m2-typography-level($typography-config, 'subtitle-2'); + font-size: 13.5px; + font-weight: 400; + &:focus { outline: none; } - //&:hover { - // background-color: #eeeeee; - //} + &:hover { + background-color: #f1f3f4; + } - //If hover but not disabled - &:hover:not(.disabledItem) { - background-color: #eeeeee; + mat-icon { + flex: none; + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + color: rgba(0, 0, 0, 0.6); } + } - padding: 0 5px; + .cm-row__label { + flex: 1; + white-space: nowrap; + } - cursor: pointer; - border: none; - align-items: center; - user-select: none; - display: flex; - flex-direction: row; + // The state a switch names, on: tinted, and ticked in the same slot the + // reason and the key use, so the right edge always says one thing. + .cm-row--on { + background-color: rgba(63, 81, 181, 0.08); + + mat-icon { + color: $primary; + } + } - button { - user-select: none; - cursor: pointer; - border: none; + .cm-row__check { + flex: none; + color: $primary; + } + + .cm-row__hint, + .cm-row__key, + .cm-row__reason { + flex: none; + padding-left: 10px; + font-size: 11.5px; + color: rgba(0, 0, 0, 0.38); + white-space: nowrap; + } + + // Greyed, with the reason beside it. Never clickable-and-then-refused. + .cm-row--off { + color: rgba(0, 0, 0, 0.32); + cursor: default; + + &:hover { background-color: transparent; - padding: 0; - margin-left: 5px; - margin-right: 20px; - @include mat.m2-typography-level($typography-config, 'subtitle-2'); } mat-icon { - padding: 2px; - //scale: 0.75; + color: rgba(0, 0, 0, 0.24); + } + + .cm-row__reason { + color: rgba(0, 0, 0, 0.36); + } + } + + // The footer. Alone under a rule, and always the last row. + .cm-row--destructive { + color: #d32f2f; + + mat-icon { + color: #d32f2f; + } + } + + .cm-row--destructive.cm-row--off { + color: rgba(0, 0, 0, 0.32); + + mat-icon { + color: rgba(0, 0, 0, 0.24); } } } diff --git a/src/app/component/context-menu/context-menu.component.ts b/src/app/component/context-menu/context-menu.component.ts index 7d6a98c2..fb8fbf3f 100644 --- a/src/app/component/context-menu/context-menu.component.ts +++ b/src/app/component/context-menu/context-menu.component.ts @@ -1,71 +1,102 @@ -import { Component, Inject, Input, ChangeDetectionStrategy } from '@angular/core'; -import { animate, state, style, transition, trigger } from '@angular/animations'; -import { MechanismService } from '../../services/mechanism.service'; -import { NewGridComponent } from '../new-grid/new-grid.component'; -import { CANNOT_EDIT } from '../../ui-text'; - -export class cMenuItem { - public label: string = 'none'; - public action: Function = () => { - console.error('Not implemented'); - }; - public icon: string = 'none'; - public disabled: boolean = false; - - constructor(_label: string, _action: Function, _icon: string, _disabled: boolean = false) { - this.label = _label; - this.action = _action; - this.icon = _icon; - this.disabled = _disabled; - } - - actionWrapper() { - if (NewGridComponent.instance.mechanismSrv.mechanismTimeStep !== 0) { - NewGridComponent.sendNotification(CANNOT_EDIT.animating); - return; - } - this.action(); - } -} +import { Component, ChangeDetectionStrategy, DestroyRef, inject, input } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.service'; +import { CdkMenu, CdkMenuItem, MENU_STACK } from '@angular/cdk/menu'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { + ContextMenuModel, + MenuCrossing, + MenuRow, + lastContextMenuPointer, + menuIsEmpty, +} from './menu-model'; +/** + * The right-click menu. + * + * A dumb renderer: every decision about what a row says, whether it can be + * used and why not is made by `ContextMenuBuilderService`, which reads those + * answers out of the model that enforces them. This lays them out. + */ @Component({ selector: 'app-context-menu', - animations: [ - trigger('openClose', [ - state( - 'open', - style({ - opacity: 1, - transform: 'scale(1)', - }) - ), - state( - 'closed', - style({ - opacity: 0, - transform: 'scale(0.5)', - }) - ), - transition('closed => open', [animate('0.2s ease-out')]), - transition('open => closed', [animate('0.2s ease-in')]), - ]), - ], templateUrl: './context-menu.component.html', styleUrls: ['./context-menu.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [CdkMenu, CdkMenuItem, MatIcon, MatTooltip], }) export class ContextMenuComponent { - @Input() menuItems: cMenuItem[] = []; - private contextMenu!: HTMLElement; + readonly model = input<ContextMenuModel>({ groups: [] }); + /** + * The stack the CDK opened this card on. + * + * A row is a `cdkMenuItem` and closes the card by itself; the crossing icon + * is a plain button in the header, so it has to say so. A menu left standing + * over a mode it no longer belongs to is the mode change half-done. + */ + private readonly stack = inject(MENU_STACK, { optional: true }); - constructor(private mechanismSrv: MechanismService) {} + constructor() { + // A shortcut acts on the selection, not on the card, and the card is a + // snapshot: pressing K with a joint's menu open locked the joint and left + // the menu showing the unlocked state, with a Delete row that was greyed + // for a lock that had just been set, or live for one that had. Delete did + // it behind the card. So any shortcut closes the card, and the next + // right-click builds it again from what is now true. + inject(KeyboardShortcutsService) + .pressed.pipe(takeUntilDestroyed(inject(DestroyRef))) + .subscribe(() => this.stack?.closeAll()); + } + private contextMenu!: HTMLElement; ngAfterViewInit() { this.contextMenu = document.querySelector('#contextMenu') as HTMLElement; - console.log(this.contextMenu); + // Measured in the same tick the card is revealed, not in ngAfterViewInit: + // the overlay has not been moved to the pointer yet at that point, so the + // rect read there is the card sitting at the origin. setTimeout(() => { + this.growFromThePointer(); this.contextMenu.classList.add('show'); }, 1); } + + /** + * Start the scale-and-fade at the corner the pointer is in. + * + * The CDK flips the card at an edge, and a card that flips up and left while + * growing down and right from its top-left reads as sliding into place from + * somewhere else. Measured rather than predicted: whichever side of the + * pointer the card actually landed on is the side it grows from. + */ + private growFromThePointer(): void { + const at = lastContextMenuPointer(); + const box = this.contextMenu.getBoundingClientRect(); + const across = box.left + box.width / 2 > at.x ? 'left' : 'right'; + const down = box.top + box.height / 2 > at.y ? 'top' : 'bottom'; + this.contextMenu.style.transformOrigin = `${down} ${across}`; + } + + /** + * Nothing to say, so nothing to show. + * + * The CDK opens the card on every right-click; a target with no rows and no + * name would otherwise leave a blank white sliver on the canvas. + */ + empty(): boolean { + const model = this.model(); + return !model.header && menuIsEmpty(model); + } + + /** Greyed is greyed: a row that says why it cannot be used does not act. */ + run(row: MenuRow): void { + if (row.disabled) return; + row.action(); + } + + cross(crossing: MenuCrossing): void { + if (crossing.refusal) return; + crossing.action(); + this.stack?.closeAll(); + } } diff --git a/src/app/component/context-menu/menu-model.ts b/src/app/component/context-menu/menu-model.ts new file mode 100644 index 00000000..ac0d5803 --- /dev/null +++ b/src/app/component/context-menu/menu-model.ts @@ -0,0 +1,146 @@ +/** + * What a right-click menu is made of. + * + * The old menu was a flat `cMenuItem[]`: eight equally weighted rows with + * Delete at the top, labels that rewrote themselves as the object changed + * ("Add Ground" becoming "Remove Ground"), and three different ways of saying + * no -- hidden here, greyed silently there, offered-and-then-refused by a + * snackbar somewhere else. + * + * This is the shape the redesign asks for instead. A menu is a header naming + * what was clicked, then a fixed ladder of groups -- Attach, State, Machine -- + * and a destructive footer, so a two-row menu and a twelve-row menu read the + * same way and the flick to Delete always lands on the last row. + */ + +/** Why a row cannot be used, in the words the model already uses. */ +export interface MenuRefusal { + /** Three or four words, shown in the row's right-hand slot. */ + short: string; + /** The model's own sentence, on hover, where one exists. */ + long?: string; +} + +export type MenuRowKind = 'action' | 'toggle'; + +export class MenuRow { + /** Title Case, because it labels a control (`docs/ui-vocabulary.md`). */ + label: string; + /** A registered SVG icon name, or a Material Icons ligature. */ + icon: string; + /** Whether `icon` is a Material Icons ligature rather than a registered SVG. */ + material = false; + kind: MenuRowKind; + action: () => void; + /** For a toggle: whether the state it names is on. */ + checked = false; + /** Set when the row is greyed. Its presence *is* the disabled flag. */ + refusal?: MenuRefusal; + /** Keys from the shortcut registry, so a hint cannot drift from its key. */ + shortcut?: string; + /** The footer row: red, and always last. */ + destructive = false; + /** + * Whether this row works away from the start pose. + * + * Almost nothing does: editing a mechanism parked mid-cycle would write the + * pose it is standing in back into the drawing. The exceptions are rows that + * do not touch the mechanism at all -- the synthesis positions are a note + * about what it was designed for, the trace is a view of it, and crossing + * into another mode changes nothing about the drawing. + */ + alwaysAllowed = false; + /** A plain-language description for the row, when the label needs help. */ + tip?: string; + /** + * The right-hand slot on an *available* row: a count, where one says + * something ("6 open"), and the shortcut otherwise. + */ + hint?: string; + + constructor(init: Partial<MenuRow> & Pick<MenuRow, 'label' | 'icon' | 'action'>) { + this.label = init.label; + this.icon = init.icon; + this.action = init.action; + this.material = init.material ?? false; + this.kind = init.kind ?? 'action'; + this.checked = init.checked ?? false; + this.refusal = init.refusal; + this.shortcut = init.shortcut; + this.destructive = init.destructive ?? false; + this.alwaysAllowed = init.alwaysAllowed ?? false; + this.tip = init.tip; + this.hint = init.hint; + } + + get disabled(): boolean { + return !!this.refusal; + } + + /** The dark chip on hover: the model's sentence, or the row's own note. */ + get hoverText(): string { + return this.refusal?.long ?? this.tip ?? ''; + } +} + +/** One rung of the ladder. The label is dropped on an unlabelled footer. */ +export interface MenuGroup { + /** Upper-cased in the stylesheet; written here as a plain word. */ + label?: string; + rows: MenuRow[]; +} + +/** The way out of this mode, as one icon beside the target's name. */ +export interface MenuCrossing { + icon: string; + material?: boolean; + /** Tooltip, with the mode's shortcut on the end. */ + tip: string; + refusal?: MenuRefusal; + action: () => void; +} + +/** Who the menu is about: "Joint B", "Pin · Links AB, BC". */ +export interface MenuHeader { + title: string; + subtitle: string; + crossing?: MenuCrossing; +} + +export interface ContextMenuModel { + header?: MenuHeader; + groups: MenuGroup[]; +} + +/** Whether there is anything at all to show. */ +export function menuIsEmpty(model: ContextMenuModel): boolean { + return model.groups.every((group) => group.rows.length === 0); +} + +/** + * Where the last right-click was, in client coordinates. + * + * The menu wants this so the card can grow from the corner the pointer is in, + * and it cannot ask the canvas for it: the CDK's own `contextmenu` listener + * runs before the template's, so by the time the canvas has recorded the point + * the card has already been created and measured. A capture-phase listener, + * installed once at start-up, is ahead of both. + */ +let lastPointer = { x: 0, y: 0 }; +let tracking = false; + +export function trackContextMenuPointer(): void { + if (tracking || typeof document === 'undefined') return; + tracking = true; + document.addEventListener( + 'contextmenu', + (event) => { + lastPointer = { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY }; + }, + true + ); +} + +export function lastContextMenuPointer(): { x: number; y: number } { + return lastPointer; +} diff --git a/src/app/component/edit-panel/edit-panel.component.html b/src/app/component/edit-panel/edit-panel.component.html index ecf14c08..95ee5251 100644 --- a/src/app/component/edit-panel/edit-panel.component.html +++ b/src/app/component/edit-panel/edit-panel.component.html @@ -12,33 +12,136 @@ } @if ( - (this.activeSrv.objType == 'Grid' || this.activeSrv.objType == 'Nothing') && - mechanismService.joints.length == 0 && - !hideEditPanel() + (this.activeSrv.objType == 'Grid' || this.activeSrv.objType == 'Nothing') && !hideEditPanel() ) { - <panel-section-collapsible [warning]="true" [expanded]="false" id="placeholderContainer"> - <title-block - alwaysShown - icon="expand_more" - description="Right click on the grid to create a link." - >Edit Help - </title-block> - <img src="assets/gifs/Create.gif" /> - </panel-section-collapsible> + <div class="helpPanel"> + <div class="helpTitle">Edit</div> + <!-- An empty grid has nothing to click, so it is not told to click. --> + <p class="helpLead"> + @if (gridIsEmpty()) { + Draw something to edit. + } @else { + Select a joint or link on the grid to edit its properties. + } + </p> + <div class="helpRule"></div> + <div class="helpHints"> + @if (!gridIsEmpty()) { + <div class="helpHint"> + <mat-icon>touch_app</mat-icon> + <span>Click a joint or link to select it. Drag a joint to move it.</span> + </div> + } + <div class="helpHint"> + <mat-icon>add_circle_outline</mat-icon> + <span> + @if (gridIsEmpty()) { + Right-click the grid to add a link or a cylinder. + } @else { + Right-click the grid to add a link or a cylinder. Right-click a link to attach a + tracer point or a force to it. + } + </span> + </div> + </div> + + <!-- The offer hangs off the panel rather than replacing it: a student who + ignores it has still read the sentence about right-click, and the + tutorial then uses that same gesture rather than a third of its own. --> + @if (tutorial.offerVisible()) { + <div class="helpRule"></div> + <div class="offer"> + <mat-icon>school</mat-icon> + <div class="offerText"> + <div class="offerTitle">First time here?</div> + <p class="offerWhy"> + Build a four-bar linkage on this grid and read a velocity off it. About three + minutes, in five steps. + </p> + <div class="offerButtons"> + <button class="offerButton" (click)="startTutorial()">Start Tutorial</button> + <button class="offerDismiss" (click)="dismissTutorial()">No thanks</button> + </div> + </div> + </div> + } + + </div> } - @if ( - this.activeSrv.objType == 'Grid' && mechanismService.joints.length != 0 && !hideEditPanel() - ) { - <panel-section-collapsible [warning]="true" [expanded]="false" id="placeholderContainer"> - <title-block - alwaysShown - icon="expand_more" - description="Select a joint or link to edit properties." - >Edit Help - </title-block> - <img src="assets/gifs/Click.gif" /> - </panel-section-collapsible> + <!-- BACKGROUND IMAGE PANEL: the tracing underlay is scenery, not a part, so + it has no name to rename and nothing to lock — just where it sits, how + big it is drawn, and how far you can see through it. --> + @if (this.activeSrv.objType == 'BackgroundImage' && !hideEditPanel()) { + @if (bgImage.image(); as bg) { + <panel-section> + <div class="bgTitle">Background Image</div> + <div class="bgFileName">{{ bg.fileName }}</div> + <collapsible-subseciton + titleLabel="Placement" + [expanded]="sectionExpanded['BGPlace']" + (opened)="sectionExpanded['BGPlace'] = true" + (closed)="sectionExpanded['BGPlace'] = false" + > + <dual-input-block + label1="X" + label2="Y" + formControl1="centerX" + formControl2="centerY" + [formGroup]="backgroundImageForm" + tooltip="Where the middle of the picture sits on the grid." + > + Image Center + </dual-input-block> + <!-- Width alone, because the height follows it: a picture stretched + out of its own proportions is no longer something you can measure + a linkage against. Set this to a length you can identify in the + picture and everything else in it is to scale. --> + <!-- No unit suffix: a length formatted by the parser already carries + its own, exactly as the position fields above do. --> + <input-block + [formGroup]="backgroundImageForm" + _formControl="width" + wide + tooltip="How wide the picture is drawn. The height follows, so the picture keeps its proportions." + >Image Width + </input-block> + <!-- A photograph is rarely taken square to the mechanism in it. --> + <input-block + [formGroup]="backgroundImageForm" + _formControl="rotation" + wide + tooltip="How far the picture is turned about its own centre. A counter-clockwise turn is a positive angle (Right hand rule)." + >Rotation + </input-block> + <input-block + [formGroup]="backgroundImageForm" + _formControl="opacity" + unit="%" + tooltip="How solid the picture is. Fade it back to see the grid and the linkage through it." + >Opacity + </input-block> + </collapsible-subseciton> + <!-- The horizontal padding a collapsible subsection gives its own + contents, so the note and the buttons line up with the fields + rather than running to the panel's edge. --> + <div class="bgActions"> + <!-- The one thing about this that trying it cannot teach: the picture + is not in the link the user is about to share. --> + <div class="bgNote"> + Background images are not saved in the share link — reloading the page clears this one. + </div> + <button-block icon="check" [click]="saveBackgroundImage.bind(this)">Save</button-block> + <button-block color="warn" customIcon="remove" [click]="deleteBackgroundImage.bind(this)" + >Delete</button-block + > + </div> + </panel-section> + } + } + + @if (this.activeSrv.objType == 'Mechanism' && !hideEditPanel()) { + <app-mechanism-panel [editable]="true"></app-mechanism-panel> } <!-- JOINT PANEL--> @@ -91,14 +194,12 @@ > Slider </toggle-block> - <!-- Read-only: the carrier comes from the drop gesture, and changing it - is delete-and-recreate. It names the link and the joint pair so the - reader can find the geometry it describes. --> - @if (slotOnLabel) { - <div class="slotNote">Slot on: {{ slotOnLabel }}</div> - } + <!-- A slider with nowhere to slide, which the canvas cannot say for + itself: the drawing looks the same either way, and this is the one + thing about a slot a reader cannot see. Which link the slot is cut + into is drawn on that link, so it is not repeated here. --> @if (isDanglingSlider) { - <div class="slotNote slotNote--warn"> + <div class="sliderWarning"> This slider has nowhere to slide. Drag it onto a link to cut its slot, or ground it. </div> } @@ -159,6 +260,26 @@ </collapsible-subseciton> </div> } + <!-- The sliding body's own mass. It lives on a link nobody can select -- + clicking a block picks the pin it rides on -- so this is the only + place in Edit it can be reached; before it, the mass table in the + force-analysis drawer was the only door, and there was no sign from + the joint's own panel that a slider weighed anything at all. --> + @if (sliderBlock) { + <collapsible-subseciton + titleLabel="Mass Settings" + [expanded]="sectionExpanded['JMass']" + (opened)="sectionExpanded['JMass'] = true" + (closed)="sectionExpanded['JMass'] = false" + > + <input-block + [formGroup]="jointForm" + _formControl="sliderMass" + tooltip="The sliding body's mass, concentrated at this pin. Gravity pulls on it, the slot has to take it, and in an in-motion analysis it is the reciprocating mass. Zero is an idealized massless slide." + >Slider mass</input-block + > + </collapsible-subseciton> + } <collapsible-subseciton titleLabel="Visual Settings" [expanded]="sectionExpanded['JVisual']" @@ -171,7 +292,12 @@ tooltip="Show / hide the path the joint takes." >Show Joint Path </toggle-block> - <color-picker type="joint"> Joint Color</color-picker> + <color-picker + type="joint" + [joint]="activeSrv.selectedJoint" + tooltip="Draw this one joint in its own colour, to tell it apart from the others. The first swatch puts it back to the colour they all share. Travels with the drawing." + >Joint Color</color-picker + > </collapsible-subseciton> <collapsible-subseciton titleLabel="Distance to Joints" @@ -292,6 +418,55 @@ </collapsible-subseciton> </div> } + <collapsible-subseciton + titleLabel="Mass Settings" + [expanded]="sectionExpanded['LMass']" + (opened)="sectionExpanded['LMass'] = true" + (closed)="sectionExpanded['LMass'] = false" + > + <div class="massArea"> + <!-- Three bodies, not one: the rod and head are welded rigid, but the + rod's mass is spread along its length while the head's rides the + pin — the number that matters in a reciprocating machine. Inertia + and centres stay automatic (uniform body per part). --> + <input-block + [formGroup]="cylinderForm" + _formControl="barrelMass" + tooltip="The barrel's mass, acting at the barrel's own center." + (mouseenter)="setCylinderPartPreview('barrel')" + (mouseleave)="setCylinderPartPreview(undefined)" + >Barrel</input-block + > + <input-block + [formGroup]="cylinderForm" + _formControl="rodMass" + tooltip="The rod's mass, spread along the rod." + (mouseenter)="setCylinderPartPreview('rod')" + (mouseleave)="setCylinderPartPreview(undefined)" + >Rod</input-block + > + <input-block + [formGroup]="cylinderForm" + _formControl="headMass" + tooltip="The piston head's mass, concentrated at the pin — the reciprocating mass." + (mouseenter)="setCylinderPartPreview('head')" + (mouseleave)="setCylinderPartPreview(undefined)" + >Piston head</input-block + > + <p class="massNote"> + Each part's rotational inertia and center of mass are computed from its own shape and the + mass you give it. + </p> + @if (cylinderHasCustomInertia()) { + <subtitle-block + icon="auto_fix_normal" + buttonLabel="Derive From Shape" + description="This cylinder's parts carry inertia values set by hand (or by an older file). Derive re-computes them from each part's own shape and keeps them current." + [click]="deriveCylinderInertiaFromShape.bind(this)" + ></subtitle-block> + } + </div> + </collapsible-subseciton> <collapsible-subseciton titleLabel="Visual Settings" [expanded]="sectionExpanded['LVisual']" @@ -345,6 +520,40 @@ (closed)="sectionExpanded['LVisual'] = false" > <color-picker type="link" [link]="activeSrv.selectedLink">Link Color</color-picker> + <!-- The same choice the right-click menu offers, in the section that + collects how this link is drawn. Disabled rather than hidden here: + the panel is describing the link you selected, so the option is + worth naming along with why it is not available. + + The label follows what is actually on the canvas, not the stored + choice: a disc whose ground has since been removed is drawn as a + bar, and a button reading "Make Bar" over a bar is a puzzle. --> + <!-- The reason it cannot be pressed is carried by this wrapper, not by + the button: a disabled button receives no pointer events, so its + own tooltip would never open — the one explanation of why it is + greyed out would be the one nobody could reach. The wrapper is + still hoverable, and the stylesheet takes the disabled button out + of the way so the hover lands here. --> + <div + class="shapeToggle" + [matTooltip]="whyNotCircular()" + [matTooltipDisabled]="activeSrv.selectedLink.canBeCircular()" + [matTooltipShowDelay]="400" + matTooltipPosition="above" + > + <button-block + [customIcon]="drawnAsDisc() ? 'make_bar' : 'make_circular'" + [disabled]="!activeSrv.selectedLink.canBeCircular()" + (click)="mechanismService.toggleLinkCircular()" + [tooltip]=" + drawnAsDisc() + ? 'Draw this link as an ordinary bar between its joints.' + : 'Draw this link as the disc it sweeps, centred on its fixed pin. Its mass and rotational inertia are unchanged.' + " + > + {{ drawnAsDisc() ? 'Make Bar' : 'Make Circular' }} + </button-block> + </div> </collapsible-subseciton> <collapsible-subseciton titleLabel="Mass Settings" @@ -352,28 +561,109 @@ (opened)="sectionExpanded['LMass'] = true" (closed)="sectionExpanded['LMass'] = false" > + <div class="massArea"> <input-block [formGroup]="linkForm" _formControl="mass" - tooltip="The mass used by force analysis." - >Link Mass</input-block - > - <input-block - wide - [formGroup]="linkForm" - _formControl="massMoI" - tooltip="The mass moment of inertia about the link's physical center of mass." - >Moment of Inertia</input-block - > - <dual-input-block - label1="X" - label2="Y" - formControl1="comX" - formControl2="comY" - [formGroup]="linkForm" - tooltip="The physical center of mass used by force analysis." - >Center of Mass Position</dual-input-block + tooltip="The mass used by force analysis. Zero is an idealized massless bar: gravity and inertia skip it, and rotational inertia reads zero." + >Mass</input-block > + <!-- Everything below Mass describes how that mass is distributed, so on + a massless bar there is nothing for any of it to describe: gravity + and inertia skip the link entirely and the numbers cannot change an + answer. They used to be shown greyed, which asks the reader to + work out that a whole section is inert. Give it a mass and they + come back. --> + @if (activeSrv.selectedLink.mass > 0) { + <!-- One legend for the two derived fields below. It sits under Mass — + which is always typed — so it introduces exactly the fields it + governs: grey behind a hollow dot follows the shape, ink behind a + filled one was set by hand and carries a ✕ to hand it back. --> + <div class="dotLegend"> + <span class="dot"></span> from shape + <span class="dot filled"></span> set by you + <button + type="button" + class="resetAll" + [disabled]="!activeSrv.selectedLink.moiIsCustom && !activeSrv.selectedLink.comIsCustom" + (click)="useUniformBody()" + > + Reset all + </button> + </div> + <div class="dotRow"> + <span class="dotRowLabel">Center of Mass</span> + <!-- Marked on the options, not with [value] on the select. Setting + the select's value only takes if its options already exist, + and these are rebuilt whenever the panel re-renders this block + — a different link selected, a mass edited — after which the + binding is unchanged and so is never re-applied, leaving the + control reading Centroid over a link held some other way. An + option that knows itself to be the selected one cannot drift. --> + <select + class="comFrameSelect" + (change)="setComFrame($any($event.target).value)" + title="What the center of mass is held against while you edit, and the frame the two numbers below are read in. Centroid rides the link; Global grid keeps its place on the drawing; a joint follows that pin alone. Once the mechanism runs it rides the link either way." + > + <option value="centroid" [selected]="comFrame === 'centroid'">Centroid</option> + <option value="grid" [selected]="comFrame === 'grid'">Global grid</option> + @for (joint of activeSrv.selectedLink.joints; track joint.id) { + <option + [value]="'joint:' + joint.id" + [selected]="comFrame === 'joint:' + joint.id" + > + Joint {{ joint.name || joint.id }} + </option> + } + </select> + </div> + <!-- Each field wears the pair's state, as the mock draws it. --> + <div class="comPairRow" [class.derived]="!activeSrv.selectedLink.comIsCustom"> + <span class="axisTag">X</span> + <state-input + [formGroup]="linkForm" + _formControl="comX" + [state]="activeSrv.selectedLink.comIsCustom ? 'custom' : 'auto'" + clearTitle="Back to the shape's centroid" + (cleared)="useUniformBodyCoM()" + (hovered)="setComPreview('x', $event)" + /> + <span class="axisTag">Y</span> + <state-input + [formGroup]="linkForm" + _formControl="comY" + [state]="activeSrv.selectedLink.comIsCustom ? 'custom' : 'auto'" + clearTitle="Back to the shape's centroid" + (cleared)="useUniformBodyCoM()" + (hovered)="setComPreview('y', $event)" + /> + </div> + <input-block + [formGroup]="linkForm" + _formControl="massMoI" + class="moiBlock" + tooltip="The mass moment of inertia about the link's center of mass. Derived from the shape as a uniform body until a value is typed." + [class.derived]="!activeSrv.selectedLink.moiIsCustom" + >Rotational Inertia + <span fieldSuffix class="stateSuffix"> + <span class="dot" [class.filled]="activeSrv.selectedLink.moiIsCustom"></span> + @if (activeSrv.selectedLink.moiIsCustom) { + <button type="button" class="clearDot" title="Back to the shape's own inertia" (click)="useUniformBodyMoI()">✕</button> + } + </span> + </input-block> + <!-- Drawing a crank as a disc is a picture, not a declaration that the + part is one. Said here, next to the two numbers it would otherwise + be reasonable to assume it had changed — and only here, because + with no mass there are no such numbers to mislead anyone. --> + @if (drawnAsDisc()) { + <p class="shapeNote"> + Drawn as a disc. These two still follow the link's joints, not the + circle — set Rotational Inertia by hand for a real flywheel. + </p> + } + } + </div> </collapsible-subseciton> @if (activeSrv.selectedLink.isCompound) { <collapsible-subseciton @@ -441,8 +731,25 @@ (opened)="sectionExpanded['FVisual'] = true" (closed)="sectionExpanded['FVisual'] = false" > - <color-picker type="force" [force]="activeSrv.selectedForce">Force Color</color-picker> + <color-picker + type="force" + [force]="activeSrv.selectedForce" + tooltip="Draw this one force in its own colour, from the same six the links use. Travels with the drawing." + >Force Color</color-picker + > </collapsible-subseciton> </panel-section> } + + <!-- The thread back to a closed tutorial. Its own small card after every + branch above, rather than inside the empty state where it started: that + state is only drawn when nothing is selected, and closing the tutorial + part-way through leaves a joint selected, so the offer vanished at + exactly the moment it became the only way back. --> + @if (tutorial.resumeVisible()) { + <button class="resumeCard" (click)="startTutorial()"> + <mat-icon>school</mat-icon> + <span>{{ tutorial.resumeLabel() }}</span> + </button> + } </div> diff --git a/src/app/component/edit-panel/edit-panel.component.scss b/src/app/component/edit-panel/edit-panel.component.scss index 576bb271..ab4cdc53 100644 --- a/src/app/component/edit-panel/edit-panel.component.scss +++ b/src/app/component/edit-panel/edit-panel.component.scss @@ -21,7 +21,6 @@ display: flex; flex-direction: column; gap: 10px; - margin: 10px; //For child components, set border to dotted for debugging //> * > * > * { @@ -33,6 +32,16 @@ //font-weight: 300; } + // The background image has no name to rename and nothing to lock, so it takes + // a plain heading rather than the editable-title row every part carries -- at + // the same size and in the same place, so the panel does not jump when the + // selection moves between a part and the picture behind it. + .bgTitle { + padding: 10px 15px 0; + @include mat.m2-typography-level($typography-config, 'headline-6'); + white-space: nowrap; + } + // Input Settings appears the moment a joint becomes the input, so it eases in // rather than popping the sections below it down the panel. .inputSettings { @@ -57,22 +66,52 @@ } } -// Read-only notes under the Slider toggle: what the slot is cut into, and what -// its angle is measured from. Not controls -- the carrier comes from the drop -// gesture, and changing it is delete-and-recreate. -.slotNote { +// Which file is behind the grid. Under the heading rather than in it, because +// a file name is as long as it is and the heading must not wrap. +.bgFileName { + padding: 0 15px 8px; + font-size: 12px; + color: rgba(0, 0, 0, 0.55); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// The note and the two buttons, given the same horizontal padding and the same +// gap a collapsible subsection gives its fields -- written out flat they ran to +// the panel's own edges and sat flush against each other. +.bgActions { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 15px 12px; +} + +// The one thing about this feature a user cannot discover by trying it: the +// picture is not in the link they are about to share. Same quiet grey as the +// other read-only notes in this panel. +.bgNote { font-size: 12px; color: rgba(0, 0, 0, 0.55); background: #f0f1f5; border-radius: 4px; - padding: 6px 8px; - margin: 0 0 8px; + padding: 8px 10px; + margin: 0; + line-height: 17px; text-wrap: pretty; } -.slotNote--warn { +// The one thing under the Slider toggle worth saying in words: a slider with +// no link to slide on. What the slot is cut into, and what its angle is +// measured from, are both drawn on the canvas and used to be said here too. +.sliderWarning { + font-size: 12px; background: #ffebee; color: #b3261e; + border-radius: 4px; + padding: 6px 8px; + margin: 0 0 8px; + text-wrap: pretty; } // The cylinder-skin override (§2.7). A view preference rather than mechanism @@ -132,3 +171,255 @@ line-height: 17px; color: #7a5a00; } + +/* --- Derived-vs-typed state, worn by the app's own input fields ---------- + The fields themselves are input-block / dual-input-block — the exact + component every other panel value uses — with the state dot and the clear + projected into their suffix slot, and grey text when the value follows the + shape. */ +.massArea .dotLegend { + display: flex; + align-items: center; + gap: 6px; + /* Flush with the label column, exactly where the mock starts it, and + closed by the mock's hairline under Reset all. Clear air above: the mock + does not crowd the legend against the Mass field. */ + margin: 8px 0 6px; + padding: 2px 0 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + font-size: 10.5px; + color: rgba(0, 0, 0, 0.55); + + .dot { margin-right: -2px; } + + .resetAll { + margin-left: auto; + border: none; + background: none; + color: #3f51b5; + font: inherit; + font-weight: 500; + cursor: pointer; + padding: 0; + + &:disabled { color: rgba(0, 0, 0, 0.3); cursor: default; } + } + +} + +/* A disabled button is not a hover target, so the pointer would fall through to + nothing and the wrapper's tooltip would never open either. Taking the button + out of the way explicitly lets the hover land on the wrapper, which is what + carries the explanation. */ +.shapeToggle:has(button:disabled) button { + pointer-events: none; +} + +/* Sits under the two derived mass fields it qualifies, in the legend's own grey + so it reads as the same aside rather than as a warning about a problem. */ +.shapeNote { + margin: 6px 0 0; + font-size: 10.5px; + line-height: 1.35; + color: rgba(0, 0, 0, 0.55); +} + +.massArea .dot { + display: inline-block; + width: 4px; + height: 4px; + border-radius: 50%; + border: 1.5px solid #9fa8da; + background: transparent; + flex: none; + + &.filled { background: #3f51b5; border-color: #3f51b5; } +} + +.massArea .dotRow { + display: flex; + align-items: center; + gap: 8px; + /* No side padding: every row's label starts at the same left edge and + every field ends at the same right edge as the input-block rows. */ + padding: 4px 0; +} + +.massArea .dotRowLabel { + font-size: 13px; + flex: 1 1 auto; + color: rgba(0, 0, 0, 0.6); + /* One line always; the field column cedes the width this needs. */ + white-space: nowrap; +} + +.massArea .comFrameSelect { + font: inherit; + font-size: 14px; + border: none; + border-radius: 4px; + background: #f0f1f5; + height: 30px; + padding: 0 8px; + width: 112px; + min-width: 0; + flex: 0 0 112px; + margin-left: auto; + + &:disabled { + color: rgba(0, 0, 0, 0.38); + opacity: 1; + } +} + +.massArea .stateSuffix { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: 4px; + + + .clearDot { + border: none; + background: none; + padding: 0; + font-size: 14px; + line-height: 1; + color: rgba(0, 0, 0, 0.45); + cursor: pointer; + + &:hover { color: rgba(0, 0, 0, 0.8); } + } +} + +/* Grey means derived; the components' own ink means typed. */ +.massArea input-block.derived .customInput, +.massArea dual-input-block.derived .customInput { + color: rgba(0, 0, 0, 0.5); +} + +.massArea .massNote { + /* Flush with the labels' left line, like everything else in the area. */ + margin: 8px 0 2px; + font-size: 11.5px; + line-height: 16px; + color: rgba(0, 0, 0, 0.55); +} + +/* Air between stacked mass rows: the cylinder's three parts read as three + settings, not one field with two echoes. */ +.massArea input-block { + display: block; +} +.massArea input-block + input-block { + margin-top: 8px; +} + + + +.massArea .comPairRow { + display: flex; + align-items: center; + gap: 5px; + /* One extra bottom pixel: the row above contributes 8px of air, the row + below only 7, and the eye catches the difference. Measured, both 8. */ + padding: 4px 0 5px; + + state-input { + flex: none; + } + + justify-content: space-between; + + /* The axis letters read as small captions, not headings. */ + .axisTag { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + flex: none; + } +} + +/* The spacer stays hidden — the field pushes itself right with margin auto, + so no phantom flex gaps stack up against the fixed field column. The help + icons are back where they fit; Rotational Inertia's wide field leaves its + wrapped label no room for one, so its tooltip rides the label alone. */ +.massArea #input-block:not(.state-input-host) .spacer { + display: none; +} +.massArea input-block.moiBlock .label-help { + display: none; +} + +/* Rotational Inertia breaks the centre line: its value carries a unit and + needs the room, so its field expands leftward while still ending on the + panel's shared right edge. */ +.massArea input-block.moiBlock #input-block:not(.state-input-host) .customInputForm { + flex-basis: 148px; + min-width: 148px; +} +.massArea input-block.moiBlock #input-block .customInputForm .mat-mdc-text-field-wrapper { + width: 148px; +} + +/* Grey means derived, in the pair too. */ +.massArea .comPairRow.derived .customInput { + color: rgba(0, 0, 0, 0.5); +} + + +/* The mock's one field column: Mass, the frame select and Rotational + Inertia share one width, one left edge and one right edge — the centre + line of the panel. The id out-ranks the base #input-block width. */ +.massArea #input-block .customInputForm .mat-mdc-text-field-wrapper { + width: 112px; + max-width: 100%; +} + +/* The dot and its ✕ sit on the field's visible centre, not the baseline. + Same anchoring as state-input: the 30px wrapper clips MDC's 56px flex + row, so top-anchor and offset instead of flex centring. */ +.massArea #input-block .mat-mdc-form-field-icon-suffix, +.massArea #input-block .mat-mdc-form-field-text-suffix { + display: inline-flex; + align-items: center; + align-self: flex-start; + /* A fixed band the height and offset of the input's own line, so the + content centres whether it is a lone 7px dot or a dot with a 14px ✕. */ + margin-top: 6px; + height: 18px; +} + +/* The projected wrapper is otherwise inline: its font-sized line box + baseline-shifts the dot a few pixels low. Flex collapses it to content. */ +.massArea #input-block .field-suffix { + display: inline-flex; + align-items: center; + line-height: 1; +} + +/* A long label wraps; the field does not shrink below its own value. The + base rule pins labels to one line, which squeezed Rotational Inertia's + field to a third of its number. Scoped off the X/Y pair, which splits its + row by design. */ +.massArea #input-block:not(.state-input-host) .label { + white-space: normal; + /* Natural width, shrink allowed: the label wraps only when it must, and + the help icon sits against its text instead of drifting to the field. */ + flex: 0 1 auto; + /* One label size for the whole section — Mass, Center of Mass and + Rotational Inertia read as siblings, not as different ranks. */ + font-size: 13px; + color: rgba(0, 0, 0, 0.6); +} + +.massArea #input-block:not(.state-input-host) .customInputForm { + flex: 0 0 112px; + min-width: 112px; + margin-left: auto; +} + +/* The X/Y fields copy the Length/Angle fields above them: the same 80px + wrapper, laid out as label–field, gap, label–field across the row. */ +.massArea #input-block.state-input-host .customInputForm .mat-mdc-text-field-wrapper { + width: 80px; +} diff --git a/src/app/component/edit-panel/edit-panel.component.ts b/src/app/component/edit-panel/edit-panel.component.ts index 3b672115..46234932 100644 --- a/src/app/component/edit-panel/edit-panel.component.ts +++ b/src/app/component/edit-panel/edit-panel.component.ts @@ -1,14 +1,22 @@ +import { Subscription } from 'rxjs'; import { AfterContentInit, - ChangeDetectorRef, Component, OnDestroy, OnInit, ChangeDetectionStrategy, + effect, + inject, } from '@angular/core'; import { ActiveObjService } from 'src/app/services/active-obj.service'; import { PrisJoint, RealJoint, RevJoint } from 'src/app/model/joint'; -import { FormArray, FormBuilder } from '@angular/forms'; +import { + AbstractControl, + FormArray, + FormBuilder, + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; import { Coord } from 'src/app/model/coord'; import { AngleUnit, @@ -20,13 +28,16 @@ import { LengthUnit, MassUnit, } from 'src/app/model/utils'; -import { AnimationBarComponent } from '../animation-bar/animation-bar.component'; import { NumberUnitParserService } from 'src/app/services/number-unit-parser.service'; import { SettingsService } from '../../services/settings.service'; import { MechanismService } from '../../services/mechanism.service'; import { GridUtilsService } from '../../services/grid-utils.service'; -import { RealLink } from '../../model/link'; +import { Link, RealLink } from '../../model/link'; import { NewGridComponent } from '../new-grid/new-grid.component'; +import { MODEL_SCALE } from '../../model/render-scale'; +import { SubtitleComponent } from '../BLOCKS/subtitle/subtitle.component'; +import { StateInputComponent } from '../BLOCKS/state-input/state-input.component'; +import { uniformBodyOf } from '../../model/uniform-body'; import { cylinderSpanLayoutFrom, cylinderSpanRange, @@ -35,6 +46,25 @@ import { cylinderMinimumSpan, cylinderSizeOf, } from '../../model/cylinder'; +import { NotificationService } from 'src/app/services/notification.service'; +import { BackgroundImageService, MIN_WIDTH } from 'src/app/services/background-image.service'; +import { NOT_A } from 'src/app/ui-text'; +import { PanelSectionCollapsibleComponent } from '../BLOCKS/panel-section-collapsible/panel-section-collapsible.component'; +import { TitleBlock } from '../BLOCKS/title/title.component'; +import { MatIcon } from '@angular/material/icon'; +import { TutorialService } from '../../services/tutorial.service'; +import { MechanismPanelComponent } from '../mechanism-panel/mechanism-panel.component'; +import { PanelSectionComponent } from '../BLOCKS/panel-section/panel-section.component'; +import { EditableTitleComponent } from '../BLOCKS/editable-title/editable-title.component'; +import { CollapsibleSubsecitonComponent } from '../BLOCKS/collapsible-subseciton/collapsible-subseciton.component'; +import { DualInputComponent } from '../BLOCKS/dual-input/dual-input.component'; +import { ToggleComponent } from '../BLOCKS/toggle/toggle.component'; +import { ButtonComponent } from '../BLOCKS/button/button.component'; +import { InputComponent } from '../BLOCKS/input/input.component'; +import { ColorPickerComponent } from '../BLOCKS/color-picker/color-picker.component'; +import { DualButtonComponent } from '../BLOCKS/dual-button/dual-button.component'; +import { RadioComponent } from '../BLOCKS/radio/radio.component'; +import { MatTooltip } from '@angular/material/tooltip'; /** * Input Settings unit choices, in the order the picker shows them. The labels @@ -51,9 +81,39 @@ const INPUT_SPEED_UNITS = [ templateUrl: './edit-panel.component.html', styleUrls: ['./edit-panel.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + PanelSectionCollapsibleComponent, + TitleBlock, + MatTooltip, + MatIcon, + SubtitleComponent, + StateInputComponent, + MechanismPanelComponent, + PanelSectionComponent, + EditableTitleComponent, + CollapsibleSubsecitonComponent, + DualInputComponent, + FormsModule, + ReactiveFormsModule, + ToggleComponent, + ButtonComponent, + InputComponent, + ColorPickerComponent, + DualButtonComponent, + RadioComponent, + ], }) export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { + activeSrv = inject(ActiveObjService); + protected settingsService = inject(SettingsService); + private fb = inject(FormBuilder); + private nup = inject(NumberUnitParserService); + mechanismService = inject(MechanismService); + gridUtils = inject(GridUtilsService); + bgImage = inject(BackgroundImageService); + private notify = inject(NotificationService); + tutorial = inject(TutorialService); + listOfOtherJoints: RealJoint[] = []; private currentlyOpenJointID: string = ''; @@ -61,6 +121,10 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { sectionExpanded: { [key: string]: boolean } = { JBasic: true, //This is the default (starting) state JInput: true, //Expanded on arrival, so a new input's settings are visible + // Open on arrival, like the link's own mass section: it is only there at + // all for a slider, so a reader who has one has come to a joint that + // weighs something. + JMass: true, JVisual: false, JDistToJ: true, LBasic: true, @@ -69,10 +133,38 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { LCompound: true, FBasic: true, FVisual: false, + BGPlace: true, }; + /** + * A massless link has no inertia and no centre of mass to speak of. + * + * Those three fields describe how a mass is distributed, so with no mass + * they describe nothing -- and a force analysis run off numbers nobody chose + * reports an answer that looks meant. Angular's own disable is what greys + * them, so the value stays and comes back the moment a mass does. + */ + private syncMassDependents(): void { + const massless = !(this.activeSrv.selectedLink?.mass > 0); + const dependents = [ + this.linkForm.controls.massMoI, + this.linkForm.controls.comX, + this.linkForm.controls.comY, + ]; + for (const control of dependents) { + if (!control) continue; + if (massless && control.enabled) control.disable({ emitEvent: false }); + if (!massless && control.disabled) control.enable({ emitEvent: false }); + } + } + + /** Nothing drawn yet, so nothing to select and nothing to drag. */ + gridIsEmpty(): boolean { + return this.mechanismService.joints.length === 0 && this.mechanismService.links.length === 0; + } + hideEditPanel() { - return AnimationBarComponent.animate || this.mechanismService.mechanismTimeStep !== 0; + return this.mechanismService.isPlaying || this.mechanismService.mechanismTimeStep !== 0; } /** Unit choices for the Input Speed field's inline picker. */ @@ -81,9 +173,22 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { label: option.label, })); + /** The joint whose drive this panel is editing, if it is editing one. */ + private get drivenJoint(): RealJoint | undefined { + const joint = this.activeSrv.selectedJoint; + return joint && joint.input ? joint : undefined; + } + /** One button for both directions: flip rather than pick. */ flipInputDirection(): void { - this.settingsService.isInputCW.next(!this.settingsService.isInputCW.value); + const joint = this.drivenJoint; + if (joint) { + // This mechanism's drive, not the document's. A drawing can hold several + // and turning one round must leave the others turning as they were. + this.mechanismService.setDriveSpeed(joint, -this.mechanismService.driveSpeedOf(joint)); + } else { + this.settingsService.isInputCW.next(!this.settingsService.isInputCW.value); + } this.mechanismService.updateMechanism(true); } @@ -95,10 +200,11 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { * where the rotational speed is kept in RPM and converted for display. */ private patchInputSpeedField(): void { + const own = Math.abs(this.mechanismService.driveSpeedOf(this.drivenJoint)); const shown = this.isSliderInput - ? this.settingsService.linearInputSpeed.value + ? own : this.nup.convertAngularVelocity( - this.settingsService.inputSpeed.value, + own, AngularVelocityUnit.RPM, this.settingsService.inputSpeedUnit.value ); @@ -122,26 +228,25 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.patchInputSpeedField(); } - constructor( - public activeSrv: ActiveObjService, - protected settingsService: SettingsService, - private fb: FormBuilder, - private nup: NumberUnitParserService, - private cd: ChangeDetectorRef, - public mechanismService: MechanismService, - public gridUtils: GridUtilsService - ) { + constructor() { //Set the instance to this EditPanelComponent.instance = this; + // The picture can now be moved and resized on the canvas as well as typed + // at, so the fields follow it rather than only leading it. Patched without + // emitting, so mirroring a drag cannot loop back into a commit. + effect(() => { + this.bgImage.image(); + this.patchBackgroundImageForm(); + }); } //Instance of this static instance: EditPanelComponent; //maintain a list of subcriptions to unsubscribe later - onDestroySubscriptions: any[] = []; + onDestroySubscriptions: Subscription[] = []; //dynamic form array subscriptions - otherJoitnsSubscriptions: any[] = []; + otherJoitnsSubscriptions: Subscription[] = []; /** * The pending re-enable pass scheduled by a selection change. It has to be * cancellable: the pass asks the mechanism what the joint may do, and a @@ -177,6 +282,11 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { // on blur like every other numeric field. Direction is a button, not a control. inputSpeed: [''], inputSpeedUnit: ['0', { updateOn: 'change' }], + // The sliding body's own mass. It lives on the block, which is a link + // nobody can select -- clicking one picks the pin it rides on -- so + // without a field here the only way to it was the mass table in the + // force-analysis drawer. + sliderMass: [''], otherJoints: this.fb.array([]), //Dynamic form array }, { updateOn: 'blur' } @@ -213,6 +323,9 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { start: [''], startUnit: ['pct', { updateOn: 'change' }], angle: [''], + barrelMass: [''], + rodMass: [''], + headMass: [''], }, { updateOn: 'blur' } ); @@ -237,6 +350,23 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { { value: 'len', label: this.nup.unitLabel(this.settingsService.lengthUnit.getValue()) }, ]; } + /** + * Where the tracing underlay sits and how solid it is drawn. + * + * Position and width are lengths in the panel's own unit; opacity is a plain + * percentage, because there is no unit for "how far you can see through it". + */ + backgroundImageForm = this.fb.group( + { + centerX: [''], + centerY: [''], + width: [''], + rotation: [''], + opacity: [''], + }, + { updateOn: 'blur' } + ); + forceForm = this.fb.group( { magnitude: [''], @@ -258,14 +388,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.mechanismService.updateMechanism(); } - disableDelete(): void { - // this.mechanismService.canDelete = false; - } - ngOnInit(): void { - // console.log(this.jointForm); - // console.log(this.activeSrv); - // console.log(this.profileForm); this.onChanges(); this.disableAndEnableJointFields(); } @@ -322,6 +445,70 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { const sliderControl = this.jointForm.get('slider'); if (!sealedMount && sliderControl?.disabled) sliderControl.enable({ emitEvent: false }); if (sealedMount && sliderControl?.enabled) sliderControl.disable({ emitEvent: false }); + + this.syncLockDisabledFields(); + } + + /** + * The fields that move geometry go quiet while a Lock holds it — the same + * statement the canvas makes by refusing the drag, made in the panel's own + * language. Everything else (ground, weld, mass, colour, rename) stays + * live: a lock pins position, it does not embalm the object. + */ + private syncLockDisabledFields(): void { + const setEnabled = (control: AbstractControl | null, on: boolean) => { + if (!control) return; + if (on && control.disabled) control.enable({ emitEvent: false }); + if (!on && control.enabled) control.disable({ emitEvent: false }); + }; + if (this.activeSrv.objType === 'Joint' && this.activeSrv.selectedJoint) { + const frozenIds = this.gridUtils.frozenJointIds(); + const held = frozenIds.has(this.activeSrv.selectedJoint.id); + setEnabled(this.jointForm.get('xPos'), !held); + setEnabled(this.jointForm.get('yPos'), !held); + // The guide's direction is part of what a lock on a slider holds; only + // the held case is written, because the grounded-guide rule above owns + // the enabled side. + if (held) setEnabled(this.jointForm.get('prisAngle'), false); + // Per row, by the joint the row would MOVE. A distance field + // repositions the *other* joint, so a held selected joint may still + // edit its distances — and a free selected joint must not be a back + // door for moving a held neighbour. + this.listOfOtherJoints.forEach((other, i) => { + const otherHeld = frozenIds.has(other.id); + setEnabled(this.otherJoints.controls[i * 2] ?? null, !otherHeld); + setEnabled(this.otherJoints.controls[i * 2 + 1] ?? null, !otherHeld); + }); + } else if (this.activeSrv.objType === 'Link' && this.activeSrv.selectedLink) { + const frozenIds = this.gridUtils.frozenJointIds(); + const sealed = this.mechanismService.cylinderAt(this.activeSrv.selectedLink); + if (sealed) { + // Travel, Starts-at and Axis all hold the barrel mount and move the + // rest of the part, so a held barrel mount alone leaves them live. + const movable = [sealed.rodFar, sealed.pin, sealed.slider, sealed.barrelNear].every( + (joint) => !frozenIds.has(joint.id) + ); + setEnabled(this.cylinderForm.get('travel'), movable); + setEnabled(this.cylinderForm.get('start'), movable); + setEnabled(this.cylinderForm.get('angle'), movable); + } else { + // On top of the >2-joint rule disableAndEnableLinkFields applies: a + // length or angle edit moves the link's joints about an anchor of its + // own choosing, which no held joint can be trusted to survive. + const anyHeld = this.activeSrv.selectedLink.joints.some((joint) => frozenIds.has(joint.id)); + if (anyHeld) { + setEnabled(this.linkForm.get('length'), false); + setEnabled(this.linkForm.get('angle'), false); + } + } + } else if (this.activeSrv.objType === 'Force' && this.activeSrv.selectedForce) { + // Direction is what the drag handles edit, so the lock covers it; + // magnitude is not a position and stays live. + const held = this.activeSrv.selectedForce.locked; + setEnabled(this.forceForm.get('angle'), !held); + setEnabled(this.forceForm.get('xComp'), !held); + setEnabled(this.forceForm.get('yComp'), !held); + } } /** Whether the selected joint is a mount of a sealed cylinder. */ @@ -480,16 +667,88 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { patchCylinderForm(): void { const sealed = this.selectedCylinder; if (!sealed) return; + const massUnits = this.massUnit(); this.cylinderForm.patchValue( { travel: this.cylinderTravelLabel(sealed), start: this.cylinderStartLabel(sealed), angle: this.cylinderAngleLabel(sealed), + barrelMass: this.nup.formatValueAndUnit(sealed.barrel.mass, massUnits), + rodMass: this.nup.formatValueAndUnit(sealed.rod.mass, massUnits), + headMass: this.nup.formatValueAndUnit(sealed.block.mass, massUnits), }, { emitEvent: false } ); } + /** + * One handler for the three bodies a sealed cylinder weighs in as. + * + * The rod and head are welded rigid, but rigidity says how they move, not + * where their mass sits: the rod's is spread along its length, the head's + * is concentrated at the pin — which is the number that matters in a + * reciprocating machine. The solver already carries all three bodies, so + * these fields are the first door to numbers that were always there. + */ + private cylinderMassEdit( + control: 'barrelMass' | 'rodMass' | 'headMass', + part: (sealed: NonNullable<EditPanelComponent['selectedCylinder']>) => Link, + raw: string | null + ): void { + const sealed = this.selectedCylinder; + if (!sealed) return; + const units = this.massUnit(); + const body = part(sealed); + const [success, value] = this.nup.parseMassString(raw ?? '', units); + if (!success || value < 0) { + this.notify.refusal('value.mass', NOT_A.mass); + this.cylinderForm.patchValue( + { [control]: this.nup.formatValueAndUnit(body.mass, units) }, + { emitEvent: false } + ); + return; + } + // Through the one door: a mount weld can fold the barrel or rod into a + // compound, and the aggregate has to keep telling the same story. + this.mechanismService.assignBodyMass(body, value); + this.mechanismService.updateMechanism(true); + this.mechanismService.onMechUpdateState.next(2); + this.cylinderForm.patchValue( + { [control]: this.nup.formatValueAndUnit(value, units) }, + { emitEvent: false } + ); + } + + /** Whether either visible cylinder body still carries typed inertia values. */ + cylinderHasCustomInertia(): boolean { + const sealed = this.selectedCylinder; + if (!sealed) return false; + return [sealed.barrel, sealed.rod].some( + (part) => part instanceof RealLink && (part.moiIsCustom || part.comIsCustom) + ); + } + + /** + * Hand every part of the cylinder back to the uniform body. + * + * Template cylinders arrive from legacy URLs with frozen custom values and + * no other door to them: the cylinder panel replaces the link panel, so the + * per-field Derive buttons are unreachable for these bodies. + */ + deriveCylinderInertiaFromShape(): void { + const sealed = this.selectedCylinder; + if (!sealed) return; + for (const part of [sealed.barrel, sealed.rod]) { + if (part instanceof RealLink) { + part.moiIsCustom = false; + part.comIsCustom = false; + } + } + this.mechanismService.updateMechanism(true); + this.mechanismService.onMechUpdateState.next(2); + this.patchCylinderForm(); + } + /** Drive (or stop driving) the selected cylinder's hidden prismatic pin. */ toggleCylinderInput(): void { const sealed = this.selectedCylinder; @@ -510,6 +769,52 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { return this.selectedSlider?.ground === true; } + /** + * The sliding body of the selected joint's slider, where it has one. + * + * Not a cylinder's: a ram's three bodies have their own fields further down + * this panel, and its piston head is one of them. + */ + get sliderBlock(): Link | undefined { + if (this.selectedCylinder) return undefined; + return this.mechanismService.slotReactionOf(this.activeSrv.selectedJoint)?.block; + } + + /** + * Write a mass to the sliding body. + * + * The same door every other mass goes through, so a slider's weight reaches + * the solver the way a bar's does: it is what gravity pulls on and what the + * guide has to take, and in an in-motion analysis it is the reciprocating + * mass -- on a slider-crank it moves the input torque further than either + * bar's does. + */ + private commitSliderMass(raw: string | null): void { + const block = this.sliderBlock; + if (!block) return; + const units = this.massUnit(); + const [success, value] = this.nup.parseMassString(raw ?? '', units); + if (!success || value < 0) { + this.notify.refusal('value.mass', NOT_A.mass); + this.patchSliderMass(); + return; + } + this.mechanismService.assignBodyMass(block, value); + this.mechanismService.updateMechanism(true); + this.mechanismService.onMechUpdateState.next(2); + this.patchSliderMass(); + } + + /** Mirror the sliding body's mass into the field, without re-firing it. */ + patchSliderMass(): void { + const block = this.sliderBlock; + if (!block) return; + this.jointForm.patchValue( + { sliderMass: this.nup.formatValueAndUnit(block.mass, this.massUnit()) }, + { emitEvent: false } + ); + } + /** * Whether the drive on this joint is a translation rather than a rotation. * @@ -572,13 +877,6 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { return this.settingsService.isInputCW.value ? 'arrow_back' : 'arrow_forward'; } - /** A slot cut into a moving link names the link and the pair that defines it. */ - get slotOnLabel(): string | undefined { - const slider = this.selectedSlider; - if (!slider?.isFloating || !slider.isSlotWellFormed) return undefined; - return `${slider.carrier!.id} (joints ${slider.slotJointA!.id}\u2013${slider.slotJointB!.id})`; - } - /** A slider with a block and nowhere to slide: invalid until it gets a carrier. */ get isDanglingSlider(): boolean { return this.selectedSlider?.isDangling === true; @@ -614,6 +912,13 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { }) ); + this.onDestroySubscriptions.push( + this.jointForm.controls['sliderMass'].valueChanges.subscribe((val) => { + if (this.hideEditPanel()) return; + this.commitSliderMass(val ?? ''); + }) + ); + this.onDestroySubscriptions.push( this.jointForm.controls['xPos'].valueChanges.subscribe((val) => { if (this.hideEditPanel()) return; @@ -622,6 +927,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.lengthUnit.getValue() ); if (!success) { + this.notify.refusal('value.length', NOT_A.length); this.jointForm.patchValue({ xPos: this.nup.formatModelLength( this.activeSrv.selectedJoint.x, @@ -659,6 +965,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.lengthUnit.getValue() ); if (!success) { + this.notify.refusal('value.length', NOT_A.length); this.jointForm.patchValue({ yPos: this.nup.formatModelLength( this.activeSrv.selectedJoint.y, @@ -698,6 +1005,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { if (!this.activeSrv.selectedJoint) return; if (!this.gridUtils.isAttachedToSlider(this.activeSrv.selectedJoint)) return; if (!success) { + this.notify.refusal('value.angle', NOT_A.angle); // Two things had to be true for this to recurse until the stack ran // out, and both were: the angle was read off the *pin*, which has no // angle_rad, so the field was restored to the string "NaN"; and the @@ -791,21 +1099,26 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { const typed = Number(String(val ?? '').trim()); // A rejected value changes nothing, so it must not mint an undo entry. if (Number.isFinite(typed) && typed !== 0) { - // The field carries magnitude; a minus sign reads as "the other way", - // so -20 becomes 20 with the direction flipped. - if (typed < 0) { - this.settingsService.isInputCW.next(!this.settingsService.isInputCW.value); - } - if (this.isSliderInput) { - this.settingsService.linearInputSpeed.next(Math.abs(typed)); - } else { - this.settingsService.inputSpeed.next( - this.nup.convertAngularVelocity( + const joint = this.drivenJoint; + const magnitude = this.isSliderInput + ? Math.abs(typed) + : this.nup.convertAngularVelocity( Math.abs(typed), this.settingsService.inputSpeedUnit.value, AngularVelocityUnit.RPM - ) - ); + ); + // The field carries magnitude; a minus sign reads as "the other way", + // so -20 becomes 20 with the direction flipped. + const wasClockwise = this.mechanismService.driveSpeedOf(joint) < 0; + const clockwise = typed < 0 ? !wasClockwise : wasClockwise; + if (joint) { + this.mechanismService.setDriveSpeed(joint, clockwise ? -magnitude : magnitude); + } else if (this.isSliderInput) { + this.settingsService.linearInputSpeed.next(magnitude); + this.settingsService.isInputCW.next(clockwise); + } else { + this.settingsService.inputSpeed.next(magnitude); + this.settingsService.isInputCW.next(clockwise); } this.mechanismService.updateMechanism(true); } @@ -870,6 +1183,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.lengthUnit.getValue() ); if (!success) { + this.notify.refusal('value.length', NOT_A.length); this.linkForm.patchValue({ length: this.nup.formatModelLength( this.activeSrv.selectedLink.length, @@ -932,6 +1246,21 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.onDestroySubscriptions.push( this.cylinderForm.controls['startUnit'].valueChanges.subscribe(() => this.patchCylinderForm()) ); + this.onDestroySubscriptions.push( + this.cylinderForm.controls['barrelMass'].valueChanges.subscribe((val) => + this.cylinderMassEdit('barrelMass', (sealed) => sealed.barrel, val) + ) + ); + this.onDestroySubscriptions.push( + this.cylinderForm.controls['rodMass'].valueChanges.subscribe((val) => + this.cylinderMassEdit('rodMass', (sealed) => sealed.rod, val) + ) + ); + this.onDestroySubscriptions.push( + this.cylinderForm.controls['headMass'].valueChanges.subscribe((val) => + this.cylinderMassEdit('headMass', (sealed) => sealed.block, val) + ) + ); this.onDestroySubscriptions.push( this.cylinderForm.controls['start'].valueChanges.subscribe((val) => { @@ -978,8 +1307,10 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { val!, this.settingsService.angleUnit.getValue() ); - if (!success) this.patchCylinderForm(); - else + if (!success) { + this.notify.refusal('value.angle', NOT_A.angle); + this.patchCylinderForm(); + } else this.reposeCylinder( undefined, this.nup.convertAngle( @@ -998,6 +1329,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.angleUnit.getValue() ); if (!success) { + this.notify.refusal('value.angle', NOT_A.angle); this.linkForm.patchValue({ angle: this.nup .convertAngle( @@ -1038,40 +1370,48 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { const units = this.massUnit(); const [success, value] = this.nup.parseMassString(val ?? '', units); if (!success || value < 0) { + this.notify.refusal('value.mass', NOT_A.mass); this.linkForm.patchValue( { mass: this.nup.formatValueAndUnit(this.activeSrv.selectedLink.mass, units) }, { emitEvent: false } ); return; } - this.activeSrv.selectedLink.mass = value; + this.mechanismService.assignBodyMass(this.activeSrv.selectedLink, value); + this.syncMassDependents(); this.mechanismService.updateMechanism(true); this.mechanismService.onMechUpdateState.next(2); this.linkForm.patchValue( { mass: this.nup.formatValueAndUnit(value, units) }, { emitEvent: false } ); + // An auto moment of inertia follows the mass it belongs to; show what + // the rebuild just derived rather than the number from before it. + this.refreshDerivedMassFields(); }) ); this.onDestroySubscriptions.push( this.linkForm.controls['massMoI'].valueChanges.subscribe((val) => { - const units = this.momentOfInertiaUnit(); - const [success, value] = this.nup.parseInertiaString(val ?? '', units); - if (!success || value < 0) { - this.linkForm.patchValue( - { massMoI: this.nup.formatValueAndUnit(this.activeSrv.selectedLink.massMoI, units) }, - { emitEvent: false } - ); + // Typed in the display unit (g·cm² for metric), stored in the unit the + // solver and every URL are written against (kg·cm²). + const length = this.settingsService.lengthUnit.getValue(); + const display = this.nup.displayInertiaUnit(length); + const [success, value] = this.nup.parseInertiaString(val ?? '', display); + if (!success || value < 0 || !(this.activeSrv.selectedLink.mass > 0)) { + if (!success || value < 0) this.notify.refusal('value.inertia', NOT_A.momentOfInertia); + this.refreshDerivedMassFields(); return; } - this.activeSrv.selectedLink.massMoI = value; + this.activeSrv.selectedLink.massMoI = this.nup.convertInertia( + value, + display, + this.nup.storedInertiaUnit(length) + ); + this.activeSrv.selectedLink.moiIsCustom = true; this.mechanismService.updateMechanism(true); this.mechanismService.onMechUpdateState.next(2); - this.linkForm.patchValue( - { massMoI: this.nup.formatValueAndUnit(value, units) }, - { emitEvent: false } - ); + this.refreshDerivedMassFields(); }) ); @@ -1094,6 +1434,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.forceUnit.getValue() ); if (!success) { + this.notify.refusal('value.force', NOT_A.force); this.forceForm.patchValue({ magnitude: this.activeSrv.selectedForce.mag.toFixed(2).toString(), }); @@ -1121,6 +1462,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.angleUnit.getValue() ); if (!success) { + this.notify.refusal('value.angle', NOT_A.angle); this.forceForm.patchValue({ angle: this.activeSrv.selectedForce.angleRad.toFixed(2).toString(), }); @@ -1153,6 +1495,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.forceUnit.getValue() ); if (!success) { + this.notify.refusal('value.force', NOT_A.force); this.forceForm.patchValue({ xComp: this.activeSrv.selectedForce.xComp.toFixed(2).toString(), }); @@ -1177,6 +1520,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.forceUnit.getValue() ); if (!success) { + this.notify.refusal('value.force', NOT_A.force); this.forceForm.patchValue({ yComp: this.activeSrv.selectedForce.yComp.toFixed(2).toString(), }); @@ -1199,11 +1543,61 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { if (this.hideEditPanel()) { return; } - // this.activeSrv.selectedForce.local = val == '0' ? true : false; this.mechanismService.changeForceLocal(); }) ); + // The three lengths behave alike, so they are wired alike. Nothing here + // calls updateMechanism or save: moving a picture is not an edit to the + // linkage, and putting it in the undo history would mean Undo silently + // re-posing the machine because the user nudged the underlay. + (['centerX', 'centerY', 'width'] as const).forEach((field) => { + this.onDestroySubscriptions.push( + this.backgroundImageForm.controls[field].valueChanges.subscribe((val) => { + this.commitBackgroundImageLength(field, val); + }) + ); + }); + + this.onDestroySubscriptions.push( + this.backgroundImageForm.controls['rotation'].valueChanges.subscribe((val) => { + if (!this.bgImage.image()) return; + const [ok, value] = this.nup.parseAngleString( + val ?? '', + this.settingsService.angleUnit.getValue() + ); + if (!ok) { + this.notify.refusal('value.angle', NOT_A.angle); + } else { + this.bgImage.place({ + rotationRad: this.nup.convertAngle( + value, + this.settingsService.angleUnit.getValue(), + AngleUnit.RADIAN + ), + }); + } + this.patchBackgroundImageForm(); + }) + ); + + this.onDestroySubscriptions.push( + this.backgroundImageForm.controls['opacity'].valueChanges.subscribe((val) => { + if (!this.bgImage.image()) return; + const typed = (val ?? '').replace('%', '').trim(); + const percent = Number(typed); + // Emptiness is not zero. Number('') is 0, so a blank field silently + // turned the picture invisible -- and then the panel reported 0% as + // though that had been asked for. + if (typed === '' || !Number.isFinite(percent) || percent < 0 || percent > 100) { + this.notify.refusal('bgImage.opacity', 'Opacity has to be a number from 0 to 100.'); + } else { + this.bgImage.place({ opacity: percent / 100 }); + } + this.patchBackgroundImageForm(); + }) + ); + this.onDestroySubscriptions.push( this.activeSrv.onActiveObjChange.subscribe((newObjType: string) => { if (newObjType == 'Joint') { @@ -1220,7 +1614,6 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.listOfOtherJoints.forEach((joint, i) => { this.setFormDistAndAngle(this.activeSrv.selectedJoint, joint, i); - // console.log('set form dist and angle'); }); this.currentlyOpenJointID = this.activeSrv.selectedJoint.id; @@ -1253,6 +1646,9 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { slider: this.gridUtils.isAttachedToSlider(this.activeSrv.selectedJoint), weld: this.activeSrv.selectedJoint.isWelded, curve: this.activeSrv.selectedJoint.showCurve, + sliderMass: this.sliderBlock + ? this.nup.formatValueAndUnit(this.sliderBlock.mass, this.massUnit()) + : '', }, { emitEvent: false } ); @@ -1280,21 +1676,11 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.angleUnit.getValue() ), mass: this.nup.formatValueAndUnit(this.activeSrv.selectedLink.mass, this.massUnit()), - massMoI: this.nup.formatValueAndUnit( - this.activeSrv.selectedLink.massMoI, - this.momentOfInertiaUnit() - ), - comX: this.nup.formatModelLength( - this.activeSrv.selectedLink.CoM.x, - this.settingsService.lengthUnit.getValue() - ), - comY: this.nup.formatModelLength( - this.activeSrv.selectedLink.CoM.y, - this.settingsService.lengthUnit.getValue() - ), }, { emitEvent: false } ); + this.refreshDerivedMassFields(); + this.syncMassDependents(); // A cylinder body reuses the joint form's Input Settings controls // (speed, unit), so they have to be truthful when the body opens. this.syncInputSettingsFields(); @@ -1326,6 +1712,9 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { }, { emitEvent: false } ); + } else if (newObjType == 'BackgroundImage') { + this.currentlyOpenJointID = ''; + this.patchBackgroundImageForm(); } else { this.currentlyOpenJointID = ''; } @@ -1333,6 +1722,163 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { ); } + /** + * Whether the selected link is on the canvas as a disc right now. + * + * Not the same question as "was it asked to be one": the choice is kept + * through a ground being removed, so that putting the ground back brings the + * disc back rather than making someone ask twice. In between, the link is + * drawn as a bar, and everything a person can see about it should agree. + */ + drawnAsDisc(): boolean { + const link = this.activeSrv.selectedLink; + return !!link?.isCircle && link.canBeCircular(); + } + + /** Why Make Circular is unavailable, in terms of this particular link. */ + whyNotCircular(): string { + const link = this.activeSrv.selectedLink; + const grounded = link.joints.filter( + (joint) => joint instanceof RealJoint && joint.ground && !(joint instanceof PrisJoint) + ); + const held = link.isCircle ? 'Drawn as a bar: a' : 'A'; + if (link.subset.length > 0) { + return `${held} disc is centred on one fixed pin, and a welded compound is drawn from the + shapes of its parts. Unweld it to draw a part of it as a disc.`; + } + if (grounded.length === 0) { + return `${held} disc is centred on the pin its link turns about, and this link has no fixed + pin. Ground one of its joints to draw it as a disc.`; + } + if (grounded.length > 1) { + return `${held} disc is centred on the pin its link turns about, and this link is fixed at + ${grounded.length} joints, so it does not turn about any of them.`; + } + return `${held} disc is centred on a fixed revolute pin; this link's fixed joint is a slider, + which anchors a slot rather than a pivot.`; + } + + /** + * The frame the Center of Mass is both typed in and held against, read off + * the selected link rather than remembered here — it is the link's property, + * so selecting another link shows that link's answer and a reloaded drawing + * shows the one it was saved with. + */ + get comFrame(): string { + const anchor = this.activeSrv.selectedLink?.comAnchor ?? 'centroid'; + if (anchor === 'grid' || anchor === 'centroid') return anchor; + return 'joint:' + anchor.joint; + } + + setComFrame(frame: string): void { + const link = this.activeSrv.selectedLink; + if (!link) return; + link.comAnchor = + frame === 'grid' + ? 'grid' + : frame.startsWith('joint:') + ? { joint: frame.slice('joint:'.length) } + : 'centroid'; + // Re-read the offsets against the new anchor before anything moves, so + // choosing a frame re-describes where the point already is instead of + // moving it there. + link.captureComOffset(); + this.mechanismService.updateMechanism(true); + this.refreshDerivedMassFields(); + } + + /** Where the chosen frame's zero sits, in model coordinates. */ + private comFrameOrigin(link: RealLink): { x: number; y: number } { + if (this.comFrame === 'grid') return { x: 0, y: 0 }; + if (this.comFrame.startsWith('joint:')) { + const id = this.comFrame.slice('joint:'.length); + const joint = link.joints.find((candidate) => candidate.id === id); + if (joint) return { x: joint.x, y: joint.y }; + } + return uniformBodyOf(link.joints).centroid; + } + + // --------------------------------------------------------------------------- + // Background image + // + // The one thing this panel edits that is not part of the mechanism: it never + // touches MechanismService, never enters the undo history, and never reaches + // the URL codec. Its numbers are ordinary lengths, so they go through the same + // parser and the same length unit as every other length here. + // --------------------------------------------------------------------------- + + /** Show the placement as it actually is, in the user's own units. */ + private patchBackgroundImageForm(): void { + const image = this.bgImage.image(); + if (!image) return; + const unit = this.settingsService.lengthUnit.getValue(); + this.backgroundImageForm.patchValue( + { + centerX: this.nup.formatModelLength(image.centerX, unit), + centerY: this.nup.formatModelLength(image.centerY, unit), + width: this.nup.formatModelLength(image.width, unit), + rotation: this.nup.formatValueAndUnit( + this.nup.convertAngle( + image.rotationRad, + AngleUnit.RADIAN, + this.settingsService.angleUnit.getValue() + ), + this.settingsService.angleUnit.getValue() + ), + opacity: Math.round(image.opacity * 100).toString(), + }, + { emitEvent: false } + ); + } + + /** + * Commit one length field, or put back the value that is still true. + * + * A width the picture cannot have is refused rather than silently rounded up + * to the minimum: the number left in the field has to be the number that took + * effect, or the panel is lying about where the picture is. + */ + private commitBackgroundImageLength( + field: 'centerX' | 'centerY' | 'width', + raw: string | null + ): void { + if (!this.bgImage.image()) return; + const [ok, value] = this.nup.parseModelLengthString( + raw ?? '', + this.settingsService.lengthUnit.getValue() + ); + if (!ok) { + this.notify.refusal('value.length', NOT_A.length); + } else if (field === 'width' && value < MIN_WIDTH) { + this.notify.refusal( + 'bgImage.width', + `A background image has to be at least ${this.nup.formatModelLength( + MIN_WIDTH, + this.settingsService.lengthUnit.getValue() + )} wide.` + ); + } else { + this.bgImage.place({ [field]: value }); + } + // Either way the field is rewritten from the picture: a rejected entry goes + // back to the truth, and an accepted one is reformatted. + this.patchBackgroundImageForm(); + } + + saveBackgroundImage(): void { + // The placement is already live -- every field commits on blur -- so this + // closes the editor rather than writing anything. Leaving the picture + // selected would keep its outline on the canvas over finished work. + this.activeSrv.updateSelectedObj(null); + this.notify.success('bgImage.saved', 'Background image placed.'); + } + + deleteBackgroundImage(): void { + this.bgImage.remove(); + this.activeSrv.updateSelectedObj(null); + this.notify.success('bgImage.removed', 'Background image removed.'); + } + private updateLinkCenterOfMass(axis: 'x' | 'y', rawValue: string | null): void { const [success, value] = this.nup.parseModelLengthString( rawValue ?? '', @@ -1340,33 +1886,74 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { ); const link = this.activeSrv.selectedLink; if (!success) { - this.linkForm.patchValue( - { - [axis === 'x' ? 'comX' : 'comY']: this.nup.formatModelLength( - link.CoM[axis], - this.settingsService.lengthUnit.getValue() - ), - }, - { emitEvent: false } - ); + this.notify.refusal('value.length', NOT_A.length); + this.refreshDerivedMassFields(); return; } - link.CoM[axis] = value; - link.updateCoMDs(); + const origin = this.comFrameOrigin(link); + const point = { + x: axis === 'x' ? origin.x + value : link.CoM.x, + y: axis === 'y' ? origin.y + value : link.CoM.y, + }; + link.placeCustomCoM(point); + this.mechanismService.updateMechanism(true); + this.mechanismService.onMechUpdateState.next(2); + this.refreshDerivedMassFields(); + } + + /** Hand both derived fields back to the shape at once. */ + useUniformBody(): void { + this.activeSrv.selectedLink.moiIsCustom = false; + this.activeSrv.selectedLink.comIsCustom = false; + this.activeSrv.selectedLink.comOffset = undefined; + this.mechanismService.updateMechanism(true); + this.mechanismService.onMechUpdateState.next(2); + this.refreshDerivedMassFields(); + } + + /** Hand a field back to the uniform body, and show what it derives. */ + useUniformBodyMoI(): void { + this.activeSrv.selectedLink.moiIsCustom = false; + this.mechanismService.updateMechanism(true); + this.mechanismService.onMechUpdateState.next(2); + this.refreshDerivedMassFields(); + } + + useUniformBodyCoM(): void { + this.activeSrv.selectedLink.comIsCustom = false; + this.activeSrv.selectedLink.comOffset = undefined; this.mechanismService.updateMechanism(true); this.mechanismService.onMechUpdateState.next(2); + this.refreshDerivedMassFields(); + } + + /** Re-read MoI and CoM from the link after a rebuild may have re-derived them. */ + private refreshDerivedMassFields(): void { + const link = this.activeSrv.selectedLink; + if (!link) return; + const length = this.settingsService.lengthUnit.getValue(); + const origin = this.comFrameOrigin(link); + const display = this.nup.displayInertiaUnit(length); + const inertia = this.nup.convertInertia( + link.massMoI, + this.nup.storedInertiaUnit(length), + display + ); + this.linkForm.patchValue( + { + // The unit is typed into the box, as every Basic Settings field does + // it; the centre-of-mass pair stays bare — its unit is the frame's. + massMoI: this.nup.formatValueAndUnit(inertia, display), + comX: ((link.CoM.x - origin.x) / MODEL_SCALE).toFixed(2), + comY: ((link.CoM.y - origin.y) / MODEL_SCALE).toFixed(2), + }, + { emitEvent: false } + ); } momentOfInertiaUnit(): InertiaUnit { - switch (this.settingsService.lengthUnit.value) { - case LengthUnit.INCH: - return InertiaUnit.LBM_IN2; - case LengthUnit.METER: - return InertiaUnit.KG_M2; - default: - return InertiaUnit.KG_CM2; - } + return this.nup.displayInertiaUnit(this.settingsService.lengthUnit.value); } massUnit(): MassUnit { @@ -1478,6 +2065,30 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { return this.gridUtils.canToggleInput(selectedJoint); } + /** Point at a CoM field, see what it states: the CoM mark, plus the + * distance drawn from the chosen frame's zero along that axis — the same + * show-me the length and angle fields give. */ + setComPreview(axis: 'x' | 'y', on: boolean) { + const link = this.activeSrv.selectedLink; + const showing = on && !!link; + this.settingsService.previewCoMLinkId = showing ? link.id : null; + NewGridComponent.instance.setComMeasureOverlay( + showing + ? { + axis, + origin: this.comFrameOrigin(link), + com: { x: link.CoM.x, y: link.CoM.y }, + mode: this.comFrame === 'grid' ? 'axis' : 'origin', + } + : undefined + ); + } + + /** Point at a part's mass field, see that part lit on the ram. */ + setCylinderPartPreview(part: 'barrel' | 'rod' | 'head' | undefined) { + NewGridComponent.instance.setCylinderPartPreview(part); + } + setShowLinkLengthOverlay($event: number) { NewGridComponent.instance.showLinkLengthOverlay = $event; } @@ -1549,7 +2160,6 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { private reloadOtherJointForm() { this.listOfOtherJoints = this.getOtherJointsInLink(this.activeSrv.selectedJoint); this.jointForm.controls['otherJoints'] = this.fb.array([]); - // console.log('killed all subscriptions to other joints'); this.otherJoitnsSubscriptions.forEach((subscription) => subscription.unsubscribe()); this.otherJoitnsSubscriptions = []; @@ -1562,6 +2172,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.lengthUnit.getValue() ); if (!success) { + this.notify.refusal('value.length', NOT_A.length); this.otherJoints.controls[i * 2].patchValue( this.nup.formatModelLength( this.getDistanceBetweenJoints(this.activeSrv.selectedJoint, joint), @@ -1569,7 +2180,6 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { ) ); } else { - // this.activeSrv.selectedLink.length = value; this.updateDistanceBetweenJoints(this.activeSrv.selectedJoint, joint, value); this.mechanismService.onMechUpdateState.next(2); this.otherJoints.controls[i * 2].patchValue( @@ -1587,6 +2197,7 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { this.settingsService.angleUnit.getValue() ); if (!success) { + this.notify.refusal('value.angle', NOT_A.angle); this.otherJoints.controls[i * 2 + 1].patchValue( this.nup .convertAngle( @@ -1623,8 +2234,6 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { otherJoint: RealJoint, otherJointID: number ) { - // console.log('setFormDistAndAngle', otherJointID); - // console.log(this.otherJoints); let distance = this.getDistanceBetweenJoints(currentJoint, otherJoint); let angle = this.getAngleBetweenJoints(currentJoint, otherJoint); @@ -1644,4 +2253,18 @@ export class EditPanelComponent implements OnInit, AfterContentInit, OnDestroy { { emitEvent: false } ); } + + /** Open the tutorial where the drawing has got to, and show the drawer. */ + startTutorial(): void { + this.tutorial.start(); + } + + /** Turn the offer down for good, and say where it went. */ + dismissTutorial(): void { + this.tutorial.dismissOffer(); + this.notify.success( + 'tutorial.dismissed', + 'Tutorial dismissed. It is in the project menu, at the top left, if you change your mind.' + ); + } } diff --git a/src/app/component/equation-panel/equation-panel.component.ts b/src/app/component/equation-panel/equation-panel.component.ts index 6fa1b164..80ce25ae 100644 --- a/src/app/component/equation-panel/equation-panel.component.ts +++ b/src/app/component/equation-panel/equation-panel.component.ts @@ -1,10 +1,19 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { PanelSectionComponent } from '../BLOCKS/panel-section/panel-section.component'; +import { NotReadyWarningComponent } from '../not-ready-warning/not-ready-warning.component'; +import { TitleBlock } from '../BLOCKS/title/title.component'; +import { CollapsibleSubsecitonComponent } from '../BLOCKS/collapsible-subseciton/collapsible-subseciton.component'; @Component({ selector: 'app-equation-panel', templateUrl: './equation-panel.component.html', styleUrls: ['./equation-panel.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + PanelSectionComponent, + NotReadyWarningComponent, + TitleBlock, + CollapsibleSubsecitonComponent, + ], }) export class EquationPanelComponent {} diff --git a/src/app/component/export-panel/export-panel.component.html b/src/app/component/export-panel/export-panel.component.html new file mode 100644 index 00000000..03b0c5e2 --- /dev/null +++ b/src/app/component/export-panel/export-panel.component.html @@ -0,0 +1,367 @@ +<div class="exportCard"> + <!-- Three bands. The question, the rule and the count stay put; only the + list between them moves; Back and Next are always reachable, however + long the drawing is. --> + <div class="exportHead"> + <div class="exportTitle">Export Data</div> + <p class="exportLead">{{ lead }}</p> + + <!-- One rule, one question each. Forces used to be a tab inside the + columns step, which put a second row of tabs under the step rule and + asked a reader to notice that the thing they were looking for was + behind one of them. --> + <div class="steps"> + @for (mark of steps; track mark.step) { + <div class="step"> + @if (flow.isBehind(mark.step)) { + <button class="stepName done" (click)="goTo(mark.step)"> + <mat-icon>check</mat-icon>{{ mark.number }}. {{ mark.name }} + </button> + } @else { + <span class="stepName" [class.current]="mark.step === flow.step" + >{{ mark.number }}. {{ mark.name }}</span + > + } + <span + class="stepBar" + [class.current]="mark.step === flow.step" + [class.done]="flow.isBehind(mark.step)" + ></span> + </div> + } + </div> + + @if (flow.step !== 'file') { + <div class="countRow"> + <span class="count">{{ countText }}</span> + <span class="spacer"></span> + @if (flow.step === 'parts') { + <button class="linkButton" (click)="everything(true)">Select all</button> + <span class="pickSep"></span> + <button class="linkButton" (click)="everything(false)">Select none</button> + } @else { + <button class="linkButton" (click)="everyColumn(true)">Select all</button> + <span class="pickSep"></span> + <button class="linkButton" (click)="everyColumn(false)">Select none</button> + } + </div> + } + </div> + + <!-- Held still while a file is being written. The writer takes its own + snapshot of the answers, so a change made now would not reach the file + anyway -- and a control that visibly answers while changing nothing is + worse than one that waits. --> + <div class="exportBody" [class.busy]="working" appScrollShadow> + @if (flow.step === 'parts') { + @for (group of groups(); track group.id) { + <!-- Each machine's name stays under the head while its own rows pass, + so a reader ticking part nineteen still knows whose it is. --> + <div class="mechHead"> + <span class="mechName">{{ group.id }}</span> + <span class="mechNote">{{ groupNote(group) }}</span> + <span class="spacer"></span> + <button class="linkButton small" (click)="flow.setParts(group.parts, true)">All</button> + <span class="pickSep"></span> + <button class="linkButton small" (click)="flow.setParts(group.parts, false)">None</button> + </div> + <div class="rows"> + @for (part of group.parts; track part.key) { + <button + class="pickRow" + [class.off]="!part.available" + [class.on]="flow.isPicked(part)" + [class.onGrid]="isOnGrid(part)" + [disabled]="!part.available" + (click)="flow.togglePart(part)" + (mouseenter)="pointAt(part)" + (mouseleave)="pointAt(undefined)" + > + <span class="box" [class.ticked]="flow.isPicked(part)"> + @if (flow.isPicked(part)) { + <mat-icon>check</mat-icon> + } + </span> + <span class="rowName">{{ part.label }}</span> + @if (part.note) { + <span class="rowNote">{{ part.note }}</span> + } + </button> + } + </div> + } + @if (groups().length === 0) { + <p class="empty"> + Nothing here has been solved yet. A mechanism has to run before it has numbers to give. + </p> + } + } + + @if (flow.step === 'kinematics' || flow.step === 'forces') { + @for (group of flow.columnGroups(); track group.key) { + <div class="groupTitle">{{ group.title }}</div> + <div class="rows"> + @for (column of group.columns; track column.key) { + <button + class="pickRow" + [class.on]="flow.isColumnPicked(column)" + (click)="flow.toggleColumn(column)" + > + <span class="box" [class.ticked]="flow.isColumnPicked(column)"> + @if (flow.isColumnPicked(column)) { + <mat-icon>check</mat-icon> + } + </span> + <span class="rowName">{{ column.label }}</span> + <span class="spacer"></span> + <!-- What this row writes, not only what it is measured in: the + components control below governs some of these rows and has + nothing to add to the others. --> + @if (componentsOf(column)) { + <span class="rowComponents">{{ componentsOf(column) }}</span> + } + <span class="rowUnit">{{ column.unit }}</span> + </button> + } + </div> + } + + @if (flow.columnGroups().length === 0) { + <p class="empty"> + {{ + flow.step === 'forces' + ? 'None of the chosen parts carries a reaction this analysis can solve.' + : 'Choose a part on the first step to see what it has to give.' + }} + </p> + } + } + + @if (flow.step === 'file') { + <div class="formatBlock"> + <span class="settingName">Format</span> + @for (choice of formats; track choice.key) { + <div class="formatRow"> + <button class="formatPick" (click)="flow.format = choice.key"> + <span class="radio" [class.on]="flow.format === choice.key"> + @if (flow.format === choice.key) { + <span class="pip"></span> + } + </span> + <span class="formatText"> + <span class="formatName">{{ choice.name }}</span> + <span class="formatNote">{{ choice.note }}</span> + </span> + </button> + </div> + } + </div> + + <div class="settingBlock"> + @if (flow.format !== 'images') { + <div class="settingRow"> + <span class="settingName">Decimals</span> + <mat-icon + class="label-help" + [matTooltip]="help['decimals']" + matTooltipPosition="left" + matTooltipClass="exportTip" + matTooltipShowDelay="400" + >help_outline</mat-icon + > + <span class="spacer"></span> + <div class="segmented"> + @for (choice of decimalChoices; track choice) { + <button [class.on]="flow.decimals === choice" (click)="flow.decimals = choice"> + {{ decimalLabel(choice) }} + </button> + } + </div> + </div> + } + + @if (flow.format === 'csv') { + <div class="settingRow"> + <span class="settingName">Files</span> + <mat-icon + class="label-help" + [matTooltip]="help['files']" + matTooltipPosition="left" + matTooltipClass="exportTip" + matTooltipShowDelay="400" + >help_outline</mat-icon + > + <span class="spacer"></span> + <div class="segmented"> + <button [class.on]="!flow.splitPerPart" (click)="flow.splitPerPart = false"> + One file + </button> + <button [class.on]="flow.splitPerPart" (click)="flow.splitPerPart = true"> + Per part + </button> + </div> + </div> + } + + @if (flow.format === 'xlsx') { + <div class="settingRow"> + <span class="settingName">Sheets</span> + <mat-icon + class="label-help" + [matTooltip]="help['sheets']" + matTooltipPosition="left" + matTooltipClass="exportTip" + matTooltipShowDelay="400" + >help_outline</mat-icon + > + <span class="spacer"></span> + <div class="segmented"> + <button [class.on]="!flow.splitPerPart" (click)="flow.splitPerPart = false"> + By analysis + </button> + <button [class.on]="flow.splitPerPart" (click)="flow.splitPerPart = true"> + By part + </button> + </div> + </div> + } + + @if (flow.format === 'images') { + <div class="settingRow"> + <span class="settingName">Images</span> + <mat-icon + class="label-help" + [matTooltip]="help['images']" + matTooltipPosition="left" + matTooltipClass="exportTip" + matTooltipShowDelay="400" + >help_outline</mat-icon + > + <span class="spacer"></span> + <div class="segmented"> + <button [class.on]="flow.imageFormat === 'png'" (click)="flow.imageFormat = 'png'"> + PNG + </button> + <button [class.on]="flow.imageFormat === 'svg'" (click)="flow.imageFormat = 'svg'"> + SVG + </button> + </div> + </div> + } + + <div class="settingRow"> + <span class="settingName">Name</span> + <mat-icon + class="label-help" + [matTooltip]="help['name']" + matTooltipPosition="left" + matTooltipClass="exportTip" + matTooltipShowDelay="400" + >help_outline</mat-icon + > + <span class="spacer"></span> + <input + class="nameField" + type="text" + [value]="flow.typedName" + [placeholder]="flow.defaultName()" + (input)="onName($event)" + /> + <span class="extension">{{ flow.extension() }}</span> + </div> + </div> + + <div class="summaryCard"> + <!-- One file, a few, or an archive: the shape of what arrives, before + it arrives. --> + <mat-icon class="summaryGlyph">{{ deliveryGlyph }}</mat-icon> + <div class="summaryText"> + <span class="summaryName">{{ arrivingName }}</span> + <span class="summaryNote">{{ summaryLine }}</span> + <span class="summaryNote">{{ deliveryNote }}</span> + </div> + </div> + } + </div> + + <!-- On the footer rather than at the end of the list: it governs every row + above it, and a control a reader has to scroll to find is a control they + decide they do not have. --> + @if (flow.step === 'forces' || (flow.step === 'kinematics' && hasMagnitude())) { + <div class="componentsBar"> + @if (flow.step === 'forces') { + <div class="barRow"> + <span class="settingName">Analysis type</span> + <mat-icon + class="label-help" + [matTooltip]="help['analysis']" + matTooltipPosition="above" + matTooltipClass="exportTip" + matTooltipShowDelay="400" + >help_outline</mat-icon + > + <span class="spacer"></span> + <div class="segmented tight"> + @for (mode of ['static', 'dynamic']; track mode) { + <button + [class.on]="flow.forceMode() === mode" + (click)="flow.setForceMode($any(mode))" + > + {{ mode === 'static' ? 'Static' : 'In-motion' }} + </button> + } + </div> + </div> + } + @if (hasMagnitude()) { + <div class="barRow"> + <span class="settingName">Components to export</span> + <mat-icon + class="label-help" + [matTooltip]="help['components']" + matTooltipPosition="above" + matTooltipClass="exportTip" + matTooltipShowDelay="400" + >help_outline</mat-icon + > + <span class="spacer"></span> + <div class="segmented tight"> + <button [class.on]="!flow.withMagnitude" (click)="flow.withMagnitude = false"> + X, Y + </button> + <button [class.on]="flow.withMagnitude" (click)="flow.withMagnitude = true"> + X, Y, Magnitude + </button> + </div> + </div> + } + </div> + } + + <div class="exportFoot"> + @if (flow.previousStep()) { + <button class="backButton" (click)="back()"> + <mat-icon>arrow_back</mat-icon> + Back + </button> + } + @if (footNote) { + <span class="footNote">{{ footNote }}</span> + } + <span class="spacer"></span> + <button class="nextButton" [disabled]="!canGoOn() || working" (click)="next()"> + @if (!flow.nextStep()) { + @if (working) { + <span class="spinner" aria-hidden="true"></span> + Writing… + } @else { + <mat-icon>download</mat-icon> + Export + } + } @else { + Next + <mat-icon>arrow_forward</mat-icon> + } + </button> + </div> +</div> diff --git a/src/app/component/export-panel/export-panel.component.scss b/src/app/component/export-panel/export-panel.component.scss new file mode 100644 index 00000000..3ffeba01 --- /dev/null +++ b/src/app/component/export-panel/export-panel.component.scss @@ -0,0 +1,639 @@ +@use 'sass:map'; +@use '@angular/material' as mat; + +@mixin css($theme) { + $color-config: mat.m2-get-color-config($theme); + $primary-palette: map.get($color-config, 'primary'); + $indigo: mat.m2-get-color-from-palette($primary-palette, 500); + $indigo-light: mat.m2-get-color-from-palette($primary-palette, 400); + + // Its own card, like every other drawer page: one card per panel, never a + // card inside a card. Three bands rather than one scrolling column -- the + // question and the decisions stay put, and only the list moves. + .exportCard { + display: flex; + flex-direction: column; + height: auto; + max-height: 100%; + min-height: 0; + overflow: hidden; + background: var(--card-surface); + border-top: 5px solid $indigo; + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + isolation: isolate; + // Something is above, out of sight. Read off the body rather than set on it, + // because the band that casts the shadow is not the one that scrolls. + &:has(.exportBody.scrolled) .exportHead { + box-shadow: 0 3px 6px -3px rgba(0, 0, 0, 0.35); + } + + .spacer { + flex: 1 1 auto; + } + + .exportHead { + flex: 0 0 auto; + padding: 8px 15px 0; + background: var(--card-surface); + // Nothing yet: the shadow only means "there is more above", so it is drawn + // when the list underneath has actually been scrolled. + box-shadow: 0 3px 6px -3px rgba(0, 0, 0, 0); + transition: box-shadow 0.15s ease-in-out; + z-index: 3; + } + + .exportTitle { + // Clear of the drawer's close button, which shares this line. + padding-right: 40px; + font-size: 20px; + line-height: 32px; + font-weight: 500; + letter-spacing: 0.0125em; + color: rgba(0, 0, 0, 0.87); + } + + .exportLead { + margin: 0; + font-size: 14px; + line-height: 18px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.6); + } + + .steps { + display: flex; + align-items: center; + // The gap does the separating, so a name never runs up against the one + // after it. + gap: 8px; + padding-top: 12px; + } + + // As wide as its own name, not an equal quarter of the rule. Four equal + // shares of 380px is 95 apiece, which `2. Kinematics` overruns -- so the + // long name crowded the short one beside it while `4. File` sat in a third + // of a segment of white. + .step { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 auto; + min-width: 0; + } + + .stepName { + display: flex; + align-items: center; + gap: 4px; + border: none; + background: transparent; + font-family: inherit; + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + padding: 0; + white-space: nowrap; + + &.current { + font-weight: 500; + color: $indigo; + } + + // A step already answered is the way back to it; one not reached yet is + // not, because there is nothing there to answer. + &.done { + color: $indigo; + cursor: pointer; + } + + mat-icon { + width: 14px; + height: 14px; + font-size: 14px; + line-height: 14px; + } + } + + .stepBar { + height: 3px; + border-radius: 2px; + background: #eceef5; + + &.current { + background: $indigo; + } + + &.done { + background: #c5cae9; + } + } + + .countRow { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 0 10px; + } + + .count { + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + font-variant-numeric: tabular-nums; + } + + .linkButton { + border: none; + background: transparent; + font-family: inherit; + font-size: 13px; + font-weight: 500; + color: $indigo; + cursor: pointer; + padding: 0; + + &.small { + font-size: 12px; + } + } + + // Drawn rather than typed: a middot at this size renders as a ring in some + // faces and takes the ink of whatever it sits between. + // + // Named for this panel, and saying `border: none` out loud. It was called + // `.dot`, which the mass table's legend also calls its own marks -- so this + // one came out as a grey pip inside that legend's lavender ring, and the + // two ends of the same row disagreed about what a dot looks like. + .pickSep { + flex: 0 0 auto; + width: 3px; + height: 3px; + border: none; + border-radius: 50%; + background: rgba(0, 0, 0, 0.3); + } + + .exportBody.busy { + pointer-events: none; + opacity: 0.55; + } + + .exportBody { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding-bottom: 8px; + } + + // The machine whose rows are passing, named while they pass. + .mechHead { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 15px; + background: var(--card-surface); + } + + .mechName { + font-size: 14px; + font-weight: 500; + color: #2c2c2c; + } + + .mechNote { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .rows { + display: flex; + flex-direction: column; + } + + .pickRow { + display: flex; + align-items: center; + // Said out loud, because a button's own alignment is not a flex row's: + // left to itself the browser centres the name between the box and the note. + justify-content: flex-start; + gap: 12px; + width: 100%; + padding: 7px 15px; + border: none; + background: transparent; + font-family: inherit; + text-align: left; + cursor: pointer; + + &:hover:not(:disabled) { + background: #f4f5f9; + } + + // The part the reader picked on the canvas, so the list opens at the + // thing they were already looking at. + &.onGrid { + background: #fff8e1; + + &:hover:not(:disabled) { + background: #fff3cd; + } + } + + // Nothing to give, so nothing to tick: greyed rather than hidden, because + // a reader looking for Joint A should find out why it is not on offer. + &.off { + cursor: default; + + .rowName, + .rowNote { + color: rgba(0, 0, 0, 0.38); + } + } + } + + .box { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 2px solid rgba(0, 0, 0, 0.54); + border-radius: 2px; + box-sizing: border-box; + flex: 0 0 auto; + color: #fff; + + &.ticked { + border-color: $indigo; + background: $indigo; + } + + mat-icon { + width: 15px; + height: 15px; + font-size: 15px; + line-height: 15px; + } + } + + .pickRow.off .box { + border-color: rgba(0, 0, 0, 0.2); + background: #f4f4f7; + } + + .rowName { + font-size: 14px; + color: #2c2c2c; + } + + .rowNote { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .rowUnit { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + // A fixed column, so the units line up down the list however wide the + // components beside them turn out to be. + flex: 0 0 auto; + min-width: 46px; + text-align: right; + } + + // What the row writes, in the ink of a fact rather than of a label. + .rowComponents { + font-size: 12px; + font-variant-numeric: tabular-nums; + color: rgba(0, 0, 0, 0.6); + } + + .groupTitle { + padding: 10px 15px 6px; + font-size: 14px; + font-weight: 500; + color: #2c2c2c; + } + + .settingRow { + display: flex; + align-items: center; + gap: 10px; + padding: 2px 15px; + + &.spaced { + margin-top: 6px; + padding-top: 12px; + border-top: 1px solid #eceef5; + } + } + + .settingName { + font-size: 14px; + color: rgba(0, 0, 0, 0.6); + flex: 0 0 auto; + } + + .hint { + margin: 2px 15px 0; + font-size: 12px; + line-height: 16px; + color: rgba(0, 0, 0, 0.45); + } + + .empty { + margin: 10px 15px; + font-size: 13px; + line-height: 18px; + color: rgba(0, 0, 0, 0.5); + } + + .segmented { + display: flex; + height: 30px; + border-radius: 4px; + overflow: hidden; + border: 1px solid rgba(0, 0, 0, 0.12); + + button { + display: flex; + align-items: center; + padding: 0 10px; + border: none; + background: #fff; + color: #2c2c2c; + font-family: inherit; + font-size: 13px; + cursor: pointer; + + & + button { + border-left: 1px solid rgba(0, 0, 0, 0.12); + } + + &.on { + background: $indigo-light; + color: #fff; + } + } + } + + .formatBlock { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 15px 0; + } + + .formatRow { + display: flex; + align-items: flex-start; + gap: 8px; + } + + .formatPick { + display: flex; + align-items: flex-start; + justify-content: flex-start; + gap: 10px; + flex: 1 1 auto; + padding: 5px 0; + border: none; + background: transparent; + font-family: inherit; + text-align: left; + cursor: pointer; + } + + .radio { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-top: 2px; + border: 2px solid rgba(0, 0, 0, 0.54); + border-radius: 50%; + box-sizing: border-box; + flex: 0 0 auto; + + &.on { + border-color: $indigo; + background: $indigo; + } + } + + .pip { + width: 6px; + height: 6px; + border-radius: 50%; + background: #fff; + } + + .formatText { + display: flex; + flex-direction: column; + gap: 1px; + } + + .formatName { + font-size: 14px; + color: #2c2c2c; + } + + .formatNote { + font-size: 12px; + color: rgba(0, 0, 0, 0.55); + } + + .settingBlock { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 0; + margin-top: 4px; + border-top: 1px solid #eceef5; + } + + .nameField { + height: 30px; + width: 170px; + padding: 0 8px; + border: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.42); + border-radius: 4px 4px 0 0; + background: #f0f1f5; + box-sizing: border-box; + font-family: inherit; + font-size: 14px; + color: #2c2c2c; + + &:focus { + outline: none; + border-bottom-color: $indigo; + } + } + + .extension { + font-size: 13px; + color: rgba(0, 0, 0, 0.55); + } + + .summaryCard { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 4px 15px 12px; + padding: 10px 12px; + background: #f7f8fc; + border-radius: 5px; + } + + .summaryGlyph { + flex: 0 0 auto; + width: 22px; + height: 22px; + font-size: 22px; + line-height: 22px; + color: #5f6368; + } + + .summaryText { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .summaryName { + font-size: 13px; + font-weight: 500; + color: #2c2c2c; + } + + .summaryNote { + font-size: 12px; + color: rgba(0, 0, 0, 0.55); + font-variant-numeric: tabular-nums; + } + + // Its own band above the footer, so these read as belonging to the whole + // list rather than to the last group in it. + .componentsBar { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 15px; + border-top: 1px solid #eceef5; + } + + .barRow { + display: flex; + align-items: center; + gap: 10px; + } + + // Both labels have to fit beside their control on a 380px drawer, and + // `Static (Equilibrium)` beside `In-motion (Dynamic)` will not do it at the + // size the rest of the panel's controls are set in. + .segmented.tight button { + padding: 0 7px; + font-size: 12px; + white-space: nowrap; + } + + .exportFoot { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 15px; + border-top: 1px solid #eceef5; + background: #fafbfd; + } + + .footNote { + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + font-variant-numeric: tabular-nums; + } + + .backButton { + display: flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 10px; + border: none; + border-radius: 4px; + background: transparent; + color: $indigo; + font-family: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; + + &:hover { + background: rgba(63, 81, 181, 0.08); + } + } + + .nextButton { + display: flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 18px; + border: none; + border-radius: 4px; + background: $indigo; + color: #fff; + font-family: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; + + &:disabled { + background: #d5d7e0; + color: rgba(0, 0, 0, 0.38); + cursor: default; + } + } + + // Turning while the cycle is sampled and the file is written. A ring rather + // than a bar: it says "still going" without pretending to know how far + // along a job whose length nobody has measured is. + .spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.45); + border-top-color: #fff; + border-radius: 50%; + animation: exportSpin 0.7s linear infinite; + } + + @keyframes exportSpin { + to { + transform: rotate(360deg); + } + } + + .backButton mat-icon, + .nextButton mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } + } +} diff --git a/src/app/component/export-panel/export-panel.component.ts b/src/app/component/export-panel/export-panel.component.ts new file mode 100644 index 00000000..5018dfea --- /dev/null +++ b/src/app/component/export-panel/export-panel.component.ts @@ -0,0 +1,402 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { ScrollShadowDirective } from '../../scroll-shadow.directive'; +import { MechanismService } from '../../services/mechanism.service'; +import { Joint } from '../../model/joint'; +import { Link } from '../../model/link'; +import { NotificationService } from '../../services/notification.service'; +import { AnalyticsService } from '../../services/analytics.service'; +import { ExportFlowService, ExportStep } from '../../services/export/export-flow.service'; +import { ExportWriterService } from '../../services/export/export-writer.service'; +import { + Decimals, + ExportColumn, + ExportFormat, + ExportPart, + ExportPartGroup, +} from '../../services/export/export-model'; +import { RightPanelComponent } from '../right-panel/right-panel.component'; + +/** One of the questions, as the rule across the top draws it. */ +interface StepMark { + step: ExportStep; + number: number; + name: string; +} + +/** + * Export Data, asked one question at a time. + * + * The old command wrote whatever the analysis panel happened to be showing for + * whatever was selected: one part, every graph of it, always a CSV. Everything + * else in the drawing — the other joints, the second mechanism, the forces — + * was unreachable. This asks the three questions that were never asked: which + * parts, which numbers, and how the file should be written. + */ +@Component({ + selector: 'app-export-panel', + templateUrl: './export-panel.component.html', + styleUrls: ['./export-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatIcon, MatTooltip, ScrollShadowDirective], +}) +export class ExportPanelComponent implements OnInit, OnDestroy { + flow = inject(ExportFlowService); + private writer = inject(ExportWriterService); + private mechanism = inject(MechanismService); + private notify = inject(NotificationService); + private analytics = inject(AnalyticsService); + + /** The names the rule across the top puts on this drawing's questions. */ + private readonly stepNames: Record<ExportStep, string> = { + parts: 'Parts', + kinematics: 'Kinematics', + forces: 'Forces', + file: 'File', + }; + + get steps(): StepMark[] { + return this.flow.steps().map((step, at) => ({ + step, + number: at + 1, + name: this.stepNames[step], + })); + } + + /** + * The four ways out, each with the line that says what it is for. + * + * A sentence apiece rather than a mark to press: there are only four, they + * are read together while choosing between them, and a tooltip that has to + * be hunted for four times is a worse way to compare them than four lines + * already on the page. + */ + readonly formats: { key: ExportFormat; name: string; note: string }[] = [ + { + key: 'csv', + name: 'CSV', + note: 'One time column, one per series.', + }, + { + key: 'xlsx', + name: 'Excel workbook', + note: 'Sheets by analysis or by part, for charting.', + }, + { + key: 'images', + name: 'Graph images', + note: 'PNG or SVG per graph.', + }, + { + key: 'report', + name: 'Report (PDF)', + note: 'The mechanism, the graphs and the table.', + }, + ]; + + readonly decimalChoices: Decimals[] = [2, 4, 6, 'full']; + + /** + * What each of the file step's settings does, in the panel's own help mark. + * + * Beside the control rather than under it: four paragraphs stacked down a + * 380px drawer is a page nobody reads to answer a question they have about + * one row of it. + */ + readonly help: Record<string, string> = { + decimals: + 'How many digits after the point every number keeps. Full writes what the solver produced, which is what to choose if the rounding is going to happen somewhere else.', + files: + 'One file holds every chosen part of a machine against one time column. Per part writes a file for each, which is easier to chart one at a time and harder to compare across. Either way a machine gets its own file, because two machines run on two clocks.', + sheets: + 'By analysis puts kinematics and forces on sheets of their own, so one can be charted without the other’s axis running through it. By part gives every chosen joint and link a sheet.', + images: + 'SVG stays sharp at any size and can be opened and edited; PNG drops into a document that will not take a vector. Both carry the PMKS+ mark.', + name: 'The stem every file is named from. Where the export writes more than one, what tells them apart is added to it.', + analysis: + 'Static solves the mechanism held still at each position, which is the equilibrium a hand calculation gives. In-motion adds the inertia of the moving parts, so a fast machine reads differently from a slow one.', + components: + 'X and Y are the components along the axes. Magnitude is √(X² + Y²) — the size of the vector, without its direction — and only a series that has two components has one to add.', + }; + + /** + * Which glyph says what is about to come down: one file, a few, or a folder. + * + * Ligatures from the classic Material set the app already loads. A name the + * font does not know renders as the name itself in a box, which is worse + * than no glyph at all. + */ + get deliveryGlyph(): string { + if (this.flow.format === 'report') return 'print'; + if (this.flow.format === 'xlsx') return 'grid_on'; + const count = this.flow.format === 'images' ? this.pictureCount() : this.writer.summary().files; + if (count > 2) return 'folder'; + return count > 1 ? 'content_copy' : 'insert_drive_file'; + } + + get deliveryNote(): string { + if (this.flow.format === 'report') return 'Opens the print dialog, where Save as PDF writes it'; + const count = this.flow.format === 'images' ? this.pictureCount() : this.writer.summary().files; + if (count > 2) return `${count} files, in one zip folder`; + if (count > 1) return `${count} files`; + return this.flow.format === 'xlsx' ? 'One workbook' : 'One file'; + } + + /** Set while the browser is drawing pictures, which is not instant. */ + working = false; + + private updates?: Subscription; + + ngOnInit(): void { + this.flow.reset(); + this.updates = this.mechanism.onMechUpdateState.subscribe(() => this.flow.refresh()); + } + + ngOnDestroy(): void { + this.updates?.unsubscribe(); + // Nothing on the canvas should stay lit once the list that lit it is gone. + this.mechanism.hoveredPart = undefined; + } + + // --- what the head says --------------------------------------------------- + + get lead(): string { + if (this.flow.step === 'parts') return 'Which parts do you want numbers for?'; + if (this.flow.step === 'file') return 'How should the file be written?'; + const names = this.flow.selectedParts().map((part) => part.label.replace(/^(Joint|Link) /, '')); + if (names.length === 0) return 'Nothing is selected yet.'; + return this.flow.step === 'forces' + ? `Which forces for ${list(names)}?` + : `Which motion for ${list(names)}?`; + } + + get countText(): string { + if (this.flow.step === 'parts') { + return `${this.flow.selectedParts().length} of ${this.flow.offeredParts().length} parts`; + } + const tab = this.flow.tab; + const of = this.flow.columnGroups(tab).flatMap((group) => group.columns).length; + const picked = this.flow.columnCount(tab); + return tab === 'forces' ? `${picked} of ${of} force columns` : `${picked} of ${of} columns`; + } + + get footNote(): string { + if (this.flow.step === 'parts') { + const parts = this.flow.selectedParts().length; + const machines = this.flow + .mechanismIndexes() + .map((index) => this.flow.partGroups()[index]?.id) + .filter(Boolean) + .join(', '); + return parts === 0 + ? 'Nothing chosen yet' + : `${parts} ${parts === 1 ? 'part' : 'parts'}${machines ? ' · ' + machines : ''}`; + } + if (this.flow.step === 'file') return ''; + const summary = this.writer.summary(); + return `${Math.max(summary.columns - 1, 0)} columns · ${summary.rows} rows`; + } + + // --- step 1 --------------------------------------------------------------- + + groups(): ExportPartGroup[] { + return this.flow.partGroups().filter((group) => group.parts.length > 0); + } + + pickedIn(group: ExportPartGroup): number { + return group.parts.filter((part) => this.flow.isPicked(part)).length; + } + + groupNote(group: ExportPartGroup): string { + const picked = this.pickedIn(group); + return picked > 0 ? `${group.note} · ${picked} selected` : group.note; + } + + /** + * Point at the part this row is about, on the canvas. + * + * A name is not a place: `Joint F` on a Jansen leg is one of eleven pins and + * the list says nothing about which. The grid defers to whatever is already + * selected there, so this can never take a reader's own mark away. + */ + pointAt(part: ExportPart | undefined): void { + this.mechanism.hoveredPart = part?.part; + } + + /** + * Whether this row is the thing the reader has picked on the canvas. + * + * Asked of the mechanism rather than read off the selection's fields: the + * type has to be right (letting go of a part leaves the old one remembered) + * and a sealed cylinder has to answer for whichever of its pieces was hit. + */ + isOnGrid(part: ExportPart): boolean { + return part.kind === 'joint' + ? this.mechanism.isSelectedJoint(part.part as Joint) + : this.mechanism.isSelectedBody(part.part as Link); + } + + everything(picked: boolean): void { + this.flow.setParts(this.flow.offeredParts(), picked); + } + + // --- the column steps ----------------------------------------------------- + + allColumnsOnTab(): ExportColumn[] { + return this.flow.columnGroups(this.flow.tab).flatMap((group) => group.columns); + } + + everyColumn(picked: boolean): void { + this.flow.setColumns(this.allColumnsOnTab(), picked); + } + + /** + * What one ticked row will actually write, said on the row itself. + * + * The components control underneath used to be the only statement of it, + * which left a reader to work out for themselves that it governs a rate and a + * reaction but has nothing to add to an angle or a position. + */ + componentsOf(column: ExportColumn): string { + const widest = column.series.reduce((most, series) => Math.max(most, series.components), 1); + if (widest < 2) return ''; + return widest === 2 || !this.flow.withMagnitude ? 'X, Y' : 'X, Y, Mag'; + } + + /** Whether anything on offer has a magnitude, and so anything to choose. */ + hasMagnitude(): boolean { + return this.allColumnsOnTab().some((column) => + column.series.some((series) => series.components === 3) + ); + } + + // --- step 3 --------------------------------------------------------------- + + get summaryLine(): string { + const summary = this.writer.summary(); + if (this.flow.format === 'images') { + const pictures = this.pictureCount(); + return `${pictures} ${pictures === 1 ? 'image' : 'images'} · ${this.flow.imageFormat.toUpperCase()}`; + } + if (this.flow.format === 'report') { + return `${Math.max(summary.columns - 1, 0)} columns · ${summary.rows} rows · ${ + summary.pages + } printed ${summary.pages === 1 ? 'page' : 'pages'}`; + } + // How many files there are is the line under this one, in the glyph's own + // words; saying it twice made a two-line card read as a paragraph. + return `${Math.max(summary.columns - 1, 0)} columns · ${summary.rows} rows`; + } + + private pictureCount(): number { + return this.flow + .selectedColumns() + .reduce((total, column) => total + column.appliesTo.length * column.series.length, 0); + } + + get fileName(): string { + return this.flow.name(); + } + + /** + * What will actually land in the downloads folder. + * + * Asked of the writer, which is what names it: the name field says `.csv` + * because that is what the files inside an archive are, and a card promising + * `M1_analysis.csv` while `M1_analysis.zip` arrives is the card being wrong + * about the one thing it is there to say. + */ + get arrivingName(): string { + return this.writer.arrivingName(); + } + + onName(event: Event): void { + this.flow.typedName = (event.target as HTMLInputElement).value; + } + + decimalLabel(choice: Decimals): string { + return choice === 'full' ? 'Full' : String(choice); + } + + // --- moving between the steps -------------------------------------------- + + /** + * Whether Next leads anywhere from here. + * + * A column step never blocks: a reader who wants forces alone passes through + * the kinematics without ticking anything, and the file step is where having + * chosen nothing at all is finally in the way. + */ + canGoOn(): boolean { + if (this.flow.step === 'parts') return this.flow.selectedParts().length > 0; + if (this.flow.step === 'file') return this.flow.canExport(); + return true; + } + + next(): void { + if (!this.canGoOn()) return; + const onward = this.flow.nextStep(); + if (!onward) { + void this.exportNow(); + return; + } + this.flow.goTo(onward); + } + + back(): void { + const behind = this.flow.previousStep(); + if (behind) this.flow.goTo(behind); + } + + /** A question already answered is the way back to it; one not reached is not. */ + goTo(step: ExportStep): void { + if (this.flow.isBehind(step)) this.flow.goTo(step); + } + + async exportNow(): Promise<void> { + if (!this.flow.canExport() || this.working) return; + this.working = true; + // Let the button paint its spinner before the work starts. Sampling a + // cycle for eighty series holds the main thread, and a press that showed + // nothing until it finished read as a press that had not registered. + await new Promise((resolve) => setTimeout(resolve, 32)); + this.analytics.logEvent(`export_data_${this.flow.format}`); + // Read before the work starts, for the same reason the writer snapshots its + // own settings: the message has to name the file that was actually written. + const arriving = this.arrivingName; + try { + const written = await this.writer.run(); + if (!written) { + this.notify.refusal( + 'export.empty', + 'Nothing was written: the parts that were ticked no longer have numbers to give.' + ); + return; + } + this.notify.success( + 'export.done', + this.flow.format === 'report' + ? 'The report is ready to print or save as PDF.' + : `${arriving} is on its way.` + ); + } catch { + this.notify.failure('export.failed', 'The export could not be written.'); + } finally { + this.working = false; + } + } + + close(): void { + RightPanelComponent.isOpen = false; + } + + isPicked = (part: ExportPart): boolean => this.flow.isPicked(part); +} + +/** `B`, `B and C`, `B, C and AB` — a list read the way it is spoken. */ +function list(names: string[]): string { + if (names.length <= 1) return names.join(''); + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; +} diff --git a/src/app/component/help-panel/help-panel.component.html b/src/app/component/help-panel/help-panel.component.html new file mode 100644 index 00000000..55b00e82 --- /dev/null +++ b/src/app/component/help-panel/help-panel.component.html @@ -0,0 +1,72 @@ +<div id="helpWrapper"> + <panel-section> + <title-block>Help</title-block> + + <collapsible-subseciton titleLabel="Learn PMKS+" [expanded]="true"> + <button-block icon="help_center" [click]="gotoHelpSite">Tutorial Videos</button-block> + <subtitle-block>PMKS+ is open source! Do you want to help us develop it?</subtitle-block> + <button-block customIcon="github" [click]="gotoGithub">Github Repo</button-block> + </collapsible-subseciton> + + <!-- Where the keys are written down. Every hint in the app is drawn from + the same table this list is, so a key that moves moves in both. --> + <collapsible-subseciton titleLabel="Keyboard Shortcuts" [expanded]="false"> + @for (group of shortcuts.bySection(); track group.section) { + <div class="keyGroup"> + <h4 class="keyHeading">{{ group.section }}</h4> + @for (one of group.shortcuts; track one.id) { + <div class="keyRow"> + <span class="keyName">{{ one.label }}</span> + <kbd class="keyCap">{{ one.keys }}</kbd> + </div> + } + </div> + } + </collapsible-subseciton> + + <collapsible-subseciton titleLabel="Send Feedback" [expanded]="true"> + <p class="sectionNote"> + Do you want to attach a file? Send us an email at gr-pmksplus@wpi.edu + </p> + <form [formGroup]="commentForm"> + <mat-form-field class="max-width" subscriptSizing="dynamic"> + <mat-label>Leave a message</mat-label> + <textarea type="text" matInput formControlName="comment"></textarea> + @if (commentForm.controls['comment'].hasError('required')) { + <mat-error> Message is required </mat-error> + } + </mat-form-field> + <mat-checkbox color="primary" formControlName="response"> + I would like a response + </mat-checkbox> + @if (commentForm.value.response) { + <mat-form-field class="max-width" subscriptSizing="dynamic"> + <mat-label>Email</mat-label> + <input + type="email" + matInput + formControlName="email" + [errorStateMatcher]="matcher" + placeholder="Ex. pat@example.com" + /> + @if ( + commentForm.controls['email'].hasError('email') && + !commentForm.controls['email'].hasError('required') + ) { + <mat-error> Invalid email address </mat-error> + } + </mat-form-field> + } + <mat-checkbox color="primary" checked formControlName="diagnostics"> + Send browser and version data + </mat-checkbox> + <mat-checkbox color="primary" checked formControlName="project"> + Send a copy of the open project + </mat-checkbox> + <button-block [disabled]="sendingEmail" icon="send" [click]="sendCommentEmail"> + Send Message + </button-block> + </form> + </collapsible-subseciton> + </panel-section> +</div> diff --git a/src/app/component/help-panel/help-panel.component.scss b/src/app/component/help-panel/help-panel.component.scss new file mode 100644 index 00000000..938374b4 --- /dev/null +++ b/src/app/component/help-panel/help-panel.component.scss @@ -0,0 +1,96 @@ +@use 'sass:map'; +@use '@angular/material' as mat; + +@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'); + $typography-config: mat.m2-get-typography-config($theme); + + // The drawer hands its height down to here, and this hands it to the card, + // which is what scrolls -- the same chain the settings panel uses, and for + // the same reason: a frame that scrolls carries the close button, which is + // positioned against it, off its own top edge. + #helpWrapper { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + + panel-section { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } + + // A line of standing text inside a section, rather than a control with a + // label: the address to write to when the form will not do. + .sectionNote { + margin: 0; + @include mat.m2-typography-level($typography-config, 'body-2'); + color: rgba(0, 0, 0, 0.6); + } + + // The fields and checkboxes stack on the same gap the sections' own + // controls do; the form is a plain box around them, not a layout of its own. + form { + display: flex; + flex-direction: column; + gap: 10px; + } + + // A checkbox is drawn as an 18px box inside a 40px touch target, so three + // of them in a row sat 32px apart while everything else in the card is 10 -- + // they read as three separate things rather than one set of options. Cut to + // 28, which is Material's own next density step down and still a comfortable + // target; the box stays 18px and stays centred in it. + mat-checkbox { + --mat-checkbox-state-layer-size: 28px; + } + + .max-width { + width: 100%; + } + + // The list of keys: a name on the left, the key on the right, and the + // groups apart far enough that a heading belongs to what is under it. + .keyGroup + .keyGroup { + margin-top: 14px; + } + + .keyHeading { + margin: 0 0 4px; + @include mat.m2-typography-level($typography-config, 'body-2'); + font-weight: 500; + color: rgba(0, 0, 0, 0.6); + } + + .keyRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 3px 0; + @include mat.m2-typography-level($typography-config, 'body-2'); + } + + // A key, drawn as one: the cap is what a reader is looking for down the + // right-hand edge, so it keeps the same width whatever is printed on it. + .keyCap { + flex: none; + min-width: 24px; + padding: 2px 6px; + border: 1px solid rgba(0, 0, 0, 0.18); + border-bottom-width: 2px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.03); + font-family: inherit; + font-size: 12px; + line-height: 16px; + text-align: center; + color: rgba(0, 0, 0, 0.75); + } + } +} diff --git a/src/app/component/help-panel/help-panel.component.ts b/src/app/component/help-panel/help-panel.component.ts new file mode 100644 index 00000000..15dc7fff --- /dev/null +++ b/src/app/component/help-panel/help-panel.component.ts @@ -0,0 +1,211 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + FormBuilder, + FormControl, + FormGroupDirective, + FormsModule, + NgForm, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatError, MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import emailjs from '@emailjs/browser'; +import { environment } from '../../../environments/environment'; +import { AnalyticsService } from '../../services/analytics.service'; +import { NotificationService } from '../../services/notification.service'; +import { UrlGenerationService } from '../../services/url-generation.service'; +import { ButtonComponent } from '../BLOCKS/button/button.component'; +import { CollapsibleSubsecitonComponent } from '../BLOCKS/collapsible-subseciton/collapsible-subseciton.component'; +import { PanelSectionComponent } from '../BLOCKS/panel-section/panel-section.component'; +import { SubtitleComponent } from '../BLOCKS/subtitle/subtitle.component'; +import { TitleBlock } from '../BLOCKS/title/title.component'; +import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.service'; + +/** Error when invalid control is dirty, touched, or submitted. */ +export class MyErrorStateMatcher implements ErrorStateMatcher { + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const isSubmitted = form && form.submitted; + return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted)); + } +} + +/** + * The Help drawer: one card with a section per subject, like Settings. + * + * It used to be two cards stacked in the drawer, each with a title of its own, + * so the drawer had two accent bars and two headings competing with the one + * that names it. A drawer is one thing; the subjects inside it are sections. + */ +@Component({ + selector: 'app-help-panel', + templateUrl: './help-panel.component.html', + styleUrls: ['./help-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + PanelSectionComponent, + TitleBlock, + CollapsibleSubsecitonComponent, + ButtonComponent, + SubtitleComponent, + FormsModule, + ReactiveFormsModule, + MatFormField, + MatLabel, + MatInput, + MatError, + MatCheckbox, + ], +}) +export class HelpPanelComponent { + readonly shortcuts = inject(KeyboardShortcutsService); + private fb = inject(FormBuilder); + private notify = inject(NotificationService); + private analytics = inject(AnalyticsService); + private urlGenerationService = inject(UrlGenerationService); + + sendingEmail = false; + + commentForm = this.fb.group({ + comment: ['', Validators.required], + email: ['', Validators.email], + response: [false], + diagnostics: [true], + project: [true], + }); + + matcher = new MyErrorStateMatcher(); + + // Arrows, because `button-block` takes the handler as a value and calls it + // bare: as ordinary methods these arrived with no `this`, and the two that + // open a link logged their analytics event off the end of an undefined + // service -- after the tab had already opened, which is why it looked fine. + readonly gotoHelpSite = (): void => { + window.open('https://pmks.mech.website/pmks-web-how-to-videos/', '_blank'); + this.analytics.logEvent('goto_help_site'); + }; + + readonly gotoGithub = (): void => { + window.open('https://github.com/PMKS-Web/PMKSWeb', '_blank'); + this.analytics.logEvent('goto_github'); + }; + + private getBrowserName(): string { + const agent = window.navigator.userAgent.toLowerCase(); + switch (true) { + case agent.indexOf('edge') > -1: + return 'Edge'; + case agent.indexOf('opr') > -1 && !!(window as unknown as Record<string, unknown>)['opr']: + return 'Opera'; + case agent.indexOf('chrome') > -1 && + !!(window as unknown as Record<string, unknown>)['chrome']: + return 'Chrome'; + case agent.indexOf('trident') > -1: + return 'Internet Explorer'; + case agent.indexOf('firefox') > -1: + return 'Firefox'; + case agent.indexOf('safari') > -1: + return 'Safari'; + default: + return 'Other'; + } + } + + private detectBrowserVersion(): string { + const userAgent = navigator.userAgent; + let tem; + let matchTest = + userAgent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || []; + + if (/trident/i.test(matchTest[1])) { + tem = /\brv[ :]+(\d+)/g.exec(userAgent) || []; + return 'IE ' + (tem[1] || ''); + } + if (matchTest[1] === 'Chrome') { + tem = userAgent.match(/\b(OPR|Edge)\/(\d+)/); + if (tem != null) return tem.slice(1).join(' ').replace('OPR', 'Opera'); + } + matchTest = matchTest[2] + ? [matchTest[1], matchTest[2]] + : [navigator.appName, navigator.appVersion, '-?']; + if ((tem = userAgent.match(/version\/(\d+)/i)) != null) matchTest.splice(1, 1, tem[1]); + return matchTest.join(' '); + } + + readonly sendCommentEmail = async (): Promise<void> => { + this.sendingEmail = true; + if (this.commentForm.invalid) { + this.notify.refusal('feedback.incomplete', 'Fill in the form before sending it.'); + this.sendingEmail = false; + return; + } + + let emailJSKey = ''; + try { + const res = await fetch('/.netlify//functions/getEmailJSKey').then((response) => + response.json() + ); + emailJSKey = res.apiKey; + } catch (err) { + console.log(err); + this.notify.failure( + 'feedback.no-key', + 'It looks like you are in a development environment. If this is not the case, please try again later or contact us directly at: gr-pmksplus@wpi.edu' + ); + this.sendingEmail = false; + return; + } + emailjs.init(emailJSKey); + + let browserInfo = ''; + if (this.commentForm.value.diagnostics) { + browserInfo += 'Browser: '; + browserInfo += this.getBrowserName(); + browserInfo += '\n Browser Version: '; + browserInfo += this.detectBrowserVersion(); + browserInfo += '\n OS: '; + browserInfo += window.navigator.platform; + browserInfo += '\n User Agent: '; + browserInfo += window.navigator.userAgent; + browserInfo += '\n App Version: '; + browserInfo += environment.appVersion; + } else { + browserInfo = 'User did not allow diagnostics'; + } + + let projectURL = 'User did not leave a project URL'; + if (this.commentForm.value.project) { + projectURL = this.urlGenerationService.generateFullUrl(); + } + + const params = { + to_email: 'gr-pmksplus@wpi.edu', + message: this.commentForm.value.comment + ? this.commentForm.value.comment + : 'User did not leave a comment', + email: this.commentForm.value.email + ? this.commentForm.value.email + : 'User did not leave an email and does not want a response', + diagnostic: browserInfo, + project: projectURL, + }; + + emailjs + .send('service_pg2k647', 'template_kfwdx5c', params) + .then(() => { + this.notify.success('feedback.sent', 'Message sent. Thank you for your feedback!'); + this.sendingEmail = false; + this.commentForm.reset(); + }) + .catch((error: unknown) => { + console.log(error); + this.notify.failure( + 'feedback.send-failed', + 'Message failed to send. Please try again later or contact us directly at: gr-pmksplus@wpi.edu' + ); + this.sendingEmail = false; + }); + }; +} diff --git a/src/app/component/left-tabs/left-tabs.component.html b/src/app/component/left-tabs/left-tabs.component.html index e7534849..1bf68bae 100644 --- a/src/app/component/left-tabs/left-tabs.component.html +++ b/src/app/component/left-tabs/left-tabs.component.html @@ -1,119 +1,29 @@ -<div> - <div class="tabContainer"> - <div class="tabList"> - <!-- One pill tracks the active group, so the highlight slides between - modes instead of blinking from one to the next. --> - <div - class="activeTabPill" - [class.hidden]="!this.tabs.isTabVisible()" - [style.transform]="'translateY(' + pillTop + 'px)'" - [style.height.px]="pillHeight" - ></div> - - <div class="tabGroup" #tabGroup> - <button - class="leftButton" - (click)="tabClicked(TabID.SYNTHESIZE)" - matTooltip="Synthesize" - matTooltipShowDelay="0" - matTooltipPosition="right" - > - <mat-icon svgIcon="synthesis"></mat-icon> - <span class="tabLabel">Synthesis</span> - </button> - </div> - - <div class="tabGroup" #tabGroup> - <button - class="leftButton" - (click)="tabClicked(TabID.EDIT)" - matTooltip="Edit" - matTooltipShowDelay="0" - matTooltipPosition="right" - > - <mat-icon svgIcon="edit_outline"></mat-icon> - <span class="tabLabel">Edit</span> - </button> - <!-- History only applies while editing, so it lives with that mode. --> - @if (isActive(TabID.EDIT)) { - <div class="tabTools" @grow> - <button - class="mini-buttons blue" - mat-stroked-button - color="primary" - (click)="handleUndo()" - [disabled]="!canUndo()" - matTooltip="Undo" - matTooltipPosition="right" - matTooltipShowDelay="1000" - > - <mat-icon>undo</mat-icon> - Undo - </button> - <button - class="mini-buttons blue" - mat-stroked-button - color="primary" - (click)="handleRedo()" - [disabled]="!canRedo()" - matTooltip="Redo" - matTooltipPosition="right" - matTooltipShowDelay="1000" - > - <mat-icon>redo</mat-icon> - Redo - </button> - </div> - } - </div> - - <div class="tabGroup" #tabGroup> - <button - class="leftButton" - (click)="tabClicked(TabID.ANALYZE)" - matTooltip="Analyze" - matTooltipShowDelay="0" - matTooltipPosition="right" - > - <mat-icon>query_stats</mat-icon> - <span class="tabLabel">Analyze</span> - </button> - @if (isActive(TabID.ANALYZE)) { - <div class="tabTools" @grow> - <app-animation-bar></app-animation-bar> - </div> - } - </div> +<!-- The canvas is full bleed and this card floats over it, so it says which + edge it takes: SvgGridService frames the linkage into what is left rather + than into the window. A panel animated off screen reports a rect outside + the canvas and so takes nothing. --> +<div + data-canvas-inset="left" + [@openClose]=" + this.tabs.isTabVisible() ? (this.tabs.isWidePanel() ? 'openWide' : 'open') : 'closed' + " + class="panel" + [class.analysis]="this.tabs.isAnalysisMode()" + [class.behindDrawer]="drawerOpen" +> + @if (this.tabs.getCurrentTab() === TabID.SYNTHESIZE) { + <div class="page1"> + <app-synthesis-panel></app-synthesis-panel> </div> - - <div class="navSpacer"></div> - <app-view-controls></app-view-controls> - </div> - - <div - [@openClose]=" - this.tabs.isTabVisible() - ? this.tabs.getCurrentTab() === TabID.ANALYZE - ? 'openWide' - : 'open' - : 'closed' - " - class="panel" - > - @if (this.tabs.getCurrentTab() === TabID.SYNTHESIZE) { - <div class="page1"> - <app-synthesis-panel></app-synthesis-panel> - </div> - } - @if (this.tabs.getCurrentTab() === TabID.EDIT) { - <div class="page2"> - <app-edit-panel></app-edit-panel> - </div> - } - @if (this.tabs.getCurrentTab() === TabID.ANALYZE) { - <div class="page3"> - <app-analysis-panel></app-analysis-panel> - </div> - } - </div> + } + @if (this.tabs.getCurrentTab() === TabID.EDIT) { + <div class="page2"> + <app-edit-panel></app-edit-panel> + </div> + } + @if (this.tabs.isAnalysisMode()) { + <div class="page3"> + <app-analysis-panel></app-analysis-panel> + </div> + } </div> diff --git a/src/app/component/left-tabs/left-tabs.component.scss b/src/app/component/left-tabs/left-tabs.component.scss index 9a343bbd..957d2145 100644 --- a/src/app/component/left-tabs/left-tabs.component.scss +++ b/src/app/component/left-tabs/left-tabs.component.scss @@ -13,150 +13,313 @@ $background: map.get($theme, background); $foreground: map.get($theme, foreground); - #collapsibleContainer { - overflow-y: hidden; + @media (max-width: 900px) { + .panel.behindDrawer { + display: none; + } + } + + // The mock's help panel: a title, a lead, a rule and a hint or two. It + // replaces a collapsible section wrapped round a GIF -- an animation of the + // gesture, which had to be watched and could not be scanned. + // + // Defined once here, where both panels this drawer hosts can reach it, rather + // than twice in the two components that draw it. + .helpPanel { + padding: 14px 15px 18px; + background: var(--card-surface); + border-top: 5px solid mat.m2-get-color-from-palette($primary-palette, 500); + // 5px, matching the 5px accent bar above it: a border's inner edge is + // rounded by the corner radius minus the border width, so any radius wider + // than the bar curves the bar's underside. The floating chrome has no bar + // and keeps the wider corner. + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + } + + .helpTitle { + font-size: 20px; + font-weight: 500; + line-height: 28px; + } + + .helpLead { + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.6); + margin: 6px 0 0; + } + + .helpRule { + height: 1px; + background: #e6e7ee; + margin: 14px 0; } - .tabList { - position: relative; + .helpHints { display: flex; flex-direction: column; + gap: 12px; } - // The active mode's controls sit inside its group, so the highlight wraps - // both the tab and its tools. - .activeTabPill { - position: absolute; - top: 0; - left: 0; - right: 0; - z-index: 0; - box-sizing: border-box; - background-color: mat.m2-get-color-from-palette($primary-palette, 50); - border-left: 5px solid mat.m2-get-color-from-palette($primary-palette, 500); - // translateY (GPU-composited) slides the highlight between tabs; height eases - // it open. Both match the tools' grow animation exactly — same 200ms and - // easing — so the highlight and its contents move as one, with no per-frame - // JS to stutter or leave it stranded mid-slide. - transition: - transform 200ms cubic-bezier(0.4, 0, 0.2, 1), - height 200ms cubic-bezier(0.4, 0, 0.2, 1), - opacity 150ms ease; - will-change: transform, height; - - &.hidden { - opacity: 0; + // The tutorial's offer, and the line it leaves behind. Below the panel's own + // hints and under a rule, so it reads as an option rather than as the thing + // the panel is for -- a student who never presses it has still been told + // about right-click. + .offer { + display: flex; + gap: 10px; + align-items: flex-start; + + > mat-icon { + flex: 0 0 auto; + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; + color: mat.m2-get-color-from-palette($primary-palette, 500); } } - .tabGroup { - position: relative; - z-index: 1; - box-sizing: border-box; - border-left: 5px solid transparent; + .offerText { + flex: 1 1 auto; + min-width: 0; + } + + .offerTitle { + font-size: 14px; + font-weight: 500; + line-height: 20px; + color: rgba(0, 0, 0, 0.87); } - .leftButton { + .offerWhy { + margin: 3px 0 0; + font-size: 13px; + line-height: 19px; + color: rgba(0, 0, 0, 0.6); + } + + // Start, and the way to say no. The offer is a question, and a question with + // only one answer keeps getting asked. + .offerButtons { display: flex; - flex-direction: column; align-items: center; - justify-content: center; - gap: 8px; - width: 100%; - padding: 10px 0 8px; - border: 0; - background: none; - cursor: pointer; - color: inherit; + gap: 4px; + margin-top: 11px; + } + + .offerDismiss { + height: 32px; + padding: 0 10px; + border: none; + border-radius: 4px; + background: transparent; font-family: inherit; + font-size: 13px; + color: rgba(0, 0, 0, 0.55); + cursor: pointer; - mat-icon { - transform: scale(1.3); - height: 24px; - width: 24px; - margin: 0; - overflow: visible; + &:hover { + background: rgba(0, 0, 0, 0.04); } + } - .tabLabel { - @include mat.m2-typography-level($typography-config, 'body-2'); - line-height: 16px; - } + .offerButton { + height: 32px; + padding: 0 14px; + border: none; + border-radius: 4px; + background: mat.m2-get-color-from-palette($primary-palette, 500); + color: #fff; + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; } - .tabTools { - // A flex column so the buttons and the animation bar stretch to one width; - // the 8px side padding gives every control an equal gutter inside the pill. - // The bottom padding is part of the box (not a child margin) so scrollHeight, - // which the pill measures, includes it. overflow:hidden lets the grow - // animation collapse the box cleanly. + // The same line as a card of its own, for when the panel above it is showing + // a selected part rather than its empty state. + .resumeCard { display: flex; - flex-direction: column; - gap: 6px; - padding: 0 8px 10px; - overflow: hidden; - - // While the grow animation constrains this box's height, its children must - // keep their natural size (not flex-shrink to fit) so scrollHeight — which - // the pill measures for its target height — stays the true, full height. - > * { - flex-shrink: 0; + gap: 10px; + align-items: center; + width: 100%; + box-sizing: border-box; + // No margin of its own: the wrapper around these cards already spaces them, + // and the frame's own note warns that a margin here is what once pushed a + // card past the bottom of a frame capped at its own height and cut the + // shadow off. Ten pixels, the same as every other pair of cards in this + // column. + padding: 12px 15px; + border: none; + border-radius: var(--border-radius); + background: var(--card-surface); + box-shadow: var(--card-shadow); + font-family: inherit; + font-size: 13px; + line-height: 19px; + text-align: left; + color: mat.m2-get-color-from-palette($primary-palette, 500); + cursor: pointer; + + mat-icon { + flex: 0 0 auto; + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; } + } - // Same button look as the edit panel; the container prefix keeps its - // border-color winning over Material's default outline. - @include blocks.mini-buttons($theme); + .resumeLine { + display: flex; + gap: 10px; + align-items: center; + width: 100%; + padding: 0; + border: none; + background: transparent; + font-family: inherit; + font-size: 13px; + line-height: 19px; + text-align: left; + color: mat.m2-get-color-from-palette($primary-palette, 500); + cursor: pointer; - .mini-buttons { - width: 100%; - justify-content: flex-start; + mat-icon { + flex: 0 0 auto; + width: 18px; + height: 18px; + font-size: 18px; + line-height: 19px; } } - .navSpacer { - flex: 1 1 auto; + .helpHint { + display: flex; + gap: 10px; + align-items: flex-start; + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.6); + + mat-icon { + flex: 0 0 auto; + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; + color: mat.m2-get-color-from-palette($primary-palette, 500); + } } + // Below the top strip, and clear of whatever is along the bottom -- which is + // the playback card in the analysis modes and only the status strip in the + // others. Edit used to keep the playback card's room clear too, and stopped + // 70px short of anything. .panel { position: absolute; - top: 30px; - left: nav.$rail-width; + top: nav.$panel-top; + // Flush with the window on its own side, and the card set in from there by + // the one gap. The frame used to start $shadow-room short of the card, which + // once the shadow needed more room than the gap is wide put the frame four + // pixels off the edge of the window -- and a box hanging off the window is a + // page that can be scrolled sideways. + left: 0; + // The inset is the frame's, not the wrapper's inside it: the frame is what + // clips, so it is the frame that has to leave the card's shadow somewhere + // to fall. It used to be a margin on the wrapper, which put the card 10px + // past the bottom of a frame capped at the frame's own height and cut the + // shadow off there. Only three sides need it: on the fourth the window + // itself is the edge, and a shadow fading off the screen fades off it. + padding: nav.$shadow-room; + padding-left: nav.$card-inset; + box-sizing: border-box; overflow-x: hidden; overflow-y: auto; - max-height: calc(100% - 30px - #{nav.$bottom-bar-height}); + --panel-clearance: #{nav.$panel-bottom-clearance}; + max-height: calc(100% - #{nav.$top-strip-height} - var(--panel-clearance)); + max-width: calc(100vw - 24px); display: flex; + z-index: 5; - animation-duration: 4s; + &.analysis { + // Measured by the playback bar, which is the only thing that knows how + // tall it is: unsynced it carries a row per machine. The fallback is the + // height of a transport with one row in it, which is what this was until + // a drawing with three machines came down through it. + --panel-clearance: var(--playback-clearance, #{nav.$playback-clearance}); - .page1 { - width: 270px; //Be careful, there are multiple places to change this value + // Wide enough for the cluster to sit beside the panel rather than under + // it, so the panel can run down to the status strip like it does in the + // other modes. + @media (min-width: nav.$cluster-clears-panel) { + --panel-clearance: #{nav.$panel-bottom-clearance}; + } } - .page2 { - width: 270px; //Be careful, there are multiple places to change this value + // Hand the height down to the card, which is what scrolls. Each step needs + // `min-height: 0` or a flex item refuses to shrink below its content and + // the cap never reaches the bottom of the chain. + .page1, + .page2, + .page3 { + display: flex; + flex-direction: column; + min-height: 0; + + > * { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } + + #editWrapper, + #analysisWrapper, + #synthesisPanel { + flex: 1 1 auto; + min-height: 0; + } + + panel-section { + display: flex; + flex-direction: column; + min-height: 0; + } } + // Never wider than the window it is in. The analysis page is 420px, which + // is wider than a phone, and the page scrolled sideways to show the rest of + // a card whose rounded corner was off the edge. The cap has to be on the + // frame too: its width is an animation state, which is a style on the + // element and beats a rule from this file. + .page1, + .page2, .page3 { - width: 420px; //Be careful, there are multiple places to change this value + max-width: calc(100vw - 24px); } - } - .tabContainer { - @include mat.elevation(2); - background-color: white; - display: flex; - flex-direction: column; - z-index: 1; + // The frame's width minus the room it keeps around the card for the card's + // shadow. The card was always this wide -- the inset used to be a margin on + // the wrapper inside the page, and moving it to the frame left the page at + // the frame's own width, so the card overhung it and its right-hand shadow + // was cut off. + // Synthesis is as wide as the analysis panel, not as wide as Edit: it + // holds a row of three numbers per position, a gallery of candidate + // linkages side by side, and a transport -- none of which fit in 250px, + // and all of which are comparisons that stop working once they wrap. + .page1 { + width: 400px; //Be careful, there are multiple places to change this value + } - position: absolute; - top: 30px; - left: 0px; - width: nav.$rail-width; - // Runs to the very bottom so the rail covers the bottom bar's left end - // rather than leaving a bare corner beneath it. - height: calc(100% - 30px); - // No scrollbar; on a short viewport the animation bar drops its vertical - // slider (see animation-bar.scss) so the remaining controls still fit. - overflow-y: hidden; + .page2 { + width: 250px; //Be careful, there are multiple places to change this value + } + + .page3 { + width: 400px; //Be careful, there are multiple places to change this value + } } } diff --git a/src/app/component/left-tabs/left-tabs.component.ts b/src/app/component/left-tabs/left-tabs.component.ts index 2d3090e6..5a554c8e 100644 --- a/src/app/component/left-tabs/left-tabs.component.ts +++ b/src/app/component/left-tabs/left-tabs.component.ts @@ -1,49 +1,28 @@ -import { - AfterViewInit, - Component, - ElementRef, - OnDestroy, - QueryList, - ViewChildren, - inject, - ChangeDetectionStrategy, -} from '@angular/core'; -import { ActiveObjService } from 'src/app/services/active-obj.service'; -import { RealJoint } from 'src/app/model/joint'; -import { RealLink } from 'src/app/model/link'; -import { Force } from 'src/app/model/force'; +import { RightPanelComponent } from '../right-panel/right-panel.component'; +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; import { animate, state, style, transition, trigger } from '@angular/animations'; -import { AnalyticsService } from '../../services/analytics.service'; import { SelectedTabService, TabID } from 'src/app/selected-tab.service'; -import { MatDialog } from '@angular/material/dialog'; -import { SynthesisWarningComponent } from '../MODALS/synthesis-warning/synthesis-warning.component'; -import { MechanismService } from 'src/app/services/mechanism.service'; -import { SaveHistoryService } from 'src/app/services/save-history.service'; +import { SynthesisPanelComponent } from '../synthesis-panel/synthesis-panel.component'; +import { EditPanelComponent } from '../edit-panel/edit-panel.component'; +import { AnalysisPanelComponent } from '../analysis-panel/analysis-panel.component'; @Component({ selector: 'app-left-tabs', templateUrl: './left-tabs.component.html', styleUrls: ['./left-tabs.component.scss'], animations: [ - // A mode's controls unfold with it rather than popping in. Same duration and - // easing as the pill's CSS transition (see the SCSS) so the highlight and the - // tools grow in lockstep. - trigger('grow', [ - transition(':enter', [ - style({ height: 0, opacity: 0 }), - animate('200ms cubic-bezier(0.4, 0, 0.2, 1)', style({ height: '*', opacity: 1 })), - ]), - transition(':leave', [ - animate('200ms cubic-bezier(0.4, 0, 0.2, 1)', style({ height: 0, opacity: 0 })), - ]), - ]), trigger('openClose', [ // ... + // The card's own width, plus the gap it keeps from the window on one side + // and the room its shadow needs on the other -- `$card-inset` and + // `$shadow-room` in left-tabs.vars.scss. An animation state is a style + // written onto the element, so it beats the stylesheet and these two have + // to be kept in step by hand: 250 + 12 + 16, and 400 + 12 + 16. state( 'open', style({ transform: 'translateX(0)', - width: '270px', //Be careful, there are multiple places to change this value + width: '278px', //Be careful, there are multiple places to change this value }) ), state( @@ -55,7 +34,7 @@ import { SaveHistoryService } from 'src/app/services/save-history.service'; state( 'openWide', style({ - width: '420px', //Be careful, there are multiple places to change this value + width: '428px', //Be careful, there are multiple places to change this value }) ), transition('open => openWide', [animate('0.1s ease-in-out')]), @@ -64,136 +43,31 @@ import { SaveHistoryService } from 'src/app/services/save-history.service'; ]), ], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [SynthesisPanelComponent, EditPanelComponent, AnalysisPanelComponent], }) -export class LeftTabsComponent implements AfterViewInit, OnDestroy { - private analytics: AnalyticsService = inject(AnalyticsService); - - @ViewChildren('tabGroup') tabGroups!: QueryList<ElementRef<HTMLElement>>; - - pillTop = 0; - pillHeight = 0; - private pendingFrame = 0; - - constructor( - public tabs: SelectedTabService, - private mechanism: MechanismService, - private saveHistoryService: SaveHistoryService, - public dialog: MatDialog - ) {} +/** + * The panel down the left: whatever the current mode has to say about the + * selected part. + * + * The mode buttons that used to sit beside it have moved to the strip along the + * top, so this is now only the drawer they open. It keeps its own slide, which + * is why it is still a component rather than a bare @if in the shell. + */ +export class LeftTabsComponent { + tabs = inject(SelectedTabService); public get TabID(): typeof TabID { return TabID; } - private readonly onResize = () => this.schedulePillUpdate(); - - ngAfterViewInit(): void { - this.schedulePillUpdate(); - // A short viewport hides the animation bar's slider, changing the active - // group's height, so the pill must re-measure when the window resizes. - window.addEventListener('resize', this.onResize); - } - - ngOnDestroy(): void { - if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame); - window.removeEventListener('resize', this.onResize); - } - /** - * The pill's target top/height are computed once per tab change and applied as - * plain values; a single CSS transition on the element eases it there in step - * with the tools' own grow animation. Nothing runs per frame, so the motion - * can't stutter or stall midway the way the old resize-tracked pill did. + * Is a right-hand drawer open? * - * Every tab is the same fixed height and only the active one carries tools, so - * the active group's final top is just its index times that height, and its - * final height is that plus the tools' natural (scroll) height — both known - * immediately, even while the tools are still animating open. + * On a wide window both can be read at once. On a narrow one they overlap, + * and two cards interleaving their borders reads as a broken layout rather + * than as one thing in front of another — so the drawer wins and this hides. */ - private updatePill(): void { - const groups = this.tabGroups; - if (!groups || groups.length === 0) return; - const tabHeight = groups.get(0)!.nativeElement.offsetHeight; - const index = this.activeTabIndex(); - const tools = groups.get(index)?.nativeElement.querySelector('.tabTools') as HTMLElement | null; - this.pillTop = index * tabHeight; - this.pillHeight = tabHeight + (tools ? tools.scrollHeight : 0); - } - - /** Measure after the new tab's tools have rendered, off the CD pass. */ - private schedulePillUpdate(): void { - if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame); - this.pendingFrame = requestAnimationFrame(() => { - this.pendingFrame = 0; - this.updatePill(); - }); - } - - private activeTabIndex(): number { - switch (this.tabs.getCurrentTab()) { - case TabID.SYNTHESIZE: - return 0; - case TabID.EDIT: - return 1; - default: - return 2; - } - } - - /** A tab is only "active" while its panel is open, so its tools hide with it. */ - isActive(tabID: TabID): boolean { - return this.tabs.isTabVisible() && this.tabs.getCurrentTab() === tabID; - } - - // No snackbar on either of these. "Undo Called!" was a developer's breadcrumb - // from when undo was being built, and it survived into a shipped app: it says - // nothing a user does not already know, it says it in the voice of a stack - // trace, and it takes over the one channel the app has for telling them - // something they could not see for themselves. The canvas moving is the - // feedback. - handleUndo() { - this.saveHistoryService.undo(); - } - - canUndo(): boolean { - if (this.mechanism.isAnimating()) return false; - return this.saveHistoryService.canUndo(); - } - - handleRedo() { - this.saveHistoryService.redo(); - } - - canRedo(): boolean { - if (this.mechanism.isAnimating()) return false; - return this.saveHistoryService.canRedo(); - } - - tabClicked(tabID: TabID) { - if (!this.tabs.isTabVisible()) { - this.tabs.setTab(tabID); - } else { - if (this.tabs.getCurrentTab() === tabID) { - this.tabs.hideTab(); - } else { - this.tabs.setTab(tabID); - } - } - - if (this.tabs.isTabVisible()) { - switch (this.tabs.getCurrentTab()) { - case TabID.SYNTHESIZE: - this.analytics.logEvent('open_synthesis_tab'); - break; - case TabID.EDIT: - this.analytics.logEvent('open_edit_tab'); - break; - case TabID.ANALYZE: - this.analytics.logEvent('open_analysis_tab'); - break; - } - } - this.schedulePillUpdate(); + get drawerOpen(): boolean { + return RightPanelComponent.isOpen; } } diff --git a/src/app/component/left-tabs/left-tabs.vars.scss b/src/app/component/left-tabs/left-tabs.vars.scss index 1c350b51..df8504eb 100644 --- a/src/app/component/left-tabs/left-tabs.vars.scss +++ b/src/app/component/left-tabs/left-tabs.vars.scss @@ -1,6 +1,69 @@ -// Width of the labelled left nav rail. The bottom bar indents past it and the -// slide-out panel starts at it, so they all read this one value. -$rail-width: 96px; +// Where the chrome sits, in one place, so the strip, the panels, the drawers, +// the playback card and the project menu cannot drift apart. +// +// Everything is measured from the *card*, which is what a reader sees. A card +// is $card-inset from the window and from its neighbour -- always that, in +// every direction. The frames the scrolling cards live in are set back by +// $shadow-room so the card's own shadow has somewhere to fall inside a box that +// clips; that is why the frame numbers are not round. -// Height of app-bottombar; the rail and panel stop above it. -$bottom-bar-height: 20px; +/** The one gap. Between a card and the window, and between a card and a card. */ +$card-inset: 12px; + +/** + * Air a card needs around it inside a frame that clips, for its shadow. + * + * Read off `--card-shadow`, not chosen: its widest layer is `0 6px 10px`, which + * reaches 6 + 10/2 = 11px below the card, and `0 1px 18px` reaches 10px to the + * sides. 10px was a pixel under both, which is why every scrolling panel ended + * in a shadow that stopped square instead of fading out. + */ +$shadow-room: 16px; + +/** Height of the cards along the top. */ +$top-card-height: 48px; + +/** Where the top strip's cards end. */ +$top-strip-bottom: $card-inset + $top-card-height; + +/** Frames are set back from the card line by the room their shadow needs. */ +$frame-inset: $card-inset - $shadow-room; +$panel-top: $top-strip-bottom + $card-inset - $shadow-room; + +// Kept for anything still measuring from the top of the panel frame. +$top-strip-height: $panel-top; + +// Room kept at the bottom for the playback card and the status strip beneath it. +$playback-clearance: 110px; + +/** + * Room kept at the bottom for the view-controls card and the status strip. + * + * The same 110px as above only because that card is 60px tall today: it is the + * card's own height plus the 38px the cluster floats above the status strip + * plus the one gap. The view controls measure themselves and publish + * `--view-controls-clearance`; this is the fallback for before that lands. + */ +$view-controls-clearance: 110px; + +/** + * Where the playback cluster can stand clear of the analysis panel. + * + * The panel is 420px wide in the analysis modes and the cluster about 880, so + * past this the two fit side by side -- and once they do, the cluster moves to + * the right and the panel takes the height the cluster was keeping free. + */ +$cluster-clears-panel: 1340px; + +// Height of app-bottombar. +$bottom-bar-height: 26px; + +/** + * How far a panel's *frame* stops short of the bottom in the non-analysis + * modes, so its *card* stops $card-inset above the status strip. + * + * The card sits $shadow-room inside the frame, so the frame has to stop that + * much sooner than the card is meant to. Measuring the frame as though it were + * the card left 22px under the panel against 12px over it. + */ +$panel-bottom-clearance: $bottom-bar-height + $frame-inset; diff --git a/src/app/component/linkage-table/linkage-table.component.html b/src/app/component/linkage-table/linkage-table.component.html index 9adfa6a2..29f14e2f 100644 --- a/src/app/component/linkage-table/linkage-table.component.html +++ b/src/app/component/linkage-table/linkage-table.component.html @@ -1,12 +1,9 @@ -<!--<button--> <!-- class="secondaryButtons"--> <!-- id="showTable"--> <!-- style="position: absolute; top: 95px; left: 10px; z-index: 1000; font-size: 17px; width: 8em; cursor: pointer"--> <!-- (click)="getLinkageVisibility()"--> <!-->--> <!-- Hide table--> -<!--</button>--> -<!--<body id="linkageTable" class="linkageTable" style="visibility: visible">--> <div class="linkageTabs" id="linkageTable" style="visibility: visible"> <input type="radio" id="tabJoint" name="linkageTabs" checked="checked" /> @@ -15,18 +12,7 @@ <thead> <tr> <th>ID</th> - <!-- <th>Type</th>--> <th>Linked To</th> - <!-- <div *ngIf="getUnit() === 'cm'">--> - <!-- <th>Dist From Joint (cm)</th>--> - <!-- <th>x (cm)</th>--> - <!-- <th>y (cm)</th>--> - <!-- </div>--> - <!-- <div *ngIf="getUnit() === 'm'">--> - <!-- <th>Dist From Joint (m)</th>--> - <!-- <th>x (m)</th>--> - <!-- <th>y (m)</th>--> - <!-- </div>--> @if (getUnit() === 'cm') { <th>Dist From Joint (cm)</th> } @@ -59,12 +45,8 @@ (change)="changeJointProp($event, joint, 'id')" /> </td> - <!-- <td class="joint-table-item">{{row[0]}}</td> <!– joint id–>--> - <!-- <td>{{row[1]}}</td> --> <!-- joint type --> - <!-- <td>--> <!-- {{joint.type}}--> - <!-- </td>--> <td> @for (conJoints of connectedJoints(joint); track conJoints) { <table> diff --git a/src/app/component/linkage-table/linkage-table.component.ts b/src/app/component/linkage-table/linkage-table.component.ts index c9e7af82..6d666d33 100644 --- a/src/app/component/linkage-table/linkage-table.component.ts +++ b/src/app/component/linkage-table/linkage-table.component.ts @@ -1,40 +1,27 @@ -import { - Component, - EventEmitter, - Input, - OnInit, - Output, - ChangeDetectionStrategy, -} from '@angular/core'; +import { Component, OnInit, ChangeDetectionStrategy, inject } from '@angular/core'; import { Force } from '../../model/force'; -import { SliderBlock, Link, RealLink, Shape } from '../../model/link'; +import { SliderBlock, Link, RealLink } from '../../model/link'; import { Joint, PrisJoint, RealJoint, RevJoint } from '../../model/joint'; import { Coord } from '../../model/coord'; import { roundNumber } from '../../model/utils'; -import { Mechanism } from '../../model/mechanism/mechanism'; -import { InstantCenter } from '../../model/instant-center'; -import { ToolbarComponent } from '../toolbar/toolbar.component'; import { MechanismService } from '../../services/mechanism.service'; -import { NewGridComponent } from '../new-grid/new-grid.component'; import { MODEL_SCALE } from '../../model/render-scale'; import { NOT_A } from '../../ui-text'; +import { NotificationService } from '../../services/notification.service'; @Component({ selector: 'app-linkage-table', templateUrl: './linkage-table.component.html', styleUrls: ['./linkage-table.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, }) export class LinkageTableComponent implements OnInit { + private mechanismService = inject(MechanismService); + private notify = inject(NotificationService); + private static linkageTable: SVGElement; - private static jointButton: SVGElement; - private static linkButton: SVGElement; - private static forceButton: SVGElement; private static showLinkageTableButton: SVGElement; - constructor(private mechanismService: MechanismService) {} - // The table's cells speak the user's units; the model is MODEL_SCALE times // larger (render-scale.ts), so every length converts at this boundary. toUserUnits(modelValue: number): number { @@ -47,15 +34,6 @@ export class LinkageTableComponent implements OnInit { LinkageTableComponent.linkageTable = document.getElementById( 'linkageTable' ) as unknown as SVGElement; - LinkageTableComponent.jointButton = document.getElementById( - 'jointButton' - ) as unknown as SVGElement; - LinkageTableComponent.linkButton = document.getElementById( - 'linkButton' - ) as unknown as SVGElement; - LinkageTableComponent.forceButton = document.getElementById( - 'forceButton' - ) as unknown as SVGElement; LinkageTableComponent.showLinkageTableButton = document.getElementById( 'showTable' ) as unknown as SVGElement; @@ -68,7 +46,7 @@ export class LinkageTableComponent implements OnInit { ); } - changeJointProp($event: any, joint: Joint, jointProp: string) { + changeJointProp($event: Event, joint: Joint, jointProp: string) { if (!(joint instanceof RealJoint)) { return; } @@ -76,10 +54,10 @@ export class LinkageTableComponent implements OnInit { // assembly re-poses about the other mount, exactly as the drag does. const sealed = this.mechanismService.cylinderAt(joint); if (sealed && (jointProp === 'x' || jointProp === 'y')) { - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.length); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.length', NOT_A.length); } - const value = Number($event.target.value) * MODEL_SCALE; + const value = Number(($event.target as HTMLInputElement).value) * MODEL_SCALE; const wanted = new Coord( jointProp === 'x' ? value : joint.x, jointProp === 'y' ? value : joint.y @@ -93,10 +71,10 @@ export class LinkageTableComponent implements OnInit { switch (jointProp) { // TODO: When changing the joint positions, be sure to also change the ('d') path of the link case 'x': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.length); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.length', NOT_A.length); } - joint.x = Number($event.target.value) * MODEL_SCALE; + joint.x = Number(($event.target as HTMLInputElement).value) * MODEL_SCALE; joint.links.forEach((l) => { if (!(l instanceof RealLink)) { return; @@ -105,7 +83,9 @@ export class LinkageTableComponent implements OnInit { const jointIndex = l.joints.findIndex((jt) => jt.id === joint.id); l.joints[jointIndex].x = roundNumber(joint.x, 3); l.joints[jointIndex].y = roundNumber(joint.y, 3); - l.CoM = RealLink.determineCenterOfMass(l.joints); + if (!l.comIsCustom) { + l.CoM = RealLink.determineCenterOfMass(l.joints); + } // l.bound = RealLink.getBounds(new Coord(l.joints[0].x, l.joints[0].y), new Coord(l.joints[1].x, l.joints[1].y), Shape.line); // l.d = RealLink.getPointsFromBounds(l.bound, l.shape); l.d = l.getPathString(); @@ -115,10 +95,10 @@ export class LinkageTableComponent implements OnInit { }); break; case 'y': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.length); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.length', NOT_A.length); } - joint.y = Number($event.target.value) * MODEL_SCALE; + joint.y = Number(($event.target as HTMLInputElement).value) * MODEL_SCALE; joint.links.forEach((l) => { if (!(l instanceof RealLink)) { return; @@ -135,22 +115,22 @@ export class LinkageTableComponent implements OnInit { }); break; case 'id': - if (!(typeof $event.target.value === 'string')) { - return NewGridComponent.sendNotification(NOT_A.name); + if (!(typeof ($event.target as HTMLInputElement).value === 'string')) { + return this.notify.refusal('value.name', NOT_A.name); } joint.links.forEach((l) => { - l.id = l.id.replace(joint.id, $event.target.value); + l.id = l.id.replace(joint.id, ($event.target as HTMLInputElement).value); }); - joint.id = $event.target.value; + joint.id = ($event.target as HTMLInputElement).value; break; case 'angle': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.angle); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.angle', NOT_A.angle); } if (!(joint instanceof PrisJoint)) { return; } - joint.angle_rad = (Number($event.target.value) * Math.PI) / 180; + joint.angle_rad = (Number(($event.target as HTMLInputElement).value) * Math.PI) / 180; } // `true`, because a value typed into a cell is a discrete edit and should // be one undo step -- the same rule the panel follows. Without it, Undo @@ -158,34 +138,44 @@ export class LinkageTableComponent implements OnInit { this.mechanismService.updateMechanism(true); } - changeLinkProp($event: any, link: Link, linkProp: string) { + changeLinkProp($event: Event, link: Link, linkProp: string) { if (!(link instanceof RealLink)) { return; } switch (linkProp) { case 'mass': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.mass); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.mass', NOT_A.mass); } - link.mass = Number($event.target.value); + link.mass = Number(($event.target as HTMLInputElement).value); break; case 'massMoI': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.momentOfInertia); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.momentOfInertia', NOT_A.momentOfInertia); } - link.massMoI = Number($event.target.value); + // A sealed cylinder's parts always follow their own shapes — the + // debug table gets no back door to re-freeze them. + if (this.mechanismService.cylinderAt(link)) break; + link.massMoI = Number(($event.target as HTMLInputElement).value); + link.moiIsCustom = true; break; case 'CoMX': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.length); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.length', NOT_A.length); } - link.CoM.x = Number($event.target.value) * MODEL_SCALE; + link.placeCustomCoM({ + x: Number(($event.target as HTMLInputElement).value) * MODEL_SCALE, + y: link.CoM.y, + }); break; case 'CoMY': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.length); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.length', NOT_A.length); } - link.CoM.y = Number($event.target.value) * MODEL_SCALE; + link.placeCustomCoM({ + x: link.CoM.x, + y: Number(($event.target as HTMLInputElement).value) * MODEL_SCALE, + }); break; } this.mechanismService.updateMechanism(true); @@ -195,37 +185,49 @@ export class LinkageTableComponent implements OnInit { return force.angleRad * (180 / Math.PI); } - changeForceProp($event: any, force: Force, forceProp: string) { + changeForceProp($event: Event, force: Force, forceProp: string) { switch (forceProp) { case 'id': - if (!(typeof $event.target.value === 'string')) { - return NewGridComponent.sendNotification(NOT_A.name); + if (!(typeof ($event.target as HTMLInputElement).value === 'string')) { + return this.notify.refusal('value.name', NOT_A.name); } - force.id = $event.target.value; + force.id = ($event.target as HTMLInputElement).value; break; case 'xPos': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.length); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.length', NOT_A.length); } - force.moveAnchor(new Coord(Number($event.target.value) * MODEL_SCALE, force.startCoord.y)); + force.moveAnchor( + new Coord( + Number(($event.target as HTMLInputElement).value) * MODEL_SCALE, + force.startCoord.y + ) + ); break; case 'yPos': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.length); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.length', NOT_A.length); } - force.moveAnchor(new Coord(force.startCoord.x, Number($event.target.value) * MODEL_SCALE)); + force.moveAnchor( + new Coord( + force.startCoord.x, + Number(($event.target as HTMLInputElement).value) * MODEL_SCALE + ) + ); break; case 'mag': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.force); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.force', NOT_A.force); } - force.setMagnitude(Number($event.target.value)); + force.setMagnitude(Number(($event.target as HTMLInputElement).value)); break; case 'angle': - if (isNaN(Number($event.target.value))) { - return NewGridComponent.sendNotification(NOT_A.angle); + if (isNaN(Number(($event.target as HTMLInputElement).value))) { + return this.notify.refusal('value.angle', NOT_A.angle); } - force.setDirectionRadians(Number($event.target.value) * (Math.PI / 180)); + force.setDirectionRadians( + Number(($event.target as HTMLInputElement).value) * (Math.PI / 180) + ); break; } this.mechanismService.updateMechanism(true); @@ -322,6 +324,5 @@ export class LinkageTableComponent implements OnInit { getUnit() { // TODO: Should return this.settingService.globalUnit.value return 'cm'; // :P - // return ToolbarComponent.unit; } } diff --git a/src/app/component/mechanism-panel/mechanism-panel.component.html b/src/app/component/mechanism-panel/mechanism-panel.component.html new file mode 100644 index 00000000..82453b22 --- /dev/null +++ b/src/app/component/mechanism-panel/mechanism-panel.component.html @@ -0,0 +1,60 @@ +@if (exists) { + <div class="mechanismPanel"> + <div class="mechHead"> + <div class="mechTitle">{{ editable() ? 'Edit' : 'Analysis for' }} Mechanism {{ id }}</div> + <div class="mechStatusRow"> + <span class="mechStatus" [class.ready]="ready" (click)="openSetup()">{{ statusText }}</span> + </div> + @if (editable()) { + <div class="mechActions"> + <button class="mechButton danger" (click)="deleteMechanism()"> + <mat-icon>delete_outline</mat-icon> + Delete + </button> + </div> + } + </div> + + <button class="sectionHeader" (click)="overviewOpen = !overviewOpen"> + <span>Mechanism Overview</span> + <mat-icon>{{ overviewOpen ? 'expand_less' : 'expand_more' }}</mat-icon> + </button> + @if (overviewOpen) { + <div class="factGrid"> + @for (fact of facts; track fact.label) { + <div class="fact"> + <span class="factLabel">{{ fact.label }}</span> + <span class="factValue">{{ fact.value }}</span> + </div> + } + </div> + } + + <button class="sectionHeader" (click)="linksOpen = !linksOpen"> + <span>Links</span> + <mat-icon>{{ linksOpen ? 'expand_less' : 'expand_more' }}</mat-icon> + </button> + @if (linksOpen) { + <div class="linkList"> + @for (link of links; track link.name) { + <div class="linkRow"> + <span class="linkName">{{ link.name }}</span> + <span class="linkRole">{{ link.role }}</span> + <span class="linkSpacer"></span> + <span class="linkLength">{{ link.length }}</span> + </div> + } + </div> + } + + <div class="mechFooter"> + <p> + {{ + editable() + ? 'Select one of its joints or links on the grid to edit that part.' + : 'Select a joint or link on the grid for its position, velocity and acceleration graphs.' + }} + </p> + </div> + </div> +} diff --git a/src/app/component/mechanism-panel/mechanism-panel.component.scss b/src/app/component/mechanism-panel/mechanism-panel.component.scss new file mode 100644 index 00000000..32952f66 --- /dev/null +++ b/src/app/component/mechanism-panel/mechanism-panel.component.scss @@ -0,0 +1,175 @@ +@use 'sass:map'; +@use '@angular/material' as mat; + +@mixin css($theme) { + $color-config: mat.m2-get-color-config($theme); + $primary-palette: map.get($color-config, 'primary'); + + .mechanismPanel { + display: block; + padding-bottom: 8px; + background: var(--card-surface); + border-top: 5px solid mat.m2-get-color-from-palette($primary-palette, 500); + // 5px, matching the 5px accent bar above it: a border's inner edge is + // rounded by the corner radius minus the border width, so any radius wider + // than the bar curves the bar's underside. The floating chrome has no bar + // and keeps the wider corner. + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + } + + .mechHead { + padding: 12px 15px 14px; + } + + .mechTitle { + font-size: 20px; + font-weight: 500; + line-height: 28px; + } + + .mechStatusRow { + display: flex; + align-items: center; + gap: 8px; + margin: 6px 0 10px; + } + + // The status is also the way to the list behind it, which is why it is a + // control rather than a label. + .mechStatus { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 10px; + background: #fce8e6; + color: #c5221f; + font-size: 12px; + cursor: pointer; + + &.ready { + background: #e6f4ea; + color: #137333; + } + } + + .mechActions { + display: flex; + gap: 10px; + } + + .mechButton { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex: 1 1 0; + height: 32px; + padding: 0 10px; + border: 1px solid mat.m2-get-color-from-palette($primary-palette, 500); + border-radius: 4px; + background: #fff; + color: mat.m2-get-color-from-palette($primary-palette, 500); + font-family: inherit; + font-size: 14px; + cursor: pointer; + + mat-icon { + width: 17px; + height: 17px; + font-size: 17px; + line-height: 17px; + } + + &.danger { + border-color: #f44336; + color: #f44336; + } + } + + .mechanismPanel .sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 40px; + padding: 0 15px; + border: none; + border-top: 1px solid #eceef5; + background: transparent; + font-family: inherit; + font-size: 15px; + font-weight: 500; + color: mat.m2-get-color-from-palette($primary-palette, 500); + cursor: pointer; + text-align: left; + } + + .mechanismPanel .factGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 12px; + padding: 10px 15px 14px; + } + + .mechanismPanel .fact { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + } + + .mechanismPanel .factLabel { + font-size: 11px; + color: rgba(0, 0, 0, 0.45); + } + + .mechanismPanel .factValue { + font-size: 13px; + color: #2c2c2c; + } + + .linkList { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 15px 14px; + } + + .linkRow { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + } + + .linkName { + font-weight: 500; + min-width: 34px; + } + + .linkRole { + color: rgba(0, 0, 0, 0.5); + } + + .linkSpacer { + flex: 1 1 auto; + } + + .linkLength { + color: #2c2c2c; + } + + .mechFooter { + padding: 12px 15px 16px; + border-top: 1px solid #eceef5; + + p { + font-size: 13px; + line-height: 19px; + color: rgba(0, 0, 0, 0.6); + margin: 0; + } + } +} diff --git a/src/app/component/mechanism-panel/mechanism-panel.component.ts b/src/app/component/mechanism-panel/mechanism-panel.component.ts new file mode 100644 index 00000000..be0f4d41 --- /dev/null +++ b/src/app/component/mechanism-panel/mechanism-panel.component.ts @@ -0,0 +1,155 @@ +import { SettingsService } from '../../services/settings.service'; +import { NumberUnitParserService } from '../../services/number-unit-parser.service'; +import { MODEL_SCALE } from '../../model/render-scale'; +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { RealJoint } from '../../model/joint'; +import { RealLink } from '../../model/link'; +import { MechanismService } from '../../services/mechanism.service'; +import { ActiveObjService } from '../../services/active-obj.service'; +import { RightPanelComponent } from '../right-panel/right-panel.component'; +import { MechanismFact } from '../../model/mechanism/readiness'; +import { MatIcon } from '@angular/material/icon'; + +/** One line of the Links section: what a link is, and how long. */ +interface LinkRow { + name: string; + role: string; + length: string; +} + +/** + * What a whole machine is, for when the selection is the machine rather than a + * part of it. + * + * The same panel serves both modes because the facts do not change with the + * question being asked — only the title above them and, in Edit, the two + * buttons for acting on the thing as a whole. + */ +@Component({ + selector: 'app-mechanism-panel', + templateUrl: './mechanism-panel.component.html', + styleUrls: ['./mechanism-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatIcon], +}) +export class MechanismPanelComponent { + mechanism = inject(MechanismService); + activeObj = inject(ActiveObjService); + private settings = inject(SettingsService); + private nup = inject(NumberUnitParserService); + + /** Edit offers to rename and delete; analysis only reports. */ + readonly editable = input(false); + + overviewOpen = true; + linksOpen = true; + + get index(): number { + return this.activeObj.selectedMechanismIndex; + } + + get id(): string { + return this.mechanism.partitions[this.index]?.id ?? ''; + } + + get exists(): boolean { + return this.index >= 0 && this.index < this.mechanism.partitions.length; + } + + get ready(): boolean { + return this.mechanism.mechanisms[this.index]?.isMechanismValid() ?? false; + } + + get blockers(): number { + return ( + this.mechanism + .readinessOfEachMechanism() + [this.index]?.checks.filter((check) => check.state === 'blocker').length ?? 0 + ); + } + + get statusText(): string { + if (this.ready) { + return 'Ready'; + } + const count = this.blockers; + return count === 1 ? '1 blocker' : `${count} blockers`; + } + + get facts(): MechanismFact[] { + return this.mechanism.readinessOfEachMechanism()[this.index]?.facts ?? []; + } + + /** + * Every link in the machine, with what it does. + * + * The role is read off the geometry rather than stored: a bar with one end on + * ground is a crank or a rocker depending on whether it can go all the way + * round, and one with neither end grounded couples the two. + */ + get links(): LinkRow[] { + const partition = this.mechanism.partitions[this.index]; + if (!partition) { + return []; + } + return partition.links + .filter((link): link is RealLink => link instanceof RealLink) + .map((link) => { + const grounded = link.joints.filter((joint) => (joint as RealJoint).ground).length; + const driven = link.joints.some((joint) => (joint as RealJoint).input); + const role = driven ? 'Input' : grounded > 0 ? 'Grounded' : 'Coupler'; + return { + name: link.name || link.id, + role, + length: this.lengthOf(link), + }; + }); + } + + /** End to end, in the units the mechanism is drawn in. */ + private lengthOf(link: RealLink): string { + const ends = link.joints; + if (ends.length < 2) { + return '—'; + } + const span = Math.hypot( + ends[ends.length - 1].x - ends[0].x, + ends[ends.length - 1].y - ends[0].y + ); + return `${(span / MODEL_SCALE).toFixed(2)} ${this.nup.unitLabel(this.settings.lengthUnit.value)}`; + } + + openSetup(): void { + RightPanelComponent.tabClicked(RightPanelComponent.KINEMATIC_SETUP_TAB); + } + + /** Select one of its parts instead, so the reader can edit that. */ + clearSelection(): void { + this.activeObj.updateSelectedObj(null); + } + + deleteMechanism(): void { + const partition = this.mechanism.partitions[this.index]; + if (!partition) { + return; + } + // Deleting its joints takes the links with them, which is what "delete this + // mechanism" means: nothing of it is left, and nothing else is touched. + // + // The joints go one at a time, but the gesture is a single press: none of + // them saves, and the removal is minted as one undo entry below. Otherwise + // restoring the mechanism would cost one undo per joint it happened to have. + // Locks are passed by here, as they are when the whole drawing is cleared: + // "Delete this mechanism" is a wholesale act aimed at the machine rather + // than at one of its parts. Left guarded, the loop deleted every unlocked + // joint and stopped at the locked ones, leaving a half-machine of orphans + // behind -- a worse outcome than either honouring the locks or ignoring + // them. + [...partition.ownJoints].forEach((joint) => { + this.activeObj.updateSelectedObj(joint); + this.mechanism.deleteJoint(false, true); + }); + this.activeObj.updateSelectedObj(null); + this.mechanism.finishStructuralEdit(true); + } +} diff --git a/src/app/component/new-grid/new-grid.component.html b/src/app/component/new-grid/new-grid.component.html index c93ff34b..374729e7 100644 --- a/src/app/component/new-grid/new-grid.component.html +++ b/src/app/component/new-grid/new-grid.component.html @@ -3,11 +3,13 @@ display="block" width="100%" height="100%" + [class.geometryLocked]="geometryLocked" [cdkContextMenuTriggerFor]="context_menu" (contextmenu)="onContextMenu($event)" (pointermove)="mouseMove($event)" (pointerup)="mouseUp($event)" (pointerdown)="mouseDown($event)" + (wheel)="onCanvasWheel($event)" > <defs> <filter id="elevation-1" x="-50%" y="-50%" width="200%" height="200%"> @@ -81,11 +83,6 @@ opacity="0.5" ></rect> } - <!-- <rect *ngIf='mechanismSrv.joints.length == 0'--> - <!-- [attr.x]='-100' [attr.y]='100'--> - <!-- [attr.width]='200'--> - <!-- [attr.height]='20' fill='#ffffff' stroke='#00000000'>--> - <!-- </rect>--> <!-- This is a strucrially intergral rect and should not be removed! --> <rect x="-1px" y="-1px" width="2px" height="2px" fill="white"></rect> @@ -106,6 +103,34 @@ fill="white" stroke="#00000000" ></rect> + <!-- The tracing underlay, between the white paper and the ruling on it. + Inside this group on purpose, and for two reasons: the grid lines and + axes have to stay readable over an opaque picture, and this group is + what "Fit to zoom" hides while it measures — a picture left outside + it made the fit frame the scenery instead of the linkage. + + The picture itself is deaf to the pointer at all times. Only the + handles, and only while its panel is open, answer one. --> + @if (bgImage.image(); as bg) { + <!-- The turn lives on the group rather than on each shape, so the + picture is laid out as though it were square to the grid and + arrives already turned. --> + <g + id="backgroundImageHolder" + pointer-events="none" + [attr.transform]="bgImage.transformOf(bg)" + > + <image + [attr.href]="bg.src" + [attr.x]="bgImage.leftOf(bg)" + [attr.y]="bgImage.topOf(bg)" + [attr.width]="bg.width" + [attr.height]="bgImage.heightOf(bg)" + [attr.opacity]="bg.opacity" + preserveAspectRatio="none" + /> + </g> + } <!-- The Minor Axes--> @if (settings.isShowMajorGrid.getValue() && settings.isShowMinorGrid.getValue()) { <ng-container> @@ -116,8 +141,8 @@ [attr.y1]="svgGrid.viewBoxMinY" [attr.x2]="line" [attr.y2]="svgGrid.viewBoxMaxY" - stroke="black" - [attr.stroke-width]="svgGrid.scaleWithZoom(0.15)" + class="gridLineMinor" + [attr.stroke-width]="svgGrid.scaleWithZoom(1)" ></line> </ng-container> } @@ -128,8 +153,8 @@ [attr.y1]="line" [attr.x2]="svgGrid.viewBoxMaxX" [attr.y2]="line" - stroke="black" - [attr.stroke-width]="svgGrid.scaleWithZoom(0.15)" + class="gridLineMinor" + [attr.stroke-width]="svgGrid.scaleWithZoom(1)" ></line> </ng-container> } @@ -145,8 +170,8 @@ [attr.y1]="svgGrid.viewBoxMinY" [attr.x2]="line" [attr.y2]="svgGrid.viewBoxMaxY" - stroke="black" - [attr.stroke-width]="svgGrid.scaleWithZoom(0.75)" + class="gridLineMajor" + [attr.stroke-width]="svgGrid.scaleWithZoom(1)" ></line> </ng-container> } @@ -157,8 +182,8 @@ [attr.y1]="line" [attr.x2]="svgGrid.viewBoxMaxX" [attr.y2]="line" - stroke="black" - [attr.stroke-width]="svgGrid.scaleWithZoom(0.75)" + class="gridLineMajor" + [attr.stroke-width]="svgGrid.scaleWithZoom(1)" ></line> </ng-container> } @@ -171,7 +196,7 @@ y1="0" [attr.x2]="svgGrid.viewBoxMaxX" y2="0" - [attr.stroke-width]="svgGrid.scaleWithZoom(3)" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" ></line> <line id="axes" @@ -179,7 +204,7 @@ [attr.y1]="svgGrid.viewBoxMinY" x2="0" [attr.y2]="svgGrid.viewBoxMaxY" - [attr.stroke-width]="svgGrid.scaleWithZoom(3)" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" ></line> <!-- Axes Labels --> <ng-container> @@ -190,8 +215,8 @@ text-anchor="middle" [attr.x]="line" [attr.y]="svgGrid.scaleWithZoom(20)" - [attr.stroke-width]="svgGrid.scaleWithZoom(5)" - [attr.font-size]="svgGrid.scaleWithZoom(15)" + [attr.stroke-width]="svgGrid.scaleWithZoom(3)" + [attr.font-size]="svgGrid.scaleWithZoom(14)" > {{ axisLabel(line) }} </text> @@ -204,8 +229,8 @@ text-anchor="end" [attr.y]="line" [attr.x]="svgGrid.scaleWithZoom(-10)" - [attr.stroke-width]="svgGrid.scaleWithZoom(5)" - [attr.font-size]="svgGrid.scaleWithZoom(15)" + [attr.stroke-width]="svgGrid.scaleWithZoom(3)" + [attr.font-size]="svgGrid.scaleWithZoom(14)" > {{ axisLabel(-line) }} </text> @@ -216,8 +241,8 @@ text-anchor="end" [attr.y]="svgGrid.scaleWithZoom(20)" [attr.x]="svgGrid.scaleWithZoom(-10)" - [attr.stroke-width]="svgGrid.scaleWithZoom(5)" - [attr.font-size]="svgGrid.scaleWithZoom(15)" + [attr.stroke-width]="svgGrid.scaleWithZoom(3)" + [attr.font-size]="svgGrid.scaleWithZoom(14)" > 0 </text> @@ -225,6 +250,47 @@ </g> } + <!-- The tracing underlay's edges and its drag surface, in a layer above the + grid's ruling and below the mechanism. They cannot ride with the picture, + which belongs under the grid lines: a surface under them is a surface the + axis line steals the press from. Below the parts on purpose — the whole + picture is a drag target, and a target that size must not make the joints + inside it unclickable. Grab an empty stretch of it to slide it. + + Drawn only while the panel has the picture, and turned with it. --> + @if (bgImage.image(); as bg) { + @if (editingBackgroundImage()) { + <g + id="backgroundImageHandles" + pointer-events="none" + [attr.transform]="bgImage.transformOf(bg)" + > + <!-- Its edges, because the fields and the handles move something the + user may have faded almost to nothing. --> + <rect + class="bgImageOutline" + [attr.x]="bgImage.leftOf(bg)" + [attr.y]="bgImage.topOf(bg)" + [attr.width]="bg.width" + [attr.height]="bgImage.heightOf(bg)" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + [attr.stroke-dasharray]="svgGrid.scaleWithZoom(6) + ' ' + svgGrid.scaleWithZoom(4)" + /> + <!-- Grab anywhere inside to slide it. Invisible rather than absent so + the whole picture is the target, not just its edges. --> + <rect + class="bgImageMoveSurface" + pointer-events="all" + [attr.x]="bgImage.leftOf(bg)" + [attr.y]="bgImage.topOf(bg)" + [attr.width]="bg.width" + [attr.height]="bgImage.heightOf(bg)" + (pointerdown)="startBackgroundImageMove($event)" + /> + </g> + } + } + <!-- Decoration, not controls: the ground triangle and the input arrow mark what a joint *is*, and both are drawn wider than the joint itself. Left hit-testable they sit over whatever is beneath them and swallow clicks @@ -401,6 +467,7 @@ (pointerout)="link.showHighlight = false" (pointerdown)="setLastLeftClick(link, $event)" (contextmenu)="setLastRightClick(link, $event)" + [attr.cursor]="mechanismSrv.isLinkLockedVisual(link) ? 'not-allowed' : null" [attr.d]="linkPathWithChannels(link)" [attr.id]="link.id" [attr.data-channels]="channelCountOn(link)" @@ -411,6 +478,26 @@ [attr.stroke-width]="svgGrid.scaleWithZoom(3)" [class]="mechanismSrv.getLinkCSSClass(link)" ></path> + <!-- The slot is a real hole in the bar, and a hole catches no pointer: + a hand aimed at the middle of a slotted link fell straight through + to the background, so the one link a reader is most likely to want + to move was the one they could not grab along its length. This + shape hands the holes back — unpainted, so the slot still reads as + a slot, and under the block, so grabbing the block still grabs the + block. --> + @if (hasChannelHit(link)) { + <path + (pointerover)="link.showHighlight = true" + (pointerout)="link.showHighlight = false" + (pointerdown)="setLastLeftClick(link, $event)" + (contextmenu)="setLastRightClick(link, $event)" + [attr.cursor]="mechanismSrv.isLinkLockedVisual(link) ? 'not-allowed' : null" + [attr.d]="channelsCutInto(link)" + [attr.id]="link.id + '__slotHit'" + fill="none" + pointer-events="all" + ></path> + } } @if ( gridUtils.typeOfLink(link) == 'R' && @@ -469,6 +556,7 @@ hitbox. --> <g class="slider-block" + [class.link-inert]="mechanismSrv.isPartInert(item.mark.pin)" cursor="move" (pointerdown)="setLastLeftClick(item.mark.pin)" (contextmenu)="setLastRightClick(item.mark.pin)" @@ -496,6 +584,19 @@ [attr.stroke-width]="svgGrid.scaleWithZoom(3)" [class]="mechanismSrv.getLinkCSSClass(item.rider.link)" ></path> + <!-- Its own slot handed back, as in the link layer: a link can be + a rider of one block and the carrier of another. --> + @if (item.rider.cuts.length > 0 || channelCountOn(item.rider.link) > 0) { + <path + (pointerover)="item.rider.link.showHighlight = true" + (pointerout)="item.rider.link.showHighlight = false" + (pointerdown)="setLastLeftClick(item.rider.link, $event)" + (contextmenu)="setLastRightClick(item.rider.link, $event)" + [attr.d]="markChannelsCutInto(item.mark, item.rider, item.rider.link)" + fill="none" + pointer-events="all" + ></path> + } } } @case ('plate') { @@ -520,6 +621,22 @@ [attr.stroke-width]="svgGrid.scaleWithZoom(3)" [class]="mechanismSrv.getLinkCSSClass(item.plate.links[0])" /> + <!-- The plate is the only thing left to click for the rider it + stands in for, so it is also the only thing that can hand + back the slot cut through it — the Scotch yoke's yoke is + both a carrier and a welded rider. --> + @if (item.plate.cuts.length > 0 || channelCountOn(item.plate.links[0]) > 0) { + <path + cursor="move" + (pointerover)="item.plate.links[0].showHighlight = true" + (pointerout)="item.plate.links[0].showHighlight = false" + (pointerdown)="setLastLeftClick(item.plate.links[0], $event)" + (contextmenu)="setLastRightClick(item.plate.links[0], $event)" + [attr.d]="markChannelsCutInto(item.mark, item.plate, item.plate.links[0])" + fill="none" + pointer-events="all" + /> + } </g> } } @@ -537,7 +654,7 @@ class="dangling-block" [attr.d]="mark.block" fill="none" - stroke="#F44336" + [attr.stroke]="orphanMarkInk" [attr.stroke-width]="svgGrid.scaleWithZoom(4)" /> </g> @@ -572,9 +689,14 @@ the interior pin and the buried barrel joint have no hitboxes at all. --> @for (cyl of cylinderList; track cyl.id) { <g class="cylinder-mark" [attr.transform]="sliderMarks.frame(cyl)"> + <!-- A ram is a body like any other: in an analysis mode, one the mode + cannot analyse is scenery and goes grey with the rest of its + machine. Its skin is painted from its own fill rather than through + the link classes, so it was the one part that stayed in colour. --> <path class="cylinder-barrel" - cursor="move" + [class.link-inert]="mechanismSrv.isPartInert(cyl.body)" + [attr.cursor]="mechanismSrv.isLinkLockedVisual(cyl.body) ? 'not-allowed' : 'move'" (pointerover)="cyl.body.showHighlight = true" (pointerout)="cyl.body.showHighlight = false" (pointerdown)="setLastLeftClick(cyl.body, $event)" @@ -589,16 +711,18 @@ own length, so the head bottoms out flush with the barrel's ends and the silhouette says where the travel ends by itself. --> <path - cursor="move" + [class.link-inert]="mechanismSrv.isPartInert(cyl.body)" + [attr.cursor]="mechanismSrv.isLinkLockedVisual(cyl.body) ? 'not-allowed' : 'move'" (pointerdown)="setLastLeftClick(cyl.body, $event)" (contextmenu)="setLastRightClick(cyl.body, $event)" [attr.d]="cyl.block" fill="#000000" - filter="url(#elevation-1)" + [attr.filter]="mechanismSrv.isPartInert(cyl.body) ? null : 'url(#elevation-1)'" /> <path class="cylinder-rod" - cursor="move" + [class.link-inert]="mechanismSrv.isPartInert(cyl.body)" + [attr.cursor]="mechanismSrv.isLinkLockedVisual(cyl.body) ? 'not-allowed' : 'move'" (pointerover)="cyl.body.showHighlight = true" (pointerout)="cyl.body.showHighlight = false" (pointerdown)="setLastLeftClick(cyl.body, $event)" @@ -609,7 +733,7 @@ [attr.stroke]="cyl.rodFill" [attr.stroke-width]="svgGrid.scaleWithZoom(3)" /> - @if (cyl.driven) { + @if (cyl.driven && !mechanismSrv.isPartInert(cyl.body)) { <g pointer-events="none"> <!-- The heavier arrow is the way the block sets off, same as the unskinned driven mark (§4.2b): the skin changes the drawing, @@ -630,15 +754,29 @@ <!-- Selecting the body highlights the WHOLE part: one accent outline around the fused barrel + block + rod silhouette, in exactly the stroke a selected link wears — not a per-piece treatment. --> - @if (isBodySelected(cyl)) { + @if (isBodySelected(cyl) || isBodyHovered(cyl)) { <path - class="link-selected cylinder-selected" + class="cylinder-selected" + [class.link-selected]="isBodySelected(cyl)" + [class.link-pointed]="!isBodySelected(cyl) && isBodyPointedAt(cyl)" + [class.link-hovered]="!isBodySelected(cyl) && !isBodyPointedAt(cyl)" pointer-events="none" [attr.d]="cylinderSilhouette(cyl)" fill="none" [attr.stroke-width]="svgGrid.scaleWithZoom(3)" /> } + <!-- Which part a pointed-at mass field means: a glaze over that one + piece, because the selected body already wears the outline and a + second ring would vanish into it. --> + @if (cylinderPartPreviewPath(cyl); as partPath) { + <path + class="cylinder-part-preview" + pointer-events="none" + [attr.d]="partPath" + [attr.stroke-width]="svgGrid.scaleWithZoom(3)" + /> + } </g> } <!-- The ghost of the two-point creation gesture: the same paths the @@ -669,9 +807,9 @@ [attr.r]="0.25 * settings.objectScale" [attr.cx]="joint.x" [attr.cy]="joint.y" - [attr.fill]="'#c5cae9'" + [attr.fill]="orphanMarkFill" [attr.fill-opacity]="0.7" - [attr.stroke]="'#F44336'" + [attr.stroke]="orphanMarkInk" [attr.stroke-width]="svgGrid.scaleWithZoom(3)" ></circle> } @@ -726,8 +864,18 @@ width="20" height="20" style="overflow: visible" - cursor="move" + [attr.cursor]="mechanismSrv.isJointLockedVisual(joint) ? 'not-allowed' : 'move'" > + <!-- The tutorial's ring: two amber circles round the joint the card is + naming, so "the ringed one" is unambiguous even on a drawing with + a dozen joints. Outside the effect group and unclickable, because + it is a pointer rather than a part. --> + @if (isTutorialTarget(joint)) { + <g class="tutorialRing" pointer-events="none"> + <circle [attr.r]="settings.objectScale * 0.5" cx="0" cy="0" class="ringOuter" /> + <circle [attr.r]="settings.objectScale * 0.35" cx="0" cy="0" class="ringInner" /> + </g> + } <!-- Grouped so a refused or completed drop can animate the whole joint --> <g [class]="jointEffectClass(joint)"> <!-- Hitbox --> @@ -743,163 +891,449 @@ [attr.cx]="0" [attr.cy]="0" [class]="mechanismSrv.getJointCSSClass(joint)" + [style.fill]="jointFillOf(joint)" /> + <!-- What "picked" means, on a joint that keeps its own colour + while it is picked. Drawn inside the edge rather than on it, + so a selected joint is the same size as an unselected one. --> + @if (selectionRingOn(joint); as ring) { + <circle + class="jointSelectionRing" + [attr.r]=" + settings.objectScale * gridUtils.getJointR(joint) - selectionRingWidth() / 2 + " + cx="0" + cy="0" + fill="none" + [attr.stroke]="ring" + [attr.stroke-width]="selectionRingWidth()" + /> + } } <!-- Welded Joint. A sealed cylinder's pin never reaches here: interior joints are filtered out above, and the skin draws its own, larger marker at that point. --> @if (gridUtils.getWelded(joint)) { + <!-- A weld mark has no inside edge to ring, so it wears the same + amber as an outline instead. --> <path [attr.id]="'joint_' + joint.id" [class]="mechanismSrv.getJointCSSClass(joint)" [attr.d]="weldMarkerPath" + [style.fill]="jointFillOf(joint)" + [style.stroke]="selectionRingOn(joint)" + [style.stroke-width]="selectionRingWidth()" ></path> } + <!-- The lock cue: a small padlock riding the joint's shoulder. + The joint is what a lock actually holds, so the joint is + where the mark sits — nothing else on the canvas changes. --> + @if (mechanismSrv.isJointLockedVisual(joint)) { + <g + class="lockBadge" + pointer-events="none" + [attr.transform]=" + gridUtils.getWelded(joint) ? offsetLockBadgeTransform() : lockBadgeTransform() + " + > + <!-- A plain joint's cream circle is chip enough, and the glyph + sits dead centre on it. A welded joint's plus-mark is its + own message, so the badge steps aside to the shoulder and + brings a white chip to stand on. --> + @if (gridUtils.getWelded(joint)) { + <circle + class="lockChip" + [attr.r]="0.13 * settings.objectScale" + [attr.stroke-width]="svgGrid.scaleWithZoom(1)" + /> + } + <g [attr.transform]="lockGlyphTransform()"> + <path + class="lockGlyph" + [attr.d]="lockGlyphPath" + [style.fill]="lockInkOn(joint)" + fill-rule="evenodd" + /> + </g> + </g> + } </g> </svg> } } </g> - <!-- show synthesis poses if on the synthesis tab --> - @if (showSynthesis()) { - <g id="synthesis"> - @for (pose of synthesisBuilder.getAllPoses(); track pose) { - <ng-container> - <g id="synthesisLinkHolder" style="transform: scaleY(-1)"> - <path - cursor="move" - (pointerover)="pose.showHighlight = true" - (pointerout)="pose.showHighlight = false" - (pointerdown)="setLastLeftClick(pose); activeObjService.updateSelectedObj(pose)" - [attr.d]="pose.pathString" - [attr.id]="" - [attr.fill]=" - pose.isAnyHighlight() - ? sConstants.LINK_COLOR_H[pose.status] - : sConstants.LINK_COLOR[pose.status] + <!-- Synthesis draws its own scene: the positions being designed for, the + candidate linkage being looked at, and the handles that move either. All + of it is a question and a proposed answer rather than part of the + drawing, which is why none of it goes through the mechanism's holders and + none of it survives a tab change without being inserted first. --> + @if (showSynthesisPositions()) { + <g id="synthesis" style="transform: scaleY(-1)" [class.shadow]="synthesisShadowOnly()"> + <!-- Where the ground pivots have to land, if that has been asked for. + Under the parts: it is a constraint on the answer, not the answer. --> + @if (showSynthesis() && synthCanvas.regionBox(); as region) { + <g id="synthesisRegion"> + <rect + class="synthRegion" + [attr.x]="region.x" + [attr.y]="region.y" + [attr.width]="region.w" + [attr.height]="region.h" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + [attr.stroke-dasharray]="svgGrid.scaleWithZoom(7) + ' ' + svgGrid.scaleWithZoom(5)" + (pointerdown)="startRegionGesture($event, 'move')" + /> + @for (corner of region.corners; track corner.id) { + <rect + class="synthHandle synthHandle--region" + [attr.x]="corner.x" + [attr.y]="corner.y" + [attr.width]="svgGrid.scaleWithZoom(10)" + [attr.height]="svgGrid.scaleWithZoom(10)" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + [attr.cursor]="corner.cursor" + (pointerdown)="startRegionGesture($event, 'corner', corner.id)" + /> + } + </g> + } + + <!-- Drawing a new region: the whole canvas is the target. --> + @if (showSynthesis() && synthesisBuilder.regionDraw) { + <rect + class="synthDrawSurface" + x="-100000" + y="-100000" + width="200000" + height="200000" + (pointerdown)="startRegionGesture($event, 'draw')" + /> + } + + <!-- The candidate that is picked, faded, while another is hovered: the + gallery's whole job is a comparison, and a comparison needs both. --> + @if (showSynthesis() && synthCanvas.hoverGhostLinks(); as ghosts) { + <g class="synthGhostLinkage" pointer-events="none"> + @for (lk of ghosts; track $index) { + <path class="synthBar" [attr.d]="lk.d" [attr.fill]="lk.color" /> + } + </g> + } + + <!-- Where the middle of the coupler goes over the whole of the travel. --> + @if (showSynthesis() && synthSolution.generated) { + <path + class="synthTrace" + pointer-events="none" + [attr.d]="synthCanvas.couplerTrace()" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.4)" + [attr.stroke-dasharray]="svgGrid.scaleWithZoom(6) + ' ' + svgGrid.scaleWithZoom(5)" + /> + } + + <!-- The candidate itself, at whatever the transport has been scrubbed to. --> + @if (showSynthesis()) { + <g id="synthesisPreview" pointer-events="none"> + <!-- Dashed, because it is a proposal. The one moment that matters in + this mode is Insert, and a solid outline is what says it has + happened -- so what has not happened yet must not look the same. --> + @for (lk of synthCanvas.previewLinks(); track $index) { + <path + class="synthBar synthBar--proposed" + [attr.d]="lk.d" + [attr.fill]="lk.color" + [attr.stroke]="lk.color" + [attr.stroke-width]="svgGrid.scaleWithZoom(2)" + [attr.stroke-dasharray]="svgGrid.scaleWithZoom(7) + ' ' + svgGrid.scaleWithZoom(5)" + /> + } + <!-- Placed and flipped exactly as the drawing's own ground marks are: + the asset is drawn y-down, so it is turned back over inside the + grid's flip and hung off a wrapper sitting on the pin. --> + @for (gd of synthCanvas.previewGrounds(); track gd.id) { + <svg [attr.x]="gd.x" [attr.y]="gd.y" width="10" height="10" style="overflow: visible"> + @if (!gd.input) { + <image + xlink:href="../../../assets/Ground.svg" + [attr.width]="settings.objectScale * 1.2" + [attr.height]="settings.objectScale * 1.2" + [attr.x]="settings.objectScale * -0.6" + [attr.y]="settings.objectScale * -0.35 * 1.2" + style="transform: scaleY(-1)" + /> + } + <!-- The pin that turns wears the motor's mark, from the same two + assets the drawing uses. It is the whole visible answer to + "Driven from": without it, swapping the drive pin rearranged + nothing a reader could see. --> + @if (gd.input) { + <image + [attr.xlink:href]=" + settings.isInputCW.value + ? '../../../assets/InputCW.svg' + : '../../../assets/InputCCW.svg' + " + [attr.width]="settings.objectScale * 1.2" + [attr.height]="settings.objectScale * 1.2" + [attr.x]="settings.objectScale * -0.505 * 1.2" + [attr.y]="settings.objectScale * -0.435 * 1.2" + style="transform: scaleY(-1)" + /> + } + </svg> + } + @for (jt of synthCanvas.previewJoints(); track jt.id) { + <circle + class="synthPreviewJoint" + [attr.cx]="jt.x" + [attr.cy]="jt.y" + [attr.r]="0.15 * settings.objectScale" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.4)" + /> + } + <!-- Named, because the panel names them. "Driven from Pin A", "Ground + A–D", "Coupler B–C" -- every one of those letters was a reference + to something the drawing never wrote down, so the reader had no way + to tell which pin or which bar was meant. Drawn whatever the + show-ids setting says: the panel is talking about them either way. --> + <g id="synthesisPreviewTags"> + @for (jt of synthCanvas.previewJoints(); track jt.id) { + <text + class="synthPreviewTag" + [attr.transform]=" + 'translate(' + jt.x + ',' + jt.y + ') scale(1,-1)' " - [attr.fill-opacity]="0.4" - [class]="" - ></path> + [attr.x]="svgGrid.scaleWithZoom(11)" + [attr.y]="svgGrid.scaleWithZoom(-9)" + [style.font-size.px]="svgGrid.scaleWithZoom(13)" + > + {{ jt.id }} + </text> + } + </g> + </g> + } + + <!-- The positions being designed for, tinted by what the chosen linkage + does with each: green it passes through, amber it can only reach by + being taken apart. --> + @for (bar of synthCanvas.poseBars(); track bar.id) { + <g + class="synthPose" + (pointerdown)="startPoseGesture($event, bar.id, 'move')" + (contextmenu)="setLastRightClick(synthesisBuilder.getPose(bar.id), $event)" + > + <path + class="synthBar" + [class.synthBar--sel]="bar.selected" + [attr.d]="bar.d" + [attr.fill]="bar.fill" + [attr.stroke]="bar.selected ? null : bar.fill" + [attr.stroke-width]="svgGrid.scaleWithZoom(3)" + /> + <!-- Which way round it is. The outline is the same capsule every link + on this canvas wears, so the direction has to be said inside it + -- otherwise a position turned end for end is the same picture as + one that is not. --> + @if (bar.arrow) { + <path + class="synthBarArrow" + [attr.d]="bar.arrow" + [attr.stroke-width]="svgGrid.scaleWithZoom(2)" + /> + } + <circle + class="synthPosePin" + [attr.cx]="bar.x1" + [attr.cy]="bar.y1" + [attr.r]="0.15 * settings.objectScale" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.4)" + /> + <circle + class="synthPosePin" + [attr.cx]="bar.x2" + [attr.cy]="bar.y2" + [attr.r]="0.15 * settings.objectScale" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.4)" + /> + <!-- Where this position is measured from, and what it turns about. + Back, Center and Front are otherwise a change nobody can see: + the numbers jump and the bar does not, so it is impossible to + tell which of the three the coordinates belong to. --> + <g class="synthRef" pointer-events="none"> + <circle + class="synthRefRing" + [attr.cx]="bar.refX" + [attr.cy]="bar.refY" + [attr.r]="svgGrid.scaleWithZoom(6)" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.6)" + /> + <line + [attr.x1]="bar.refX - svgGrid.scaleWithZoom(9)" + [attr.y1]="bar.refY" + [attr.x2]="bar.refX + svgGrid.scaleWithZoom(9)" + [attr.y2]="bar.refY" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.6)" + /> + <line + [attr.x1]="bar.refX" + [attr.y1]="bar.refY - svgGrid.scaleWithZoom(9)" + [attr.x2]="bar.refX" + [attr.y2]="bar.refY + svgGrid.scaleWithZoom(9)" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.6)" + /> </g> - <g id="synthesisPoseHolder" style="transform: scaleY(-1)"> - <svg - width="20" - height="20" - style="overflow: visible" - cursor="move" - (pointerover)="pose.showHighlight = true" - (pointerout)="pose.showHighlight = false" - (pointerdown)="setLastLeftClick(pose); activeObjService.updateSelectedObj(pose)" + </g> + } + + <!-- The bar about to be dropped, following the pointer. --> + @if (showSynthesis() && synthCanvas.ghostBar(); as ghost) { + <g class="synthGhost" pointer-events="none"> + <path class="synthBar" [attr.d]="ghost.d" fill="#c5cae9" stroke="#c5cae9" + [attr.stroke-width]="svgGrid.scaleWithZoom(3)" /> + @if (ghost.arrow) { + <path class="synthBarArrow" [attr.d]="ghost.arrow" + [attr.stroke-width]="svgGrid.scaleWithZoom(2)" /> + } + </g> + } + + <!-- The handles on the selected position: the same dashed box, corner + grips and turn knob the tracing underlay wears, because it is the + same kind of gesture. Its corners pull the end-effector's length. --> + @if (showSynthesis() && synthCanvas.selectionBox(); as box) { + <!-- One handle per thing a position has: a place, a heading, and one + length. The four corners this replaces promised two axes of + scaling and left the reader to discover that only one of them did + anything. The body is the place, the knob above is the heading, + and the grip off the front -- on the bar's own axis, which is the + direction it pulls -- is the length. --> + <g id="synthesisHandles" [attr.transform]="box.rotate"> + <rect + class="synthOutline" + pointer-events="none" + [attr.x]="box.x" + [attr.y]="box.y" + [attr.width]="box.w" + [attr.height]="box.h" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + [attr.stroke-dasharray]="svgGrid.scaleWithZoom(6) + ' ' + svgGrid.scaleWithZoom(4)" + /> + <line + class="synthStalk" + pointer-events="none" + [attr.x1]="box.knobX" + [attr.y1]="box.y + box.h" + [attr.x2]="box.knobX" + [attr.y2]="box.knobY" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + /> + <circle + class="synthHandle synthKnob" + cursor="grab" + [attr.cx]="box.knobX" + [attr.cy]="box.knobY" + [attr.r]="box.grip * 0.72" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + (pointerdown)="startPoseGesture($event, synthesisBuilder.selectedPose, 'rotate')" + /> + <!-- Squared off and sitting on the axis, so it reads as a pull along + the bar rather than as a corner. On the end away from the fixed + reference, and on both ends when the reference is the middle. --> + @for (pull of box.lengthGrips; track $index) { + <rect + class="synthHandle" + cursor="ew-resize" + [attr.x]="pull.x - box.grip / 2" + [attr.y]="pull.y - box.grip / 2" + [attr.width]="box.grip" + [attr.height]="box.grip" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + (pointerdown)="startPoseGesture($event, synthesisBuilder.selectedPose, 'length')" + /> + } + </g> + } + + <!-- What the chosen linkage does with each position, said beside it, so + the answer can be read off the grid rather than off the panel. Only + in Synthesis: the verdict belongs to the candidate being compared, + and the shadow left in the other modes is a record of where the + positions were, not of which solution won. --> + @if (showSynthesis()) { + <g id="synthesisChips" pointer-events="none"> + @for (chip of synthCanvas.poseChips(); track chip.id) { + <!-- Placed in model coordinates, then flipped back so the words read + the right way up inside the grid's own y-flip. A filled pill + rather than bare text: it is read against whatever the drawing + puts behind it, and grey words over a link are words nobody can + read. --> + <g [attr.transform]="'translate(' + chip.x + ',' + chip.y + ') scale(1,-1)'"> + <rect + class="synthChip" + [class.synthChip--sel]="chip.selected" + [attr.x]="svgGrid.scaleWithZoom(-11)" + [attr.y]="svgGrid.scaleWithZoom(-16)" + [attr.width]="svgGrid.scaleWithZoom(chip.width)" + [attr.height]="svgGrid.scaleWithZoom(22)" + [attr.rx]="svgGrid.scaleWithZoom(11)" + [attr.stroke-width]="svgGrid.scaleWithZoom(1)" + /> + <circle + class="synthChipDot" + [attr.cx]="0" + [attr.cy]="svgGrid.scaleWithZoom(-5)" + [attr.r]="svgGrid.scaleWithZoom(7.5)" + [attr.fill]="chip.dot" + /> + <text + class="synthChipNumber" + [attr.x]="0" + [attr.y]="svgGrid.scaleWithZoom(-1.5)" + [style.font-size.px]="svgGrid.scaleWithZoom(10.5)" > - @for (nodePos of [pose.posBack, pose.posFront]; track nodePos) { - <circle - class="joint_circles" - [attr.r]="sConstants.CIRCLE_RADIUS * settings.objectScale" - [attr.cx]="nodePos.x" - [attr.cy]="nodePos.y" - [attr.fill]=" - pose.showHighlight ? sConstants.CIRCLE_COLOR_H : sConstants.CIRCLE_COLOR - " - [class]="" - /> - } - </svg> - <svg - [attr.height]="10" - [attr.width]="10" - style="overflow: visible; text-anchor: middle" - cursor="move" - (pointerdown)="setLastLeftClick(pose); activeObjService.updateSelectedObj(pose)" + {{ chip.id }} + </text> + <text + class="synthChipText" + [attr.x]="svgGrid.scaleWithZoom(12)" + y="0" + [style.font-size.px]="svgGrid.scaleWithZoom(13)" + [class.synthChipText--sel]="chip.selected" > - <!-- reference frame arrows --> - <g - app-svg-arrow - (pointerover)="pose.showHighlight = true; pose.showHighlightX = true" - (pointerout)="pose.showHighlight = false; pose.showHighlightX = false" - (pointerdown)=" - setLastLeftClick(pose); - activeObjService.updateSelectedObj(pose); - setSynthesisClickMode(mode.X) - " - [x]="pose.position.x" - [y]="pose.position.y" - [direction]="pose.thetaDegrees" - [magnitude]="1 * settings.objectScale" - [width]="0.075 * settings.objectScale" - [color]=" - pose.showHighlightX ? sConstants.ARROW_X_COLOR_H : sConstants.ARROW_X_COLOR - " - ></g> - <g - app-svg-arrow - (pointerover)="pose.showHighlight = true; pose.showHighlightY = true" - (pointerout)="pose.showHighlight = false; pose.showHighlightY = false" - (pointerdown)=" - setLastLeftClick(pose); - activeObjService.updateSelectedObj(pose); - setSynthesisClickMode(mode.Y) - " - [x]="pose.position.x" - [y]="pose.position.y" - [direction]="pose.thetaDegrees + 90" - [magnitude]="1 * settings.objectScale" - [width]="0.075 * settings.objectScale" - [color]=" - pose.showHighlightY ? sConstants.ARROW_Y_COLOR_H : sConstants.ARROW_Y_COLOR - " - ></g> - <!-- small black circle for COR --> - <circle - (pointerover)="pose.showHighlight = true" - (pointerout)="pose.showHighlight = false" - class="joint_circles" - [attr.r]="sConstants.COR_RADIUS * settings.objectScale" - [attr.cx]="pose.position.x" - [attr.cy]="pose.position.y" - [attr.fill]="sConstants.COR_COLOR" - [class]="" - /> - <!-- rotation circle --> - <circle - (pointerover)="pose.showHighlightR = true" - (pointerout)="pose.showHighlightR = false" - (pointerdown)=" - setLastLeftClick(pose); - activeObjService.updateSelectedObj(pose); - setSynthesisClickMode(mode.ROTATE) - " - [attr.r]="sConstants.ROTATION_CIRCLE_RADIUS * 1.5 * settings.objectScale" - [attr.cx]="pose.rotationCircleX" - [attr.cy]="pose.rotationCircleY" - fill="black" - style="opacity: 0" - /> - <circle - class="joint_circles" - [attr.r]="sConstants.ROTATION_CIRCLE_RADIUS * settings.objectScale" - [attr.cx]="pose.rotationCircleX" - [attr.cy]="pose.rotationCircleY" - [attr.fill]=" - pose.showHighlightR - ? sConstants.ROTATION_CIRCLE_COLOR_H - : sConstants.ROTATION_CIRCLE_COLOR - " - style="pointer-events: none" - /> - <text - style="pointer-events: none" - transform="scale(+1,-1)" - [attr.x]="pose.posCenter.x + 0.5" - [attr.y]="-pose.posCenter.y - 0.5" - style="font-size: {{ svgGrid.scaleWithZoom(20) }}; text-anchor: middle" - > - Pose {{ pose.id }} - </text> - </svg> + {{ chip.text }} + </text> </g> - </ng-container> + } + </g> + } + + <!-- What the next click will do, beside the pointer that will do it. --> + @if (showSynthesis() && synthesisHint; as hint) { + <g + pointer-events="none" + [attr.transform]="'translate(' + hint.x + ',' + hint.y + ') scale(1,-1)'" + > + <text + class="synthHint" + [attr.x]="svgGrid.scaleWithZoom(18)" + [attr.y]="svgGrid.scaleWithZoom(20)" + [style.font-size.px]="svgGrid.scaleWithZoom(14)" + > + {{ hint.text }} + </text> + @if (hint.sub) { + <text + class="synthHint synthHint--sub" + [attr.x]="svgGrid.scaleWithZoom(18)" + [attr.y]="svgGrid.scaleWithZoom(36)" + [style.font-size.px]="svgGrid.scaleWithZoom(12)" + > + {{ hint.sub }} + </text> + } + </g> } </g> } @@ -987,7 +1421,7 @@ </g> } - @if (mechanismSrv.showPathHolder) { + @if (mechanismSrv.showPathHolder && settings.isShowTraces.value) { <g id="pathsHolder"> @for (joint of mechanismSrv.getJoints(); track joint) { <ng-container> @@ -1004,9 +1438,17 @@ </g> } @for (force of mechanismSrv.getForces(); track force) { + <!-- A force is something to place, and placing is an Edit job: the analysis + modes have no panel for one and cannot move it, so it is drawn and + nothing more. It also goes grey with the body it acts on, which is what + the rest of an unanalysable machine does. --> <g id="forcesHolder" - style="transform: scaleY(-1); cursor: move" + [attr.cursor]="force.locked && mechanismSrv.lockVisualsOn() ? 'not-allowed' : 'move'" + [class.force-inert]="mechanismSrv.isPartInert(force.link)" + [class.force-locked]="force.locked && mechanismSrv.lockVisualsOn()" + [attr.pointer-events]="geometryLocked ? 'none' : null" + style="transform: scaleY(-1)" (contextmenu)="setLastRightClick(force)" (pointerdown)="beginDraggingForceBody(force, $event); setLastLeftClick(force)" (pointerover)="force.showHighlight = true" @@ -1022,7 +1464,11 @@ reads around it. At the old 0.15 object scales it was the same size as the joint and covered it exactly. --> @if (!force.local) { - <circle [attr.r]="forceAnchorRadius(force)" class="forceAnchor"></circle> + <circle + [attr.r]="forceAnchorRadius(force)" + class="forceAnchor" + [style.fill]="forceInkOf(force)" + ></circle> } <!-- Local: the welded joint's own plus. A local force is carried by the body rather than fixed in the world, which is the statement a @@ -1032,14 +1478,35 @@ <path [attr.d]="forceWeldMark(force)" class="forceAnchor" + [style.fill]="forceInkOf(force)" style="transform: rotateZ({{ force.angleRad }}rad)" ></path> } + <!-- Same badge the joints wear, at the anchor: a force has no joint + to carry it, so the mark rides where the load is applied. --> + @if (force.locked && mechanismSrv.lockVisualsOn()) { + <g + class="lockBadge" + pointer-events="none" + [attr.transform]="offsetLockBadgeTransform()" + > + <circle + [attr.r]="0.13 * settings.objectScale" + fill="#ffffff" + stroke="#26323855" + [attr.stroke-width]="svgGrid.scaleWithZoom(1)" + /> + <g [attr.transform]="lockGlyphTransform()"> + <path [attr.d]="lockGlyphPath" fill="#263238" fill-rule="evenodd" /> + </g> + </g> + } </svg> <!-- Transpent thicker force to increase hitbox for the group for highliting--> <path [attr.d]="force.forceLine" class="forceLine" + [style.stroke]="forceInkOf(force)" [attr.stroke-width]="force.visualWidth * settings.objectScale" ></path> <path @@ -1047,7 +1514,11 @@ stroke="transparent" [attr.stroke-width]="force.visualWidth * settings.objectScale + svgGrid.scaleWithZoom(10)" ></path> - <path [attr.d]="force.forceArrow" class="forceArrow" [attr.fill]="force.fill"></path> + <path + [attr.d]="force.forceArrow" + class="forceArrow" + [style.fill]="forceInkOf(force)" + ></path> </g> @if (activeObjService.selectedForce == force && activeObjService.objType == 'Force') { <ng-container> @@ -1074,7 +1545,8 @@ " height="10" width="10" - style="overflow: visible; cursor: move" + cursor="move" + style="overflow: visible" > <circle [attr.r]="0.075 * settings.objectScale" class="forceEndpointHandle"></circle> </svg> @@ -1091,7 +1563,8 @@ " height="10" width="10" - style="overflow: visible; cursor: move" + cursor="move" + style="overflow: visible" > <circle [attr.r]="0.075 * settings.objectScale" class="forceEndpointHandle"></circle> </svg> @@ -1100,6 +1573,54 @@ </g> } + <!-- The corners and the turn knob, above every part: they are the controls of + the gesture, and a link lying across one made it ungrabbable. Small + enough that what they cover is not worth having. --> + @if (bgImage.image(); as bg) { + @if (editingBackgroundImage()) { + <g id="backgroundImageGrips" pointer-events="none" [attr.transform]="bgImage.transformOf(bg)"> + <!-- The turn knob, on a stalk above the top edge so it cannot be taken + for a corner. Round, because it swings rather than pulls. Alt + suspends its 15° snap, as it does everywhere else on this canvas. --> + <line + class="bgImageRotateStalk" + [attr.x1]="bg.centerX" + [attr.y1]="bgImage.topOf(bg)" + [attr.x2]="bg.centerX" + [attr.y2]="bgImage.topOf(bg) - backgroundImageRotateReach()" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + /> + <circle + class="bgImageHandle bgImageRotateKnob" + pointer-events="all" + cursor="grab" + [attr.cx]="bg.centerX" + [attr.cy]="bgImage.topOf(bg) - backgroundImageRotateReach()" + [attr.r]="backgroundImageHandleSize() * 0.65" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + (pointerdown)="startBackgroundImageRotate($event)" + /> + <!-- Corners resize about the opposite corner, with the file's own + proportions locked: a picture stretched out of shape is no longer + something a linkage can be measured against. Drawn at a constant + size on screen, so they stay grabbable at every zoom. --> + @for (corner of backgroundImageHandles(); track corner.id) { + <rect + class="bgImageHandle" + pointer-events="all" + [attr.x]="corner.x - backgroundImageHandleSize() / 2" + [attr.y]="corner.y - backgroundImageHandleSize() / 2" + [attr.width]="backgroundImageHandleSize()" + [attr.height]="backgroundImageHandleSize()" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + [attr.cursor]="corner.cursor" + (pointerdown)="startBackgroundImageResize($event, corner.id)" + /> + } + </g> + } + } + <g id="jointTagHolder" style="pointer-events: none"> @for (joint of mechanismSrv.getJoints(); track joint) { <svg @@ -1113,7 +1634,11 @@ <!-- While a joint is captured, the two names sit on the same point and overlap into an unreadable smudge. One label naming the merge says what is about to happen instead. --> - <text style="font-size: {{ svgGrid.scaleWithZoom(20) }}px; text-anchor: middle"> + <text + [attr.stroke-width]="settings.objectScale * 0.035" + [style.font-size.px]="tagFontSize" + style="text-anchor: middle" + > {{ mergeLabel }} </text> } @else if ( @@ -1124,71 +1649,130 @@ (this.activeObjService.selectedJoint == joint && this.activeObjService.objType == 'Joint')) ) { - <text style="font-size: {{ svgGrid.scaleWithZoom(20) }}px; text-anchor: middle"> + <text + [attr.stroke-width]="settings.objectScale * 0.035" + [style.font-size.px]="tagFontSize" + style="text-anchor: middle" + > {{ joint.name }} </text> } </svg> } </g> - @if (settings.isShowCOM.value) { + @if (settings.isShowCOM.value || settings.previewCoMLinkId) { <g id="comTagHolder" style="transform: scaleY(-1); pointer-events: none"> @for (link of mechanismSrv.getLinks(); track link) { - <svg style="overflow: visible"> - <path - stroke-width="2" - stroke-linejoin="round" - fill="black" - opacity="1.0" - [attr.d]="mechanismSrv.getLinkProp(link, 'CoM_d1')" - ></path> - <path - stroke-width="2" - stroke-linejoin="round" - fill="white" - opacity="1.0" - [attr.d]="mechanismSrv.getLinkProp(link, 'CoM_d2')" - ></path> - <path - stroke-width="2" - stroke-linejoin="round" - fill="black" - opacity="1.0" - [attr.d]="mechanismSrv.getLinkProp(link, 'CoM_d3')" - ></path> - <path - stroke-width="2" - stroke-linejoin="round" - fill="white" - opacity="1.0" - [attr.d]="mechanismSrv.getLinkProp(link, 'CoM_d4')" - ></path> - </svg> + @if (showsCoM(link)) { + <svg style="overflow: visible"> + <!-- The standard mark, drawn to be read over whatever is beneath it. + Two of the four quarters are filled and the other two are not + painted at all, so the link's own colour shows through where the + white used to be, and everything is half opaque. The outline is + the same four wedges stroked rather than a circle drawn beside + them -- one set of coordinates, so it cannot land anywhere else. --> + <!-- The shape's own centre, kept visible while the CoM mark is + placed elsewhere: the custom position is an offset from + exactly this point. --> + @if (comCentroidDot(link); as centroid) { + <circle + [attr.cx]="centroid.x" + [attr.cy]="centroid.y" + [attr.r]="0.035 * settings.objectScale" + fill="black" + fill-opacity="0.55" + ></circle> + } + <!-- A right-click here is still the link's right-click. Selecting a + link is what makes this handle appear, so the press that + selects it puts the handle under the cursor before the + contextmenu event arrives -- and the mark, which had no + handler of its own, swallowed it. Aiming at the middle of a + bar, which is where a hand naturally lands, then opened no + menu at all until the reader right-clicked a second time. --> + <g + [style.pointer-events]="comDraggable(link) ? 'auto' : 'none'" + [style.cursor]="comDraggable(link) ? 'move' : null" + (pointerdown)="comDraggable(link) && startComDrag($any(link), $event)" + (contextmenu)="setLastRightClick(link, $event)" + > + <!-- The selected link's mark is a handle: the dashed accent ring + says grab me, and doubles as the hit area. --> + @if (comDraggable(link)) { + <circle + class="com-drag-ring" + [attr.cx]="$any(link).CoM.x" + [attr.cy]="$any(link).CoM.y" + [attr.r]="0.16 * settings.objectScale" + fill="transparent" + [attr.stroke-width]="svgGrid.scaleWithZoom(1.5)" + [attr.stroke-dasharray]="svgGrid.scaleWithZoom(4) + ' ' + svgGrid.scaleWithZoom(3)" + ></circle> + } + @for ( + quarter of [ + mechanismSrv.getLinkProp(link, 'CoM_d1'), + mechanismSrv.getLinkProp(link, 'CoM_d2'), + mechanismSrv.getLinkProp(link, 'CoM_d3'), + mechanismSrv.getLinkProp(link, 'CoM_d4'), + ]; + track $index + ) { + <path + [attr.d]="quarter" + [attr.fill]="$index % 2 === 0 ? 'black' : 'none'" + fill-opacity="0.45" + stroke="black" + stroke-opacity="0.45" + stroke-linejoin="round" + [attr.stroke-width]="svgGrid.scaleWithZoom(1)" + ></path> + } + </g> + </svg> + } } </g> } <g id="linkTagHolder" style="pointer-events: none"> + <!-- One call, not four: this walks the link's joints, and the horse has + forty-five of them across twenty-seven links on every redraw. --> @for (link of mechanismSrv.getLinks(); track link) { - <svg - [attr.x]="mechanismSrv.getLinkProp(link, 'CoMX')" - [attr.y]="mechanismSrv.getLinkProp(link, 'CoMY')" - [attr.height]="10" - [attr.width]="10" - style="overflow: visible; text-anchor: middle" - > - @if ( - this.showLinkLengthOverlay < -1 && - this.showLinkAngleOverlay < -1 && - !isSecondaryCylinderTag(link) && - ((gridUtils.typeOfLink(link) !== 'P' && settings.isShowID.value) || - link.showHighlight || - (this.activeObjService.selectedLink == link && this.activeObjService.objType == 'Link')) - ) { - <text style="font-size: {{ svgGrid.scaleWithZoom(20) }}px; text-anchor: middle"> - {{ linkDisplayName(link) }} - </text> - } - </svg> + @if (linkLabelStyle(link); as tag) { + <svg + [attr.x]="tag.x" + [attr.y]="-tag.y" + [attr.height]="10" + [attr.width]="10" + style="overflow: visible; text-anchor: middle" + > + <!-- Always drawn, unlike the joint and force labels: a link is a body + with room to hold its own name, and the name is how the panels and + the analysis refer to it. The toggle still governs the labels that + would otherwise crowd the joints. --> + @if ( + this.showLinkLengthOverlay < -1 && + this.showLinkAngleOverlay < -1 && + !isSecondaryCylinderTag(link) && + gridUtils.typeOfLink(link) !== 'P' + ) { + <!-- Both the name and the centre-of-mass mark belong at the centre + of the link, so when the mark is being shown the name steps up + out of its way -- along the name's own up, which on a bar + written lengthwise is across the bar. --> + <text + [attr.fill]="tag.ink" + [attr.fill-opacity]="tag.opacity" + [attr.transform]="tag.angle ? 'rotate(' + tag.angle + ')' : null" + [attr.dy]="showsCoM(link) ? -settings.objectScale * 0.13 : 0" + [style.font-size.px]="tagFontSize" + style="text-anchor: middle" + > + {{ tag.name }} + </text> + } + </svg> + } } </g> <g id="forceTagHolder" style="pointer-events: none"> @@ -1205,7 +1789,7 @@ force.showHighlight || (this.activeObjService.selectedForce == force && this.activeObjService.objType == 'Force') ) { - <text style="font-size: {{ svgGrid.scaleWithZoom(20) }}px; text-anchor: left"> + <text [style.font-size.px]="tagFontSize" style="text-anchor: left"> {{ force.name }} </text> } @@ -1323,6 +1907,45 @@ </text> </g> } + <!-- The distance a CoM field states, drawn where it is measured: from the + frame's zero, along the hovered axis, to the centre of mass — whose + mark the same hover previews. --> + @if (comMeasure && comMeasureValue(comMeasure) > 1e-9 ? comMeasure : null; as measure) { + <g style="pointer-events: none; transform: scaleY(-1)"> + <line + [attr.x1]="comMeasureLine(measure).from.x" + [attr.y1]="comMeasureLine(measure).from.y" + [attr.x2]="comMeasureLine(measure).to.x" + [attr.y2]="comMeasureLine(measure).to.y" + stroke="#000000" + [attr.stroke-width]="0.02 * settings.objectScale" + /> + <path + [attr.d]="comMeasureCaps(measure)" + stroke="#000000" + [attr.stroke-width]="0.02 * settings.objectScale" + ></path> + @if (comMeasureConnector(measure); as connector) { + <path + [attr.d]="connector" + stroke="#000000" + [attr.stroke-width]="0.02 * settings.objectScale" + [attr.stroke-dasharray]="0.08 * settings.objectScale" + ></path> + } + <!-- A vertical measure's label starts clear of its line; centring it + there would run the digits through the stroke. --> + <text + [attr.text-anchor]="measure.axis === 'y' ? 'start' : 'middle'" + style="transform: scaleY(-1)" + [attr.x]="comMeasureLabelPos(measure).x" + [attr.y]="-comMeasureLabelPos(measure).y" + [attr.font-size]="svgGrid.scaleWithZoom(20)" + > + {{ nup.formatModelLength(comMeasureValue(measure), settings.lengthUnit.getValue()) }} + </text> + </g> + } <!-- The ram's travel, as the stretch of ground its rod mount covers. One picture for both size fields: Travel is how long this line is, Starts at is how far along it the ram is standing right now. --> @@ -1416,6 +2039,17 @@ </g> } </svg> +<!-- The file dialog the Add background image menu item opens. Off-screen + rather than display:none, because a hidden input cannot be driven by the + browser's own picker on every platform. Its value is cleared after each + pick so choosing the same file twice still fires a change. --> +<input + #backgroundImageInput + type="file" + accept="image/*" + class="offscreenFileInput" + (change)="onBackgroundImageChosen($event)" +/> @if (settings.isGridDebugOn) { <div id="mouseLocation"> Mouse Location: {{ (mouseLocation.x / MODEL_SCALE).toFixed(2) }}, @@ -1428,14 +2062,7 @@ </div> } -<!--<div id='coordinateFrame' style='pointer-events: none'>--> -<!-- <div style='position: relative;display: flex; flex-direction: column; align-items: end'>--> <!-- {{humanReadableString(settings.globalUnit.getValue())}}--> -<!-- <img src='assets/coords.svg' style='width: 80px; margin-top: -20px'>--> -<!-- </div>--> -<!--</div>--> <ng-template #context_menu> - <app-context-menu [menuItems]="cMenuItems"> </app-context-menu> + <app-context-menu [model]="cMenu"></app-context-menu> </ng-template> - -<!--<div cdkContextMenuTriggerFor="context_menu" #trigger>Right click me</div>--> diff --git a/src/app/component/new-grid/new-grid.component.scss b/src/app/component/new-grid/new-grid.component.scss index b6a449ea..98279616 100644 --- a/src/app/component/new-grid/new-grid.component.scss +++ b/src/app/component/new-grid/new-grid.component.scss @@ -17,10 +17,62 @@ opacity: 0.3; } + // The tutorial's pointer at one joint. Amber, because every other mark a + // joint carries here is about what the joint *is* -- ground, input, lock -- + // and this one is about what to do next. Two rings rather than one, so it + // reads as a target rather than as a halo the joint has grown. + // + // Stroke widths are in model units like everything else on this canvas, so + // they are given as a fraction of the joint they surround rather than in + // pixels: a pixel here is whatever the current zoom says it is. + .tutorialRing { + circle { + fill: none; + stroke: #ffca28; + vector-effect: non-scaling-stroke; + } + + // A constant width on screen rather than in model units: this is a pointer + // at a joint, not a part of the drawing, so it should not thin out as the + // student zooms out of the mechanism it is pointing at. + .ringOuter { + stroke-width: 2; + opacity: 0.35; + } + + .ringInner { + stroke-width: 2.5; + opacity: 0.6; + } + } + + // The grid is a background, and reads as one: two greys a shade apart, the + // minor one barely there, and the axes the only line on it with any weight. + // Both were black at a hairline width, which is a grey the browser chooses + // rather than one the app does -- and a different grey at every zoom level. + .gridLineMinor { + stroke: #f2f2f4; + } + + .gridLineMajor { + stroke: #e6e6ea; + } + + // A joint's name lands wherever its joint is, which is often on top of a + // link, another joint's name, or a ground hatch. The same white halo the + // grid's own numbers wear, so it reads over whatever it has landed on. + #jointTagHolder text { + paint-order: stroke; + stroke: #fff; + stroke-linejoin: round; + stroke-linecap: round; + } + #axes_numbers { - fill: mat.m2-get-color-from-palette($foreground, 'text'); + // Lighter than body text: these are a reference, not something to read. + fill: rgba(0, 0, 0, 0.55); paint-order: stroke; - stroke: white; + stroke: #fff; stroke-linejoin: round; stroke-linecap: round; alignment-baseline: middle; @@ -34,8 +86,11 @@ opacity: 0; } + // Switched on for one move by enableGridAnimationForThisAction, which takes + // it off again a moment after this ends. Out fast and in gently, so the eye + // follows where the drawing is going rather than watching it leave. .animated { - transition: transform 0.3s ease; + transition: transform 0.32s cubic-bezier(0.22, 0.61, 0.36, 1); } .my-custom-snackbar { @@ -51,6 +106,10 @@ stroke: mat.m2-get-color-from-palette($primary-palette, 900); } + // What a joint is drawn in when it has not been given a colour of its own. + // A joint that has been keeps these rules for everything but the fill, which + // it carries inline -- so the shadow, and every state that means something + // other than "resting", still say the same thing on every joint. .joint-default { filter: url(#elevation-1); fill: mat.m2-get-color-from-palette($accent-palette, 50); @@ -68,10 +127,358 @@ fill: mat.m2-get-color-from-palette($accent-palette, 400); } + // Drawn on a joint rather than beside it, so its ink follows the joint's own + // colour -- a dark pin needs a light padlock or the mark disappears into what + // it is sitting on. The component picks which; this is the default, for a + // joint drawn in the pale colour it has always been drawn in. + .lockBadge { + .lockGlyph { + fill: #263238; + } + + // Only a welded joint brings its own chip -- a plain one's circle is chip + // enough. + .lockChip { + fill: #ffffff; + stroke: #26323855; + } + } + .link-selected { stroke: mat.m2-get-color-from-palette($accent-palette, 400); } + // Pointed at from the transport, not picked: enough to say which machine is + // which, not enough to be mistaken for a selection. + .link-hovered { + stroke: mat.m2-get-color-from-palette($accent-palette, 200); + } + + // Pointed at from a list that names one part. Stronger than the whole-machine + // hover above, because it has to answer "which of these eleven pins" across + // the width of the window: the tint alone was a change a reader had to hunt + // for. Elevated as well as filled, so it lifts off whatever it overlaps. + .joint-pointed { + filter: url(#elevation-3); + fill: mat.m2-get-color-from-palette($accent-palette, 400); + stroke: #3f51b5; + stroke-width: 0.06; + } + + .link-pointed { + stroke: mat.m2-get-color-from-palette($accent-palette, 400); + filter: url(#elevation-2); + } + + // The grab-me ring around the selected link's CoM mark: dashed so it reads + // as an affordance rather than part of the standard symbol. + .com-drag-ring { + stroke: mat.m2-get-color-from-palette($accent-palette, 400); + } + + // The cylinder part whose mass field is being pointed at: a glaze over the + // one piece, since the selected body already wears the accent outline. + .cylinder-part-preview { + fill: mat.m2-get-color-from-palette($accent-palette, 400); + fill-opacity: 0.45; + stroke: mat.m2-get-color-from-palette($accent-palette, 400); + } + + // Scenery in the analysis modes: still drawn, so the reader can see it is + // there, but grey and out of the way of the machine being analysed. + .link-inert { + fill: #c4c7d0 !important; + stroke: #c4c7d0 !important; + fill-opacity: 0.45; + } + + .joint-inert { + filter: none; + fill: #d9dbe2; + } + + // A force on a body the analysis modes have nothing to say about. The arrow + // is the loudest thing on the canvas and it was still full-strength navy over + // a machine drawn as scenery. + .force-inert { + .forceLine, + .forceArrow, + .forceAnchor { + stroke: #c4c7d0; + fill: #c4c7d0; + } + + .forceAnchor { + stroke: #c4c7d0; + } + } + + // Geometry is frozen in the analysis modes, so the four-headed move cursor is + // an offer the mode will not honour: the drag it promises does nothing. These + // parts can still be *clicked*, to choose what the panel graphs, which is + // what a pointer says. + #canvas.geometryLocked [cursor='move'] { + cursor: pointer; + } + + // Held in place while editing: the cue is the small padlock badge riding + // each held joint (and a locked force's anchor) — drawn in the template, so + // nothing here restyles strokes and no state can cover another. The classes + // below stay as truthful state markers for tests and tooling. + + // The edges of the tracing underlay, drawn only while its panel is open. The + // same accent every other "this is the thing you are editing" mark uses, + // broken rather than solid so a rectangle of picture cannot read as a part. + .bgImageOutline { + fill: none; + stroke: mat.m2-get-color-from-palette($accent-palette, 500); + pointer-events: none; + } + + // Grab anywhere inside to slide the picture. Invisible, but a target: the + // whole picture moves, not just its edges. + .bgImageMoveSurface { + fill: transparent; + cursor: move; + } + + // The corners and the turn knob. Filled rather than hollow so they read as + // things to take hold of, and white-edged so they stay findable over a dark + // photograph. + .bgImageHandle { + fill: mat.m2-get-color-from-palette($accent-palette, 500); + stroke: #fff; + } + + // What holds the turn knob off the top edge, so it is not read as a corner. + .bgImageRotateStalk { + stroke: mat.m2-get-color-from-palette($accent-palette, 500); + 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 { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; + left: -9999px; + } + #mouseLocation { position: absolute; top: 35px; diff --git a/src/app/component/new-grid/new-grid.component.spec.ts b/src/app/component/new-grid/new-grid.component.spec.ts index a3cfa055..a883bd9a 100644 --- a/src/app/component/new-grid/new-grid.component.spec.ts +++ b/src/app/component/new-grid/new-grid.component.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { AppModule } from '../../app.module'; +import { provideAnimations } from '@angular/platform-browser/animations'; import { RevJoint } from '../../model/joint'; import { RealLink } from '../../model/link'; import { ActiveObjService } from '../../services/active-obj.service'; @@ -46,8 +46,15 @@ async function configureGridTestBed() { // call used to be a second behind a timer, so it threw after the test had // finished, into nothing. It happens inline now. scaleToFitLinkage: vi.fn(), + // These tests are about what a drag does, not about snapping, so the grid + // here has no squares to snap to and hands every point back as it came. + minorCellSize: 0, + snapToGrid: (coord: { x: number; y: number }) => coord, }; - await TestBed.configureTestingModule({ imports: [AppModule] }) + await TestBed.configureTestingModule({ + imports: [NewGridComponent], + providers: [provideAnimations()], + }) .overrideProvider(SvgGridService, { useValue: svgGridStub }) .compileComponents(); } diff --git a/src/app/component/new-grid/new-grid.component.ts b/src/app/component/new-grid/new-grid.component.ts index d4739654..e4ba9ad2 100644 --- a/src/app/component/new-grid/new-grid.component.ts +++ b/src/app/component/new-grid/new-grid.component.ts @@ -1,58 +1,55 @@ import { SvgGridService } from '../../services/svg-grid.service'; import { - AfterViewInit, OnDestroy, Component, + ElementRef, HostListener, - ViewChild, ChangeDetectionStrategy, + inject, + viewChild, } from '@angular/core'; import { fromEvent } from 'rxjs'; -import { debounceTime } from 'rxjs/operators'; import { MechanismService } from '../../services/mechanism.service'; +import { TutorialService } from '../../services/tutorial.service'; import { UrlProcessorService } from '../../services/url-processor.service'; import { GridUtilsService } from '../../services/grid-utils.service'; import { SettingsService } from '../../services/settings.service'; import { ActiveObjService } from '../../services/active-obj.service'; -import { cMenuItem } from '../context-menu/context-menu.component'; -import { Link, RealLink } from '../../model/link'; +import { ContextMenuComponent } from '../context-menu/context-menu.component'; +import { ContextMenuModel, trackContextMenuPointer } from '../context-menu/menu-model'; +import { + ContextMenuBuilderService, + MenuHandlers, +} from '../../services/context-menu-builder.service'; +import { Link, RealLink, SliderBlock } from '../../model/link'; +import { Lockable } from '../../model/lock-set'; import { Joint, PrisJoint, RealJoint, RevJoint } from '../../model/joint'; import { Coord } from '../../model/coord'; import { forceStates, gridStates, - is_touch_enabled, has_mouse_pointer, jointStates, - line_line_intersect, linkStates, local_storage_available, - isInside, getDistance, AngleUnit, radToDeg, - GlobalUnit, point_on_line_segment_closest_to_point, } from '../../model/utils'; import { Force } from '../../model/force'; -import { PositionSolver } from '../../model/mechanism/position-solver'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { AnimationBarComponent } from '../animation-bar/animation-bar.component'; -import { animate, style, transition, trigger } from '@angular/animations'; -import { MatMenuTrigger } from '@angular/material/menu'; -import { CdkContextMenuTrigger, Menu } from '@angular/cdk/menu'; +import { NotificationService } from '../../services/notification.service'; +import { BackgroundImageService, MIN_WIDTH } from '../../services/background-image.service'; +import { CdkContextMenuTrigger } from '@angular/cdk/menu'; import { MatDialog } from '@angular/material/dialog'; import { TouchscreenWarningComponent } from '../MODALS/touchscreen-warning/touchscreen-warning.component'; -import * as util from 'util'; import { Line } from '../../model/line'; 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'; @@ -82,6 +79,7 @@ import { import { JointDropCandidate, MERGE_REFUSAL_MESSAGES, + MergeRefusal, resolveDropCandidate, resolveSlotDropTarget, SlotDropCandidate, @@ -93,13 +91,13 @@ import { cylinderHeadHalf, cylinderSizeOf, cylinderSpanRange, - cylinderMinimumSpan, cylinderJoints, isCylinderInterior as isCylinderInteriorOf, } from '../../model/cylinder'; import { SnapGuide, snapToAxes } from '../../model/axis-snap'; import { drawDepths } from '../../model/draw-order'; import { MODEL_SCALE } from '../../model/render-scale'; +import { uniformBodyOf } from '../../model/uniform-body'; import { buildCompoundPath } from '../../model/compound-link-path'; import { angleReference, GROUND_BODY, resolveActuator } from '../../model/actuator'; @@ -115,51 +113,78 @@ export interface SlotStackItem { /** Set for a plate; the fused rider-and-block outline this item draws. */ plate?: WeldPlate; } -import introJs from 'intro.js'; -import { CANNOT_EDIT } from '../../ui-text'; +import { SvgArrowComponent } from '../svg-arrow/svg-arrow.component'; +import { KeyboardShortcutsService, ShortcutId } from '../../services/keyboard-shortcuts.service'; +import { INK_FLIPS_AT, luminanceOf } from '../../model/contrast'; +import { DEFAULT_FORCE_COLOR, SELECTION_RING } from '../../model/joint-colors'; + +/** Which corner of the tracing underlay a resize gesture is holding. */ +type BackgroundImageCorner = 'tl' | 'tr' | 'bl' | 'br'; + +/** + * How long the canvas takes to glide to a new frame. + * + * A hair longer than the CSS transition it switches on, so the class comes off + * after the move has landed rather than exactly as it lands -- the two used to + * be the same 300, which cut the last frame of every reframe. + */ +const GRID_GLIDE_MS = 380; + +/** How wide the ring inside a selected joint's edge is drawn, in screen pixels. */ +const SELECTION_RING_PX = 3; @Component({ selector: 'app-new-grid', templateUrl: './new-grid.component.html', styleUrls: ['./new-grid.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [CdkContextMenuTrigger, ContextMenuComponent], }) export class NewGridComponent implements OnDestroy { - public static debugValue: any; + svgGrid = inject(SvgGridService); + mechanismSrv = inject(MechanismService); + private tutorial = inject(TutorialService); + private urlParser = inject(UrlProcessorService); + gridUtils = inject(GridUtilsService); + private colors = inject(ColorService); + settings = inject(SettingsService); + 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); + saveHistoryService = inject(SaveHistoryService); + private colorService = inject(ColorService); + nup = inject(NumberUnitParserService); + dragState = inject(DragStateService); + sliderMarks = inject(SliderMarkService); + bgImage = inject(BackgroundImageService); + private menuBuilder = inject(ContextMenuBuilderService); + + public static debugValue: unknown; static debugPoints: Coord[] = []; public static debugLines: Line[] = []; public originInScreen: Coord = new Coord(0, 0); private timeMouseDown: number = 0; - constructor( - public svgGrid: SvgGridService, - public mechanismSrv: MechanismService, - private urlParser: UrlProcessorService, - public gridUtils: GridUtilsService, - public settings: SettingsService, - public activeObjService: ActiveObjService, - private tabService: SelectedTabService, - public synthesisBuilder: SynthesisBuilderService, - private snackBar: MatSnackBar, - public dialog: MatDialog, - public saveHistoryService: SaveHistoryService, - private colorService: ColorService, - public nup: NumberUnitParserService, - public dragState: DragStateService, - public sliderMarks: SliderMarkService - ) { + constructor() { //This is for debug purposes, do not make anything else static! NewGridComponent.instance = this; + // Ahead of the CDK's own contextmenu listener, so the menu card knows + // which corner the pointer is in before it is measured. + trackContextMenuPointer(); } private svgGridElement!: HTMLElement; - public cMenuItems: cMenuItem[] = []; - public lastRightClick: Joint | Link | Force | String = ''; + public cMenu: ContextMenuModel = { groups: [] }; + public lastRightClick: Joint | Link | Force | string | SynthesisPose = ''; public lastRightClickCoord: Coord = new Coord(0, 0); - public lastLeftClick: Joint | Link | Force | String | SynthesisPose = ''; + public lastLeftClick: Joint | Link | Force | string | SynthesisPose = ''; lastLeftClickType: string = 'Nothing'; /** @@ -171,7 +196,6 @@ export class NewGridComponent implements OnDestroy { * Whether this gesture has already said it reached the ram's floor. Reset * when a drag begins, so the message is per-gesture and not per-frame. */ - private cylinderFloorReported = false; /** * A joint in range that will not take the merge, kept with its reason so the @@ -190,6 +214,20 @@ export class NewGridComponent implements OnDestroy { /** Where the link being dragged was last placed, in SVG coordinates. */ private linkDragAnchor: Coord = new Coord(0, 0); + /** + * Where the dragged body's reference point stood when the drag began. + * + * Snapping needs the whole distance dragged, not this frame's part of it: a + * single pointer event moves a few units, which rounds to no move at all, and + * a body measured frame by frame would never leave its corner however far the + * cursor went. + * + * Taken on the first move rather than at the press, because which point is + * the reference depends on what turned out to be under the cursor -- a bar's + * first joint, or the mount a ram is named from. + */ + private bodyDragOrigin?: { at: Coord; from: Coord }; + //This is terrible but: // -2 => hidden // -1 => Link length and angle shown @@ -198,17 +236,13 @@ export class NewGridComponent implements OnDestroy { public showLinkAngleOverlay: number = -2; static instance: NewGridComponent; - private lastNotificationTime: number = Date.now(); //To distinguish between a click and a drag public delta: number = 6; private startX!: number; 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. */ @@ -225,9 +259,13 @@ export class NewGridComponent implements OnDestroy { return Math.round((line / MODEL_SCALE) * 1e6) / 1e6; } - @ViewChild('trigger') contextMenu!: CdkContextMenuTrigger; + readonly contextMenu = viewChild.required<CdkContextMenuTrigger>('trigger'); + private readonly backgroundImageInput = + viewChild.required<ElementRef<HTMLInputElement>>('backgroundImageInput'); ngOnInit() { + this.shortcuts.pressed.subscribe((id) => this.onShortcut(id)); + const svgElement = document.getElementById('canvas') as HTMLElement; this.svgGrid.setNewElement(svgElement); @@ -240,59 +278,25 @@ export class NewGridComponent implements OnDestroy { }); } - if (this.mechanismSrv.joints.length == 0) { - setTimeout(() => { - introJs() - .setOptions({ - steps: [ - { - title: '👋 Welcome', - intro: 'Let us show you around Planar Mechanism Kinematic Simulator Plus!', - }, - { - element: document.querySelector('.tabContainer') as HTMLElement, - intro: 'PMKS+ is divided into 3 modes. Synthesis, Editing, and Analysis.', - }, - { - element: document.querySelector('#editWrapper') as HTMLElement, - intro: - 'The Edit mode is active. Selecting a joint or link will show its properties here.', - }, - { - element: document.querySelector('#barContainer') as HTMLElement, - intro: 'Once the mechanism is created, you can animate it here.', - }, - { - element: document.querySelector('#helpButton') as HTMLElement, - intro: 'If you get stuck at any point, click here for help.', - }, - { - element: document.querySelector('#templatesButton') as HTMLElement, - title: "🙌 That's it!", - intro: 'Get started by opening an example linkage!', - }, - ], - dontShowAgain: true, - }) - .start(); - }); - } - fromEvent(window, 'resize').subscribe((event) => { - // console.log('resize'); - this.svgGrid.panZoomObject.resize(); - // this.svgGrid.panZoomObject.fit(); - // this.svgGrid.panZoomObject.center(); - // this.svgGrid.panZoomObject.resize(); - this.svgGrid.handlePan(); - // console.log(this.svgGrid.getZoom()); - // this.svgGrid.panZoomObject.updateBBox(); - // this.svgGrid.scaleToFitLinkage(); + // Through `ourOwnMove`: telling the library its viewport changed size, + // and redrawing the ruling for it, is the app keeping up with the window + // rather than the reader choosing a view. Read as a choice, it threw away + // the view the canvas was about to give back once the resize settled. + this.svgGrid.ourOwnMove(() => { + this.svgGrid.panZoomObject.resize(); + this.svgGrid.handlePan(); + }); }); this.activeObjService.onActiveObjChange.subscribe((obj) => { this.showLinkAngleOverlay = -2; this.showLinkLengthOverlay = -2; + // The hover previews die with the selection they described: a panel + // swap can eat the mouseleave that would have cleared them. + this.comMeasure = undefined; + this.cylinderPartPreview = undefined; + this.settings.previewCoMLinkId = null; //Disable focus on any text input when changing active object if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); @@ -300,6 +304,17 @@ export class NewGridComponent implements OnDestroy { }); } + /** + * Whether the tutorial's current step is about this joint. + * + * Asked per joint rather than the ring being drawn from a coordinate, so it + * rides the joint through a drag and cannot be left behind at an old + * position. + */ + isTutorialTarget(joint: Joint): boolean { + return this.tutorial.ringJoint() === joint; + } + ngOnDestroy() { // Let go of the static. It is a debug handle, but services reach the // snackbar through it and it was never cleared — so a torn-down grid stayed @@ -338,24 +353,110 @@ 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(); + } + + /** + * The handlers the menu needs that only the canvas can perform. + * + * Everything else a row does is an edit the services already own; these are + * gestures — the next click lands the thing — and the gesture state lives + * here, on the component that reads the pointer. + */ + private menuHandlers(): MenuHandlers { + return { + attachLink: () => this.startCreatingLink(), + attachCylinder: () => this.startCreatingCylinder(), + attachTracerPoint: () => this.addJoint(), + attachForce: (onLink) => this.createForce(onLink), + backgroundImage: () => this.openBackgroundImage(), + deletePosition: (id) => this.deleteSynthesisPosition(id), + deleteAllPositions: () => this.deleteAllSynthesisPositions(), + }; + } + + /** 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(); + } + + /** + * Let the canvas glide to wherever it is being sent, for this one move. + * + * Added and removed from the list rather than written over it: this element + * is svg-pan-zoom's viewport and carries the class the library found it by, + * so setting `class` outright stripped that name off it permanently -- the + * first reframe of a session left nothing on the page called + * `svg-pan-zoom_viewport`. + */ enableGridAnimationForThisAction() { - this.svgGridElement.setAttribute('class', 'animated'); - //Disable after 0.5 seconds - setTimeout(() => { - this.svgGridElement.removeAttribute('class'); - }, 300); + if (!this.svgGridElement) return; + this.svgGridElement.classList.add('animated'); + window.clearTimeout(this.gridAnimationOff); + this.glideEndsAt = performance.now() + GRID_GLIDE_MS; + this.gridAnimationOff = window.setTimeout(() => { + this.glideEndsAt = 0; + this.svgGridElement?.classList.remove('animated'); + }, GRID_GLIDE_MS); + } + + private gridAnimationOff = 0; + private glideEndsAt = 0; + + /** + * Do this once the canvas has stopped gliding, or now if it is not. + * + * Anything that measures the drawing has to wait: mid-transition the + * transform *attribute* already reads as the destination while the picture on + * screen is still on its way there, so a measurement taken then divides one + * by the other and lands the drawing somewhere neither of them meant. + */ + isGliding(): boolean { + return this.glideEndsAt > performance.now(); + } + + afterGlide(run: () => void): void { + const left = this.glideEndsAt - performance.now(); + if (left <= 0) { + run(); + return; + } + window.setTimeout(run, left + 16); } static getLastLeftClickType(): string { return this.instance.objectKind(this.instance.lastLeftClick); } - private objectKind(value: Joint | Link | String | Force | SynthesisPose): string { + private objectKind(value: Joint | Link | string | Force | SynthesisPose): string { if (value instanceof Force) return 'Force'; if (value instanceof RealLink) return 'RealLink'; if (value instanceof PrisJoint) return 'PrisJoint'; @@ -365,276 +466,245 @@ export class NewGridComponent implements OnDestroy { return 'Unknown'; } + /** + * Rebuild the menu for whatever was just right-clicked. + * + * The whole of the decision — which rows, which of them are switches, which + * are greyed and why — lives in `ContextMenuBuilderService`, which reads the + * answers out of the model that enforces them. + */ updateContextMenuItems() { - //Switch case based on what type the object is - this.cMenuItems = []; - switch (this.objectKind(this.lastRightClick)) { - case 'Force': - this.cMenuItems.push( - new cMenuItem( - 'Delete Force', - this.mechanismSrv.deleteForce.bind(this.mechanismSrv), - 'remove' - ) - ); - //Switch force direction, switch force local, delete Force - this.cMenuItems.push( - new cMenuItem( - (this.lastRightClick as Force).local ? 'Make Force Global' : 'Make Force Local', - this.mechanismSrv.changeForceLocal.bind(this.mechanismSrv), - (this.lastRightClick as Force).local ? 'force_global' : 'force_local' - ) - ); - this.cMenuItems.push( - new cMenuItem( - 'Switch Force Direction', - this.mechanismSrv.changeForceDirection.bind(this.mechanismSrv), - 'switch_force_dir' - ) - ); - break; - case 'RealLink': { - // The BODY of a sealed cylinder: barrel, rod or the skin itself. Two - // actions only — the assembly is one part, so no attach items (a - // tracer on the barrel would be a third joint on a two-joint bar) and - // Delete takes the whole cylinder (§ cylinder 3, 5). - const bodyCylinder = this.mechanismSrv.cylinderAt(this.lastRightClick as RealLink); - if (bodyCylinder) { - this.cMenuItems.push( - new cMenuItem( - 'Delete Cylinder', - () => this.mechanismSrv.deleteCylinder(bodyCylinder), - 'remove' - ) - ); - this.cMenuItems.push( - new cMenuItem( - bodyCylinder.slider.input ? 'Remove Input' : 'Add Input', - () => this.mechanismSrv.toggleCylinderInput(bodyCylinder), - bodyCylinder.slider.input ? 'remove_input' : 'add_input' - ) - ); - break; - } - //Delete Link, Attach Link, Attach Tracer Point, Attach Joint - //Don't give options if a fillet it selected and not a primary link - let weldedLinkFilletSelected = - (this.lastRightClick as RealLink).isWelded && - (this.lastRightClick as RealLink).lastSelectedSublink == null; - - this.cMenuItems.push( - new cMenuItem( - 'Delete Link', - this.mechanismSrv.deleteLink.bind(this.mechanismSrv), - 'remove' - ) - ); - this.cMenuItems.push( - new cMenuItem( - 'Attach Link', - this.startCreatingLink.bind(this), - 'new_link', - weldedLinkFilletSelected - ) - ); - // Beside Attach Link, because it is the same gesture with a different - // member on the end of it: this link is what the ram is bolted to, and - // the next click is where its rod finishes. - this.cMenuItems.push( - new cMenuItem( - 'Attach Cylinder', - this.startCreatingCylinder.bind(this), - 'add_cylinder', - weldedLinkFilletSelected - ) - ); - this.cMenuItems.push( - new cMenuItem( - 'Attach Tracer Point', - this.addJoint.bind(this), - 'add_tracer', - weldedLinkFilletSelected - ) - ); - this.cMenuItems.push( - new cMenuItem( - 'Attach Force', - this.createForce.bind(this), - 'add_force', - weldedLinkFilletSelected - ) - ); - break; - } - case 'RevJoint': { - let jointIsSlider = this.gridUtils.isAttachedToSlider(this.lastRightClick); - 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); - - // A MOUNT of a sealed cylinder (the interior joints have no hitboxes, - // so no other member can arrive here). Ground and Weld stay; Slider is - // structurally off the table, and Delete cascades to the whole part — - // mirrored by the edit panel so the two surfaces cannot disagree. - const mountCylinder = this.mechanismSrv.cylinderAt(this.lastRightClick as RealJoint); - if (mountCylinder) { - this.cMenuItems.push( - new cMenuItem( - 'Delete Cylinder', - () => this.mechanismSrv.deleteCylinder(mountCylinder), - 'remove' - ) - ); - this.cMenuItems.push( - new cMenuItem( - 'Attach Link', - this.startCreatingLink.bind(this), - 'new_link', - jointIsInput - ) - ); - this.cMenuItems.push( - new cMenuItem( - jointIsGround ? 'Remove Ground' : 'Add Ground', - this.mechanismSrv.toggleGround.bind(this.mechanismSrv), - jointIsGround ? 'remove_ground' : 'add_ground' - ) - ); - this.cMenuItems.push( - new cMenuItem( - (this.lastRightClick as RealJoint).input ? 'Remove Input' : 'Add Input', - this.mechanismSrv.adjustInput.bind(this.mechanismSrv), - (this.lastRightClick as RealJoint).input ? 'remove_input' : 'add_input', - !canToggleInput - ) - ); - this.cMenuItems.push(new cMenuItem('Add Slider', () => {}, 'add_slider', true)); - this.cMenuItems.push( - new cMenuItem( - (this.lastRightClick as RealJoint).isWelded ? 'Unweld Joint' : 'Weld Joint', - this.mechanismSrv.toggleWeldedJoint.bind(this.mechanismSrv), - (this.lastRightClick as RealJoint).isWelded ? 'unweld_joint' : 'weld_joint', - !this.gridUtils.canToggleWeld(this.lastRightClick as RealJoint) - ) - ); - break; - } - let canTogglePath = - !(this.lastRightClick as RealJoint).ground && this.mechanismSrv.oneValidMechanismExists(); - - this.cMenuItems.push( - new cMenuItem( - 'Delete Joint', - this.mechanismSrv.deleteJoint.bind(this.mechanismSrv), - 'remove' - ) - ); + this.cMenu = this.menuBuilder.build(this.lastRightClick, this.menuHandlers()); + } - // A third body at a driven joint is what "driven" stops being able to - // describe (§2.9), so the item that would add one is greyed rather - // than offered and then refused after the fact. - this.cMenuItems.push( - new cMenuItem('Attach Link', this.startCreatingLink.bind(this), 'new_link', jointIsInput) - ); - // Beside Attach Link here for the same reason it is beside Attach Link - // on a body: the two are one gesture with a different member on the end - // of it. This joint becomes the ram's own mount, so it swings with - // whatever already meets here. - // - // Not on a welded joint. A weld is the statement that everything - // meeting here is one rigid body, and a ram's mount arriving would be a - // third body joining that statement without being part of it — the - // reconcilers then disagree about what the compound is, which is a - // broken mechanism rather than a refused edit. - this.cMenuItems.push( - new cMenuItem( - 'Attach Cylinder', - this.startCreatingCylinder.bind(this), - 'add_cylinder', - jointIsInput || (this.lastRightClick as RealJoint).isWelded - ) - ); + /** + * Whether the panel is currently editing the background image. + * + * Every clause is a way the panel can go without the selection changing: the + * analysis modes replace it, and playback covers it with the stop-the- + * animation placeholder. An outline for controls that are not on screen is a + * mark nobody can explain. + * + * The last one is not about the panel at all. `tempGridDisable` is the flag + * "Fit to zoom" sets while it measures the drawing, and an outline the size + * of the picture counts towards that box exactly as the picture would -- the + * fit framed a hundred-centimetre photograph and left the linkage too small + * to work on. + */ + editingBackgroundImage(): boolean { + return ( + this.activeObjService.objType === 'BackgroundImage' && + !this.tabService.isAnalysisMode() && + !this.mechanismSrv.isPlaying && + this.mechanismSrv.mechanismTimeStep === 0 && + !this.settings.tempGridDisable + ); + } - // Enabled whatever else the joint is, exactly as the panel's toggle is: - // Ground and Slider became independent axes of the 2x2 in §4.1, so - // greying one out because of the other puts a reachable cell out of - // reach from this surface and not from the other. - this.cMenuItems.push( - new cMenuItem( - jointIsGround ? 'Remove Ground' : 'Add Ground', - this.mechanismSrv.toggleGround.bind(this.mechanismSrv), - jointIsGround ? 'remove_ground' : 'add_ground' - ) - ); //Rev Joint - Ground - - if (jointIsSlider) { - this.cMenuItems.push( - new cMenuItem( - (this.gridUtils.getSliderJoint(this.lastRightClick as RealJoint) as RealJoint).input - ? 'Remove Input' - : 'Add Input', - this.mechanismSrv.adjustInput.bind(this.mechanismSrv), - (this.gridUtils.getSliderJoint(this.lastRightClick as RealJoint) as RealJoint).input - ? 'remove_input' - : 'add_input', - !canToggleInput - ) - ); //Rev Joint Slider - } else { - this.cMenuItems.push( - new cMenuItem( - (this.lastRightClick as RealJoint).input ? 'Remove Input' : 'Add Input', - this.mechanismSrv.adjustInput.bind(this.mechanismSrv), - (this.lastRightClick as RealJoint).input ? 'remove_input' : 'add_input', - !canToggleInput - ) //Rev Joint - Input - ); - } + /** Half a centimetre of screen, whatever the zoom: a grabbable corner. */ + backgroundImageHandleSize(): number { + return this.svgGrid.scaleWithZoom(10); + } - // A block is a body too, so adding one to a driven pin puts a third at - // the joint exactly as attaching a link does. Removing one is always - // allowed -- that direction takes a body away. - this.cMenuItems.push( - new cMenuItem( - this.gridUtils.isAttachedToSlider(this.lastRightClick) ? '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) - ) - ); //Rev Joint - Always - - this.cMenuItems.push( - new cMenuItem( - (this.lastRightClick as RealJoint).isWelded ? 'Unweld Joint' : 'Weld Joint', - this.mechanismSrv.toggleWeldedJoint.bind(this.mechanismSrv), - (this.lastRightClick as RealJoint).isWelded ? 'unweld_joint' : 'weld_joint', - // Greyed only when there is structurally nothing to fuse (fewer than - // two links); a grounded or driven joint still gets the explained - // refusal, exactly as the panel's toggle does. - !this.gridUtils.canToggleWeld(this.lastRightClick as RealJoint) - ) - ); //Rev Joint - the service explains a refusal, as the panel's toggle does - - // this.cMenuItems.push( - // new cMenuItem( - // (this.lastRightClick as RealJoint).showCurve ? 'Hide Path' : 'Show Path', - // () => { - // this.gridUtils.toggleCurve(this.lastRightClick); - // }, - // (this.lastRightClick as RealJoint).showCurve ? 'hide_path' : 'show_path', - // !canTogglePath - // ) - // ); //Rev Joint - Not Ground and at least one valid mechanism exists - break; - } + /** How far above the top edge the turn handle stands, at any zoom. */ + backgroundImageRotateReach(): number { + return this.svgGrid.scaleWithZoom(34); + } - case 'String': //This means grid - this.cMenuItems.push( - new cMenuItem('Add Link', this.startCreatingLink.bind(this), 'new_link') - ); - this.cMenuItems.push( - new cMenuItem('Add Cylinder', this.startCreatingCylinder.bind(this), 'add_cylinder') - ); + /** + * The four corners, in SVG coordinates, each with the cursor that says which + * way it pulls. + */ + backgroundImageHandles(): { id: BackgroundImageCorner; x: number; y: number; cursor: string }[] { + const image = this.bgImage.image(); + if (!image) return []; + const left = this.bgImage.leftOf(image); + const top = this.bgImage.topOf(image); + const right = left + image.width; + const bottom = top + this.bgImage.heightOf(image); + return [ + { id: 'tl', x: left, y: top, cursor: 'nwse-resize' }, + { id: 'tr', x: right, y: top, cursor: 'nesw-resize' }, + { id: 'bl', x: left, y: bottom, cursor: 'nesw-resize' }, + { id: 'br', x: right, y: bottom, cursor: 'nwse-resize' }, + ]; + } + + /** + * The gesture in flight on the picture, if there is one. + * + * `stopPropagation` on the press is what keeps svg-pan-zoom out of it — the + * library binds its own handler to the canvas root, so a press that reaches + * it starts a pan under the drag. The state lives here rather than in + * DragStateService's enums because moving scenery is not an edit: it earns no + * rebuild and no undo entry. + */ + private bgDrag?: { + corner?: BackgroundImageCorner; + /** For a move: model-space offset from the pointer to the picture's centre. */ + grabOffset?: Coord; + /** For a resize: the corner that stays where it is, in model coordinates. */ + anchor?: Coord; + /** For a turn: the angle the hand grabbed at, less the picture's own. */ + grabAngleRad?: number; + }; + + startBackgroundImageMove(event: PointerEvent): void { + const image = this.bgImage.image(); + if (!image || !this.editingBackgroundImage()) return; + event.stopPropagation(); + const at = this.svgGrid.screenToSVGfromXY(event.clientX, event.clientY); + this.bgDrag = { grabOffset: new Coord(image.centerX - at.x, image.centerY - at.y) }; + this.dragState.press(); + this.dragState.beginDraggingBackgroundImage(); + } + + /** + * How far along the picture's own axes a corner sits from its centre: +1 to + * the right and up in the picture's frame, whatever the picture is turned to. + */ + private cornerSigns(corner: BackgroundImageCorner): { alongX: number; alongY: number } { + return { + alongX: corner === 'tr' || corner === 'br' ? 1 : -1, + alongY: corner === 'tl' || corner === 'tr' ? 1 : -1, + }; + } + + startBackgroundImageResize(event: PointerEvent, corner: BackgroundImageCorner): void { + const image = this.bgImage.image(); + if (!image || !this.editingBackgroundImage()) return; + event.stopPropagation(); + // The opposite corner is what the drag pivots on, and it is held in world + // coordinates: the resize moves the centre, and the picture may be turned, + // so an anchor remembered in the picture's own frame would not stay put. + const { alongX, alongY } = this.cornerSigns(corner); + const axes = this.backgroundImageAxes(image); + const halfHeight = this.bgImage.heightOf(image) / 2; + this.bgDrag = { + corner, + anchor: new Coord( + image.centerX - alongX * (image.width / 2) * axes.u.x - alongY * halfHeight * axes.v.x, + image.centerY - alongX * (image.width / 2) * axes.u.y - alongY * halfHeight * axes.v.y + ), + }; + this.dragState.press(); + this.dragState.beginDraggingBackgroundImage(); + } + + startBackgroundImageRotate(event: PointerEvent): void { + const image = this.bgImage.image(); + if (!image || !this.editingBackgroundImage()) return; + event.stopPropagation(); + const at = this.svgGrid.screenToSVGfromXY(event.clientX, event.clientY); + // What the hand grabbed at, less what the picture already is: the drag then + // turns the picture with the hand rather than snapping it to the pointer. + this.bgDrag = { + grabAngleRad: Math.atan2(at.y - image.centerY, at.x - image.centerX) - image.rotationRad, + }; + this.dragState.press(); + this.dragState.beginDraggingBackgroundImage(); + } + + /** The picture's own axes in model coordinates: along its width, and up it. */ + private backgroundImageAxes(image: { rotationRad: number }): { u: Coord; v: Coord } { + const cos = Math.cos(image.rotationRad); + const sin = Math.sin(image.rotationRad); + return { u: new Coord(cos, sin), v: new Coord(-sin, cos) }; + } + + /** + * Follow the pointer for whichever gesture is in flight. Returns whether it + * handled the move, so mouseMove can stop before the mechanism's own drags. + */ + private dragBackgroundImage(at: Coord): boolean { + const image = this.bgImage.image(); + if (!this.bgDrag || !image) return false; + + if (this.bgDrag.grabOffset) { + this.bgImage.place({ + centerX: at.x + this.bgDrag.grabOffset.x, + centerY: at.y + this.bgDrag.grabOffset.y, + }); + return true; + } + + if (this.bgDrag.grabAngleRad !== undefined) { + const turned = + Math.atan2(at.y - image.centerY, at.x - image.centerX) - this.bgDrag.grabAngleRad; + // Quarter turns and the common skews land exactly, which is most of what + // squaring a photograph to a grid actually needs. Alt suspends it, the + // same key that suspends snapping everywhere else on this canvas. + const step = Math.PI / 12; // 15 degrees + this.bgImage.place({ + rotationRad: this.snapSuspended ? turned : Math.round(turned / step) * step, + }); + return true; + } + + const anchor = this.bgDrag.anchor!; + const ratio = image.naturalHeight / image.naturalWidth; + const { alongX, alongY } = this.cornerSigns(this.bgDrag.corner!); + const axes = this.backgroundImageAxes(image); + // How far the pointer is from the anchor along each of the picture's own + // axes. Measuring in the picture's frame is what lets a turned picture + // resize by the corner the hand is actually holding. + const reach = new Coord(at.x - anchor.x, at.y - anchor.y); + const alongWidth = reach.x * axes.u.x + reach.y * axes.u.y; + const alongHeight = reach.x * axes.v.x + reach.y * axes.v.y; + // Whichever axis the hand pulled further decides the size, so the corner + // keeps up with a diagonal drag instead of tracking only one of them. + const width = Math.max(MIN_WIDTH, Math.abs(alongWidth), Math.abs(alongHeight) / ratio); + const height = width * ratio; + // The signs come from which corner is held, not from where the pointer is: + // dragged past its anchor, the picture pins at its minimum rather than + // flipping to the other side of it. + this.bgImage.place({ + width, + centerX: anchor.x + alongX * (width / 2) * axes.u.x + alongY * (height / 2) * axes.v.x, + centerY: anchor.y + alongX * (width / 2) * axes.u.y + alongY * (height / 2) * axes.v.y, + }); + return true; + } + + /** + * The one menu item, doing whichever half applies: pick a file if there is no + * picture yet, otherwise just open the panel on the one there is. + */ + private openBackgroundImage(): void { + this.tabService.setTab(TabID.EDIT); + if (this.bgImage.image()) { + this.activeObjService.selectBackgroundImage(); + return; + } + const input = this.backgroundImageInput().nativeElement; + input.value = ''; + input.click(); + } + + /** Read the chosen file, place it across half the visible grid, and select it. */ + async onBackgroundImageChosen(event: Event): Promise<void> { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file) return; + + try { + // Half the width of what is on screen: big enough to be obviously there, + // small enough that the mechanism under construction is still visible + // around it. Zoom-dependent by design — the picture arrives where the + // user is looking rather than at some fixed size off the edge. + const visibleWidth = this.svgGrid.viewBoxMaxX - this.svgGrid.viewBoxMinX; + await this.bgImage.load(file, visibleWidth / 2); + this.activeObjService.selectBackgroundImage(); + this.notify.success( + 'bgImage.added', + `${file.name} is behind the grid. It is not saved in the share link.` + ); + } catch (error) { + this.notify.failure( + 'bgImage.failed', + error instanceof Error ? error.message : 'That image could not be loaded.' + ); } } @@ -725,7 +795,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 @@ -750,22 +820,16 @@ 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) { + 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. + if ( + (clickedObj instanceof Joint || clickedObj instanceof Link) && + this.mechanismSrv.isPartInert(clickedObj) + ) { + return; + } this.lastLeftClick = clickedObj; - // console.warn('Last Left Click: '); switch (this.objectKind(clickedObj)) { case 'Force': this.lastLeftClickType = 'Force'; @@ -795,9 +859,6 @@ export class NewGridComponent implements OnDestroy { } addJoint() { - // const newJoint = this.createRevJoint() - // const screenX = Number(GridComponent.contextMenuAddTracerPoint.children[0].getAttribute('x')); - // const screenY = Number(GridComponent.contextMenuAddTracerPoint.children[0].getAttribute('y')); // TODO: Make sure you add logic within here so that joint is part of fixedLocations for respective link subset const coord = this.svgGrid.screenToSVGfromXY( this.lastRightClickCoord.x, @@ -807,20 +868,29 @@ export class NewGridComponent implements OnDestroy { this.mechanismSrv.addJointAt(coord); } - createForce() { + /** + * Begin the force gesture on a named link. + * + * Named rather than inferred, because the row that starts this is on the + * joint's menu as well as the link's: a load at a joint belongs to the one + * link that meets there, and the joint is not itself a body that can carry + * one. Where several links meet, the menu greys the row instead of guessing. + */ + createForce(onLink: RealLink) { + this.forceCreateOn = onLink; this.dragState.beginCreatingForce(); // A real Force, built on the link the gesture started from, so the preview // is drawn by the same code as the finished arrow rather than by a line // that only resembles one. Same reason the cylinder gesture previews its // actual members: what is shown is what the next click will make. const at = this.svgGrid.screenToSVG(this.lastRightClickCoord); - this.forceGhost = - this.lastRightClick instanceof RealLink - ? new Force('ghost', this.lastRightClick, at, new Coord(at.x, at.y)) - : undefined; + this.forceGhost = new Force('ghost', onLink, at, new Coord(at.x, at.y)); this.mechanismSrv.onMechUpdateState.next(3); } + /** The link the force being drawn will be anchored to. */ + private forceCreateOn?: RealLink; + /** The force being drawn, tracking the cursor. Undefined when not drawing. */ forceGhost?: Force; @@ -936,9 +1006,10 @@ export class NewGridComponent implements OnDestroy { const link = force.link; const anchor = this.gridUtils.forceAnchorAt(link, wanted, this.settings.objectScale); if (!anchor) { - this.sendNotification( + this.notify.refusal( + 'force.ambiguous-anchor', 'Several links meet at that joint, so a force there would not say which one it acts on.', - 1500 + { cooldownMs: 1500 } ); return; } @@ -1039,48 +1110,43 @@ export class NewGridComponent implements OnDestroy { default: return; } - // const mouseRawPos = this.getMousePosition($event); - // if (mouseRawPos === undefined) { - // return; - // } - // const mousePos = this.screenToGrid(mouseRawPos.x, mouseRawPos.y * -1); - // // TODO: Within future, create a tempJoint and temp Link and set those values as these values in order to avoid - // // TODO: having to call setAttribute and have HTML update for you automatically - // console.log(startCoord); this.linkCreateStart = startCoord; - // this.onMechUpdateState.next(3); } mouseMove($event: MouseEvent) { + this.snapSuspended = $event.altKey; const mousePosInSvg = this.svgGrid.screenToSVGfromXY($event.clientX, $event.clientY); this.lastMouseLocation = this.mouseLocation; this.originInScreen = this.svgGrid.SVGtoScreen(new Coord(0, 0)); this.mouseLocationRaw = new Coord($event.clientX, $event.clientY); this.mouseLocation = mousePosInSvg; + this.svgGrid.cursorAt = mousePosInSvg; this.dragState.notePointerMoved(); + + // The picture is scenery, and its gesture owns the pointer outright: no + // 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; - 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 - ); - } + // The press earned a refusal, and the pointer has now actually tried to + // move the object: say it, once per gesture. + if ( + this.heldGestureNotice && + this.dragState.isPointerDown && + this.canEditNow() && + this.pastDragThreshold($event) + ) { + this.heldGestureNotice(); + this.heldGestureNotice = undefined; } switch (this.dragState.joint) { @@ -1106,30 +1172,17 @@ export class NewGridComponent implements OnDestroy { // does not drag one and deform the other. const wanted = this.snapTargetJoint ? new Coord(this.snapTargetJoint.x, this.snapTargetJoint.y) - : this.mountAxisSnap(draggedCylinders[0], mousePosInSvg); + : this.mountAxisSnap( + draggedCylinders[0], + this.activeObjService.selectedJoint, + mousePosInSvg + ); // Through dragJoint rather than straight at dragCylinderMount, so a // mount two rams share is agreed between them before either moves. this.gridUtils.dragJoint(this.activeObjService.selectedJoint, wanted); - const atMinimum = draggedCylinders.some( - (draggedCylinder) => - this.gridUtils.getPointDistance( - draggedCylinder.barrelFar.x, - draggedCylinder.barrelFar.y, - draggedCylinder.rodFar.x, - draggedCylinder.rodFar.y - ) <= - cylinderMinimumSpan(0.15 * this.settings.objectScale) + 1e-6 - ); - // The mount stops following the cursor at the shortest ram there is, - // and a gesture that stops should say why. Once per drag: this runs - // on every pointermove, and a message repeated sixty times a second - // is noise rather than an explanation. - if (atMinimum && !this.cylinderFloorReported) { - this.cylinderFloorReported = true; - this.sendNotification( - 'That is the shortest cylinder there is — any less and the barrel has no room to slide in.' - ); - } + // Not announced. The mount stops following the cursor at the + // shortest ram there is, and a part that stops moving under your own + // hand has already said so. this.dragState.noteMechanismModified(); this.activeObjService.updateSelectedObj(this.activeObjService.selectedJoint); this.showPathWhileDragging(); @@ -1149,7 +1202,13 @@ export class NewGridComponent implements OnDestroy { ? new Coord(this.snapTargetJoint.x, this.snapTargetJoint.y) : this.slotCandidate ? new Coord(this.slotCandidate.x, this.slotCandidate.y) - : this.alongItsSlot(this.activeObjService.selectedJoint, mousePosInSvg) + : this.alongItsSlot( + this.activeObjService.selectedJoint, + // Last, and only here: a capture is a target the reader + // picked, and a joint on a slot has a line to stay on. The + // grid gets the position nothing else has a claim on. + this.svgGrid.snapToGrid(mousePosInSvg, $event.altKey) + ) ); this.dragState.noteMechanismModified(); //So that the panel values update continously @@ -1165,6 +1224,26 @@ export class NewGridComponent implements OnDestroy { if (!this.canEditNow() || !this.pastDragThreshold($event)) { return; } + // One carried joint is locked: the drag is a swing about it. The body + // turns by however far the cursor's bearing from the pivot changed + // since the last move, so the grabbed point tracks the cursor's angle + // without ever leaving its own radius. + if (this.linkRotationPivot) { + const pivot = this.linkRotationPivot; + const bearing = Math.atan2(mousePosInSvg.y - pivot.y, mousePosInSvg.x - pivot.x); + const theta = bearing - this.linkRotationGrabAngle; + this.linkRotationGrabAngle = bearing; + const swungCylinder = this.mechanismSrv.cylinderAt(this.activeObjService.selectedLink); + if (swungCylinder) { + this.gridUtils.rotateCylinder(swungCylinder, pivot, theta); + } else { + this.gridUtils.rotateLink(this.activeObjService.selectedLink, pivot, theta); + } + this.dragState.noteMechanismModified(); + this.activeObjService.updateSelectedObj(this.activeObjService.selectedLink); + this.showPathWhileDragging(); + break; + } // Measured from where the body was last placed, not from the previous // pointer event: the moves held back below the click threshold would // otherwise be lost motion, leaving the link trailing the cursor by @@ -1172,18 +1251,35 @@ export class NewGridComponent implements OnDestroy { const bodyCylinder = this.mechanismSrv.cylinderAt(this.activeObjService.selectedLink); if (bodyCylinder) { // Dragging the body translates the whole assembly rigidly; the - // mounts are the handles for re-posing. - this.gridUtils.dragCylinder( - bodyCylinder, - mousePosInSvg.x - this.linkDragAnchor.x, - mousePosInSvg.y - this.linkDragAnchor.y - ); + // mounts are the handles for re-posing. It follows the cursor freely, + // the same way a bar does, measured on the mount the ram is named + // from. + const mount = bodyCylinder.barrelFar; + const target = this.placeDraggedBody(mount, mousePosInSvg); + this.gridUtils.dragCylinder(bodyCylinder, target.x - mount.x, target.y - mount.y); + this.linkDragAnchor = mousePosInSvg; + this.dragState.noteMechanismModified(); + this.activeObjService.updateSelectedObj(this.activeObjService.selectedLink); + this.showPathWhileDragging(); + break; } else { + // The body travels by however far the cursor has, measured on the + // joint the link is named from. Measured from where the grab started, + // not from the last frame: one pointer event is a few units, which + // rounds to no move at all, and a link asked frame by frame would sit + // on its corner for ever. + const reference = this.activeObjService.selectedLink.joints[0]; + const landed = this.placeDraggedBody(reference, mousePosInSvg); this.gridUtils.dragLink( this.activeObjService.selectedLink, - mousePosInSvg.x - this.linkDragAnchor.x, - mousePosInSvg.y - this.linkDragAnchor.y + landed.x - reference.x, + landed.y - reference.y ); + this.linkDragAnchor = mousePosInSvg; + this.dragState.noteMechanismModified(); + this.activeObjService.updateSelectedObj(this.activeObjService.selectedLink); + this.showPathWhileDragging(); + break; } this.linkDragAnchor = mousePosInSvg; this.dragState.noteMechanismModified(); @@ -1231,28 +1327,180 @@ export class NewGridComponent implements OnDestroy { } /** - * Whether an edit is allowed right now, notifying the user when it is not. + * Whether an edit is allowed right now. + * * Editing is only defined at the t=0 pose in Edit mode: the solved timesteps * are derived from it, so a change made anywhere else has nothing to write to. + * + * Silently. Each of these used to announce itself, and each was saying + * something the reader could already see — which mode they are in is written + * across the top strip and down the side of the window, and whether the + * animation is running is the thing they are watching. A message for it was + * a fifth place saying the same word. + */ + /** + * The pivot of the current link drag, when one carried joint is locked. + * Set at the grab and read per pointer move; a drag with no locked joint + * clears it, so a stale pivot cannot outlive its gesture. + */ + private linkRotationPivot?: Coord; + private linkRotationGrabAngle = 0; + + /** + * The refusal a press on a held object has earned but not yet been given. + * Spoken only when the pointer actually tries to move — a plain click is a + * selection, and selecting a locked object is the way to its own Unlock + * button, not an offence. Cleared on release. + */ + private heldGestureNotice?: () => void; + + /** + * The padlock the canvas badges wear. Unlike lock.svg's fully hollow + * outline, the body here is solid — at joint size the hollow body read as + * a thin frame and disappeared, and the badge is a mark, not a control. + */ + readonly lockGlyphPath = 'M7 10V7a5 5 0 0 1 10 0v3h2.5v11h-15V10H7Zm2 0h6V7a3 3 0 0 0-6 0v3Z'; + + /** + * Dead centre of the joint, unflipped: the joint layer draws in the y-up + * model frame (scaleY(-1) on the holder), so the badge flips itself back + * to keep the padlock upright. The glyph sits inside the joint's own + * circle, so the circle's fill keeps saying what it always says — cream, + * amber when selected — behind the mark. + */ + lockBadgeTransform(): string { + return `scale(1,-1)`; + } + + /** + * The shoulder position, for badges whose centre spot is already taken: a + * force's anchor is a small dark disc a centred glyph would vanish into, + * and a welded joint's plus-mark is the very thing a centred chip covered. + */ + offsetLockBadgeTransform(): string { + const offset = 0.19 * this.settings.objectScale; + return `translate(${offset}, ${offset}) scale(1,-1)`; + } + + /** Centre the 24-unit glyph on the badge point, sized to the drawing. */ + lockGlyphTransform(): string { + const scale = (0.17 * this.settings.objectScale) / 24; + return `scale(${scale}) translate(-12, -13.5)`; + } + + /** + * The joints a drag of this link would carry, filtered to the ones the + * current Lock marks hold still. Carried means moved *as a body*: the + * link's own joints, the coincident block joints riding them, and a sealed + * cylinder's five. + * + * A floating slider riding this link is not among them, locked or not. Its + * mark holds where it sits along the slot, and moving the link moves the + * slot with the block still at that place on it — so a locked block is no + * reason to refuse the drag, and pivoting the link about one would be + * anchoring a point nothing asked to have held. */ + private frozenCarriedJoints(link: Link): Joint[] { + const carried = new Map<string, Joint>(); + const add = (joint: Joint) => carried.set(joint.id, joint); + const bodyCylinder = this.mechanismSrv.cylinderAt(link); + if (bodyCylinder) { + cylinderJoints(bodyCylinder).forEach(add); + } else { + link.joints.forEach((joint) => { + add(joint); + if (!(joint instanceof RealJoint)) return; + joint.links.forEach((other) => { + if (other instanceof SliderBlock) other.joints.forEach(add); + }); + }); + } + const frozen = this.gridUtils.frozenJointIds(); + return [...carried.values()].filter((joint) => frozen.has(joint.id)); + } + + /** Refuse a joint drag because the joint is held, naming what holds it. */ + private refuseLockedJoint(joint: RealJoint): boolean { + if (!this.gridUtils.isJointFrozen(joint)) return false; + const holds = this.gridUtils.locksHolding(joint); + // A mark on the other half of a block counts as this joint's own: the two + // are the same point, and only one of them has a letter the reader can see + // to unlock. + const block = joint.links.find((link): link is SliderBlock => link instanceof SliderBlock); + const itself = new Set([joint.id, ...(block?.joints.map((member) => member.id) ?? [])]); + const heldByItself = holds.some((lock) => itself.has(lock.id)); + const slider = this.mechanismSrv.sliderFor(joint); + // A locked block has not been pinned to the grid — its slot is free to + // move and will take it along. What it cannot do is slide, so that is what + // the refusal says; "is locked" alone would promise a stillness this mark + // does not buy. + const text = !heldByItself + ? `Joint ${joint.name} is on a locked ${this.lockNoun(holds)}.` + : slider?.isFloating + ? `Slider ${joint.name} is locked to its place in the slot.` + : `Joint ${joint.name} is locked.`; + this.refuseWithUnlock('lock.joint', text, holds); + return true; + } + + private refuseHeldLink(link: Link, held: Joint[]): void { + const holds = this.uniqueLocks(held.flatMap((joint) => this.gridUtils.locksHolding(joint))); + const text = this.mechanismSrv.isLockedTarget(link) + ? this.mechanismSrv.cylinderAt(link) + ? 'This cylinder is locked.' + : `Link ${link.name} is locked.` + : 'Two of the joints this drag would carry are locked. Unlock one to swing the body about the other.'; + this.refuseWithUnlock('lock.link', text, holds); + } + + private refuseLockedForce(force: Force): void { + this.refuseWithUnlock('lock.force', `Force ${force.name} is locked.`, [force]); + } + + /** One refusal, carrying its own way out. */ + private refuseWithUnlock(id: string, text: string, holds: Lockable[]): void { + this.notify.refusal(id, text, { + actions: + holds.length > 0 ? [{ label: 'Unlock', run: () => this.mechanismSrv.unlock(holds) }] : [], + }); + } + + private uniqueLocks(locks: Lockable[]): Lockable[] { + return locks.filter((lock, index) => locks.indexOf(lock) === index); + } + + /** + * What kind of thing holds this joint, for the refusal's sentence. Marks + * live on joints, so the question is what the marked joint *is*: part of a + * sealed cylinder, one of a link's fully-marked joints, or just itself. + */ + private lockNoun(holds: Lockable[]): string { + const joint = holds.find((lock): lock is RealJoint => lock instanceof RealJoint); + if (!joint) return 'object'; + if (this.mechanismSrv.cylinderAt(joint)) return 'cylinder'; + const lockedLink = joint.links.find((link) => this.mechanismSrv.isLockedTarget(link)); + if (lockedLink) return `link ${lockedLink.name}`; + return `joint ${joint.name}`; + } + private canEditNow(): boolean { - if (AnimationBarComponent.animate) { - this.sendNotification(CANNOT_EDIT.animating); + if (this.mechanismSrv.isPlaying) { return false; } if (this.mechanismSrv.mechanismTimeStep !== 0) { - this.sendNotification(CANNOT_EDIT.awayFromStart); return false; } if (this.tabService.getCurrentTab() === TabID.SYNTHESIZE) { - this.sendNotification(CANNOT_EDIT.synthesizeMode); return false; } // Analyze already refuses to open an edit context menu (see onContextMenu), // but dragging bypassed that: the mode was read-only by menu only. Whole-link // drag would have widened the hole, so the guard covers every drag instead. - if (this.tabService.getCurrentTab() === TabID.ANALYZE) { - this.sendNotification(CANNOT_EDIT.analyzeMode); + // + // Both analysis modes, not just the kinematic one. Force analysis reads the + // same solved cycle and is no more able to survive the geometry moving + // under it. + if (this.tabService.isAnalysisMode()) { return false; } return true; @@ -1388,9 +1636,52 @@ export class NewGridComponent implements OnDestroy { return 4 * MARK.barHalf * 0.15 * this.settings.objectScale; } + /** + * Where a whole body being dragged should put its reference point. + * + * The cursor's own displacement, and nothing else. A body drag gets neither + * of the two helps a joint drag gets, and for the same reason in both cases: + * they act on the body's reference joint, which is whichever joint the link + * is named from -- not the point under the hand. So the guide line appeared + * at a corner the reader was not holding, naming a neighbour they had not + * aimed at, and the body jumped a fraction of a grid square sideways to put + * one of its several joints on a corner the others could not reach anyway. + * + * A joint dragged on its own still squares up and still lands on the grid: + * there the thing that snaps is the thing in your hand. + */ + private placeDraggedBody(reference: { x: number; y: number }, cursor: Coord): Coord { + // Measured from the press, not from this first qualifying move: the moves + // held back below the click threshold are still part of the gesture, and + // starting the sum after them leaves the body trailing the cursor by + // however far the hold lasted. + this.bodyDragOrigin ??= { + at: new Coord(reference.x, reference.y), + from: new Coord(this.linkDragAnchor.x, this.linkDragAnchor.y), + }; + const held = this.bodyDragOrigin; + this.axisSnapGuides = []; + return new Coord(held.at.x + (cursor.x - held.from.x), held.at.y + (cursor.y - held.from.y)); + } + /** Lines showing which joints a drag has just squared itself against. */ public axisSnapGuides: SnapGuide[] = []; + /** + * Whether the Option key is down, meaning "no help from the app". + * + * Read at the top of every move rather than passed down: the axis snap is + * reached through `alongItsSlot`, three call sites deep, and a flag set once + * a gesture is easier to keep true than a parameter threaded through all of + * them. + */ + private snapSuspended = false; + + /** Is the app allowed to square this drag up with anything? */ + private alignmentAllowed(): boolean { + return this.settings.isSnapToAlignment.value && !this.snapSuspended; + } + /** * Pull a free drag onto a neighbour's axis when it is nearly on it. * @@ -1399,6 +1690,10 @@ export class NewGridComponent implements OnDestroy { * where the joint goes. */ private withAxisSnap(joint: Joint, wanted: Coord): Coord { + if (!this.alignmentAllowed()) { + this.axisSnapGuides = []; + return wanted; + } const others = this.mechanismSrv .getJoints() .filter((other) => other.id !== joint.id && !(other instanceof PrisJoint)); @@ -1412,8 +1707,20 @@ export class NewGridComponent implements OnDestroy { * they move with the drag, so squaring the mount against them would be the * drag chasing its own tail. Every other joint's H/V guides still work. */ - private mountAxisSnap(sealed: Cylinder, wanted: Coord): Coord { + private mountAxisSnap(sealed: Cylinder, dragged: Joint | undefined, wanted: Coord): Coord { + if (!this.alignmentAllowed()) { + this.axisSnapGuides = []; + return wanted; + } const memberIds = new Set(cylinderJoints(sealed).map((joint) => joint.id)); + // All but the mount at the other end. That one does not move when this one + // is dragged, so it is exactly the thing to square up against -- and + // squaring a ram against its own far mount is what stands it at 0, 90 or + // 180, which is what a bar has always been able to do against its own far + // joint. The rest of the assembly travels with the drag, and squaring + // against those is the drag chasing its own tail. + const opposite = dragged?.id === sealed.barrelFar.id ? sealed.rodFar : sealed.barrelFar; + memberIds.delete(opposite.id); const others = this.mechanismSrv .getJoints() .filter((other) => !memberIds.has(other.id) && !(other instanceof PrisJoint)); @@ -1496,9 +1803,15 @@ export class NewGridComponent implements OnDestroy { return ''; } - /** Shake the dropped joint and say why it could not land where it was aimed. */ - private refuseDrop(jointID: string, message: string): void { - this.sendNotification(message); + /** + * Shake the dropped joint and say why it could not land where it was aimed. + * + * The reason is its own message rather than a shared "cannot drop here", so + * dragging against one rule and then another says both instead of falling + * silent on the second. + */ + private refuseDrop(jointID: string, reason: MergeRefusal): void { + this.notify.refusal(`merge.${reason}`, MERGE_REFUSAL_MESSAGES[reason]); this.shakingJointID = jointID; setTimeout(() => (this.shakingJointID = undefined), 420); } @@ -1510,54 +1823,84 @@ export class NewGridComponent implements OnDestroy { } private showPathWhileDragging(): void { - if (this.mechanismSrv.mechanisms[0].joints[0].length === 0) return; - if (this.mechanismSrv.mechanisms[0].dof !== 1) return; + // The machine being dragged, not whichever was built first — and possibly + // none at all, while a chain is still being drawn and has yet to reach + // ground. + const dragged = this.activeObjService.selectedJoint; + const solved = dragged ? this.mechanismSrv.mechanismContaining(dragged) : undefined; + if (!solved || solved.joints[0].length === 0) return; + if (solved.dof !== 1) return; if (this.mechanismSrv.showPathHolder === false) { this.mechanismSrv.onMechUpdateState.next(1); } this.mechanismSrv.showPathHolder = true; } + /** + * Remember where the pointer was, so the gestures a row starts land there + * and the card can grow from the corner nearest it. + * + * What the menu *contains* was decided by `setLastRightClick`, which the + * element under the pointer fired first — including whether it contains + * anything at all. The menu is open in every mode now: the analysis modes + * get the trace switch and the way back into Edit, and Edit while the + * mechanism is parked mid-cycle greys its editing rows with the reason + * rather than refusing to open. + */ onContextMenu($event: MouseEvent) { - if (this.tabService.getCurrentTab() === TabID.SYNTHESIZE) { - this.sendNotification(CANNOT_EDIT.synthesizeMode); - this.cMenuItems = []; - return; - } - - if (this.tabService.getCurrentTab() === TabID.ANALYZE) { - // Analyze mode is read-only. Show no edit menu; setLastRightClick has - // already declined to change the selection. - this.cMenuItems = []; - return; - } - - if (AnimationBarComponent.animate == true) { - this.sendNotification(CANNOT_EDIT.animating); - this.cMenuItems = []; - return; - } - if (this.mechanismSrv.mechanismTimeStep !== 0) { - this.sendNotification(CANNOT_EDIT.awayFromStart); - this.cMenuItems = []; - //Close the MatContextMenu - // console.log(this.contextMenu); - // this.contextMenu.close(); - return; - } this.lastRightClickCoord.x = $event.clientX; this.lastRightClickCoord.y = $event.clientY; - console.log('context menu'); - console.log(this.lastRightClickCoord); } mouseUp($event: MouseEvent) { //This is the mouseUp that is called no matter what is clicked on - this.synthesisClickMode = SynthesisClickMode.NORMAL; + this.bgDrag = undefined; + 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 = []; - // As does the floor message: the next gesture gets to say it again. - this.cylinderFloorReported = false; + // A press on a held object that never tried to move it is a click — a + // selection, most likely on the way to the panel's own Unlock — and a + // click deserves no scolding. + this.heldGestureNotice = undefined; // Resolve the drop before releasing: the snap target is only meaningful // while the drag it belongs to is still in flight. @@ -1605,7 +1948,7 @@ export class NewGridComponent implements OnDestroy { const source = this.activeObjService.selectedJoint; if (refused?.refusal) { - this.refuseDrop(source.id, MERGE_REFUSAL_MESSAGES[refused.refusal]); + this.refuseDrop(source.id, refused.refusal); return false; } if (!target) { @@ -1621,7 +1964,7 @@ export class NewGridComponent implements OnDestroy { const wasWelded = source.isWelded || target.isWelded; const refusal = this.mechanismSrv.mergeJoints(source, target); if (refusal) { - this.refuseDrop(source.id, MERGE_REFUSAL_MESSAGES[refusal]); + this.refuseDrop(source.id, refusal); return false; } @@ -1629,38 +1972,173 @@ export class NewGridComponent implements OnDestroy { // slider-carrying survivor cannot be welded at all. Losing the weld // silently would leave the user with a linkage they did not ask for. A // merge that goes exactly as asked says nothing: the pop is the receipt. + // A warning, not a refusal: the merge happened, and the linkage the reader + // now has is not quite the one they drew. It waits to be dismissed. if (wasWelded && !target.isWelded) { - this.sendNotification( - `Merged joint ${source.id} into ${target.id}, but ${target.id} cannot be welded` + this.notify.warning( + 'merge.weld-lost', + `Merged ${source.id} into ${target.id}. The weld did not carry over — ${target.id} cannot be welded.` ); } this.popJoint(target.id); 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(); - // console.warn('mouseDown'); - // console.log(typeChosen); - // console.log(thing); - // $event.preventDefault(); - // $event.stopPropagation(); - // this.disappearContext(); + this.synthPressTaken = false; this.dragState.press(); this.startX = $event.pageX; this.startY = $event.pageY; - // console.log(this.startX, this.startY); let joint1: RevJoint; let joint2: RevJoint; let link: RealLink; const mousePosInSvg = this.svgGrid.screenToSVGfromXY($event.clientX, $event.clientY); this.mouseLocation = mousePosInSvg; + this.svgGrid.cursorAt = mousePosInSvg; // Where a link drag measures its offset from. Without anchoring it here the // first move would translate the link by the distance from whatever // unrelated pointer event came last — a jump on grab. this.linkDragAnchor = mousePosInSvg; + this.bodyDragOrigin = undefined; switch ($event.button) { case 0: // Handle Left-Click on canvas @@ -1671,11 +2149,6 @@ export class NewGridComponent implements OnDestroy { this.commitCylinderCreation(mousePosInSvg); break; } - // let clickPos = new Coord($event.pageX, $event.pageY); - // let mousePosInSvg = this.svgGrid.screenToSVG(clickPos); - // console.warn('Mouse down: '); - // console.log(NewGridComponent.isInsideLink(this.mechanismSrv.links[0], mousePosInSvg)); - // console.warn(this.activeObjService.objType); switch (this.lastLeftClickType) { case 'Grid': switch (this.dragState.grid) { @@ -1723,7 +2196,6 @@ export class NewGridComponent implements OnDestroy { this.linkCreateStart = undefined; break; case gridStates.createJointFromLink: - // console.warn('reset position'); //This is werid bug, ensures that when you use a context menu it always counts as a real click instead of a mis-drag this.startY = 9999999; this.startX = 9999999; @@ -1740,7 +2212,13 @@ export class NewGridComponent implements OnDestroy { // Have within constructor other joints so when you add joint, that joint's connected joints also attach joint1.connectedJoints.push(joint2); joint2.connectedJoints.push(joint1); - link = new RealLink(joint1.id + joint2.id, [joint1, joint2]); + // Through the same door every other creation gesture uses: this is + // where a link is given its colour, and the two branches that built + // one directly skipped it. The link came out in RealLink's own + // stand-in grey -- and the ghost the gesture had just drawn was in + // the palette colour it was promised, so the bar changed colour at + // the moment of the click. + link = this.gridUtils.createRealLink(joint1.id + joint2.id, [joint1, joint2]); joint1.links.push(link); joint2.links.push(link); // TODO: Be sure that I think joint1 also changes the link to add the desired joint to it's connected Joints and to its connected Links @@ -1778,19 +2256,17 @@ export class NewGridComponent implements OnDestroy { break; case gridStates.createForce: const startCoord = this.svgGrid.screenToSVG(this.lastRightClickCoord); - // const endCoordRaw = this.getMousePosition($event); const endCoord = this.svgGrid.screenToSVG( new Coord($event.clientX, $event.clientY) ); - this.mechanismSrv.createForce(startCoord, endCoord); + this.mechanismSrv.createForce(startCoord, endCoord, this.forceCreateOn); this.dragState.finishCreating(); this.forceGhost = undefined; + this.forceCreateOn = undefined; break; } break; case 'Joint': - // this.jointXatMouseDown = thing.x; - // this.jointYatMouseDown = thing.y; // Get the joint that was clicked on and top left of the rectangualr bounds switch (this.dragState.grid) { case gridStates.waiting: @@ -1841,7 +2317,10 @@ export class NewGridComponent implements OnDestroy { if (commonLinkCheck) { this.dragState.finishCreating(); this.linkCreateStart = undefined; - this.sendNotification('Those two joints are already on one link.'); + this.notify.refusal( + 'link.already-joined', + 'Those two joints are already on one link.' + ); return; } this.activeObjService.prevSelectedJoint.connectedJoints.push(joint2); @@ -1873,7 +2352,13 @@ export class NewGridComponent implements OnDestroy { // Have within constructor other joints so when you add joint, that joint's connected joints also attach joint1.connectedJoints.push(joint2); joint2.connectedJoints.push(joint1); - link = new RealLink(joint1.id + joint2.id, [joint1, joint2]); + // Through the same door every other creation gesture uses: this is + // where a link is given its colour, and the two branches that built + // one directly skipped it. The link came out in RealLink's own + // stand-in grey -- and the ghost the gesture had just drawn was in + // the palette colour it was promised, so the bar changed colour at + // the moment of the click. + link = this.gridUtils.createRealLink(joint1.id + joint2.id, [joint1, joint2]); joint1.links.push(link); joint2.links.push(link); // TODO: Be sure that I think joint1 also changes the link to add the desired joint to it's connected Joints and to its connected Links @@ -1897,22 +2382,50 @@ export class NewGridComponent implements OnDestroy { break; } switch (this.dragState.joint) { - case jointStates.waiting: + case jointStates.waiting: { + // Decided at the grab, not per pointer move: a locked joint + // never enters the dragging state, so nothing downstream has + // to remember to hold it still. + const grabbed = this.activeObjService.selectedJoint; + if (this.gridUtils.isJointFrozen(grabbed)) { + this.heldGestureNotice = () => this.refuseLockedJoint(grabbed); + break; + } this.dragState.beginDraggingJoint(); - // this.selectedJoint = thing; break; + } } break; case 'Link': if (this.dragState.isCreatingLink) { - this.sendNotification( - 'Cannot link to a bar. Please create and select a tracer point on the link.' + this.notify.refusal( + 'link.needs-a-joint', + 'A link joins two joints. Add a tracer point to that bar, then draw to it.' ); this.dragState.cancel(); this.linkCreateStart = undefined; break; } if (this.dragState.link === linkStates.waiting) { + // Of the joints this drag would carry: none locked moves freely, + // exactly one turns the drag into a swing about that joint — + // the only motion the linkage would allow if the pin were + // bolted down — and two or more leave the body nowhere to go. + const held = this.frozenCarriedJoints(this.activeObjService.selectedLink); + if (held.length >= 2) { + const grabbedLink = this.activeObjService.selectedLink; + this.heldGestureNotice = () => this.refuseHeldLink(grabbedLink, held); + break; + } + if (held.length === 1) { + this.linkRotationPivot = new Coord(held[0].x, held[0].y); + this.linkRotationGrabAngle = Math.atan2( + mousePosInSvg.y - held[0].y, + mousePosInSvg.x - held[0].x + ); + } else { + this.linkRotationPivot = undefined; + } this.dragState.beginDraggingLink(); } break; @@ -1920,6 +2433,11 @@ export class NewGridComponent implements OnDestroy { console.log('force is last left click'); switch (this.dragState.force) { case forceStates.waiting: + if (this.activeObjService.selectedForce.locked) { + const grabbedForce = this.activeObjService.selectedForce; + this.heldGestureNotice = () => this.refuseLockedForce(grabbedForce); + break; + } if (this.activeObjService.selectedForce.isStartSelected) { this.dragState.beginDraggingForceStart(); } else if (this.activeObjService.selectedForce.isEndSelected) { @@ -1953,27 +2471,6 @@ export class NewGridComponent implements OnDestroy { } } - static sendNotification(text: string, rateLimitMS?: number) { - // Services reach the snackbar through here, and they are also exercised in - // tests with no component standing. A missing canvas means nobody is - // looking, not that the caller did something wrong. - NewGridComponent.instance?.sendNotification(text, rateLimitMS); - } - - sendNotification(text: string, rateLimitMS?: number) { - rateLimitMS = rateLimitMS || 1000; //Default to 1 second - //If there is more than one notification in the last seccond, ingore all but the first - if (this.lastNotificationTime + rateLimitMS < Date.now()) { - this.lastNotificationTime = Date.now(); - this.snackBar.open(text, '', { - panelClass: 'my-custom-snackbar', - horizontalPosition: 'center', - verticalPosition: 'top', - duration: 4000, - }); - } - } - debug() { console.log('debug'); } @@ -1986,12 +2483,12 @@ export class NewGridComponent implements OnDestroy { } getFirstPosCoords(link: Link) { - if (this.mechanismSrv.oneValidMechanismExists()) { - const jointIndex = this.mechanismSrv.joints.indexOf(link.joints[0]); - return this.mechanismSrv.mechanisms[0].joints[0][jointIndex]; - } else { - return link.joints[0]; - } + // The link's own machine, and its joint found by name. Indexing another + // mechanism's frames by this drawing's array position would label the link + // at some unrelated joint's coordinates. + const solved = this.mechanismSrv.mechanismContaining(link); + const anchor = link.joints[0]; + return solved?.joints[0]?.find((candidate) => candidate.id === anchor.id) ?? anchor; } getFirstXPos(link: Link) { @@ -2016,22 +2513,30 @@ export class NewGridComponent implements OnDestroy { get sliderMarkList(): SliderMark[] { const marks = this.freshMarks().marks; - // A slider dragged over a valid slot previews the accepted state: the block - // turns to the slot's own angle and the red nowhere-to-slide highlight goes - // out while the drop would land. Without this the preview showed the cut - // opening in the bar while the block stayed unturned and red — the channel - // said yes and the block said no about the same release. Presentation only: - // the joint itself is not reseated until the drop commits (cutSlotOn). const slot = this.slotCandidate; if (!slot || this.dragState.joint !== jointStates.dragging) return marks; const pinID = this.activeObjService.selectedJoint?.id; if (!pinID) return marks; const slotAngleDeg = (Math.atan2(slot.b.y - slot.a.y, slot.b.x - slot.a.x) * 180) / Math.PI; - return marks.map((mark) => - mark.pin.id === pinID ? { ...mark, rotation: slotAngleDeg, dangling: false } : mark - ); + // Re-framed rather than re-stamped: turning the frame alone turns the + // riders with it, and this getter is read several times a change and once + // per pointer move, so the turned geometry is kept until the angle moves. + return marks.map((mark) => { + if (mark.pin.id !== pinID) return mark; + if (this.previewMark?.key !== `${pinID}|${slotAngleDeg}` || this.previewMark.from !== mark) { + this.previewMark = { + key: `${pinID}|${slotAngleDeg}`, + from: mark, + mark: { ...this.sliderMarks.reframed(mark, slotAngleDeg), dangling: false }, + }; + } + return this.previewMark.mark; + }); } + /** The one re-framed mark of a drop preview, kept while its angle holds. */ + private previewMark?: { key: string; from: SliderMark; mark: SliderMark }; + get channelList(): Channel[] { return this.freshMarks().channels; } @@ -2061,26 +2566,30 @@ export class NewGridComponent implements OnDestroy { */ private guides(): Map<string, Guide> { const found = new Map<string, Guide>(); - const mechanism = this.mechanismSrv.mechanisms[0]; - const frames = mechanism?.isMechanismValid() ? mechanism.joints : undefined; - if (!frames?.length) return found; - - const rest = frames[0]; - for (const joint of rest) { - if (!(joint instanceof PrisJoint) || !joint.ground) continue; - const angle = joint.slotAngle; - const cos = Math.cos(angle); - const sin = Math.sin(angle); - let lo = 0; - let hi = 0; - for (const frame of frames) { - const at = frame.find((member) => member.id === joint.id); - if (!at) continue; - const along = (at.x - joint.x) * cos + (at.y - joint.y) * sin; - lo = Math.min(lo, along); - hi = Math.max(hi, along); + // Every machine in the drawing, because a rail belongs to whichever one + // slides along it. Reading one mechanism's frames would leave the guides of + // every other linkage undrawn. + for (const mechanism of this.mechanismSrv.mechanisms) { + const frames = mechanism?.isMechanismValid() ? mechanism.joints : undefined; + if (!frames?.length) continue; + + const rest = frames[0]; + for (const joint of rest) { + if (!(joint instanceof PrisJoint) || !joint.ground) continue; + const angle = joint.slotAngle; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + let lo = 0; + let hi = 0; + for (const frame of frames) { + const at = frame.find((member) => member.id === joint.id); + if (!at) continue; + const along = (at.x - joint.x) * cos + (at.y - joint.y) * sin; + lo = Math.min(lo, along); + hi = Math.max(hi, along); + } + found.set(joint.id, { x: joint.x, y: joint.y, lo, hi }); } - found.set(joint.id, { x: joint.x, y: joint.y, lo, hi }); } return found; } @@ -2208,6 +2717,171 @@ export class NewGridComponent implements OnDestroy { this.cylinderRangeOverlay = which; } + /** + * The centre-of-mass distance being pointed at in the Edit panel, drawn + * where it is measured: from the chosen frame's zero, along one axis, to + * the link's centre of mass. The panel hands the points over because the + * frame choice lives there, not here. + */ + comMeasure?: { + axis: 'x' | 'y'; + origin: { x: number; y: number }; + com: { x: number; y: number }; + /* 'origin' measures along the frame origin's own line with a dashed run + up to the mark; 'axis' (the global-grid frame) measures at the CoM's + height straight to the grid's axis line, which needs no connector. */ + mode: 'origin' | 'axis'; + }; + + setComMeasureOverlay(measure: NewGridComponent['comMeasure']): void { + this.comMeasure = measure; + } + + /** + * The selected link's CoM mark is a handle, not just a glyph: drag it to + * place a custom centre of mass, exactly as typing in the panel's X/Y + * would. Only the selected link's mark — a whole canvas of grabbable + * marks would fight the links they sit on for every click. + */ + comDraggable(link: Link): boolean { + return ( + link instanceof RealLink && + this.activeObjService.objType === 'Link' && + this.activeObjService.selectedLink === link && + !this.mechanismSrv.cylinderAt(link) && + this.canEditNow() + ); + } + + /** + * Where the shape's own centre sits, for a link whose CoM was placed + * elsewhere: the custom mark is defined as an offset from this point, so + * the point deserves to be visible while the mark is off wandering. + */ + comCentroidDot(link: Link): { x: number; y: number } | null { + if (!(link instanceof RealLink) || !link.comIsCustom) return null; + const centroid = uniformBodyOf(link.joints).centroid; + if (Math.hypot(centroid.x - link.CoM.x, centroid.y - link.CoM.y) < 1e-6) return null; + return centroid; + } + + /** Live while a CoM mark rides the pointer; the ring stays lit through it. */ + draggingCoMLink?: RealLink; + + startComDrag(link: RealLink, event: PointerEvent): void { + event.stopPropagation(); + event.preventDefault(); + this.draggingCoMLink = link; + const move = (e: PointerEvent) => { + const pos = this.svgGrid.screenToSVGfromXY(e.clientX, e.clientY); + const placed = e.altKey ? pos : this.snapComToJointLines(link, pos); + link.placeCustomCoM({ x: placed.x, y: placed.y }); + // So the panel's X/Y read the drag as it happens, like a force's do. + this.activeObjService.fakeUpdateSelectedObj(); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + this.draggingCoMLink = undefined; + // One undo step for the whole gesture, then the panel re-reads its + // fields the same way a unit change makes it re-read them. + this.mechanismSrv.updateMechanism(true); + this.mechanismSrv.onMechUpdateState.next(2); + this.activeObjService.fakeUpdateSelectedObj(); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + } + + /** + * A dragged centre snaps to the lines a reader would put it on: the + * centreline of a bar, each side of a triangle — every joint-pair segment + * of the link. Alt suspends it, like every other snap on the canvas. + */ + private snapComToJointLines(link: RealLink, pos: Coord): { x: number; y: number } { + const joints = link.joints; + let best: { x: number; y: number } | undefined; + let bestDist = this.svgGrid.scaleWithZoom(12); + // The centroid outranks the lines: it is the point the whole feature is + // an offset from, and a pointer near it means it exactly. + const centroid = uniformBodyOf(joints).centroid; + if (Math.hypot(pos.x - centroid.x, pos.y - centroid.y) < bestDist) { + return centroid; + } + for (let i = 0; i < joints.length; i++) { + for (let j = i + 1; j < joints.length; j++) { + const a = joints[i]; + const b = joints[j]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSq = dx * dx + dy * dy; + if (!(lengthSq > 0)) continue; + const t = Math.max(0, Math.min(1, ((pos.x - a.x) * dx + (pos.y - a.y) * dy) / lengthSq)); + const proj = { x: a.x + t * dx, y: a.y + t * dy }; + const dist = Math.hypot(pos.x - proj.x, pos.y - proj.y); + if (dist < bestDist) { + bestDist = dist; + best = proj; + } + } + } + return best ?? pos; + } + + /** Which cylinder part's mass field is being pointed at in the panel. */ + cylinderPartPreview?: 'barrel' | 'rod' | 'head'; + + setCylinderPartPreview(part: NewGridComponent['cylinderPartPreview']): void { + this.cylinderPartPreview = part; + } + + /** The pointed-at part's own outline, in the hover accent — barrel and rod + * by their skins, the piston head by the block that draws it. */ + cylinderPartPreviewPath(cyl: CylinderMark): string | null { + if (!this.cylinderPartPreview || !this.isBodySelected(cyl)) return null; + if (this.cylinderPartPreview === 'barrel') return cyl.barrel; + if (this.cylinderPartPreview === 'rod') return cyl.rod; + return cyl.block; + } + + /** The measured stretch: the frame's zero to the CoM's coordinate on one axis. */ + comMeasureLine(m: NonNullable<NewGridComponent['comMeasure']>) { + if (m.mode === 'axis') { + const to = m.axis === 'x' ? { x: 0, y: m.com.y } : { x: m.com.x, y: 0 }; + return { from: { x: m.com.x, y: m.com.y }, to }; + } + const to = m.axis === 'x' ? { x: m.com.x, y: m.origin.y } : { x: m.origin.x, y: m.com.y }; + return { from: m.origin, to }; + } + + /** End caps like the length overlay's: short bars across the line. */ + comMeasureCaps(m: NonNullable<NewGridComponent['comMeasure']>): string { + const { from, to } = this.comMeasureLine(m); + const t = SettingsService.objectScale / 7; + return m.axis === 'x' + ? `M${from.x} ${from.y - t} L${from.x} ${from.y + t} M${to.x} ${to.y - t} L${to.x} ${to.y + t}` + : `M${from.x - t} ${from.y} L${from.x + t} ${from.y} M${to.x - t} ${to.y} L${to.x + t} ${to.y}`; + } + + /** A dashed run from the line's end to the mark it measures to. */ + comMeasureConnector(m: NonNullable<NewGridComponent['comMeasure']>): string { + if (m.mode === 'axis') return ''; + const { to } = this.comMeasureLine(m); + return `M${to.x} ${to.y} L${m.com.x} ${m.com.y}`; + } + + comMeasureLabelPos(m: NonNullable<NewGridComponent['comMeasure']>) { + const { from, to } = this.comMeasureLine(m); + const off = 0.25 * this.settings.objectScale; + return m.axis === 'x' + ? { x: (from.x + to.x) / 2, y: from.y + off } + : { x: from.x + off, y: (from.y + to.y) / 2 }; + } + + comMeasureValue(m: NonNullable<NewGridComponent['comMeasure']>): number { + return m.axis === 'x' ? Math.abs(m.com.x - m.origin.x) : Math.abs(m.com.y - m.origin.y); + } + /** * The stretch of ground the rod's mount covers, drawn on the canvas. * @@ -2338,12 +3012,31 @@ export class NewGridComponent implements OnDestroy { ); } - /** Whether the selection is this cylinder's body, however it was selected. */ + /** + * Whether the selection is this cylinder's body, however it was selected. + * + * Including by selecting the whole machine it belongs to: the cylinder is + * drawn as one part by its own skin rather than through the link classes, so + * it was the one body a machine-wide selection left unlit. + */ isBodySelected(mark: CylinderMark): boolean { - return ( + if ( this.activeObjService.objType === 'Link' && this.activeObjService.selectedLink?.id === mark.body.id - ); + ) { + return true; + } + return this.mechanismSrv.isPartInSelectedMechanism(mark.body); + } + + /** The reader is pointing at this cylinder's machine in the transport. */ + isBodyHovered(mark: CylinderMark): boolean { + return this.mechanismSrv.isPartInHoveredMechanism(mark.body) || this.isBodyPointedAt(mark); + } + + /** Or at this ram itself, from a list that offers it as one part. */ + isBodyPointedAt(mark: CylinderMark): boolean { + return this.mechanismSrv.isPointedAtBody(mark.body); } /** @@ -2480,6 +3173,242 @@ export class NewGridComponent implements OnDestroy { return !!sealed && link.id !== sealed.barrel.id; } + /** Nothing on the canvas can be moved in an analysis mode. */ + get geometryLocked(): boolean { + return this.tabService.isAnalysisMode(); + } + + /** + * The ink the "this is not attached to anything" marks are drawn in. + * + * Red on the canvas means "fix this", which is a thing to do in Edit. In an + * analysis mode the same geometry is scenery -- greyed out, not analysed, not + * even selectable -- so a red ring around it is the loudest thing on a canvas + * about something the reader cannot act on and did not ask about. It goes + * grey with the rest of the body it marks. + */ + /** + * The colour one joint is drawn in, or nothing for the family they all share. + * + * A family is three colours, one per state, so this follows the state rather + * than standing aside from it -- that is what keeps a joint reading as one + * object resting, pointed at and picked. Amber returns nothing at all: it is + * what the stylesheet already draws, so a drawing nobody has coloured is + * drawn by exactly the rules it always was. + * + * The greyed-out analysis state is the one exception. Scenery is scenery + * whatever colour it would otherwise be. + */ + jointFillOf(joint: Joint): string | null { + if (!joint.colorFamily) return null; + const state = this.mechanismSrv.getJointCSSClass(joint); + if (state.includes('joint-inert')) return null; + const family = this.colors.jointFamily(joint.colorFamily); + if (state.includes('joint-selected')) return family.selected; + if (state.includes('joint-highlight') || state.includes('joint-dragging')) return family.hover; + return family.normal; + } + + /** + * The ring a selected joint wears inside its own edge, if it needs one. + * + * Amber is what "picked" means everywhere in the app, and a joint in another + * family keeps its own colour rather than borrowing it -- so the fill says + * which joint this is and the ring says it is the selected one. A joint + * already drawn in amber needs no ring: its picked colour is the ring colour. + */ + selectionRingOn(joint: Joint): string | null { + if (!joint.colorFamily) return null; + return this.mechanismSrv.getJointCSSClass(joint).includes('joint-selected') + ? SELECTION_RING + : null; + } + + /** How wide that ring is drawn, in screen pixels however far this is zoomed. */ + selectionRingWidth(): number { + return this.svgGrid.scaleWithZoom(SELECTION_RING_PX); + } + + /** + * The padlock's ink on a given joint. + * + * The badge is drawn on the joint rather than beside it, so on a dark one the + * default near-black glyph disappears into the pin it is sitting on. A welded + * joint brings its own white chip and the glyph stands on that instead. + */ + lockInkOn(joint: Joint): string | null { + const fill = this.jointFillOf(joint); + if (!fill || this.gridUtils.getWelded(joint)) return null; + return luminanceOf(fill) > INK_FLIPS_AT ? '#263238' : '#eceff1'; + } + + /** + * The colour one force is drawn in -- line, arrowhead and anchor together. + * + * Nothing in an analysis mode where the force is scenery: a load on a body + * this analysis has nothing to say about is greyed with the body, and a + * colour somebody chose is still a louder thing than the machine being + * analysed. + */ + forceInkOf(force: Force): string | null { + if (this.mechanismSrv.isPartInert(force.link)) return null; + return force.color || DEFAULT_FORCE_COLOR; + } + + get orphanMarkInk(): string { + return this.tabService.isAnalysisMode() ? '#b6bac6' : '#F44336'; + } + + /** The pale fill inside that mark, likewise. */ + get orphanMarkFill(): string { + return this.tabService.isAnalysisMode() ? '#d9dbe2' : '#c5cae9'; + } + + /** + * How big a name on the canvas is drawn, for joints, links and forces alike. + * + * A fraction of the object scale rather than a pixel size, so a name keeps + * its proportion to the part it names at every zoom level. + */ + get tagFontSize(): number { + return this.settings.objectScale * 0.2; + } + + /** + * The angle a link's name is written at, in degrees clockwise from flat. + * + * A bar is long and thin, and a name longer than a couple of letters written + * across it hangs off both edges. Written along it, it has the whole bar to + * sit in. Only bars: a compound body is not a direction, and a two-letter + * name already fits, so both stay level and stay easy to read. + * + * Kept within a quarter turn of flat, so the name is never upside down. + */ + private linkLabelAngle(link: Link, name: string): number { + if ((link.joints?.length ?? 0) !== 2) return 0; + if (name.length <= 2) return 0; + const [from, to] = link.joints; + // Screen degrees: y runs down here, and the label sits in a frame that has + // already flipped it, so the rise is negated. + let angle = (Math.atan2(-(to.y - from.y), to.x - from.x) * 180) / Math.PI; + if (angle > 90) angle -= 180; + if (angle < -90) angle += 180; + return Math.round(angle * 10) / 10; + } + + /** + * Whether this link's centre-of-mass mark is on screen right now. + * + * The view toggle governs it, but a massless link is excused: mass starts at + * zero until someone chooses one, and a centre-of-mass mark on a link with no + * mass points at a property the link does not have. The preview (hovering + * the analysis panel's centre-of-mass heading) still shows it, because there + * the reader is asking about exactly that property. + */ + showsCoM(link: Link): boolean { + if (this.settings.previewCoMLinkId === link.id) return true; + // A slider block carries mass but has no centre-of-mass mark to draw -- + // getLinkProp declines it -- so asking for one drew four undefined + // quarters. The rule is a body with a mass, and a block is not one of the + // bodies this mark is about. + return this.settings.isShowCOM.value && link instanceof RealLink && link.mass > 0; + } + + /** + * Where a link's name goes, and in what ink. + * + * The anchor is the average of the joints the link is made of, which is + * inside the hull they describe -- and the body is drawn around that hull, so + * it is inside the body. Not the centre of mass, which is a physical property + * with a field of its own in the Edit panel: a link told its mass sits out at + * one end is a link whose name was written off the metal. + * + * Two bodies are not their own hull. A welded link is the Boolean union of + * its parts and can be any shape at all -- an L has nothing in the crook -- + * so the name goes in the middle of its biggest part, which is inside the + * union because that part is. And a bar carrying a slot is drawn as a rail + * with a channel down it, so the name goes in the channel, in full black: + * there is no body colour behind it there to be read against. + */ + linkLabelStyle(link: Link): { + x: number; + y: number; + ink: string; + opacity: number; + name: string; + angle: number; + } { + const name = this.linkDisplayName(link); + const angle = this.linkLabelAngle(link, name); + const slot = this.slotCarriedBy(link); + if (slot) { + const [from, to] = [slot.slotJointA!, slot.slotJointB!]; + return { + x: (from.x + to.x) / 2, + y: (from.y + to.y) / 2, + ink: 'black', + opacity: 1, + name, + angle, + }; + } + const parts = (link instanceof RealLink ? link.subset : []) ?? []; + const middleOf = (of: Link) => { + const joints = of.joints ?? []; + if (joints.length === 0) return { x: 0, y: 0 }; + return { + x: joints.reduce((total, joint) => total + joint.x, 0) / joints.length, + y: joints.reduce((total, joint) => total + joint.y, 0) / joints.length, + }; + }; + const span = (part: Link) => { + const first = part.joints[0]; + const last = part.joints[part.joints.length - 1]; + return first && last ? Math.hypot(last.x - first.x, last.y - first.y) : 0; + }; + const on = + parts.length === 0 + ? link + : parts.reduce((best, part) => (span(part) > span(best) ? part : best)); + return { ...middleOf(on), ink: this.linkLabelInk(link), opacity: 0.55, name, angle }; + } + + /** The slot this bar carries, if it is a plain bar carrying one. */ + private slotCarriedBy(link: Link): PrisJoint | undefined { + if ((link.joints?.length ?? 0) !== 2) return undefined; + return this.mechanismSrv.joints.find( + (joint): joint is PrisJoint => + joint instanceof PrisJoint && + joint.carrier?.id === link.id && + !!joint.slotJointA && + !!joint.slotJointB + ); + } + + /** + * Black or white, whichever the link's own colour can be read against. + * + * The label sits on the body it names, and the bodies run from pale mint to + * navy, so one ink cannot serve them all. Relative luminance decides it, the + * same rule a contrast checker uses. + */ + linkLabelInk(link: Link): string { + // A body the analysis modes have nothing to say about is drawn in one pale + // grey whatever colour it was given, so its name is read against that grey + // rather than against the colour it no longer wears. A dark link's white + // name went invisible the moment the body went grey under it. + if (this.mechanismSrv.isPartInert(link)) return 'black'; + const fill = (link as { fill?: string }).fill ?? '#ffffff'; + const hex = fill.replace('#', ''); + if (hex.length < 6) return 'black'; + const channel = (at: number) => { + const value = parseInt(hex.slice(at, at + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + const luminance = 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4); + return luminance > 0.45 ? 'black' : 'white'; + } + linkDisplayName(link: Link): string { const sealed = this.mechanismSrv.cylinderAt(link); if (!sealed) return link.name; @@ -2501,6 +3430,14 @@ export class NewGridComponent implements OnDestroy { ); } + /** + * Whether the link layer draws this body with holes in it. A skinned or + * plated link is drawn somewhere else, and its holes are handed back there. + */ + hasChannelHit(link: Link): boolean { + return this.channelCountOn(link) > 0 && !this.skinnedLink(link) && !this.platedLink(link); + } + channelCountOn(link: Link): number { return ( this.channelList.filter((channel) => channel.carrierId === link.id).length + @@ -2566,20 +3503,28 @@ export class NewGridComponent implements OnDestroy { // its own edge inside that outline and double the fill's alpha over itself. if (this.platedLink(link)) return ''; const outline = this.outlineWithMotor(link); + const channels = this.channelsCutInto(link); + + return channels === '' ? outline : `${outline} ${channels}`; + } + + /** + * The channels cut into this link, as one path in the world frame. + * + * The slot being previewed goes through the same subtraction a committed one + * does, rather than being drawn as a stand-in on top. Two reasons: the hover + * state is then pixel-identical to the result, and a real hole has no + * legibility to lose against a link colour it cannot predict -- every + * stand-in considered (a white fill, an outline, an amber highlight) fell + * below contrast on part of the palette, because the palette is random. + */ + channelsCutInto(link: Link): string { const paths = this.channelList .filter((channel) => channel.carrierId === link.id) .map((channel) => channel.path); - - // The slot being previewed goes through the same subtraction a committed - // one does, rather than being drawn as a stand-in on top. Two reasons: the - // hover state is then pixel-identical to the result, and a real hole has no - // legibility to lose against a link colour it cannot predict -- every - // stand-in considered (a white fill, an outline, an amber highlight) fell - // below contrast on part of the palette, because the palette is random. const preview = this.previewChannelOn(link); if (preview) paths.push(preview); - - return paths.length === 0 ? outline : `${outline} ${mergedChannels(paths)}`; + return paths.length === 0 ? '' : mergedChannels(paths); } /** @@ -2603,6 +3548,20 @@ export class NewGridComponent implements OnDestroy { ): string { const preview = this.previewChannelOn(link); if (!preview) return piece.path; + return `${piece.outline} ${this.markChannelsCutInto(mark, piece, link)}`.trim(); + } + + /** + * The same channels on their own, in the mark's frame: what the piece's `d` + * has taken out of itself, for the hit shape that hands the holes back. + */ + markChannelsCutInto( + mark: SliderMark, + piece: { outline: string; cuts: string[] }, + link: Link + ): string { + const preview = this.previewChannelOn(link); + if (!preview) return piece.cuts.length === 0 ? '' : mergedChannels(piece.cuts); const angle = (mark.rotation * Math.PI) / 180; const localPreview = transformRigidPath( preview, @@ -2611,7 +3570,7 @@ export class NewGridComponent implements OnDestroy { { x: 0, y: 0 }, { x: 1, y: 0 } ); - return `${piece.outline} ${mergedChannels([...piece.cuts, localPreview])}`.trim(); + return mergedChannels([...piece.cuts, localPreview]); } private previewChannelOn(link: Link): string | undefined { @@ -2711,46 +3670,92 @@ export class NewGridComponent implements OnDestroy { } // Angular keys host listeners by event name, so this component gets exactly - // one window:keydown. Anything that needs the key down hangs off here. + // one window:keydown. Alt is the only key the canvas reads for itself: it + // means something only while a drag is in flight, which is state no registry + // outside this component can see. Every other key is a shortcut, and those + // are declared once in KeyboardShortcutsService and answered below. @HostListener('window:keydown', ['$event']) onKeyPress($event: KeyboardEvent) { this.reconsiderDrop($event, true); + } - if (($event.ctrlKey || $event.metaKey) && $event.keyCode == 90) { - //Ctrl + Z - NewGridComponent.sendNotification( - 'You attempted to undo. What were you trying to undo? Please let us know through the report button in the help section.' - ); + /** Answer the shortcuts whose action is the canvas's own. */ + private onShortcut(id: ShortcutId): void { + switch (id) { + case 'edit.deselect': + this.activeObjService.updateSelectedObj(undefined); + return; + case 'edit.delete': + // Every key that changes the drawing is held outside Edit, not just + // the two that had the check. An analysis mode is a reading of a + // finished mechanism: it hides the lock marks, greys the panels and + // takes Undo away -- and Delete was still going through, removing a + // joint from a drawing the reader was in the middle of measuring, with + // no way back short of leaving the mode. + if (this.tabService.isAnalysisMode()) return; + this.deleteSelection(); + return; + case 'edit.lock': + if (this.tabService.isAnalysisMode()) return; + this.toggleLockOnSelection(); + return; + case 'history.undo': + case 'history.redo': + // Nothing to take back in the analysis modes, which is why the buttons + // are not there either. + if (this.tabService.isAnalysisMode()) return; + if (id === 'history.redo') { + this.saveHistoryService.redo(); + } else { + this.saveHistoryService.undo(); + } + return; } + } - if ($event.keyCode == 27) { - //Escape Key - // NewGridComponent.sendNotification( - // 'You pressed the "Escape" key. What were you trying to do and in what context? (This is an Easter Egg. Please talk about in the final question of the survey.)' - // ); - this.activeObjService.updateSelectedObj(undefined); + /** + * Lock or unlock whatever is selected -- the same act as the panel's own + * Lock button, which is the only other way to do it. + * + * With nothing selected the key has no target, and says so rather than doing + * nothing: a lock is not a state the grid as a whole can be in. + */ + private toggleLockOnSelection(): void { + const selected = this.activeObjService.getSelectedObj(); + if ( + !(selected instanceof RealJoint) && + !(selected instanceof Link) && + !(selected instanceof Force) + ) { + this.notify.refusal( + 'lock.nothing-selected', + 'Select something first — Lock holds whatever is selected.' + ); + return; } + this.mechanismSrv.toggleLock(selected); + } - if ($event.keyCode == 46) { - //Delete Key - if (true) { - //TODO: Sorry jacob you need to fix this it used to say: if(GridComponent.canDelete) - if (this.activeObjService.objType === 'Grid') { - NewGridComponent.sendNotification( - 'Select something first — Delete removes whatever is selected.' - ); - return; - } - if (this.activeObjService.objType === 'Joint') { - this.mechanismSrv.deleteJoint(); - } else if (this.activeObjService.objType === 'Link') { - this.mechanismSrv.deleteLink(); - } - this.activeObjService.updateSelectedObj(undefined); - } else { - return; - } + private deleteSelection(): void { + if (this.activeObjService.objType === 'Grid') { + this.notify.refusal( + 'delete.nothing-selected', + 'Select something first — Delete removes whatever is selected.' + ); + return; + } + if (this.activeObjService.objType === 'Joint') { + this.mechanismSrv.deleteJoint(); + } else if (this.activeObjService.objType === 'Link') { + this.mechanismSrv.deleteLink(); + } else if (this.activeObjService.objType === 'Force') { + // A force is a thing on the drawing with a Delete of its own in its + // menu, so the key that says "delete what is selected" has to mean it + // here too. Left out, the key cleared the selection and left the arrow + // standing -- which reads as a delete that did not take. + this.mechanismSrv.deleteForce(); } + this.activeObjService.updateSelectedObj(undefined); } returnDebugValue() { diff --git a/src/app/component/not-ready-warning/not-ready-warning.component.ts b/src/app/component/not-ready-warning/not-ready-warning.component.ts index 30c3381a..3d9e1cec 100644 --- a/src/app/component/not-ready-warning/not-ready-warning.component.ts +++ b/src/app/component/not-ready-warning/not-ready-warning.component.ts @@ -5,6 +5,5 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; templateUrl: './not-ready-warning.component.html', styleUrls: ['./not-ready-warning.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, }) export class NotReadyWarningComponent {} diff --git a/src/app/component/notification/notification.component.html b/src/app/component/notification/notification.component.html new file mode 100644 index 00000000..0b5716ad --- /dev/null +++ b/src/app/component/notification/notification.component.html @@ -0,0 +1,30 @@ +<div class="notificationStack"> + @for (one of notifications.live; track one.key) { + <div + class="notification notification--{{ one.kind }}" + [@card] + role="status" + [attr.aria-live]="politenessFor(one)" + > + <mat-icon class="notificationIcon">{{ iconFor(one) }}</mat-icon> + <span class="notificationText">{{ one.text }}</span> + <!-- Where there is an obvious thing to do about it, the message does it, + rather than naming a control elsewhere for the reader to go and find. --> + @for (action of one.actions; track action.label) { + <button class="notificationAction" type="button" (click)="notifications.act(one, action)"> + {{ action.label }} + </button> + } + <!-- On every kind, not only the ones that wait. A message that has been + read is in the way, and four seconds is long enough to be in the way. --> + <button + class="notificationClose" + type="button" + (click)="notifications.dismiss(one.key)" + aria-label="Dismiss" + > + <mat-icon>close</mat-icon> + </button> + </div> + } +</div> diff --git a/src/app/component/notification/notification.component.scss b/src/app/component/notification/notification.component.scss new file mode 100644 index 00000000..a2835704 --- /dev/null +++ b/src/app/component/notification/notification.component.scss @@ -0,0 +1,151 @@ +@use 'sass:map'; +@use '@angular/material' as mat; +@use 'src/app/component/left-tabs/left-tabs.vars.scss' as vars; + +/** Width of the accent, and therefore the corner radius. See below. */ +$accent: 5px; + +@mixin css($theme) { + // Which colour each kind is written in. Only the accent and the glyph are + // coloured -- the sentence stays the app's text colour, because a red + // sentence is harder to read and the glyph has already said which of the + // four this is. + $kinds: ( + 'success': #2e7d32, + 'refusal': #455a64, + 'warning': #ef6c00, + 'failure': #c62828, + ); + + // Below the top strip rather than over it, and out of the way of the pointer: + // the canvas underneath is still being worked on while a message sits here. + .notificationStack { + position: fixed; + top: vars.$top-strip-bottom + vars.$card-inset; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; + width: max-content; + max-width: min(640px, 90vw); + pointer-events: none; + } + + // Everything on one centre line. The glyph, the sentence, the buttons and the + // close were each aligned by a different rule -- flex-start plus a 1px nudge, + // align-self: center, and a -2px margin -- so no two of them agreed on where + // the middle was. One rule for the row, and nothing overriding it. + .notification { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 8px 11px 13px; + background: var(--card-surface); + color: #2c2c2c; + box-shadow: var(--card-shadow); + pointer-events: auto; + + // The same trick the help panel uses for its top bar: a border's *inner* + // edge is rounded by the radius minus the border width, so a radius equal + // to the bar leaves that edge straight while the card's own corners stay + // round. At the floating chrome's 10px against a 4px bar the accent curved + // away from the card on both ends and read as a separate rounded pill + // sitting behind it. + border-left: $accent solid transparent; + border-radius: $accent; + } + + // Material gives mat-icon a 24px box whatever the glyph inside it is, so the + // glyph has to be centred in the box the box actually is, not left to sit at + // the top of it. + .notificationIcon { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + font-size: 20px; + line-height: 1; + } + + .notificationText { + flex: 1 1 auto; + min-width: 0; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.15px; + } + + // A second action sits next to the first rather than a full gap away: the two + // are one choice, and the sentence is what they are both about. + .notificationAction + .notificationAction, + .notificationAction + .notificationClose { + margin-left: -6px; + } + + // Reads as the message's own verb, so it takes the message's own colour. + .notificationAction { + flex: 0 0 auto; + display: flex; + align-items: center; + // The same height as the close button beside it, so a row of one button and + // a row of two sit at the same height and their hover fills line up. + height: 24px; + padding: 0 8px; + border: none; + border-radius: 4px; + background: transparent; + font-family: inherit; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + + &:hover { + background: rgba(0, 0, 0, 0.06); + } + } + + // Small and grey: it is the way out of a message, not part of what the + // message says. + .notificationClose { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(0, 0, 0, 0.4); + cursor: pointer; + + &:hover { + background: rgba(0, 0, 0, 0.06); + color: rgba(0, 0, 0, 0.7); + } + + .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 1; + } + } + + @each $kind, $color in $kinds { + .notification--#{$kind} { + border-left-color: $color; + + .notificationIcon, + .notificationAction { + color: $color; + } + } + } +} diff --git a/src/app/component/notification/notification.component.ts b/src/app/component/notification/notification.component.ts new file mode 100644 index 00000000..d2f059eb --- /dev/null +++ b/src/app/component/notification/notification.component.ts @@ -0,0 +1,70 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { animate, style, transition, trigger } from '@angular/animations'; +import { + LiveNotification, + NotificationKind, + NotificationService, +} from '../../services/notification.service'; +import { MatIcon } from '@angular/material/icon'; + +/** + * The glyph that says which of the four this is before the sentence is read. + * + * `block` rather than an exclamation for a refusal: the app is declining, not + * reporting damage, and the reader has done nothing wrong by asking. + */ +const ICONS: Record<NotificationKind, string> = { + success: 'check_circle', + refusal: 'block', + warning: 'warning', + failure: 'error', +}; + +/** + * What the app is saying right now, stacked newest last. + * + * Every message used to be the same white bar for the same four seconds, one + * at a time — so "Message sent. Thank you for your feedback!" and "A cylinder + * cannot fold onto itself" were the same object until you had read them both, + * and a refusal arriving took away a warning nobody had read yet. + */ +@Component({ + selector: 'app-notification-stack', + animations: [ + // In from above, out by collapsing so the ones below close the gap rather + // than jumping up into it. + trigger('card', [ + transition(':enter', [ + style({ opacity: 0, transform: 'translateY(-8px)' }), + animate('160ms cubic-bezier(0.4, 0, 0.2, 1)', style({ opacity: 1, transform: 'none' })), + ]), + transition(':leave', [ + animate( + '140ms cubic-bezier(0.4, 0, 0.2, 1)', + style({ opacity: 0, transform: 'translateY(-6px)' }) + ), + ]), + ]), + ], + templateUrl: './notification.component.html', + styleUrls: ['./notification.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatIcon], +}) +export class NotificationComponent { + notifications = inject(NotificationService); + + iconFor(one: LiveNotification): string { + return ICONS[one.kind]; + } + + /** + * How a screen reader is told. + * + * A failure interrupts; the rest wait for a pause. Both are announced, which + * is the point of the region existing at all. + */ + politenessFor(one: LiveNotification): string { + return one.kind === 'failure' ? 'assertive' : 'polite'; + } +} diff --git a/src/app/component/playback-bar/playback-bar.component.html b/src/app/component/playback-bar/playback-bar.component.html new file mode 100644 index 00000000..725d30fa --- /dev/null +++ b/src/app/component/playback-bar/playback-bar.component.html @@ -0,0 +1,177 @@ +<div class="playbackRow"> + <!-- In and out from the bottom edge, the way a transport arrives. The view + controls beside it never move: they say what you can see, which is a + question every mode has, so they belong in the same corner in all of + them. --> + @if (tabs.isAnalysisMode()) { + <div class="transportCard" @riseFromBottom> + <button + class="speedButton" + (click)="cycleSpeed()" + [disabled]="!canPlay" + [matTooltip]="shortcuts.tip('Playback speed', 'playback.speed')" + matTooltipPosition="above" + matTooltipShowDelay="600" + > + {{ speed }}x + </button> + <button + class="playButton" + [class.live]="canPlay" + (click)="play()" + [disabled]="!canPlay" + [matTooltip]="shortcuts.tip(playing ? 'Pause' : 'Play', 'playback.toggle')" + matTooltipPosition="above" + matTooltipShowDelay="600" + [attr.aria-label]="playing ? 'Pause' : 'Play'" + > + <mat-icon>{{ playing ? 'pause' : 'play_arrow' }}</mat-icon> + </button> + <!-- Back to the pose the mechanism was drawn in, which is the one every + other mode shows. Beside the play button rather than in the row of + readings: it is the other half of the same question. --> + <button + class="stopButton" + (click)="stop()" + [disabled]="!canStop" + matTooltip="Back to the start pose" + matTooltipPosition="above" + matTooltipShowDelay="600" + aria-label="Back to the start pose" + > + <mat-icon>stop</mat-icon> + </button> + </div> + + <div class="scrubCard" @riseFromBottom> + <!-- A tab off the top edge of the card, as in the mock, rather than a + control inside it: it governs the whole card, so it sits on it. --> + @if (canSync) { + <button class="syncToggle" [class.on]="synced" (click)="toggleSync()"> + <mat-icon>{{ synced ? 'unfold_more' : 'unfold_less' }}</mat-icon> + {{ synced ? 'Control each mechanism' : 'Sync all mechanisms' }} + </button> + } + + @for (row of rows; track row.id) { + <!-- The row's own surface carries the selection, so nothing has to be + drawn around the controls and no button ever crosses the state. + Everything to read sits on the first line; the handle gets the + card's whole width underneath it. --> + <div + class="mechRow" + [class.selectable]="row.isMechanism" + [class.combined]="!row.isMechanism" + [class.selected]="isSelected(row)" + (click)="selectMechanism(row)" + (mouseenter)="hoverMechanism(row, true)" + (mouseleave)="hoverMechanism(row, false)" + > + <div class="rowHead"> + <div class="rowLabel"> + <!-- A machine's name is the way to select it. The combined row is + not a machine, so its name is a label rather than a control: + as a button it could be focused, pressed and shown as + selected, none of which lead anywhere. --> + @if (row.isMechanism) { + <!-- The row already selects on click, so the button stops here + rather than letting one press run both handlers. --> + <button + class="mechChip" + (click)="selectMechanism(row); $event.stopPropagation()" + [attr.title]="'Select ' + row.id" + > + {{ row.id }} + </button> + } @else { + <span class="mechChip plain" title="All mechanisms move together">{{ + row.id + }}</span> + } + + @if (row.note) { + <span class="rowNote">{{ row.note }}</span> + } + + <!-- What the handle does when it gets to the end of the track. + Grey in both readings: coming round again and turning back + are two facts, not a good one and a bad one. --> + @for (end of row.ends; track end.text) { + <span class="rowEnd"> + <mat-icon>{{ end.glyph }}</mat-icon> + {{ end.text }} + </span> + } + + <!-- Where the input is, and how long it has been going. Read + only: the handle is how you move it, and a typed time is a + question about a coordinate the transport no longer uses. A + fixed width, so a value going negative or gaining a digit + cannot shift the buttons beside it. --> + <span class="rowReadout"> + @if (row.position) { + <span class="rowPosition" [id]="row.master ? 'playbackPosition' : null">{{ + row.position + }}</span> + } + <span class="rowTime" [id]="row.master ? 'playbackTime' : null">{{ + row.time + }}</span> + </span> + </div> + + <!-- The transport stops the click here rather than letting it reach + the row. The row's whole surface selects the machine, which + changes what the panel is showing -- open analysis and all -- + and these two are what a reader presses over and over while + watching one machine run. Sharing a hitbox with the selector + meant every play and every reversal took their place away. --> + @if (row.ownPlay) { + <button + class="rowPlay" + [class.live]="canPlay" + (click)="toggleRow(row); $event.stopPropagation()" + [title]="shortcuts.tip('Play / pause', 'playback.toggle')" + [attr.aria-label]="row.playing ? 'Pause ' + row.id : 'Play ' + row.id" + > + <mat-icon>{{ row.playing ? 'pause' : 'play_arrow' }}</mat-icon> + </button> + } + + @if (row.isMechanism) { + <button + class="dirButton" + (click)="flipDirection(row); $event.stopPropagation()" + title="Input direction" + [attr.aria-label]="'Reverse ' + row.id" + > + <mat-icon>{{ row.clockwise ? 'rotate_right' : 'rotate_left' }}</mat-icon> + </button> + } + </div> + + <div class="trackWell"> + <!-- The handle likewise: moving one machine along its own travel is + watching it, not picking it. --> + <input + class="rowScrubber" + (click)="$event.stopPropagation()" + [id]="row.master ? 'slider' : null" + type="range" + min="0" + max="1000" + [value]="row.scrub" + [style.--along.%]="row.scrub / 10" + [disabled]="!canPlay" + (mousedown)="onScrubDown(row)" + (mouseup)="onScrubUp(row)" + (input)="scrubRow(row, $event)" + /> + </div> + </div> + } + </div> + } + + <app-view-controls></app-view-controls> +</div> diff --git a/src/app/component/playback-bar/playback-bar.component.scss b/src/app/component/playback-bar/playback-bar.component.scss new file mode 100644 index 00000000..89d9daf8 --- /dev/null +++ b/src/app/component/playback-bar/playback-bar.component.scss @@ -0,0 +1,504 @@ +@use 'sass:map'; +@use '@angular/material' as mat; + +// Every measurement in this file comes from the mock's own inline styles rather +// than from an eye: the bottom cluster was tuned there, and re-deriving it by +// looking at a screenshot is how it drifted the first time. +@mixin css($theme) { + $primary-palette: map.get(mat.m2-get-color-config($theme), 'primary'); + // How far the handle has come, and what is still ahead of it. Said once, + // because the two engines each want their own pseudo-element for the track. + // The part ahead of the handle sits as far below the selected row's fill as + // that fill sits below white, so the bar reads on a chosen row as clearly as + // it does on a plain one. The mock's own grey was the platform's, and it + // disappeared into the highlight. + $track: linear-gradient(to right, #3f51b5 0 var(--along, 0%), #d5d7e0 var(--along, 0%) 100%); + + // Right-aligned in every mode, so the view controls at its right-hand end sit + // in exactly the same place whatever mode is open. They used to be pushed + // there by a growing spacer in Edit and centred with the transport in + // analysis, which moved them -- twice, and by different amounts at different + // window widths. + .playbackRow { + position: absolute; + left: 0; + right: 0; + bottom: 38px; + display: flex; + align-items: flex-end; + justify-content: flex-end; + // The sync tab hangs 26px off the top of the scrub card, so a wrapped row + // needs that much again between lines or the tab lands on the card above + // it. Column gap stays the cluster's own. + column-gap: var(--card-gap); + row-gap: calc(26px + var(--card-gap)); + padding: 0 var(--card-gap); + z-index: 8; + pointer-events: none; + } + + .transportCard { + display: flex; + align-items: center; + gap: 8px; + height: 60px; + padding: 0 12px; + background: var(--card-surface); + border-radius: var(--card-radius); + pointer-events: auto; + flex: 0 0 auto; + box-sizing: border-box; + box-shadow: var(--card-shadow); + } + + // The card's own padding is now 4px rather than 12: a row is a surface of its + // own that has to be able to reach the card's edge, and the 12px optical + // inset the contents used to get is made up again inside it (4 card + 4 row + // + 4 head). Radii nest by the same rule -- outer = inner + padding. + .scrubCard { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + padding: 4px; + min-height: 60px; + background: var(--card-surface); + border-radius: var(--card-radius); + pointer-events: auto; + // Wider than it was: the line above the handle now carries the end-of-cycle + // reading as well as the name, the direction and the two numbers. + flex: 0 1 520px; + min-width: 250px; + box-sizing: border-box; + box-shadow: var(--card-shadow); + } + + .speedButton { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex: 0 0 auto; + border: none; + border-radius: 6px; + background: transparent; + color: #5f6368; + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + padding: 0; + + &:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.04); + } + + &:disabled { + color: #b7bac7; + cursor: default; + } + } + + // A rounded square rather than a disc, and the *same* rounded square as the + // speed button beside it and the view controls along the strip: every + // 40px button on this chrome takes one corner, so the one that is filled + // reads as the loud one of a set rather than as a shape of its own. + .playButton { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + border-radius: 6px; + background: #d5d7e0; + color: #fff; + cursor: pointer; + padding: 0; + + &.live { + background: #5c6bc0; + } + + &:disabled { + cursor: default; + } + + mat-icon { + width: 26px; + height: 26px; + font-size: 26px; + line-height: 26px; + } + } + + // The same button as the speed control on its other side: forty square, one + // corner, no fill until it is pointed at. Play is the only filled thing in + // the cluster, which is what makes it the one you press. + .stopButton { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #5f6368; + cursor: pointer; + + &:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.04); + } + + &:disabled { + color: #c8cad3; + cursor: default; + } + + mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; + } + } + + // A tab off the top edge of the card, with the shadow above it that makes it + // read as attached rather than floating. + .syncToggle { + position: absolute; + top: -26px; + left: 16px; + display: flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 12px; + border: none; + border-radius: var(--card-radius) var(--card-radius) 0 0; + background: var(--card-surface); + color: #5f6368; + font-family: inherit; + font-size: 12px; + white-space: nowrap; + cursor: pointer; + z-index: 1; + // Upward, unlike every other card: this one is a tab under nothing, and + // the card below it would otherwise cast a shadow onto its own tab. + box-shadow: 0 -3px 6px -2px rgba(0, 0, 0, 0.18); + + &.on { + background: #eef1fc; + color: #3f51b5; + } + + mat-icon { + width: 17px; + height: 17px; + font-size: 17px; + line-height: 17px; + } + } + + // One machine, on two lines: everything to read on the first, the handle with + // the card's whole width under it on the second. The row is a surface of its + // own, which is what carries the selection -- no rail, no outline, nothing + // drawn around the controls, and so nothing that a control can cross. + .mechRow { + display: flex; + flex-direction: column; + justify-content: center; + gap: 3px; + padding: 3px 4px; + // Nested by the card's rule: outer radius = inner + the padding between. + border-radius: calc(var(--card-radius) - 4px); + // A single row is the card's whole content, so its fill is the card's. + flex: 1 1 auto; + transition: background 0.12s ease; + + &.selectable { + cursor: pointer; + + &:hover { + background: #eceef5; + } + + // A shade darker, and that is the whole of it. Nothing added, nothing + // moved -- not even the name's weight, which would reflow the line it + // leads for a state the surface under it already says. + &.selected { + background: #e9ebf3; + } + } + } + + // Two spacings, as the mock has them: 8px between the things to read, and + // 6px between that whole group and the buttons -- which sit against the + // row's own padding rather than a second inset inside the line. + .rowHead { + display: flex; + align-items: center; + gap: 6px; + height: 22px; + overflow: hidden; + } + + .rowLabel { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 auto; + min-width: 0; + height: 22px; + padding: 0 4px; + overflow: hidden; + } + + // The way to select a machine, and the only one outside the canvas. It no + // longer has to look like a control on its own: the row it names is the + // control, so the name is just the name, in the app's own blue. + .mechChip { + display: flex; + align-items: center; + height: 22px; + padding: 0; + flex: 0 0 auto; + border: none; + background: transparent; + color: mat.m2-get-color-from-palette($primary-palette, 500); + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + // A name, not a word to be dragged over. Selecting it paints the highlight + // across the row and leaves it looking like a second state. + user-select: none; + + // The combined row stands for every machine at once, so there is no single + // thing for it to select. + &.plain { + cursor: default; + color: #5f6368; + } + } + + // Which way the input is going right now, and what it does when it gets to + // the end of the track. The second is grey in both readings: coming round + // again and turning back are two facts, not a good one and a bad one. + .rowNote { + flex: 0 1 auto; + min-width: 0; + font-size: 11px; + color: rgba(0, 0, 0, 0.5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .rowEnd { + display: inline-flex; + align-items: center; + flex: 0 1 auto; + min-width: 0; + gap: 4px; + font-size: 11px; + color: #5f6368; + white-space: nowrap; + overflow: hidden; + + mat-icon { + width: 13px; + height: 13px; + flex: 0 0 auto; + font-size: 13px; + line-height: 13px; + color: #9aa0a6; + } + } + + // A fixed width holding the two numbers hard against the buttons, so a value + // gaining a digit or a minus sign cannot move anything beside it. + .rowReadout { + display: flex; + align-items: center; + justify-content: flex-end; + margin-left: auto; + flex: 0 0 108px; + gap: 9px; + white-space: nowrap; + } + + // The combined row names no machine and shows no position, so there is + // nothing for the column to line up with. + .mechRow.combined .rowReadout { + flex: 0 0 auto; + } + + // Where the input is, then how long it has been going. The position leads + // because it is what the handle below it means. + .rowPosition { + font-size: 11px; + font-weight: 500; + color: #2c2c2c; + } + + .rowTime { + font-size: 11px; + color: rgba(0, 0, 0, 0.5); + } + + // Inside the row's padding rather than on its edge, at the same 22px as the + // line they sit on, so no button ever crosses the selected surface. + .rowPlay, + .dirButton { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex: 0 0 auto; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + padding: 0; + + &:hover { + background: rgba(0, 0, 0, 0.06); + } + } + + // Playing is the row's own doing, so its button carries the app's blue; the + // direction is a property of the drawing, so its button stays grey. + .rowPlay { + color: mat.m2-get-color-from-palette($primary-palette, 500); + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } + } + + .dirButton { + color: #5f6368; + + mat-icon { + width: 15px; + height: 15px; + font-size: 15px; + line-height: 15px; + } + } + + // The track sits in a 16px well with the input pinned 6px down it, so the + // handle centres on the line rather than on the row. The well is inset the + // same 4px the label group is, so the handle starts under the name. + .trackWell { + position: relative; + height: 16px; + margin: 0 4px; + } + + // Drawn rather than themed, and drawn to look like what the platform was + // drawing. Left to the browser, `accent-color` colours the travelled part + // and the handle but the track keeps a 1px rim -- a grey line above and + // below the bar, a border round the one control here that has one. Taking + // the painting means saying the rest ourselves: the bar's thickness, the + // capsule handle macOS drew, and how far along it sits, which is the same + // number the handle itself is bound to. + // + // It also settles a difference. The native slider is the platform's, so the + // same page did not look the same on two machines; this one does. + .rowScrubber { + position: absolute; + left: 0; + right: 0; + // The handle is taller than the bar and taller than the well it sits in -- + // 20px in 16px, as the mock has it, overhanging into the row's own padding + // rather than making the row taller. The element holds the handle, so it + // is the handle's height, hung so that its middle lands on the well's. + top: -2px; + width: 100%; + min-width: 0; + margin: 0; + height: 20px; + padding: 0; + border: none; + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; + + &::-webkit-slider-runnable-track { + height: 8px; + border: none; + border-radius: 4px; + background: $track; + } + + &::-moz-range-track { + height: 8px; + border: none; + border-radius: 4px; + background: $track; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + box-sizing: border-box; + width: 24px; + height: 20px; + // Centred on the 8px bar rather than on the element. + 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; + } + + &:disabled { + cursor: default; + } + } + + // A phone cannot hold three cards across one line. The cluster wraps, the + // scrub card takes the width it can get, and the view controls sit under it + // rather than half off the edge. + @media (max-width: 720px) { + .playbackRow { + flex-wrap: wrap; + justify-content: flex-end; + // The scrub card goes to a line of its own here, and its sync tab hangs + // 26px off its top edge -- into the transport card above it, unless the + // line below is given room for it. + column-gap: 8px; + row-gap: 34px; + padding: 0 8px; + } + + .scrubCard { + flex: 1 1 100%; + min-width: 0; + order: 3; + } + } +} diff --git a/src/app/component/playback-bar/playback-bar.component.ts b/src/app/component/playback-bar/playback-bar.component.ts new file mode 100644 index 00000000..1c37d8da --- /dev/null +++ b/src/app/component/playback-bar/playback-bar.component.ts @@ -0,0 +1,579 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + OnInit, + inject, +} from '@angular/core'; +import { animate, style, transition, trigger } from '@angular/animations'; +import { Subscription } from 'rxjs'; +import { MechanismService } from '../../services/mechanism.service'; +import { SettingsService } from '../../services/settings.service'; +import { NumberUnitParserService } from '../../services/number-unit-parser.service'; +import { ActiveObjService } from '../../services/active-obj.service'; +import { SelectedTabService } from '../../selected-tab.service'; +import { TimeUnit } from '../../model/utils'; +import { Mechanism } from '../../model/mechanism/mechanism'; +import { MODEL_SCALE } from '../../model/render-scale'; +import { MatIcon } from '@angular/material/icon'; +import { ViewControlsComponent } from '../view-controls/view-controls.component'; +import { MatTooltip } from '@angular/material/tooltip'; +import { KeyboardShortcutsService, ShortcutId } from '../../services/keyboard-shortcuts.service'; + +/** How far the cluster floats above the status strip, matching its own CSS. */ +const BOTTOM_OFFSET = 38; +/** The one gap the chrome keeps between any two cards. */ +const CARD_GAP = 12; + +/** + * What the input does when it reaches the end of its track. + * + * Two facts, not a good one and a bad one, so both are drawn in the same grey: + * a crank comes round again, a ram turns back. On the combined row one of these + * stands for every machine that behaves that way, which is why the words are + * carried rather than derived from a flag at the point of drawing. + */ +export interface CycleEnd { + /** `loop` for a machine that comes round again, `swap_horiz` for one that turns back. */ + glyph: string; + /** "Loops", "Reverses" -- or, on the combined row, "M1, M2 reverse". */ + text: string; +} + +/** One line in the transport: a machine, or all of them together. */ +export interface PlaybackRow { + id: string; + /** -1 for the combined row, which stands for every machine at once. */ + index: number; + /** The machine the row's handle is measured against. */ + leader: number; + /** Whether this line is one machine, and so has a direction to flip. */ + isMechanism: boolean; + /** The line the shared scrubber and the time field belong to. */ + master: boolean; + /** How long this machine has been going, from its start pose. */ + time: string; + /** Where its input is, in the input's own units. */ + position: string; + /** + * 0–1000 along the track. + * + * For a machine that reverses this runs up and back down again, because the + * input does: it is the drive's position, not an index into the samples. + */ + scrub: number; + clockwise: boolean; + /** Which way the input is going right now: "Clockwise", "Retracting", ... */ + note: string; + playing: boolean; + /** Whether this line carries a play button of its own. */ + ownPlay: boolean; + /** + * What happens at the end of the cycle: one entry for a machine, and for the + * combined row one per behaviour present among the machines it stands for. + */ + ends: CycleEnd[]; + period: number; +} + +/** + * The transport: what is playing, how fast, and where each machine is in its + * own cycle. + * + * One row per mechanism that can actually run. A mechanism that cannot is not + * listed at all — a disabled scrubber for a linkage that has no cycle is a + * control that can only disappoint, and what to do about it is the Analysis + * setup drawer's job, not a row's. + * + * Neither degrees of freedom nor readiness appear here. Being in this list is + * what "ready" means, so saying it again beside every row is a word that can + * never read anything but yes. + */ +@Component({ + selector: 'app-playback-bar', + templateUrl: './playback-bar.component.html', + styleUrls: ['./playback-bar.component.scss'], + // Off the bottom of the window and back, rather than fading in place: these + // cards belong to the analysis modes, and a transport arriving from the edge + // it lives on reads as the mode bringing its own controls with it. + animations: [ + trigger('riseFromBottom', [ + transition(':enter', [ + style({ opacity: 0, transform: 'translateY(calc(100% + 38px))' }), + animate('260ms cubic-bezier(0.4, 0, 0.2, 1)', style({ opacity: 1, transform: 'none' })), + ]), + transition(':leave', [ + animate( + '200ms cubic-bezier(0.4, 0, 1, 1)', + style({ opacity: 0, transform: 'translateY(calc(100% + 38px))' }) + ), + ]), + ]), + ], + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatIcon, MatTooltip, ViewControlsComponent], +}) +export class PlaybackBarComponent implements OnInit, AfterViewInit, OnDestroy { + mechanism = inject(MechanismService); + settings = inject(SettingsService); + activeObj = inject(ActiveObjService); + tabs = inject(SelectedTabService); + private nup = inject(NumberUnitParserService); + shortcuts = inject(KeyboardShortcutsService); + private host = inject<ElementRef<HTMLElement>>(ElementRef); + + private positionSub?: Subscription; + private wasAnimating = false; + private wasRowAnimating = false; + + ngOnInit(): void { + // The rows are rebuilt from the service on every change detection, and the + // pose moving is what has to trigger one. + this.positionSub = this.mechanism.onMechPositionChange.subscribe(() => undefined); + } + + /** + * Tell the rest of the chrome how much room this cluster is taking. + * + * The panels stop short of the bottom of the window so they do not run under + * the transport, and that was a fixed 110px -- the height of a transport with + * one row in it. Unsynced, this card carries a row per machine and grows to + * twice that, and the setup drawer came down through it. Measured rather than + * guessed, so a drawing with six machines in it works out the same way. + */ + ngAfterViewInit(): void { + const row = this.host.nativeElement.querySelector('.playbackRow') as HTMLElement | null; + if (!row || typeof ResizeObserver === 'undefined') return; + this.publishHeight(row); + this.heightWatch = new ResizeObserver(() => this.publishHeight(row)); + this.heightWatch.observe(row); + } + + private heightWatch?: ResizeObserver; + + private publishHeight(row: HTMLElement): void { + // The card's own height, the gap it floats above the status strip, and one + // more gap between it and whatever stops above it. + const clearance = Math.round(row.getBoundingClientRect().height) + BOTTOM_OFFSET + CARD_GAP; + document.documentElement.style.setProperty('--playback-clearance', `${clearance}px`); + } + + /** + * The transport's own keys. Each goes through the button's own method -- and + * only where that button is: the transport belongs to the analysis modes, so + * Space in Edit would be a key with no control behind it, playing a drawing + * the reader is in the middle of changing. + */ + private readonly keyed: Partial<Record<ShortcutId, () => void>> = { + 'playback.toggle': () => this.play(), + 'playback.back': () => this.stepBy(-1), + 'playback.forward': () => this.stepBy(1), + 'playback.speed': () => this.canPlay && this.cycleSpeed(), + }; + + private keySub = this.shortcuts.pressed.subscribe((id) => { + if (!this.tabs.isAnalysisMode()) return; + this.keyed[id]?.(); + }); + + ngOnDestroy(): void { + this.keySub.unsubscribe(); + this.positionSub?.unsubscribe(); + this.heightWatch?.disconnect(); + document.documentElement.style.removeProperty('--playback-clearance'); + } + + private format(seconds: number): string { + return this.nup.formatValueAndUnit(seconds, TimeUnit.SECOND); + } + + get playing(): boolean { + return this.mechanism.isPlaying; + } + + get canPlay(): boolean { + return this.mechanism.oneValidMechanismExists(); + } + + get speed(): number { + return this.mechanism.animationSpeedMultiplier; + } + + /** The scrubber spans the longest cycle in the drawing; shorter ones wrap. */ + get maxStep(): number { + const master = this.mechanism.masterMechanism(); + return master ? master.joints.length - 1 : 0; + } + + get step(): number { + return this.mechanism.mechanismTimeStep; + } + + /** + * One line per machine that can run — or one line for all of them, synced. + * + * Synced, the machines move together and there is nothing to say about them + * separately, so they collapse to a single `All` row. A row carries a play + * button only when it is one of several being controlled apart; otherwise the + * transport's own button is the only one, and two buttons doing the same + * thing side by side is worse than one. + */ + get rows(): PlaybackRow[] { + const runnable = this.mechanism.mechanisms + .map((mechanism, index) => ({ mechanism, index })) + .filter(({ mechanism }) => mechanism.isMechanismValid()); + + if (runnable.length === 0) { + return []; + } + + // Synced, the machines are started and stopped together and there is one + // answer about what is running -- but not one answer about where anything + // is, because each measures a different thing. The combined row follows the + // first machine and says nothing about direction. + if (this.mechanism.syncMechanisms) { + // The longest cycle in the drawing, so one handle can reach every frame + // of every machine. Following the first one instead left the slower + // machines with a stretch at the end of their cycles the handle could + // not get to. + const lead = runnable.reduce((longest, candidate) => + candidate.mechanism.cyclePeriod > longest.mechanism.cyclePeriod ? candidate : longest + ); + const alone = runnable.length === 1; + if (alone) { + return [this.rowFor(lead.index, true, false)]; + } + // Synced, the row stands for every machine at once, so what happens at + // the end of the cycle is said about the group rather than about the one + // machine the handle happens to follow. + return [ + this.rowFor( + lead.index, + true, + false, + 'All', + this.combinedEnds(runnable.map((r) => r.index)) + ), + ]; + } + + return runnable.map(({ index }, position) => + this.rowFor(index, position === 0, runnable.length > 1) + ); + } + + private rowFor( + index: number, + master: boolean, + ownPlay: boolean, + name?: string, + ends?: CycleEnd[] + ): PlaybackRow { + const mechanism = this.mechanism.mechanisms[index]; + const seconds = this.mechanism.secondsOf(index); + const combined = name !== undefined; + return { + id: name ?? this.nameOf(index), + index: combined ? -1 : index, + leader: index, + isMechanism: !combined, + master, + time: this.format(seconds), + position: combined ? '' : this.positionLabel(index), + // A machine's own handle is where its input has got to; the combined one + // is where the clock has got to. See `seekAllAlong`: an input that rocks + // is in the same place twice a cycle, and a handle that means two times + // at once cannot be dragged. + scrub: combined + ? Math.round(Math.min(Math.max(seconds / (mechanism.cyclePeriod || 1), 0), 1) * 1000) + : Math.round((this.mechanism.travelOf(index) ?? 0) * 1000), + clockwise: this.drivenSpeedOf(mechanism) < 0, + note: combined ? '' : this.noteFor(index), + playing: this.mechanism.isMechanismPlaying(index), + ownPlay, + ends: ends ?? [this.endOf(index)], + period: mechanism.cyclePeriod || 1, + }; + } + + /** + * Where the input is, in the units the input is measured in. + * + * The handle says how far along; this says how far along *what*. A crank + * reads in degrees of its own turn, a ram in the length its rod has come out + * past the shortest it gets. + */ + private positionLabel(index: number): string { + const profile = this.mechanism.driveProfileOf(index); + const along = this.mechanism.travelOf(index); + if (!profile || along === undefined) { + return ''; + } + if (!profile.linear) { + const bearing = this.mechanism.inputAngleDegrees(index); + return bearing === undefined ? '' : `${Math.round(bearing)}\u00b0`; + } + if (!(profile.span > 0)) { + return `${Math.round(along * 100)}%`; + } + return this.nup.formatValueAndUnit( + (along * profile.span) / MODEL_SCALE, + this.settings.lengthUnit.value + ); + } + + /** + * Which way the input is travelling at this moment, in words. + * + * A linear drive extends and retracts; a rotary one turns one way or the + * other. "Reciprocating" said only that the machine was of a kind that turns + * around, which is not something the reader needs told twice a cycle. + */ + private noteFor(index: number): string { + const profile = this.mechanism.driveProfileOf(index); + const outward = this.mechanism.travellingForward(index); + if (profile?.linear) { + return outward ? 'Extending' : 'Retracting'; + } + return outward ? 'Clockwise' : 'Counter-clockwise'; + } + + /** Only worth offering when there is more than one machine to get out of step. */ + get canSync(): boolean { + return this.mechanism.mechanisms.filter((m) => m.isMechanismValid()).length > 1; + } + + get synced(): boolean { + return this.mechanism.syncMechanisms; + } + + toggleSync(): void { + this.mechanism.setSyncMechanisms(!this.mechanism.syncMechanisms); + } + + /** The chip names a machine, so pressing it selects that machine. */ + selectMechanism(row: PlaybackRow): void { + if (row.index >= 0) { + this.activeObj.selectMechanism(row.index); + } + } + + /** Light the machine up on the canvas while the reader points at its name. */ + hoverMechanism(row: PlaybackRow, over: boolean): void { + this.mechanism.hoveredMechanismIndex = over && row.index >= 0 ? row.index : -1; + } + + isSelected(row: PlaybackRow): boolean { + return ( + this.activeObj.getSelectedObjType() === 'Mechanism' && + this.activeObj.selectedMechanismIndex === row.index + ); + } + + toggleRow(row: PlaybackRow): void { + this.mechanism.toggleMechanismPlaying(row.index); + } + + /** + * Drag a row's handle to a place along its input's travel. + * + * The handle is a position, so this is a position. What time that is, is the + * service's problem -- and on a machine that turns back it is two times, one + * on each leg, which is why it is told where the machine is now. + */ + scrubRow(row: PlaybackRow, event: Event): void { + const along = Number((event.target as HTMLInputElement).value) / 1000; + if (row.index === -1) { + // The combined row stands for all of them, so all of them go -- led by + // the longest cycle, which is the one whose frames the handle spans. + this.mechanism.seekAllAlong(row.leader, along); + return; + } + const index = row.index; + this.mechanism.seekMechanismTo(index, along); + } + + /** What the machine is called, everywhere the transport has to name it. */ + private nameOf(index: number): string { + return this.mechanism.partitions[index]?.id ?? `M${index + 1}`; + } + + /** + * What this machine does when its handle reaches the end of the track. + * + * The row said where the input was and which way it was going, and nothing at + * all about either end of the track -- so a handle that jumped back to the + * start and a handle that turned round and came back looked the same until + * you watched one happen. + */ + private endOf(index: number): CycleEnd { + return this.endWords(!this.isReciprocating(this.mechanism.mechanisms[index])); + } + + private endWords(loops: boolean): CycleEnd { + return { glyph: loops ? 'loop' : 'swap_horiz', text: loops ? 'Loops' : 'Reverses' }; + } + + /** + * The same fact about several machines at once. + * + * Grouped by behaviour rather than listed per machine: what the reader wants + * from the combined row is how many kinds of ending there are, and with one + * kind the names are noise -- the row is already called All. + */ + private combinedEnds(indices: number[]): CycleEnd[] { + const groups = new Map<boolean, string[]>(); + indices.forEach((index) => { + const loops = !this.isReciprocating(this.mechanism.mechanisms[index]); + groups.set(loops, [...(groups.get(loops) ?? []), this.nameOf(index)]); + }); + if (groups.size === 1) { + return [this.endWords([...groups.keys()][0])]; + } + return [...groups].map(([loops, names]) => { + // One machine is the subject of a singular verb, several of a plural one. + const verb = loops ? 'loop' : 'reverse'; + return { + glyph: this.endWords(loops).glyph, + text: `${names.join(', ')} ${names.length === 1 ? verb + 's' : verb}`, + }; + }); + } + + /** + * Out-and-back rather than round and round. + * + * From the sign of the recorded input velocity, which the solver flips at + * each reversal: a cycle holding both signs is one that turned around. + */ + private isReciprocating(mechanism: Mechanism): boolean { + const speeds = mechanism.inputAngularVelocities; + return speeds.some((speed) => speed > 0) && speeds.some((speed) => speed < 0); + } + + private drivenSpeedOf(mechanism: Mechanism): number { + return mechanism.inputAngularVelocities[0] ?? 0; + } + + /** + * One frame along, the way the handle moves it. + * + * Through the same seek the scrubber uses, so a keyed step and a nudge of + * the handle are the same motion -- and paused first, because a step is a + * look at one pose and playback would carry it off before it could be read. + */ + stepBy(delta: number): void { + if (!this.canPlay) return; + const master = this.rows.find((row) => row.master); + if (!master) return; + if (this.mechanism.isPlaying) this.mechanism.setAllPlaying(false); + const frames = Math.max(this.maxStep, 1); + const along = Math.min(1, Math.max(0, master.scrub / 1000 + delta / frames)); + if (master.index === -1) { + this.mechanism.seekAllAlong(master.leader, along); + } else { + this.mechanism.seekMechanismTo(master.index, along); + } + this.settings.animating.next(along !== 0); + } + + play(): void { + if (!this.canPlay) return; + // Every row, not just the shared flag: unsynced it is the rows that run, + // and a master button that left them alone showed a pause icon over a + // drawing standing still. + this.mechanism.setAllPlaying(!this.mechanism.isPlaying); + this.settings.animating.next(this.mechanism.mechanismTimeStep !== 0); + } + + /** + * Whether there is anything to come back from. + * + * A machine at the start of its cycle is already showing the pose it was + * drawn in, and a button that does nothing when pressed is worse than one + * that says so. + */ + get canStop(): boolean { + return this.canPlay && !this.mechanism.isAtStartPose(); + } + + /** + * Back to the pose the mechanism was drawn in. + * + * Eased rather than cut, and by whichever way round is shorter for each + * machine: a linkage that teleports reads as the drawing breaking, where the + * same move played over a fifth of a second reads as playback ending. + */ + stop(): void { + if (!this.canPlay) return; + this.mechanism.setAllPlaying(false); + this.settings.animating.next(false); + this.mechanism.easeToStart(); + } + + cycleSpeed(): void { + // 1x plays back in real time: one revolution takes 60/RPM seconds. The + // other stops are explicit fast-forwards for slow input speeds. + const rates = [1, 2, 4]; + const next = rates.indexOf(this.mechanism.animationSpeedMultiplier) + 1; + this.mechanism.animationSpeedMultiplier = rates[next % rates.length]; + } + + /** + * Turn this machine round. + * + * A continuously driven machine is turned round by reversing its drive; a + * machine whose input already reverses on its own has no other direction to + * be driven in, so the only thing left to turn round is playback, and that is + * a view of the same motion rather than a change to the drawing. + * + * Either way nothing moves. The linkage holds the pose it was in and the + * handle holds its place; the time is what jumps. + */ + flipDirection(row: PlaybackRow): void { + const mechanism = this.mechanism.mechanisms[row.index]; + if (mechanism && this.isReciprocating(mechanism)) { + this.mechanism.setPlaybackDirection(row.index, -this.mechanism.directionOf(row.index)); + return; + } + this.mechanism.reverseDrive(row.index); + } + + /** + * Dragging one machine's handle stops that machine, not the drawing. + * + * The rows have their own clocks precisely so they can be read apart; pausing + * everything to scrub one of them threw that away. + */ + onScrubDown(row?: PlaybackRow): void { + if (row && row.ownPlay && row.index >= 0) { + this.wasRowAnimating = this.mechanism.isMechanismPlaying(row.index); + if (this.wasRowAnimating) this.mechanism.toggleMechanismPlaying(row.index); + return; + } + this.wasAnimating = this.mechanism.isPlaying; + this.mechanism.isPlaying = false; + } + + onScrubUp(row?: PlaybackRow): void { + if (row && row.ownPlay && row.index >= 0) { + if (this.wasRowAnimating && !this.mechanism.isMechanismPlaying(row.index)) { + this.mechanism.toggleMechanismPlaying(row.index); + } + this.wasRowAnimating = false; + return; + } + if (this.wasAnimating) { + this.mechanism.isPlaying = true; + this.mechanism.animate(this.mechanism.mechanismTimeStep, true); + } + } + + onScrub(event: Event): void { + const value = Number((event.target as HTMLInputElement).value); + this.mechanism.animate(value, this.mechanism.isPlaying); + this.settings.animating.next(value !== 0); + } +} diff --git a/src/app/component/right-panel/right-panel.component.html b/src/app/component/right-panel/right-panel.component.html index 97c9f1fa..629a5363 100644 --- a/src/app/component/right-panel/right-panel.component.html +++ b/src/app/component/right-panel/right-panel.component.html @@ -1,135 +1,115 @@ <div> <div [@openClose]=" - getIsOpen() ? (getOpenTab() === 1 || getOpenTab() == 3 ? 'open' : 'openWide') : 'closed' + frameOpen() + ? getOpenTab() === 1 || + getOpenTab() === 3 || + getOpenTab() === 5 || + getOpenTab() === 6 || + getOpenTab() === 7 + ? 'open' + : 'openWide' + : 'closed' " id="rightPanel" + data-canvas-inset="right" + [class.insisting]="attention" + [class.wide]="isWidePage()" + [class.exportPage]="isExportPage()" > - @if (getOpenTab() === 1) { - <div class="page1"> - <app-settings-panel></app-settings-panel> + <!-- Pinned above whatever else the drawer is showing, rather than taking + the drawer to itself. A student is meant to be able to open Settings or + Export *while* following a step, and the drawer showing one page at a + time meant doing either put the tutorial away. --> + @if (tutorialShowing()) { + <div class="tutorialSlot"> + <app-tutorial-panel></app-tutorial-panel> </div> } - @if (getOpenTab() === 2) { - <div class="page2"> - <app-equation-panel></app-equation-panel> - </div> - } - @if (getOpenTab() === 3) { - <div class="page3"> - <div id="helpWrapper"> - <panel-section> - <title-block>Help</title-block> - <div style="padding: 10px 0 10px 10px; width: calc(100% - 20px)"> - <button-block icon="help_center" [click]="gotoHelpSite">Tutorial Videos</button-block> - <!-- <button-block icon='info' [click]='sendNotReady'>Restart Tutorial</button-block> --> - <subtitle-block> - PMKS+ is open source! Do you want to help us develop it?</subtitle-block + <!-- One box for whichever page is open, so the close button can be + positioned against *it* rather than against the frame. With the + tutorial pinned above, a button anchored to the frame landed on the + tutorial's own corner. --> + @if (getIsOpen()) { + <div class="drawerPage" [class.page4]="getOpenTab() === 4"> + @if (getOpenTab() === 5) { + <app-analysis-setup mode="kinematic"></app-analysis-setup> + } + @if (getOpenTab() === 6) { + <app-analysis-setup mode="force"></app-analysis-setup> + } + @if (getOpenTab() === 7) { + <app-export-panel></app-export-panel> + } + @if (getOpenTab() === 1) { + <app-settings-panel></app-settings-panel> + } + @if (getOpenTab() === 2) { + <app-equation-panel></app-equation-panel> + } + @if (getOpenTab() === 3) { + <app-help-panel></app-help-panel> + } + @if (getOpenTab() === 4) { + <div id="debugWrapper"> + <panel-section> + <button-block icon="terminal" [click]="printActiveObject.bind(this)" + >Console Log Active Object</button-block > - <button-block customIcon="github" [click]="gotoGithub">Github Repo</button-block> - </div> - </panel-section> - <panel-section> - <title-block - description="Do you want to attach a file? Send us an email at gr-pmksplus@wpi.edu" - > - Send Feedback - </title-block> - <form [formGroup]="commentForm"> - <mat-form-field class="max-width"> - <mat-label>Leave a message</mat-label> - <textarea type="text" matInput formControlName="comment"></textarea> - @if (commentForm.controls['comment'].hasError('required')) { - <mat-error> Message is required </mat-error> - } - </mat-form-field> - <mat-checkbox color="primary" formControlName="response" - >I would like a response</mat-checkbox + <button-block icon="terminal" [click]="printMechanism.bind(this)" + >Console Log Mechanism</button-block > - @if (commentForm.value.response) { - <mat-form-field class="max-width"> - <mat-label>Email</mat-label> - <input - type="email" - matInput - formControlName="email" - [errorStateMatcher]="matcher" - placeholder="Ex. pat@example.com" - /> - @if ( - commentForm.controls['email'].hasError('email') && - !commentForm.controls['email'].hasError('required') - ) { - <mat-error> Invalid email address </mat-error> - } - </mat-form-field> - } - <mat-checkbox color="primary" checked formControlName="diagnostics" - >Send browser and version data - </mat-checkbox> - <mat-checkbox color="primary" checked formControlName="project" - >Send a copy of the open project - </mat-checkbox> - <div style="padding: 10px 0 10px 10px; width: calc(100% - 20px)"> - <button-block - [disabled]="sendingEmail" - icon="send" - [click]="sendCommentEmail.bind(this)" - >Send Message - </button-block> - </div> - </form> - </panel-section> - </div> - </div> - } - @if (getOpenTab() === 4) { - <div class="page4"> - <div id="debugWrapper"> - <panel-section> - <button-block icon="terminal" [click]="printActiveObject.bind(this)" - >Console Log Active Object</button-block - > - <button-block icon="terminal" [click]="printMechanism.bind(this)" - >Console Log Mechanism</button-block - > - <button-block icon="draw" [click]="redrawAllLinks.bind(this)">Redraw</button-block> - <button-block icon="quiz" [click]="runGeometryUnitTests.bind(this)" - >Run Intersection Unit Tests - </button-block> - <button-block icon="report" [click]="turnOnDebugger.bind(this)" - >Turn on Grid Debug Info</button-block - > - <app-linkage-table></app-linkage-table> - <li>GridState: {{ debugGetGridState() }}</li> - <li>LinkState: {{ debugGetLinkState() }}</li> - <li>JointState: {{ debugGetJointState() }}</li> - <li>ForceState: {{ debugGetForceState() }}</li> - <li> - AOS.objType: <b>{{ activeObjService.objType }}</b> - </li> - <li> - AOS.selectedJoint.id: <b>{{ activeObjService.selectedJoint?.id }}</b> - </li> - <li> - AOS.selectedLink.id: <b>{{ activeObjService.selectedLink?.id }}</b> - </li> - <li> - AOS.selectedForce.id: <b>{{ activeObjService.selectedForce?.id }}</b> - </li> - <li> - AOS.prevSelectedJoint.id: <b>{{ activeObjService.prevSelectedJoint?.id }}</b> - </li> - <li> - AOS.selectedForce.isStartSelected: - {{ activeObjService.selectedForce?.isStartSelected }} - </li> - <li> - AOS.selectedForce.isEndSelected: {{ activeObjService.selectedForce?.isEndSelected }} - </li> - <div style="height: 50px"></div> - </panel-section> - </div> + <button-block icon="draw" [click]="redrawAllLinks.bind(this)">Redraw</button-block> + <button-block icon="quiz" [click]="runGeometryUnitTests.bind(this)" + >Run Intersection Unit Tests + </button-block> + <button-block icon="report" [click]="turnOnDebugger.bind(this)" + >Turn on Grid Debug Info</button-block + > + <app-linkage-table></app-linkage-table> + <li>GridState: {{ debugGetGridState() }}</li> + <li>LinkState: {{ debugGetLinkState() }}</li> + <li>JointState: {{ debugGetJointState() }}</li> + <li>ForceState: {{ debugGetForceState() }}</li> + <li> + AOS.objType: <b>{{ activeObjService.objType }}</b> + </li> + <li> + AOS.selectedJoint.id: <b>{{ activeObjService.selectedJoint?.id }}</b> + </li> + <li> + AOS.selectedLink.id: <b>{{ activeObjService.selectedLink?.id }}</b> + </li> + <li> + AOS.selectedForce.id: <b>{{ activeObjService.selectedForce?.id }}</b> + </li> + <li> + AOS.prevSelectedJoint.id: <b>{{ activeObjService.prevSelectedJoint?.id }}</b> + </li> + <li> + AOS.selectedForce.isStartSelected: + {{ activeObjService.selectedForce?.isStartSelected }} + </li> + <li> + AOS.selectedForce.isEndSelected: {{ activeObjService.selectedForce?.isEndSelected }} + </li> + <div style="height: 50px"></div> + </panel-section> + </div> + } + + <!-- Every drawer closes the same way, from the same corner. Last in the + page so it paints over the panel it sits on: it shares a line with + the title, and a title row drawn after it covered it up. --> + <button + class="closeDrawer" + (click)="close()" + aria-label="Close" + matTooltip="Close" + matTooltipShowDelay="600" + > + <mat-icon>close</mat-icon> + </button> </div> } </div> diff --git a/src/app/component/right-panel/right-panel.component.scss b/src/app/component/right-panel/right-panel.component.scss index 5df62b19..a7dff7e0 100644 --- a/src/app/component/right-panel/right-panel.component.scss +++ b/src/app/component/right-panel/right-panel.component.scss @@ -1,6 +1,8 @@ @use 'sass:map'; @use '@angular/material' as mat; +@use '../left-tabs/left-tabs.vars' as nav; + @mixin css($theme) { // Get the color config from the theme. $color-config: mat.m2-get-color-config($theme); @@ -10,62 +12,262 @@ $background: map.get($theme, background); $foreground: map.get($theme, foreground); - #rightPanel { - //pointer-events: none; + // A floating card like the strip above it, clear of both: the drawer used to + // start under a toolbar that ran the width of the window and could sit flush + // against the edge. Nothing is flush any more. + // Inside the card and level with its title, not hard against the drawer's + // outer corner where it overhung the rounded edge and the accent bar. Every + // panel in here was written before the drawer could be closed from inside + // it, so none of them leaves room for this -- these are the drawer's own + // 10px inset plus the card's 8px gutter, and the title row's centre line. + // The same air on all three sides the button has a free edge on: above it to + // the accent bar, right of it to the card's edge, below it to the rule under + // the title. Centring it on the title's text instead got the middle one right + // and left the other two at ten and five, which read as a button sitting low. + // + // The button is 32px, so the band it sits in has to be 32 + 2 x 8; the drawer + // is the only place a title shares its line with a button, so the band is set + // here rather than on the title block every panel in the app uses. + $close-air: 8px; + + .closeDrawer { position: absolute; - top: 30px; - right: 0px; - // width: 300px; - overflow-x: hidden; - overflow-y: auto; - max-height: calc(100% - 128px); + // Measured from the page box rather than from the frame, because the frame + // may be holding the tutorial's card above this one -- and a button placed + // against the frame then lands in the tutorial's corner rather than this + // page's. The page box starts at the frame's own origin when it is alone, + // so the numbers are unchanged: one shadow-room in, 5px of accent bar, and + // one card-inset off the right edge. + top: nav.$shadow-room + 5px + $close-air; + right: nav.$card-inset + $close-air; + // Above the sticky heads it shares a line with, which are also 2. A tie + // leaves the winner to document order, and document order is the weakest + // thing to rest on: the heads are the ones that get promoted to a layer of + // their own the moment a card is scrolled. + z-index: 3; display: flex; - border-radius: var(--border-radius); - - animation-duration: 4s; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: transparent; + color: #5f6368; + cursor: pointer; + padding: 0; - //Check here for 2nd parameter options: node_modules/@angular/material/core/theming/_palette.scss: Line 674 - // background-color: mat.get-color-from-palette($background, 'background'); - // background-color: mat.get-color-from-palette($primary-palette, 50); + mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; + } - .page1 { - width: 300px; - //height: 100%; - // border-radius: var(--border-radius); + &:hover { + background: rgba(0, 0, 0, 0.06); } + } + + // The band the close button sits in, on the drawers whose title is a plain + // title block. Elsewhere a title block keeps less space below it than above, + // because what follows a title belongs to it; here what follows is a rule, + // and the button between them wants the same air on both sides. + #rightPanel #title-block { + padding-top: $close-air; + padding-bottom: $close-air; + } + + // Asked for while already open. A deliberate pull rather than a wobble: it + // steps out and back twice, at a size a reader reads as "look here" instead + // of as a drawer having trouble closing. + .insisting { + animation: drawerNudge 0.62s cubic-bezier(0.22, 1, 0.36, 1); + } - .page2 { - //position: absolute; - //top: 0; - //right: 0; - width: 500px; - //overflow-y: visible; - ////Be careful, there are multiple places to change this value - //height: 1000px; - // border-radius: var(--border-radius); + @keyframes drawerNudge { + 0% { + transform: translateX(0); + } + 14% { + transform: translateX(-26px); + } + 34% { + transform: translateX(0); + } + 54% { + transform: translateX(-26px); + } + 76% { + transform: translateX(0); + } + 100% { + transform: translateX(0); } + } - .page3 { - width: 300px; + #rightPanel { + position: absolute; + top: nav.$panel-top; + // Flush with the window; the card is set in from there by the gap, and the + // frame's own padding is what leaves the shadow room on the other three + // sides. Four pixels of frame hanging off the right of the window is four + // pixels the page can be scrolled sideways. + right: 0; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; + // Down to one gap above the view controls, and no further -- measured off + // that card, which the view controls publish. + // + // The transport is deliberately not part of this. It stands to the left of + // the drawer and grows a row per machine while unsynced, and taking its + // height into account walked the drawer up the window as machines were + // unsynced, away from controls that had not moved. Where the two do meet, + // the drawer is in front. + // + // Plus the shadow room, because that clearance is measured to where the + // *card* stops and this frame holds the card that much inside it. + // Measured against the window rather than against the page. They are the + // same number until something else makes the document taller than the + // viewport, at which point `100%` grows with it and the drawer is free to + // run off the bottom of the screen. + max-height: calc( + 100dvh - + #{nav.$top-strip-height} - var(--view-controls-clearance, #{nav.$view-controls-clearance}) + + #{nav.$shadow-room} + ); + // A column, because the tutorial's card stacks above whichever page is + // open rather than replacing it. With one child this is what a row did + // anyway; with two, a row put them side by side in a drawer one card wide. + display: flex; + flex-direction: column; + z-index: 9; + + // As wide as the view controls it stands over, which the view controls + // measure and publish -- so the two edges line up however many buttons that + // card ends up carrying. The frame is that plus the room its card needs on + // either side. Two pages hold a table rather than a panel and say so. + // The fallback is what six buttons and the divider between them measure to, + // and is only used for the first paint before the real measurement lands. + width: calc(var(--view-controls-width, 301px) + #{nav.$shadow-room} + #{nav.$card-inset}); + transition: width 0.1s ease-in-out; + + &.wide { + width: calc(500px + #{nav.$shadow-room} + #{nav.$card-inset}); } - .page4 { - width: 500px; + // Wider than the view controls, and narrower than the table pages: an + // export row is a checkbox, a part's name and what is notable about it, and + // at the drawer's usual width the third of those was always ellipsed away. + // Named for the page rather than after its card: the export card is + // `.exportCard`, and a frame sharing a class with the card it holds was + // drawing a second background, corner, shadow and accent bar around it. + &.exportPage { + width: calc(380px + #{nav.$shadow-room} + #{nav.$card-inset}); } } - #debugWrapper { + // One inset and one gap for every drawer page. The two setup drawers had + // neither, so their card came out 20px wider than the Settings card and sat + // 10px closer to the window edge -- the drawer visibly changed shape when you + // switched between them. + // The inset lives on the page rather than on the drawer around it: a closed + // drawer has no page, and padding on the drawer itself gives the closed one a + // box of exactly that width, which its parked transform then carries off the + // right of the window. + // The page fills the frame, which is what decides the width; it used to be + // the other way round, and a page and a frame that each named a width came + // out 28px apart -- a drawer that says 300 holding a card of 272. + #rightPanel > div { display: flex; flex-direction: column; gap: 10px; - padding: 10px; + width: 100%; + box-sizing: border-box; + padding: nav.$shadow-room; + padding-right: nav.$card-inset; + min-height: 0; } - #helpWrapper { + // The page box is what the close button is measured against. + #rightPanel > .drawerPage { + position: relative; + } + + // The tutorial keeps its own height and the page below gives way when the two + // together are taller than the drawer. + // + // Between them, one $card-inset -- the same gap the drawer keeps from the top + // strip above it and from the view controls below it, so the column reads as + // evenly spaced rather than as two cards that happen to be near each other. + // Both need the id, or `#rightPanel > div` outweighs them and each card keeps + // a full $shadow-room, which is more than twice the gap. + #rightPanel > .tutorialSlot { + flex: 0 0 auto; + } + + #rightPanel > .tutorialSlot + div { + // Both cards keep the shadow room every card in this frame gets, and the + // overlap is taken back here, so what shows between them is one + // $card-inset -- the gap the drawer already keeps from the strip above and + // the view controls below. + // + // A margin rather than less padding. Taking the room away instead leaves + // the tutorial's bottom shadow nowhere to fall, and when the tutorial is + // the only card in the drawer its bottom edge lands exactly on the frame's, + // which scrolls -- so the shadow is clipped off square. + margin-top: nav.$card-inset - nav.$shadow-room * 2; + + // Its own height, not whatever is left over. As a growing-and-shrinking + // flex item it gave way to the tutorial above it until there was nothing + // left: on a short window the export page collapsed to its title and its + // first line, with the parts list and Next simply unreachable. Sized to its + // content, the two cards together overflow the frame instead, and the frame + // already scrolls. + flex: 0 0 auto; + } + + // Hand the drawer's height down to the card, which is what scrolls -- the + // same chain the left-hand panels use to keep their own title in place. Each + // step needs `min-height: 0`, or a flex item refuses to shrink below its + // content and the cap never reaches the card. + // + // The frame must not be the one that scrolls. The close button is positioned + // against the frame, so a frame that scrolls carries the button up off its + // own top edge: on a short window the Settings drawer overflowed, and the + // button went with the content -- gone from the corner while the half of it + // still showing went on taking the click. + app-analysis-setup, + app-export-panel, + app-settings-panel, + app-help-panel { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } + + // This page holds several cards rather than one, so there is no single card + // to hand the height to and the page itself has to scroll. It takes the + // inset over from the page around it: an overflow box drawn at the cards' + // own edge would clip their shadows, and an inset outside the scrolling box + // is an inset that scrolls away. + #rightPanel > .page4 { + padding: 0; + overflow: hidden; + } + + #debugWrapper { display: flex; flex-direction: column; gap: 10px; - padding: 10px; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + box-sizing: border-box; + padding: nav.$shadow-room; + padding-right: nav.$card-inset; } .max-width { diff --git a/src/app/component/right-panel/right-panel.component.ts b/src/app/component/right-panel/right-panel.component.ts index c21c0570..ffe01837 100644 --- a/src/app/component/right-panel/right-panel.component.ts +++ b/src/app/component/right-panel/right-panel.component.ts @@ -1,35 +1,29 @@ -import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; +import { TabID } from '../../selected-tab.service'; +import { CHROME_MOVED } from '../../model/chrome-motion'; +import { Component, inject, ChangeDetectionStrategy, DoCheck } from '@angular/core'; import { animate, state, style, transition, trigger } from '@angular/animations'; import { NewGridComponent } from '../new-grid/new-grid.component'; -import { - gridStates, - jointStates, - linkStates, - forceStates, - shapeEditModes, - createModes, - moveModes, - roundNumber, - determineSlope, - determineYIntersect, - determineX, - determineY, -} from '../../model/utils'; +import { gridStates, jointStates, linkStates, forceStates } from '../../model/utils'; import { ActiveObjService } from '../../services/active-obj.service'; import { MechanismService } from '../../services/mechanism.service'; -import { Link, RealLink } from '../../model/link'; +import { RealLink } from '../../model/link'; import { AnalyticsService } from '../../services/analytics.service'; -import { FormBuilder, FormControl, FormGroupDirective, NgForm, Validators } from '@angular/forms'; -import { ErrorStateMatcher } from '@angular/material/core'; -import { environment } from '../../../environments/environment'; -import emailjs, { EmailJSResponseStatus } from '@emailjs/browser'; import { SettingsService } from '../../services/settings.service'; import { Arc, Line } from '../../model/line'; import { Coord } from '../../model/coord'; import { SvgGridService } from '../../services/svg-grid.service'; -import { ToolbarComponent } from '../toolbar/toolbar.component'; -import introJs from 'intro.js'; -import { UrlGenerationService } from 'src/app/services/url-generation.service'; +import { TutorialService } from '../../services/tutorial.service'; +import { AnalysisSetupComponent } from '../analysis-setup/analysis-setup.component'; +import { ExportPanelComponent } from '../export-panel/export-panel.component'; +import { TutorialPanelComponent } from '../tutorial-panel/tutorial-panel.component'; +import { SettingsPanelComponent } from '../settings-panel/settings-panel.component'; +import { EquationPanelComponent } from '../equation-panel/equation-panel.component'; +import { HelpPanelComponent } from '../help-panel/help-panel.component'; +import { PanelSectionComponent } from '../BLOCKS/panel-section/panel-section.component'; +import { ButtonComponent } from '../BLOCKS/button/button.component'; +import { LinkageTableComponent } from '../linkage-table/linkage-table.component'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatIcon } from '@angular/material/icon'; @Component({ selector: 'app-right-panel', @@ -38,62 +32,126 @@ import { UrlGenerationService } from 'src/app/services/url-generation.service'; animations: [ trigger('openClose', [ // ... + // No width here: the drawer is as wide as the view controls it sits + // above, which only the stylesheet can know -- see `--view-controls-width`. + // An animation state is written onto the element and beats a stylesheet, + // so a width here would be a second, silent opinion about it. state( 'open', style({ transform: 'translateX(0)', - width: '300px', }) ), state( 'closed', style({ - transform: 'translateX(calc(100% + 10px))', - }) - ), - state( - 'openWide', - style({ - width: '500px', //Be careful, there are multiple places to change this value + // Clear of the 12px inset the drawer now floats at, plus a margin. + // The old 10px was measured against a panel flush to the edge and + // left a two-pixel sliver of it on screen. + transform: 'translateX(calc(100% + 24px))', + // And genuinely gone. Parked off the edge it still occupied the + // page's width, so a closed drawer could be scrolled back into view. + visibility: 'hidden', }) ), + state('openWide', style({})), transition('open => openWide', [animate('0.1s ease-in-out')]), transition('openWide => open', [animate('0.1s ease-in-out')]), transition('* => *', [animate('0.3s ease-in-out')]), ]), ], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + AnalysisSetupComponent, + ExportPanelComponent, + TutorialPanelComponent, + SettingsPanelComponent, + EquationPanelComponent, + HelpPanelComponent, + PanelSectionComponent, + ButtonComponent, + LinkageTableComponent, + MatTooltip, + MatIcon, + ], }) -export class RightPanelComponent { - private analytics: AnalyticsService = inject(AnalyticsService); - - public sendingEmail: boolean = false; +export class RightPanelComponent implements DoCheck { + activeObjService = inject(ActiveObjService); + mechanismService = inject(MechanismService); + settingsService = inject(SettingsService); + svgService = inject(SvgGridService); + private tutorial = inject(TutorialService); + + /** + * The tutorial asks to be shown rather than reaching in and setting the tab. + * + * A service that imported this component to open it would close the loop + * this component's own page has already opened -- the tutorial page injects + * the service -- so the request travels the other way. + */ + /** + * Whether the tutorial's card is showing above whatever page is open. + * + * It is pinned rather than paged: a student following a step has to be able + * to open Settings or Export without the thing they are following being put + * away, so it is not one of the numbered pages and does not take the drawer + * from one. + */ + tutorialShowing(): boolean { + return this.tutorial.started && !this.tutorial.exited; + } - commentForm = this.fb.group({ - comment: ['', Validators.required], - email: ['', Validators.email], - response: [false], - diagnostics: [true], - project: [true], - }); + /** The frame stands open for a page, for the tutorial, or for both. */ + frameOpen(): boolean { + return RightPanelComponent.isOpen || this.tutorialShowing(); + } - matcher = new MyErrorStateMatcher(); + private analytics: AnalyticsService = inject(AnalyticsService); static openTab = 0; //Default open tab to "Edit" / static isOpen = false; // Is the tab open? + /** + * The two setup drawers, one per analysis mode. + * + * Separate because they answer different questions with different fixes: a + * mechanism that will not run and a force analysis that has nothing to react + * against are not the same problem, and a reader refused by one mode should + * not have to read past the other mode's list to find out why. + * + * Numbered like their neighbours because that is how this panel has always + * been addressed; named because "5" at a call site says nothing. + */ + static readonly KINEMATIC_SETUP_TAB = 5; + static readonly FORCE_SETUP_TAB = 6; + /** + * Export Data, which is a drawer rather than a dialog for the same reason the + * setups are: the canvas stays visible, so ticking a part is done next to the + * drawing it is a part of. + */ + static readonly EXPORT_TAB = 7; turnOnDebugger() { this.settingsService.isGridDebugOn = !this.settingsService.isGridDebugOn; } - constructor( - public activeObjService: ActiveObjService, - public mechanismService: MechanismService, - public settingsService: SettingsService, - public svgService: SvgGridService, - public urlGenerationService: UrlGenerationService, - private fb: FormBuilder - ) {} + /** + * Bumped when a drawer is asked for that is already showing. + * + * Pressing a mode that is not ready opens the setup that says why. Pressing + * it again used to close that setup -- so the reader who did not spot it the + * first time asked twice and got nothing. It draws attention to itself + * instead, which is what the second press was asking for. + */ + static attentionCount = 0; + + /** Ask for a drawer without ever closing it. */ + static insistOn(tabID: number): void { + if (this.isOpen && this.openTab === tabID) { + this.attentionCount++; + return; + } + this.isOpen = true; + this.openTab = tabID; + } static tabClicked(tabID: number) { if (!this.isOpen) { @@ -106,14 +164,115 @@ export class RightPanelComponent { this.openTab = tabID; } } - // console.warn(this.openTab); - // console.warn(this.isOpen); + } + + /** + * Whether the drawer is currently being pointed at. + * + * A class for one animation's length, taken off again so a second ask plays + * it a second time rather than doing nothing. + */ + attention = false; + private shownAttention = 0; + + /** + * The drawer's shape, as the canvas behind it cares about it: whether it is + * there, and how much room it takes when it is. + * + * Announced from here rather than from each of the five places that open or + * close a drawer, because that is five places to remember and this is one -- + * and the state is a static that any of them may set. Seeded with what is + * already true, so the first check announces nothing. + */ + private shownShape = this.drawerShape(); + + private drawerShape(): string { + return `${RightPanelComponent.isOpen}:${this.isWidePage()}`; + } + + ngDoCheck(): void { + const shape = this.drawerShape(); + if (shape !== this.shownShape) { + this.shownShape = shape; + CHROME_MOVED.next(); + } + // The Edit panel's resume line is offered only when the card is not up, and + // a closed drawer still *renders* the page it was last showing -- it parks + // off the edge rather than being torn down -- so the card cannot answer + // this from its own lifecycle. + this.tutorial.onScreen = this.tutorialShowing(); + if (RightPanelComponent.attentionCount !== this.shownAttention && !this.attention) { + this.shownAttention = RightPanelComponent.attentionCount; + this.attention = true; + setTimeout(() => (this.attention = false), 650); + } + } + + /** Shut the drawer, whichever one is open. */ + close(): void { + RightPanelComponent.dismiss(); + } + + /** Shut the drawer from outside it, the mirror of `insistOn`. */ + static dismiss(): void { + RightPanelComponent.isOpen = false; + } + + /** + * Close a setup drawer that no longer describes the mode being shown. + * + * Settings and Help are about the app rather than a mode, so they stay. + */ + static closeSetupUnlessFor(tab: TabID): void { + if (!this.isOpen) { + return; + } + // The force drawer holds the mass table, whose own header offers "Switch + // to Edit mode" — a switch that must not close the thing that offered it. + // So it survives Edit as well as Force, and only leaves for Synthesis. + const forceDrawerBelongs = tab === TabID.FORCE || tab === TabID.EDIT; + const wanted = + tab === TabID.FORCE + ? RightPanelComponent.FORCE_SETUP_TAB + : tab === TabID.ANALYZE + ? RightPanelComponent.KINEMATIC_SETUP_TAB + : -1; + if (this.openTab === RightPanelComponent.FORCE_SETUP_TAB) { + if (!forceDrawerBelongs) { + this.isOpen = false; + } + return; + } + if (this.openTab === RightPanelComponent.KINEMATIC_SETUP_TAB && this.openTab !== wanted) { + this.isOpen = false; + } + // Export is an analysis-mode command: there is nothing to take away from a + // mechanism being drawn, and the drawer's own lists come from a solved + // cycle that Edit is about to change. + if (this.openTab === RightPanelComponent.EXPORT_TAB && wanted === -1) { + this.isOpen = false; + } } getOpenTab() { return RightPanelComponent.openTab; } + /** Two of these pages hold a table rather than a panel, and need the room. */ + isWidePage(): boolean { + return this.getOpenTab() === 2 || this.getOpenTab() === 4; + } + + /** + * The export drawer is wider than the view controls it stands over. + * + * Its rows are a checkbox, a part's name and what is notable about it, and at + * the drawer's usual width the third of those was always ellipsed away. + */ + isExportPage(): boolean { + return this.getOpenTab() === RightPanelComponent.EXPORT_TAB; + } + getIsOpen() { return RightPanelComponent.isOpen; } @@ -160,7 +319,7 @@ export class RightPanelComponent { printMechanism() { this.analytics.logEvent('debug_print_mechanism'); - console.log(this.mechanismService.mechanisms[0]); + console.log(this.mechanismService.mechanisms); console.log(this.mechanismService.links); console.log(this.mechanismService.joints); } @@ -265,144 +424,4 @@ export class RightPanelComponent { console.log(arc10.intersectsWith(arc11)); console.log(arc11.intersectsWith(arc10)); } - - gotoHelpSite() { - //Open a new tab to this site: https://pmks.mech.website/pmks-web-how-to-videos/ - window.open('https://pmks.mech.website/pmks-web-how-to-videos/', '_blank'); - this.analytics.logEvent('goto_help_site'); - } - - gotoGithub() { - //Open a new tab to this site: https://pmks.mech.website/pmks-web-how-to-videos/ - window.open('https://github.com/PMKS-Web/PMKSWeb', '_blank'); - this.analytics.logEvent('goto_github'); - } - - sendNotReady() { - introJs().start(); - // NewGridComponent.sendNotification('Sorry, the tutorial is not ready yet.'); - // this.analytics.logEvent('tutorial_not_ready'); - } - - getBrowserName() { - const agent = window.navigator.userAgent.toLowerCase(); - switch (true) { - case agent.indexOf('edge') > -1: - return 'Edge'; - case agent.indexOf('opr') > -1 && !!(<any>window).opr: - return 'Opera'; - case agent.indexOf('chrome') > -1 && !!(<any>window).chrome: - return 'Chrome'; - case agent.indexOf('trident') > -1: - return 'Internet Explorer'; - case agent.indexOf('firefox') > -1: - return 'Firefox'; - case agent.indexOf('safari') > -1: - return 'Safari'; - default: - return 'Other'; - } - } - - detectBrowserVersion() { - var userAgent = navigator.userAgent, - tem, - matchTest = - userAgent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || []; - - if (/trident/i.test(matchTest[1])) { - tem = /\brv[ :]+(\d+)/g.exec(userAgent) || []; - return 'IE ' + (tem[1] || ''); - } - if (matchTest[1] === 'Chrome') { - tem = userAgent.match(/\b(OPR|Edge)\/(\d+)/); - if (tem != null) return tem.slice(1).join(' ').replace('OPR', 'Opera'); - } - matchTest = matchTest[2] - ? [matchTest[1], matchTest[2]] - : [navigator.appName, navigator.appVersion, '-?']; - if ((tem = userAgent.match(/version\/(\d+)/i)) != null) matchTest.splice(1, 1, tem[1]); - return matchTest.join(' '); - } - - async sendCommentEmail() { - this.sendingEmail = true; - if (this.commentForm.invalid) { - NewGridComponent.sendNotification('Please fill out the form correctly.'); - this.sendingEmail = false; - return; - } else { - let emailJSKey = ''; - try { - const res = await fetch('/.netlify//functions/getEmailJSKey').then((response) => - response.json() - ); - emailJSKey = res.apiKey; - } catch (err) { - console.log(err); - NewGridComponent.sendNotification( - 'It looks like you are in a development environment. If this is not the case, please try again later or contact us directly at: gr-pmksplus@wpi.edu' - ); - this.sendingEmail = false; - return; - } - emailjs.init(emailJSKey); - - let browserInfo = ''; - if (this.commentForm.value.diagnostics) { - browserInfo += 'Browser: '; - browserInfo += this.getBrowserName(); - browserInfo += '\n Browser Version: '; - browserInfo += this.detectBrowserVersion(); - browserInfo += '\n OS: '; - browserInfo += window.navigator.platform; - browserInfo += '\n User Agent: '; - browserInfo += window.navigator.userAgent; - browserInfo += '\n App Version: '; - browserInfo += environment.appVersion; - } else { - browserInfo = 'User did not allow diagnostics'; - } - - let projectURL: string = 'User did not leave a project URL'; - if (this.commentForm.value.project) { - projectURL = this.urlGenerationService.generateFullUrl(); - } - - const params = { - to_email: 'gr-pmksplus@wpi.edu', - message: this.commentForm.value.comment - ? this.commentForm.value.comment - : 'User did not leave a comment', - email: this.commentForm.value.email - ? this.commentForm.value.email - : 'User did not leave an email and does not want a response', - diagnostic: browserInfo, - project: projectURL, - }; - - emailjs - .send('service_pg2k647', 'template_kfwdx5c', params) - .then(() => { - NewGridComponent.sendNotification('Message sent. Thank you for your feedback!'); - this.sendingEmail = false; - this.commentForm.reset(); - }) - .catch((error: any) => { - console.log(error); - NewGridComponent.sendNotification( - 'Message failed to send. Please try again later or contact us directly at: gr-pmksplus@wpi.edu' - ); - this.sendingEmail = false; - }); - } - } -} - -/** Error when invalid control is dirty, touched, or submitted. */ -export class MyErrorStateMatcher implements ErrorStateMatcher { - isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { - const isSubmitted = form && form.submitted; - return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted)); - } } diff --git a/src/app/component/settings-panel/settings-panel.component.html b/src/app/component/settings-panel/settings-panel.component.html index d3938867..f0f267c5 100644 --- a/src/app/component/settings-panel/settings-panel.component.html +++ b/src/app/component/settings-panel/settings-panel.component.html @@ -9,8 +9,8 @@ option3="SI (m)" [formGroup]="this.settingsForm" _formControl="globalunit" - tooltip="Select the global units to be used for the analysis." - [disabled]="this.settingsService.animating.value" + tooltip="Select the global units to be used for the analysis. Available in Edit mode." + [disabled]="!unitsEditable()" >Global Units </radio-block> <radio-block @@ -18,10 +18,17 @@ option2="Radian" [formGroup]="this.settingsForm" _formControl="angleunit" - tooltip="Select the units to be used for angles. This updates units for derived units as well (e.g. angular velocity, etc.)" - [disabled]="this.settingsService.animating.value" + tooltip="Select the units to be used for angles. This updates units for derived units as well (e.g. angular velocity, etc.) Available in Edit mode." + [disabled]="!unitsEditable()" >Angle Units </radio-block> + <toggle-block + [formGroup]="this.settingsForm" + _formControl="gravity" + tooltip="Weight loads every link that has mass. Turn off to analyze forces without gravity. Stored in the URL, so a shared analysis keeps it." + > + Gravity + </toggle-block> <!-- Input direction and speed belong to the input joint, so they live in that joint's Input Settings section in the Edit panel rather than here. --> </collapsible-subseciton> @@ -43,6 +50,20 @@ Minor Gridlines </toggle-block> } + <toggle-block + [formGroup]="this.settingsForm" + _formControl="snapToGrid" + tooltip="Dragging a joint or a link lands it on the nearest grid square. Hold Option to drag free of it." + > + Snap to Grid + </toggle-block> + <toggle-block + [formGroup]="this.settingsForm" + _formControl="snapToAlignment" + tooltip="Dragging squares up with a joint it is nearly level with, and says which one. Hold Option to drag free of it." + > + Snap to Alignment + </toggle-block> <input-block type="number" [formGroup]="this.settingsForm" @@ -54,5 +75,14 @@ >Update Object Scale</button-block > </collapsible-subseciton> + + <!-- Which build this is. It used to sit in the status strip along the + bottom, where it was permanent furniture answering a question asked + about twice a year; here it is one line away from the person who wants + it, and inside the card rather than floating under it. --> + <div class="versionRow"> + <span class="versionLabel">Version</span> + <span class="versionValue">PMKS+ {{ appVersion }}</span> + </div> </panel-section> </div> diff --git a/src/app/component/settings-panel/settings-panel.component.scss b/src/app/component/settings-panel/settings-panel.component.scss index 95b7ba64..6e12128b 100644 --- a/src/app/component/settings-panel/settings-panel.component.scss +++ b/src/app/component/settings-panel/settings-panel.component.scss @@ -11,10 +11,47 @@ $background: map.get($theme, background); $foreground: map.get($theme, foreground); + // The drawer hands its height down to here, and this hands it to the card, + // which is what scrolls. Without the chain the card grew as tall as its + // settings and the drawer frame scrolled instead -- taking the close button, + // which is positioned against that frame, off the top of it. #settingsWrapper { display: flex; flex-direction: column; gap: 10px; - padding: 10px; + flex: 1 1 auto; + min-height: 0; + + panel-section { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } + } + + // The panel stretches its children to its own width, which is a content-box + // width -- so a row with side padding came out wider than the drawer and the + // version ran off the edge. + .versionRow { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 5px; + // The same 40px row on a 15px gutter every section header in the app uses, + // rather than 10px of air above it and 14px below. + height: 40px; + padding: 0 15px; + border-top: 1px solid #eceef5; + font-size: 13px; + } + + .versionLabel { + color: rgba(0, 0, 0, 0.45); + } + + .versionValue { + margin-left: auto; + color: rgba(0, 0, 0, 0.6); } } diff --git a/src/app/component/settings-panel/settings-panel.component.ts b/src/app/component/settings-panel/settings-panel.component.ts index d643ba31..46a232da 100644 --- a/src/app/component/settings-panel/settings-panel.component.ts +++ b/src/app/component/settings-panel/settings-panel.component.ts @@ -1,30 +1,49 @@ -import { Component, ChangeDetectionStrategy, OnDestroy } from '@angular/core'; -import { SettingsService } from 'src/app/services/settings.service'; +import { SelectedTabService, TabID } from '../../selected-tab.service'; +import { environment } from '../../../environments/environment'; +import { Component, ChangeDetectionStrategy, OnDestroy, inject } from '@angular/core'; +import { SettingsService, writeStoredFlag } from 'src/app/services/settings.service'; import { LengthUnit, AngleUnit, ForceUnit, GlobalUnit } from 'src/app/model/utils'; -import { FormBuilder, Validators } from '@angular/forms'; +import { FormBuilder, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MechanismService } from '../../services/mechanism.service'; -import { Link, RealLink } from '../../model/link'; import { SvgGridService } from '../../services/svg-grid.service'; import { NumberUnitParserService } from '../../services/number-unit-parser.service'; import { Coord } from '../../model/coord'; -import { combineLatest, Subscription } from 'rxjs'; +import { combineLatest, skip, Subscription } from 'rxjs'; import { MODEL_SCALE } from '../../model/render-scale'; +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 { RadioComponent } from '../BLOCKS/radio/radio.component'; +import { ToggleComponent } from '../BLOCKS/toggle/toggle.component'; +import { InputComponent } from '../BLOCKS/input/input.component'; +import { ButtonComponent } from '../BLOCKS/button/button.component'; @Component({ selector: 'app-settings-panel', templateUrl: './settings-panel.component.html', styleUrls: ['./settings-panel.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ + PanelSectionComponent, + TitleBlock, + CollapsibleSubsecitonComponent, + RadioComponent, + FormsModule, + ReactiveFormsModule, + ToggleComponent, + InputComponent, + ButtonComponent, + ], }) export class SettingsPanelComponent implements OnDestroy { - constructor( - public settingsService: SettingsService, - private fb: FormBuilder, - public mechanismSrv: MechanismService, - private svgGrid: SvgGridService, - private nup: NumberUnitParserService - ) {} + settingsService = inject(SettingsService); + private fb = inject(FormBuilder); + mechanismSrv = inject(MechanismService); + private svgGrid = inject(SvgGridService); + private nup = inject(NumberUnitParserService); + private tabs = inject(SelectedTabService); + + readonly appVersion = environment.appVersion; currentLengthUnit!: LengthUnit; currentForceUnit!: ForceUnit; @@ -51,10 +70,19 @@ export class SettingsPanelComponent implements OnDestroy { globalunit: (this.currentGlobalUnit - 30).toString(), showMajorGrid: this.settingsService.isShowMajorGrid.value, showMinorGrid: this.settingsService.isShowMinorGrid.value, + snapToGrid: this.settingsService.isSnapToGrid.value, + snapToAlignment: this.settingsService.isSnapToAlignment.value, + gravity: this.settingsService.isGravity.value, }); this.settingsSubscriptions.add( - SettingsService._objectScale.subscribe((val) => { + // `skip(1)`, because this is a BehaviorSubject: subscribing to it hands + // back the value the drawing is *already* drawn at, and acting on that + // re-derived every link's outline and re-solved every mechanism from + // scratch. On a forty-five joint linkage that is seven seconds of work to + // arrive at the picture already on screen -- which is why opening + // Settings, which changes nothing, was the slowest thing in the app. + SettingsService._objectScale.pipe(skip(1)).subscribe((val) => { this.currentObjectScaleSetting = val / MODEL_SCALE; this.settingsForm.patchValue( { objectScale: this.currentObjectScaleSetting.toString() }, @@ -83,7 +111,8 @@ export class SettingsPanelComponent implements OnDestroy { this.settingsService.globalUnit, this.settingsService.isShowMajorGrid, this.settingsService.isShowMinorGrid, - ]).subscribe(([length, angle, force, global, showMajorGrid, showMinorGrid]) => { + this.settingsService.isGravity, + ]).subscribe(([length, angle, force, global, showMajorGrid, showMinorGrid, gravity]) => { this.currentLengthUnit = length; this.currentAngleUnit = angle; this.currentForceUnit = force; @@ -95,6 +124,7 @@ export class SettingsPanelComponent implements OnDestroy { globalunit: (global - 30).toString(), showMajorGrid, showMinorGrid, + gravity, }, { emitEvent: false } ); @@ -153,13 +183,35 @@ export class SettingsPanelComponent implements OnDestroy { this.settingsService.isShowMajorGrid.next(Boolean(val)); this.mechanismSrv.updateMechanism(); }); + // Remembered on this machine rather than written to the URL: see + // SettingsService.isSnapToGrid. + this.settingsForm.controls['snapToGrid'].valueChanges.subscribe((val) => { + const on = val === true; + this.settingsService.isSnapToGrid.next(on); + writeStoredFlag('snapToGrid', on); + }); + + this.settingsForm.controls['snapToAlignment'].valueChanges.subscribe((val) => { + const on = val === true; + this.settingsService.isSnapToAlignment.next(on); + writeStoredFlag('snapToAlignment', on); + }); + this.settingsForm.controls['showMinorGrid'].valueChanges.subscribe((val) => { this.settingsService.isShowMinorGrid.next(Boolean(val)); this.mechanismSrv.updateMechanism(); }); - // this.settingsForm.controls['torqueunit'].valueChanges.subscribe(() => { - // this.settingsService.inputTorque.next(this.currentTorqueUnit); - // }); + // Gravity changes what the force analysis means, so it is an edit: the + // mechanisms are rebuilt with the new flag and the change is undoable. + this.settingsForm.controls['gravity'].valueChanges.subscribe((val) => { + const on = val === true; + if (on === this.settingsService.isGravity.value) return; + this.settingsService.isGravity.next(on); + this.mechanismSrv.updateMechanism(true); + // An open force graph is plotting the old gravity; ask for a redraw the + // same way a unit change does. + this.mechanismSrv.onMechUpdateState.next(2); + }); } /** @@ -167,6 +219,17 @@ export class SettingsPanelComponent implements OnDestroy { * length control funnel here so a unit change always rescales the mechanism's * stored geometry, mass, inertia, and forces — never just relabels them. */ + /** + * A unit change rescales the drawing's stored geometry, so it is an edit. + * + * The analysis modes lock the geometry, and Synthesis is writing its own -- + * changing what a number means underneath either of them is the same class + * of surprise the lock exists to prevent. + */ + unitsEditable(): boolean { + return this.tabs.getCurrentTab() === TabID.EDIT && !this.settingsService.animating.value; + } + private changeLengthUnit(toUnit: LengthUnit): void { const fromUnit = this.settingsService.lengthUnit.value; this.currentLengthUnit = toUnit; @@ -179,10 +242,15 @@ export class SettingsPanelComponent implements OnDestroy { // Compensate the viewport zoom so the mechanism keeps its apparent size, // then scale visual affordances to match. const tempOriginInScreen = this.svgGrid.SVGtoScreen(new Coord(0, 0)); - this.svgGrid.panZoomObject.zoomAtPointBy(this.nup.convertLength(1, toUnit, fromUnit), { - x: tempOriginInScreen.x, - y: tempOriginInScreen.y, - }); + // Through `ourOwnMove`: holding the drawing still across a change of units + // is the app compensating, not the reader choosing a zoom, and the canvas + // tells the two apart by which of them made the move. + this.svgGrid.ourOwnMove(() => + this.svgGrid.panZoomObject.zoomAtPointBy(this.nup.convertLength(1, toUnit, fromUnit), { + x: tempOriginInScreen.x, + y: tempOriginInScreen.y, + }) + ); SettingsService._objectScale.next( this.nup.convertLength(SettingsService.objectScale, fromUnit, toUnit) ); @@ -219,6 +287,9 @@ export class SettingsPanelComponent implements OnDestroy { globalunit: ['', { updateOn: 'change' }], showMinorGrid: [true, { updateOn: 'change' }], showMajorGrid: [true, { updateOn: 'change' }], + snapToGrid: [false, { updateOn: 'change' }], + snapToAlignment: [true, { updateOn: 'change' }], + gravity: [true, { updateOn: 'change' }], }, { updateOn: 'blur' } ); diff --git a/src/app/component/shape-selector/shape-selector.component.html b/src/app/component/shape-selector/shape-selector.component.html deleted file mode 100644 index f3e6513f..00000000 --- a/src/app/component/shape-selector/shape-selector.component.html +++ /dev/null @@ -1,17 +0,0 @@ -<!--<div id="shape-selector" *ngIf="getShowcaseShapeSelector()">--> -<!-- <p id="shape-selector-title">Select shapes</p>--> -<!-- <div id="shape-selector-wrapper">--> -<!-- <table id="shape-selector-table">--> -<!-- <tr *ngFor="let shape of shapes">--> -<!-- <td style="height: 120px; border-bottom: 1px solid black; text-align: center" class="shape-on-wrapper">--> -<!-- <img src="{{ getURL(shape) }}"/>--> -<!-- </td>--> -<!-- </tr>--> -<!-- </table>--> -<!-- </div>--> -<!-- <div id="button-wrapper">--> -<!-- <button class="secondary-buttons" (click)="save()">Save</button>--> -<!-- <button class="secondary-buttons" (click)="cancel()">Cancel</button>--> -<!-- <button class="secondary-buttons" (click)="revert()">Revert</button>--> -<!-- </div>--> -<!--</div>--> diff --git a/src/app/component/shape-selector/shape-selector.component.scss b/src/app/component/shape-selector/shape-selector.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/component/shape-selector/shape-selector.component.ts b/src/app/component/shape-selector/shape-selector.component.ts deleted file mode 100644 index 2d4b1375..00000000 --- a/src/app/component/shape-selector/shape-selector.component.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - Component, - EventEmitter, - Input, - OnInit, - Output, - ChangeDetectionStrategy, -} from '@angular/core'; -// import { GridComponent } from '../grid/grid.component'; -// import { RealLink, Shape } from '../../model/link'; -// import { Coord } from '../../model/coord'; -// -@Component({ - selector: 'app-shape-selector', - templateUrl: './shape-selector.component.html', - styleUrls: ['./shape-selector.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, -}) -export class ShapeSelectorComponent {} - -// shapes: Shape[] = [ -// Shape.line, -// Shape.bar, -// Shape.eTriangle, -// Shape.rTriangle, -// Shape.rectangle, -// Shape.square, -// Shape.circle, -// Shape.cShape, -// Shape.tShape, -// Shape.lShape, -// ]; -// -// constructor() {} -// -// ngOnInit(): void {} -// -// getURL(shape: Shape) { -// switch (shape) { -// case Shape.line: -// return '../../assets/shapes/line.svg'; -// case Shape.bar: -// return '../../assets/shapes/bar.svg'; -// case Shape.eTriangle: -// return '../../assets/shapes/eTriangle.svg'; -// case Shape.rTriangle: -// return '../../assets/shapes/rTriangle.svg'; -// case Shape.rectangle: -// return '../../assets/shapes/rectangle.svg'; -// case Shape.square: -// return '../../assets/shapes/square.svg'; -// case Shape.circle: -// return '../../assets/shapes/circle.svg'; -// case Shape.cShape: -// return '../../assets/shapes/cShape.svg'; -// case Shape.tShape: -// return '../../assets/shapes/tShape.svg'; -// case Shape.lShape: -// return '../../assets/shapes/lShape.svg'; -// default: -// return ''; -// } -// } -// -// getShowcaseShapeSelector() { -// return GridComponent.showcaseShapeSelector; -// } -// -// save() { -// GridComponent.showcaseShapeSelector = false; -// GridComponent.updateMechanism(); -// } -// -// cancel() { -// if (GridComponent.initialLink !== undefined) { -// // GridComponent.selectedLink.bound = GridComponent.initialLink.bound; -// GridComponent.selectedLink.d = GridComponent.initialLink.d; -// GridComponent.selectedLink.CoM = GridComponent.initialLink.CoM; -// } -// GridComponent.showcaseShapeSelector = false; -// GridComponent.updateMechanism(); -// } -// -// revert() { -// // TODO: Fix a bug where a binary link returns bound when it shouldn't -// // GridComponent.selectedLink.bound = GridComponent.initialLink.bound; -// GridComponent.selectedLink.d = GridComponent.initialLink.d; -// GridComponent.selectedLink.CoM = GridComponent.initialLink.CoM; -// GridComponent.updateMechanism(); -// } -// } diff --git a/src/app/component/svg-arrow/svg-arrow.component.html b/src/app/component/svg-arrow/svg-arrow.component.html index 85a66f66..ef3f6c1a 100644 --- a/src/app/component/svg-arrow/svg-arrow.component.html +++ b/src/app/component/svg-arrow/svg-arrow.component.html @@ -2,19 +2,19 @@ <!-- //Make a oversized hibox to make it easier to click--> <line id="hibox" - [attr.x1]="x" - [attr.y1]="y" + [attr.x1]="x()" + [attr.y1]="y()" [attr.x2]="x2" [attr.y2]="y2" - [attr.stroke-width]="width * 5" - [attr.stroke]="color" + [attr.stroke-width]="width() * 5" + [attr.stroke]="color()" stroke-linecap="round" ></line> <path [attr.d]="getPath()" - [attr.stroke]="color" - [attr.stroke-width]="width" + [attr.stroke]="color()" + [attr.stroke-width]="width()" stroke-linecap="round" /> </svg> diff --git a/src/app/component/svg-arrow/svg-arrow.component.spec.ts b/src/app/component/svg-arrow/svg-arrow.component.spec.ts index d5881153..3923f8b0 100644 --- a/src/app/component/svg-arrow/svg-arrow.component.spec.ts +++ b/src/app/component/svg-arrow/svg-arrow.component.spec.ts @@ -8,7 +8,7 @@ describe('SvgArrowComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [SvgArrowComponent], + imports: [SvgArrowComponent], }).compileComponents(); fixture = TestBed.createComponent(SvgArrowComponent); diff --git a/src/app/component/svg-arrow/svg-arrow.component.ts b/src/app/component/svg-arrow/svg-arrow.component.ts index 123bc3c1..512b5619 100644 --- a/src/app/component/svg-arrow/svg-arrow.component.ts +++ b/src/app/component/svg-arrow/svg-arrow.component.ts @@ -1,38 +1,37 @@ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; @Component({ selector: '[app-svg-arrow]', templateUrl: './svg-arrow.component.html', styleUrls: ['./svg-arrow.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, }) export class SvgArrowComponent { - @Input() x: number = 0; - @Input() y: number = 0; - @Input() magnitude: number = 0; - @Input() direction: number = 0; - @Input() width: number = 0.1; - @Input() color: string = 'black'; + readonly x = input<number>(0); + readonly y = input<number>(0); + readonly magnitude = input<number>(0); + readonly direction = input<number>(0); + readonly width = input<number>(0.1); + readonly color = input<string>('black'); readonly SCALAR: number = 0.2; constructor() {} get x2(): number { - return this.x + this.magnitude * Math.cos(this.directionRadians); + return this.x() + this.magnitude() * Math.cos(this.directionRadians); } get y2(): number { - return this.y + this.magnitude * Math.sin(this.directionRadians); + return this.y() + this.magnitude() * Math.sin(this.directionRadians); } get delta(): number { - return this.magnitude * this.SCALAR; + return this.magnitude() * this.SCALAR; } get directionRadians(): number { - return (this.direction * Math.PI) / 180; + return (this.direction() * Math.PI) / 180; } // arrow point A @@ -61,6 +60,6 @@ export class SvgArrowComponent { } getPath() { - return `M ${this.x} ${this.y} L ${this.x2} ${this.y2} M ${this.ax1} ${this.ay1} L ${this.x2} ${this.y2} M ${this.ax2} ${this.ay2} L ${this.x2} ${this.y2}`; + return `M ${this.x()} ${this.y()} L ${this.x2} ${this.y2} M ${this.ax1} ${this.ay1} L ${this.x2} ${this.y2} M ${this.ax2} ${this.ay2} L ${this.x2} ${this.y2}`; } } diff --git a/src/app/component/synthesis-panel/synthesis-panel.component.html b/src/app/component/synthesis-panel/synthesis-panel.component.html index d5717681..7a19ca2a 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.html +++ b/src/app/component/synthesis-panel/synthesis-panel.component.html @@ -1,109 +1,573 @@ -<div id="synthesisWrapper"> - <panel-section> - <title-block description="Automatically create a 4-bar linkage based on 3 coupler poses."> - 3 Position Synthesis - </title-block> - <!-- <subtitle-block>PMKS+ will create a 4-bar linkage based on 3 poses.</subtitle-block>--> - <!-- <input-block unit='cm'>Length</input-block>--> - <!-- <app-not-ready-warning></app-not-ready-warning>--> - <!-- <img src='assets/temp3pos.png'> --> - - <collapsible-subseciton [expanded]="true" [hideHeader]="true"> - <subtitle-block>End-Effector Link (Coupler)</subtitle-block> - <input-block - [formGroup]="synthesisPoseForm" - _formControl="length" - tooltip="The length of the end-effector link" - > - Length - </input-block> - <radio-block - [formGroup]="synthesisPoseForm" - _formControl="cor" - option1="Back" - option2="Center" - option3="Front" - tooltip="The center of rotation of the end-effector link, and what the position coordinate represents" - > - Fixed Reference - </radio-block> - <mat-divider></mat-divider> - - <!-- Construct panel sections for each pose --> - @for (i of [1, 2, 3]; track i) { - <div> - <!-- display header, with edit button on right if pose already has been created --> - <div> - <subtitle-block class="poseHeader"> - <p>Pose {{ i }}</p> - <!-- <button-block class = "poseEdit" *ngIf="getCurrentPose() !== i && synthesisBuilder.isPoseDefined(i)" icon="edit" (click)="setCurrentPose(i)">Edit</button-block> --> - </subtitle-block> +<div id="synthesisPanel"> + <!-- What kind of synthesis this is. One kind exists; the second is named + rather than hidden, because "can it fit a path?" is the first question + anyone asks of this screen and an empty answer is worse than a dated one. --> + @if (isChooser) { + <div class="chooser"> + <div class="chooser__head"> + <div class="chooser__title">Synthesis</div> + <div class="chooser__sub"> + Build a linkage from the motion you want, then insert it into the drawing. + </div> + </div> + <div class="chooser__body"> + <div class="chooser__prompt">What are you synthesising?</div> + + <button type="button" class="kindCard kindCard--on" (click)="startMotionSynthesis()"> + <span class="kindCard__icon"> + <mat-icon svgIcon="synthesis"></mat-icon> + </span> + <span class="kindCard__text"> + <span class="kindCard__row"> + <span class="kindCard__name">Motion — 3 positions</span> + <span class="chip chip--on">4-bar or 6-bar</span> + </span> + <span class="kindCard__detail"> + Place a coupler in three positions. Every four-bar that passes through them is listed + for you to compare. + </span> + </span> + </button> + + <div class="kindCard kindCard--off"> + <span class="kindCard__icon"><mat-icon>show_chart</mat-icon></span> + <span class="kindCard__text"> + <span class="kindCard__row"> + <span class="kindCard__name">Path — points on a curve</span> + <span class="chip">Coming soon</span> + </span> + <span class="kindCard__detail"> + Sketch a curve and fit a linkage whose coupler point follows it. Not available yet. + </span> + </span> + </div> + </div> + </div> + } @else { + <div class="work"> + <div class="work__head"> + <button type="button" class="iconBtn" (click)="backToChooser()" aria-label="Back"> + <mat-icon>arrow_back</mat-icon> + </button> + <div class="work__headText"> + <div class="work__title">Motion synthesis</div> + <div class="work__note">{{ headerNote() }}</div> + </div> + </div> + + <div class="work__scroll" #workScroll [formGroup]="poseForm"> + <!-- The bar being positioned ------------------------------------- --> + <!-- Named for what it is. Titled "Coupler", this section held a Length + field belonging to a different bar: the coupler is the four-bar's + own floating member, pinned *to* this link and only the same length + when it happens to be pinned at its ends. That is precisely what + the first requirement decides, and the two readings of the word + made the requirement read like a contradiction. --> + <collapsible-subseciton class="sect" titleLabel="End-effector link" [expanded]="true"> + <div class="row"> + <span class="row__label">Length</span> + <mat-icon class="row__help" [matTooltip]="help.length" [matTooltipShowDelay]="400"> + help_outline + </mat-icon> + <span class="row__spacer"></span> + <label class="field field--wide"> + <input + type="text" + inputmode="decimal" + formControlName="length" + spellcheck="false" + aria-label="End-effector link length" + /> + </label> + </div> + <div class="row"> + <span class="row__label">Fixed reference</span> + <mat-icon class="row__help" [matTooltip]="help.ref" [matTooltipShowDelay]="400"> + help_outline + </mat-icon> + <span class="row__spacer"></span> + <div class="seg seg--wide"> + @for (opt of referenceOptions(); track opt.value) { + <button + type="button" + class="seg__opt" + [class.seg__opt--on]="opt.active" + (click)="setReference(opt.value)" + > + {{ opt.label }} + </button> + } + </div> </div> - <!-- display editable pose configuration if pose is both created AND selected --> - @if (synthesisBuilder.isPoseDefined(i)) { - <div class="poseData"> - <dual-input-block - [formGroup]="synthesisPoseForm" - formControl1="{{ getFormIDPoseX(i) }}" - formControl2="{{ getFormIDPoseY(i) }}" - tooltip="The position of the center of the end effector link" + </collapsible-subseciton> + + <!-- The three positions ------------------------------------------ --> + <collapsible-subseciton class="sect" titleLabel="Positions" [expanded]="true"> + <!-- Beside the heading rather than under it: they were a row of their + own, which cost a band of vertical space in a panel that has + little to spare, and left a gap once all three were placed and + there was nothing left to put in it. --> + <ng-container headerActions> + @if (showAddButton) { + <button + type="button" + class="pill" + [class.pill--armed]="design.armed" + (click)="toggleArmed()" > - Pose {{ i }} Position - </dual-input-block> - <input-block - [formGroup]="synthesisPoseForm" - _formControl="{{ getFormIDPoseTheta(i) }}" - tooltip="The angle of the end effector link in degrees" + <mat-icon>{{ design.armed ? 'close' : 'ads_click' }}</mat-icon> + {{ addLabel }} + </button> + } + @if (canDuplicate) { + <button + type="button" + class="pill pill--square" + [matTooltip]="help.duplicate" + [matTooltipShowDelay]="400" + (click)="duplicateLast()" + aria-label="Duplicate last position" > - Pose {{ i }} Angle - </input-block> + <mat-icon>content_copy</mat-icon> + </button> + } + </ng-container> + + @for (i of rows; track i) { +<!-- The row is a container, not a control. It was given + role="button" so it could be reached by keyboard, which made it + a button wrapping two textboxes and another button -- a shape + no assistive technology can make sense of, and whose Space + handler swallowed the nested button's own activation. The + numbered chip is the control instead: one real button, with + nothing inside it. Clicking anywhere on the row still selects, + for the mouse. + + Clicking the row's own space arms the placer, which is what an + empty row is for; clicking into one of its boxes does not, and + says so by stopping there. Both ways of filling a position are + live at the same time -- arming does not switch typing off, and + typing does not switch arming off. --> + <div + class="poseRow" + [class.poseRow--sel]="isSelectedRow(i)" + [class.poseRow--empty]="!isPlaced(i)" + (click)="selectRow(i)" + > + <button + type="button" + class="poseRow__n" + [class.poseRow__n--placed]="isPlaced(i)" + [attr.aria-pressed]="isSelectedRow(i)" + [attr.aria-label]="isPlaced(i) ? 'Select position ' + i : 'Place position ' + i" + (click)="selectRow(i); $event.stopPropagation()" + > + {{ i }} + </button> + <span class="poseRow__axis">X</span> + <label class="field field--num"> + <input + type="text" + inputmode="decimal" + spellcheck="false" + placeholder="—" + [formControlName]="'p' + i + 'x'" + [attr.aria-label]="'Position ' + i + ' X'" + (focus)="aimAtRow(i)" + (click)="$event.stopPropagation()" + /> + </label> + <span class="poseRow__axis">Y</span> + <label class="field field--num"> + <input + type="text" + inputmode="decimal" + spellcheck="false" + placeholder="—" + [formControlName]="'p' + i + 'y'" + [attr.aria-label]="'Position ' + i + ' Y'" + (focus)="aimAtRow(i)" + (click)="$event.stopPropagation()" + /> + </label> + <span class="poseRow__axis poseRow__axis--angle">⊾</span> + <label class="field field--angle"> + <input + type="text" + inputmode="decimal" + spellcheck="false" + placeholder="—" + [formControlName]="'p' + i + 'theta'" + [attr.aria-label]="'Position ' + i + ' angle'" + (focus)="aimAtRow(i)" + (click)="$event.stopPropagation()" + /> + </label> + <mat-icon + class="poseRow__status" + [class.poseRow__status--ok]="reached(i) === true" + [class.poseRow__status--defect]="reached(i) === false" + [class.poseRow__status--arm]="isPreviewingRow(i)" + [matTooltip]="rowStatusTip(i)" + [matTooltipShowDelay]="300" + > + {{ rowStatusIcon(i) }} + </mat-icon> + <button + type="button" + class="poseRow__remove" + [class.poseRow__remove--off]="!isPlaced(i)" + [disabled]="!isPlaced(i)" + [attr.aria-label]="'Remove position ' + i" + (click)="removeRow($event, i)" + > + <mat-icon>close</mat-icon> + </button> </div> } - <!-- if pose has not yet been created and its the first undefined pose, show "create pose" button --> - @if (synthesisBuilder.getFirstUndefinedPose() === i) { - <div class="poseCreate"> - <button-block - icon="add" - (click)="synthesisBuilder.createPose(i); this.svgGrid.updateObjectScale()" - >Create Pose - </button-block> + </collapsible-subseciton> + + <!-- What a solution has to satisfy -------------------------------- --> + <collapsible-subseciton class="sect" titleLabel="Requirements" [expanded]="true"> + @for (rq of requirements(); track rq.key) { + <div class="req" [class.req--on]="rq.on"> + <button type="button" class="req__line" (click)="rq.toggle()"> + <mat-icon class="req__icon"> + {{ rq.on ? 'check_circle' : 'radio_button_unchecked' }} + </mat-icon> + <span class="req__text"> + <span class="req__label">{{ rq.label }}</span> + <span class="req__detail">{{ rq.detail }}</span> + </span> + <span class="switch" [class.switch--on]="rq.on" + ><span class="switch__knob"></span + ></span> + </button> + + @if (rq.hasRegion) { + <div class="region" [formGroup]="regionForm"> + <div class="region__line"> + <span class="region__summary">{{ regionSummary() }}</span> + <button + type="button" + class="region__draw" + [class.region__draw--on]="design.regionDraw" + (click)="toggleRegionDraw()" + > + {{ design.regionDraw ? 'Cancel' : 'Redraw' }} + </button> + </div> + <!-- Four numbers on one row cannot each carry a unit and + still show their digits, so the unit is said once for + all of them and the boxes hold plain numbers. --> + <div class="region__fields"> + <span class="region__tag">X</span> + <label class="field field--tiny" + ><input + type="text" + inputmode="decimal" + formControlName="rx" + aria-label="Region X" + /></label> + <span class="region__tag">Y</span> + <label class="field field--tiny" + ><input + type="text" + inputmode="decimal" + formControlName="ry" + aria-label="Region Y" + /></label> + <span class="region__tag">W</span> + <label class="field field--tiny" + ><input + type="text" + inputmode="decimal" + formControlName="rw" + aria-label="Region width" + /></label> + <span class="region__tag">H</span> + <label class="field field--tiny" + ><input + type="text" + inputmode="decimal" + formControlName="rh" + aria-label="Region height" + /></label> + <span class="region__unit">{{ lengthUnit }}</span> + </div> + </div> + } + </div> + } + </collapsible-subseciton> + + <!-- Under the requirements rather than inside them: it is not one of + them, it is what they have jointly done. --> + @if (requirementsBlocking) { + <div class="sect sect--note"> + <div class="note note--warn"> + <mat-icon>rule</mat-icon> + <span>{{ requirementsBlockingNote() }}</span> </div> + </div> + } + + <!-- Running the search -------------------------------------------- --> + <!-- What came back ------------------------------------------------ --> + @if (showResults) { + <!-- Only when it has something in it: with one solution and nothing + to explain, this was an empty band above the Solution heading. --> + @if (showGallery || !hasSolution || showNoCandidateReason) { + <section class="sect"> + <!-- A heading over a gallery says what there is to choose between. + Over a single solution it only repeats what the section below + already says, at greater length. --> + @if (showGallery || !hasSolution) { + <div class="sect__head"> + <div class="sect__title">{{ candidateHeading() }}</div> + </div> + } + + @if (showNoCandidateReason) { + <div class="note note--bad"> + <mat-icon>error_outline</mat-icon> + <span>{{ noCandidateReason() }}</span> + </div> + } + + @if (showGallery) { + <div class="gallery" [class.gallery--all]="solution.showAll"> + @for (c of visibleCandidates(); track c.key) { + <button + type="button" + class="card" + [class.card--sel]="c.selected" + (click)="pickCandidate(c.key)" + (pointerenter)="hoverCandidate(c.key)" + (pointerleave)="hoverCandidate(null)" + > + <span class="card__head"> + <span class="card__name">{{ c.name }}</span> + <span class="card__kind">{{ c.kind }}</span> + </span> + <svg class="card__thumb" width="100%" height="48" viewBox="0 0 120 48"> + <path + [attr.d]="c.thumb" + fill="none" + stroke="#c5cae9" + stroke-width="4" + stroke-linecap="round" + ></path> + <path + [attr.d]="c.thumbCoupler" + fill="none" + stroke="#303e9f" + stroke-width="4" + stroke-linecap="round" + ></path> + </svg> + <span class="card__reach" [class.card__reach--ok]="c.defectFree"> + {{ c.reachText }} + </span> + <span class="card__metric" [class.card__metric--binds]="c.binds">{{ + c.metric + }}</span> + </button> + } + </div> + + @if (hasMoreCandidates) { + <button type="button" class="linkBtn" (click)="toggleAllCandidates()"> + {{ moreLabel() }} + <mat-icon>{{ solution.showAll ? 'expand_less' : 'expand_more' }}</mat-icon> + </button> + } + } + </section> } + + @if (hasSolution) { + <section class="sect sect--last"> + <div class="sect__title">{{ solutionHeading }}</div> + + <div class="row"> + <span class="row__label">Assembly branch</span> + <mat-icon class="row__help" [matTooltip]="help.branch" [matTooltipShowDelay]="400"> + help_outline + </mat-icon> + <span class="row__spacer"></span> + <div class="seg seg--wide"> + @for (opt of branchOptions(); track opt.label) { + <button + type="button" + class="seg__opt" + [class.seg__opt--on]="opt.active" + [disabled]="!opt.available" + (click)="pickBranch(opt.key)" + > + {{ opt.label }} + </button> + } + </div> + </div> + + <div class="row"> + <span class="row__label">Driven from</span> + <mat-icon class="row__help" [matTooltip]="help.pin" [matTooltipShowDelay]="400"> + help_outline + </mat-icon> + <span class="row__spacer"></span> + <div class="seg seg--wide"> + @for (opt of pinOptions(); track opt.label) { + <button + type="button" + class="seg__opt" + [class.seg__opt--on]="opt.active" + (click)="setPin(opt.far)" + > + {{ opt.label }} + </button> + } + </div> + </div> + + <div class="row"> + <button + type="button" + class="row__label row__label--btn" + [disabled]="!!driverRefusal" + (click)="toggleDriver()" + > + Add driver (six-bar) + </button> + <mat-icon class="row__help" [matTooltip]="help.driver" [matTooltipShowDelay]="400"> + help_outline + </mat-icon> + <span class="row__spacer"></span> + <span + [matTooltip]="driverRefusal" + [matTooltipDisabled]="!driverRefusal" + [matTooltipShowDelay]="200" + matTooltipPosition="above" + > + <button + type="button" + class="switch" + [class.switch--on]="driverOn" + [disabled]="!!driverRefusal" + (click)="toggleDriver()" + aria-label="Add driver" + > + <span class="switch__knob"></span> + </button> + </span> + </div> + + <div class="dims"> + <button type="button" class="dims__head" (click)="toggleDimensions()"> + <span class="dims__title">Link lengths</span> + <span class="dims__summary">{{ dimensionsSummary() }}</span> + <mat-icon>{{ solution.dimensionsOpen ? 'expand_less' : 'expand_more' }}</mat-icon> + </button> + @if (solution.dimensionsOpen) { + <div class="dims__body"> + @for (d of dimensionRows(); track d.label) { + <div class="dims__row"> + <span>{{ d.label }}</span> + <span class="dims__value">{{ d.value }}</span> + </div> + } + </div> + } + </div> + </section> + } + } + </div> + + <!-- The transport, and the one button that changes the drawing ------- --> + <div class="foot"> + @if (hasSolution) { + <div class="transport" [class.transport--playing]="solution.playing"> + <div class="transport__line"> + @if (showGallery) { + <span class="transport__name">{{ solutionName }}</span> + } + <span class="transport__note">{{ previewNote() }}</span> + <span class="transport__angle">{{ angleLabel() }}</span> + <button + type="button" + class="iconBtn iconBtn--sm" + (click)="togglePlay()" + [attr.aria-label]="solution.playing ? 'Pause preview' : 'Play preview'" + > + <mat-icon>{{ solution.playing ? 'pause' : 'play_arrow' }}</mat-icon> + </button> + <button + type="button" + class="iconBtn iconBtn--sm" + (click)="flipDirection()" + aria-label="Reverse direction" + > + <mat-icon>{{ solution.clockwise ? 'rotate_right' : 'rotate_left' }}</mat-icon> + </button> + </div> + <div class="track"> + <div class="track__channel"></div> + <div class="track__done" [style.width]="alongPercent()"></div> + @for (tk of poseTicks(); track $index) { + <div + class="track__tick" + [class.track__tick--ok]="tk.reached" + [style.left.%]="tk.percent" + ></div> + } + <input + class="track__scrub" + type="range" + step="1" + [min]="scrubMin()" + [max]="scrubMax()" + [value]="scrubValue()" + (input)="setScrub($event)" + aria-label="Scrub preview" + /> + </div> + </div> + } + <!-- One button, because there is only ever one next step: search, or + commit what the search found. --> + <!-- A bar and nothing else. The sentence that used to be here described + the search accurately and was gone before anybody finished reading + it, which is a sentence doing no work. --> + @if (solution.generating) { + <div class="foot__progress"> + <div class="sweep"><div class="sweep__bar"></div></div> + </div> + } + <div class="foot__buttons"> + <button + type="button" + class="cta cta--insert" + [class.cta--busy]="solution.generating" + [disabled]="primaryDisabled" + (click)="primaryAction()" + > + <mat-icon>{{ primaryIcon }}</mat-icon> + {{ primaryLabel }} + </button> + <button type="button" class="danger" (click)="deleteAll()"> + <mat-icon>delete_outline</mat-icon>Delete all positions + </button> </div> - } - - <mat-divider></mat-divider> - - <!-- <button-block icon='add' [disabled]='!synthesisBuilder.isFullyDefined()' (click)='synthesisFunction()'>--> - <!-- Generate Linkage--> - <!-- </button-block>--> - - <button-block - [disabled]="synthesisBuilder.getAllPoses().length === 0" - (click)="synthesisBuilder.deleteAllPoses()" - customIcon="remove" - color="warn" - > - Remove All Poses - </button-block> - </collapsible-subseciton> - </panel-section> - <!-- <panel-section-collapsible [expanded]='true'>--> - <!-- <subtitle-block buttonLabel='Edit Pose' icon='edit' alwaysShown>Pose 1</subtitle-block>--> - <!-- <dual-input-block>Pose 1 Position</dual-input-block>--> - <!-- <input-block>Pose 1 Angle</input-block>--> - <!-- <button-block icon='arrow_forward'>Next Pose</button-block>--> - <!-- </panel-section-collapsible>--> - <!-- <panel-section-collapsible [expanded]='false'>--> - <!-- <subtitle-block buttonLabel='Edit Pose' icon='edit' alwaysShown>Pose 2</subtitle-block>--> - <!-- <dual-input-block>Pose 2 Position</dual-input-block>--> - <!-- <input-block>Pose 2 Angle</input-block>--> - <!-- <button-block icon='arrow_forward'>Next Pose</button-block>--> - <!-- </panel-section-collapsible>--> - <!-- <panel-section-collapsible [expanded]='false'>--> - <!-- <subtitle-block buttonLabel='Edit Pose' icon='edit' alwaysShown>Pose 3</subtitle-block>--> - <!-- <dual-input-block>Pose 3 Position</dual-input-block>--> - <!-- <input-block>Pose 3 Angle</input-block>--> - <!-- <button-block icon='auto_fix_normal'>Generate</button-block>--> - <!-- </panel-section-collapsible>--> + @if (solution.inserted) { + <div class="note note--good"> + <mat-icon>check_circle</mat-icon> + <span>{{ insertedNote() }}</span> + <button type="button" class="note__undo" (click)="undoInsert()">Undo</button> + </div> + } + </div> + </div> + } </div> diff --git a/src/app/component/synthesis-panel/synthesis-panel.component.scss b/src/app/component/synthesis-panel/synthesis-panel.component.scss index 296ae46d..f99db59a 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.scss +++ b/src/app/component/synthesis-panel/synthesis-panel.component.scss @@ -1,93 +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; - margin: 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; + } + } + + /* --- the chooser --------------------------------------------------------- */ + + .chooser__head { + padding: 10px 15px 8px; + } + + .chooser__title { + white-space: nowrap; + } + + .chooser__sub { + font-size: 14px; + line-height: 18px; + color: rgba(0, 0, 0, 0.6); + } + + .chooser__body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 15px 14px; + border-top: 1px solid $rule; + } + + .chooser__prompt { + font-size: 14px; + font-weight: 500; + line-height: 22px; + } + + .kindCard { + display: flex; + gap: 12px; + padding: 12px; + border-radius: 6px; + text-align: left; + font: inherit; + color: inherit; + + &--on { + border: 1.5px solid $indigo; + background: $indigo-tint; + cursor: pointer; + + &:hover { + background: #eef1fc; + } + } + + /* 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; + + .kindCard__name, + .kindCard__detail { + color: rgba(0, 0, 0, 0.45); + } + + .kindCard__icon { + background: $fill; + color: #b0b3c0; + } + } + + &__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; + } + + &__text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + &__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 <section> 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; + } -// #headline-1 { -// @include mat.typography-level($typography-config, 'headline-1'); -// } + &__angle { + margin-left: auto; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + } + } -// #headline-2 { -// @include mat.typography-level($typography-config, 'headline-2'); -// } + /* 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; -// #headline-3 { -// @include mat.typography-level($typography-config, 'headline-3'); -// } + &__channel { + position: absolute; + left: 0; + right: 0; + top: 0; + height: 8px; + border-radius: 4px; + background: #d5d7e0; + } -// #headline-4 { -// @include mat.typography-level($typography-config, 'headline-4'); -// } + &__done { + position: absolute; + left: 0; + top: 0; + height: 8px; + border-radius: 4px; + background: $indigo; + } -// #headline-5 { -// @include mat.typography-level($typography-config, 'headline-5'); -// } + &__tick { + position: absolute; + top: 1px; + z-index: 1; + width: 6px; + height: 6px; + margin-left: -3px; + border-radius: 3px; + background: $amber; + pointer-events: none; -// #headline-6 { -// @include mat.typography-level($typography-config, 'headline-6'); -// } + &--ok { + background: #43a047; + } + } -// #subtitle-1 { -// @include mat.typography-level($typography-config, 'subtitle-1'); -// } + &__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; -// #body-1 { -// @include mat.typography-level($typography-config, 'body-2'); -// } + &::-webkit-slider-runnable-track { + height: 8px; + border: none; + background: transparent; + } -// #body-2 { -// @include mat.typography-level($typography-config, 'body-2'); -// } + &::-moz-range-track { + height: 8px; + border: none; + background: transparent; + } -// #subtitle-2 { -// @include mat.typography-level($typography-config, 'subtitle-2'); -// } + &::-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; + } -// #caption { -// @include mat.typography-level($typography-config, 'caption'); -// } + &::-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; + } + } + } -// #overline { -// @include mat.typography-level($typography-config, 'overline'); -// } + .foot__buttons { + display: flex; + gap: 8px; + padding: 0 4px 2px; + } -// #button { -// @include mat.typography-level($typography-config, 'button'); -// } + .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; -//#input { -// @include mat.typography-level($typography-config, 'input'); -//} + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } -.poseData { - display: flex; - flex-direction: column; - gap: 10px; + &: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 6bd8903a..14712355 100644 --- a/src/app/component/synthesis-panel/synthesis-panel.component.ts +++ b/src/app/component/synthesis-panel/synthesis-panel.component.ts @@ -1,594 +1,1267 @@ -import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; -import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; -import { NewGridComponent } from '../new-grid/new-grid.component'; -import { Pose } from '../../model/pose'; -import { Coord } from '../../model/coord'; -import { Joint, RealJoint, RevJoint } from '../../model/joint'; -import { GridUtilsService } from '../../services/grid-utils.service'; +import { + Component, + OnInit, + OnDestroy, + ChangeDetectionStrategy, + ElementRef, + inject, + viewChild, +} from '@angular/core'; +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; +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 { MechanismBuilder } from '../../services/transcoding/mechanism-builder'; -import { Mechanism } from '../../model/mechanism/mechanism'; -import { Link, 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 { 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'; + +/** 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, - standalone: false, + imports: [FormsModule, ReactiveFormsModule, MatIcon, MatTooltip, CollapsibleSubsecitonComponent], }) -export class SynthesisPanelComponent implements OnInit { - PoseID: any; - - private _alreadyHandlingPoseChange: boolean = false; - - constructor( - private fb: FormBuilder, - public mechanismSrv: MechanismService, - public synthesisBuilder: SynthesisBuilderService, - private nup: NumberUnitParserService, - private settings: SettingsService, - public svgGrid: SvgGridService, - private colorService: ColorService - ) {} - - 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: ' ', - }); +export class SynthesisPanelComponent implements OnInit, OnDestroy { + private fb = inject(FormBuilder); + mechanismSrv = inject(MechanismService); + private notify = inject(NotificationService); + design = inject(SynthesisBuilderService); + solution = inject(SynthesisSolutionService); + private nup = inject(NumberUnitParserService); + private settings = inject(SettingsService); + svgGrid = inject(SvgGridService); + + readonly help = HELP; + readonly rows = [1, 2, 3]; + + /** The panel's scrolling half, so a finished search can be scrolled to. */ + private readonly workScroll = viewChild<ElementRef<HTMLElement>>('workScroll'); + + private subs: Subscription[] = []; + private syncing = false; + /** Rows that had a position last time the panel read the design. */ + private hadPose = new Set<number>(); + private frame: number | undefined; + + poseForm = this.fb.group( + { + length: [''], + p1x: [''], + p1y: [''], + p1theta: [''], + p2x: [''], + p2y: [''], + p2theta: [''], + p3x: [''], + p3y: [''], + p3theta: [''], + }, + { updateOn: 'blur' } + ); - // initialize form values from model - this.updateFormFromModel(); + 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(); + }) + ); - // when model updates, update form values as well - this.synthesisBuilder.valueChanges.subscribe((value) => { - this.updateFormFromModel(); - if (this.synthesisBuilder.isFullyDefined()) { - this.synthesisFunction(); - } + this.subs.push( + this.poseForm.valueChanges.subscribe((value) => { + if (this.syncing) return; + this.syncing = true; + const before = new Set(this.design.getAllPoses().map((pose) => pose.id)); + this.design.updatePosesFromForm({ ...value, cor: this.corIndex() }); + this.readFromModel(); + this.syncing = false; + this.record(); + // A typed position can name any coordinate at all, and nobody pointed + // at where it landed -- so the canvas goes to it if it is not already + // there. Only for one that has just come into existence: editing a + // number on a position already on screen leaves the view alone. + const arrived = this.design.getAllPoses().find((pose) => !before.has(pose.id)); + // Out of this render. Framing can resize the drawn marks, which this + // very render has already read, and a value that changes after it was + // checked is an error Angular is right to raise. + if (arrived) setTimeout(() => this.svgGrid.revealOnCanvas(arrived.position)); + }) + ); + + this.subs.push( + this.regionForm.valueChanges.subscribe(() => { + if (this.syncing) return; + this.readRegionFromForm(); + }) + ); + + this.subs.push( + SettingsService._objectScale.subscribe(() => { + this.design.getAllPoses().forEach((pose) => pose.recompute()); + }) + ); + + // 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; + + /** + * 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); + } - // set up subscriptions to synthesis form changes to update model - this.synthesisPoseForm.valueChanges.subscribe((value) => { - // prevent infinite loop - if (this._alreadyHandlingPoseChange) return; + // --- units --------------------------------------------------------------- - this._alreadyHandlingPoseChange = true; + 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()); + } - let valid = this.synthesisBuilder.updatePosesFromForm(value); - this.updateFormFromModel(); + private corIndex(): string { + return this.design.COR === COR.BACK ? '0' : this.design.COR === COR.CENTER ? '1' : '2'; + } - if (this.synthesisBuilder.isFullyDefined()) { - this.synthesisFunction(); + // --- 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; + if (pose) { + controls[`p${i}x`].setValue(this.lengthText(pose.position.x), { emitEvent: false }); + controls[`p${i}y`].setValue(this.lengthText(pose.position.y), { emitEvent: false }); + controls[`p${i}theta`].setValue(this.angleText(pose.thetaDegrees), { emitEvent: false }); + this.hadPose.add(i); + continue; + } + // A row with no position behind it is either one nobody has started or + // one somebody is halfway through typing, and the two look the same from + // here -- so it is left exactly as it is. The exception is a row that had + // a position a moment ago: something removed it, or an undo did, and the + // numbers it left behind are about a position that is gone. + if (this.hadPose.delete(i)) { + [`p${i}x`, `p${i}y`, `p${i}theta`].forEach((name) => + controls[name].setValue('', { 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; + } - this._alreadyHandlingPoseChange = 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(); + } - SettingsService._objectScale.subscribe((val) => { - this.synthesisBuilder.getAllPoses().forEach((pose) => { - pose.recompute(); - }); + /** + * 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(); + } + + // --- stage --------------------------------------------------------------- + + get isChooser(): boolean { + return this.design.stage === 'chooser'; + } + + startMotionSynthesis(): void { + this.design.stage = 'working'; + this.design.setArmed(false); + } + + backToChooser(): void { + this.design.stage = 'chooser'; + this.design.regionDraw = false; + this.design.setArmed(false); + this.solution.playing = false; + } + + headerNote(): string { + if (!this.design.isFullyDefined()) { + return this.design.getAllPoses().length + ' of 3 positions placed'; + } + if (!this.solution.generated) return '3 positions · no solutions yet'; + const count = this.solution.candidates().length; + const kind = this.solution.dyad() ? 'six-bar' : 'four-bar'; + return `${kind} · ${count} ${count === 1 ? '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; } - 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()); + get showAddButton(): boolean { + return !this.design.isFullyDefined(); } - private convertA(value: number): string { - return this.nup.formatValueAndUnit(value, this.settings.angleUnit.getValue()); + get addLabel(): string { + return this.design.armed ? 'Cancel' : 'Add position ' + this.nextPositionNumber; } - // given synthesis model, update form values to sync with model - updateFormFromModel() { - this._alreadyHandlingPoseChange = true; + /** + * 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); + } - let poses = this.synthesisBuilder.poses; - let controls = this.synthesisPoseForm.controls; + isSelectedRow(i: number): boolean { + return this.design.selectedPose === i; + } - controls.length.setValue(this.convertL(this.synthesisBuilder.length)); + /** 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; + } - 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)); + 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); } - 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)); + } + + 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(); + } + + /** + * Typing in a row is a way of pointing at it. + * + * The nine boxes are live whether or not the position behind them exists yet, + * so a design can be typed out as readily as dropped on the drawing -- and + * the row somebody is filling in is the one the panel should be talking + * about. Deliberately silent about arming: the two ways in are both live at + * once, and choosing one is not turning the other off. + */ + aimAtRow(i: number): void { + this.design.selectedPose = i; + } + + /** + * 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]; + } + + rowStatusIcon(i: number): string { + if (!this.isPlaced(i)) { + return this.isPreviewingRow(i) ? 'ads_click' : 'radio_button_unchecked'; } - 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) return 'help_outline'; + return ok ? 'check_circle' : 'link_off'; + } + + rowStatusTip(i: number): string { + if (!this.isPlaced(i)) { + return this.isPreviewingRow(i) ? 'Click the grid to drop this position' : 'Not placed yet'; } + const ok = this.reached(i); + if (ok === undefined) { + if (!this.design.isFullyDefined()) return 'Waiting for all three positions'; + return this.solution.generated + ? 'No 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(); + } - //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: [''], - }); - - handleButton() { - //Send notification to grid for now - NewGridComponent.sendNotification( - 'Call your backend function with these values! A0: (' + - this.synthesisForm.value.a0x! + - ',' + - this.synthesisForm.value.a0y! + - ') B0: (' + - this.synthesisForm.value.b0x! + - ',' + - this.synthesisForm.value.b0y! + - ') A1: (' + - this.synthesisForm.value.a1x! + - ',' + - this.synthesisForm.value.a1y! + - ') B1: (' + - this.synthesisForm.value.b1x! + - ',' + - this.synthesisForm.value.b1y! + - ') A2: (' + - this.synthesisForm.value.a2x! + - ',' + - this.synthesisForm.value.a2y! + - ') B2: (' + - this.synthesisForm.value.b2x! + - ',' + - this.synthesisForm.value.b2y! + - ')' - ); - //If you need the values as a number instead of a string, use this: - console.log(Number(this.synthesisForm.value.a0x!)); + /** 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(); } - // for html to get current pose as a number - getCurrentPose(): number { - return this.synthesisBuilder.selectedPose; + /** + * 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`; } - setCurrentPose(pose: number) { - this.synthesisBuilder.selectedPose = pose; + regionSummary(): string { + return 'Drag the box on the grid, or its corners.'; } - getFormIDPoseX(pose: number): string { - if (pose == 1) return 'p1x'; - else if (pose == 2) return 'p2x'; - else return 'p3x'; + toggleRegionDraw(): void { + this.design.regionDraw = !this.design.regionDraw; + this.design.setArmed(false); } - getFormIDPoseY(pose: number): string { - if (pose == 1) return 'p1y'; - else if (pose == 2) return 'p2y'; - else return 'p3y'; + /** Named only when the requirements are what stands between reader and answer. */ + get requirementsBlocking(): boolean { + return this.showResults && this.solution.candidates().length === 0; } - getFormIDPoseTheta(pose: number): string { - if (pose == 1) return 'p1theta'; - else if (pose == 2) return 'p2theta'; - else return 'p3theta'; + 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}` + ); } - synthesisFunction() { - //call synthesis functions + // --- generating ---------------------------------------------------------- - //populate pose information + get showGenerate(): boolean { + return this.design.isFullyDefined() && !this.solution.generated; + } - 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; + 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.'; + } - let qualityfromUser = Number(this.synthesisForm.value.quality); + generate(): void { + this.solution.generate(); + } - // NewGridComponent.sendNotification(qualityfromUser + ';'); + /** + * The one button at the foot of the panel. + * + * 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. + */ + 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; + } - //find first itnersection point + get primaryLabel(): string { + if (this.primaryIsGenerate) { + return this.solution.generating ? 'Searching…' : 'Generate solutions'; + } + return this.insertLabel; + } - 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); + get primaryIcon(): string { + if (this.primaryIsGenerate) { + return this.solution.generating ? 'hourglass_top' : 'auto_awesome'; + } + return this.solutionIsOnGrid ? 'check' : 'add_circle_outline'; + } - //now create joints, links, etc. from the above four coordinates + get primaryDisabled(): boolean { + if (!this.primaryIsGenerate) return !this.canInsert; + return this.solution.generating || !this.design.isFullyDefined(); + } - let joint1 = new RevJoint('A', firstPoint.x, firstPoint.y, true, true); - let joint2 = new RevJoint('B', secondPoint.x, secondPoint.y, false, false); - let joint3 = new RevJoint('C', thirdPoint.x, thirdPoint.y, false, false); - let joint4 = new RevJoint('D', fourthPoint.x, fourthPoint.y, false, true); + primaryAction(): void { + if (this.primaryIsGenerate) this.generate(); + else this.insert(); + } - joint1.connectedJoints.push(joint2); - joint2.connectedJoints.push(joint1, joint3); - joint3.connectedJoints.push(joint2, joint4); - joint4.connectedJoints.push(joint3); + // --- results ------------------------------------------------------------- - let link1 = new RealLink('AB', [joint1, joint2]); - link1.fill = this.colorService.getLinkColorFromIndex(0); - let link2 = new RealLink('BC', [joint2, joint3]); - link2.fill = this.colorService.getLinkColorFromIndex(1); - let link3 = new RealLink('CD', [joint3, joint4]); - link3.fill = this.colorService.getLinkColorFromIndex(0); + get showResults(): boolean { + return this.design.isFullyDefined() && this.solution.generated; + } - joint1.links.push(link1); - joint2.links.push(link1, link2); - joint3.links.push(link2, link3); - joint4.links.push(link3); + /** + * Whether the gallery is worth drawing. + * + * 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. + */ + get showGallery(): boolean { + return this.solution.candidates().length > 1; + } - this.mechanismSrv.joints.splice(0); - this.mechanismSrv.links.splice(0); + /** + * 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'; + } - this.mechanismSrv.mergeToJoints([joint1, joint2, joint3, joint4]); - this.mechanismSrv.mergeToLinks([link1, link2, link3]); + 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`; + } - this.mechanismSrv.mechanismTimeStep = 0; - this.mechanismSrv.updateMechanism(); + /** + * The geometric explanation, for when no requirement is standing in the way. + * With one switched on, the Requirements note is the better answer. + */ + get showNoCandidateReason(): boolean { + return ( + this.showResults && + this.solution.candidates().length === 0 && + !this.design.endsOnly && + this.design.allowDefect && + !this.design.constrain + ); + } - // update flag to indicate that mechanism has been modified since last synthesis - this.synthesisBuilder.modifiedMechanism = true; + 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.'; + } - let posCoords = [ - pose1_coord1, - pose1_coord2, - pose2_coord1, - pose2_coord2, - pose3_coord1, - pose3_coord2, - ]; + 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)); + } - let quality = this.compareTheQualityofSynthesis( - this.mechanismSrv.mechanisms[0].joints, - posCoords, - qualityfromUser - ); + 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`), + }; + } - // let trialCoord = new Coord(this.mechanismSrv.mechanisms[0].joints[0][0].x, this.mechanismSrv.mechanisms[0].joints[0][0].y); + get hasMoreCandidates(): boolean { + return this.solution.candidates().length > 3; + } - // NewGridComponent.sendNotification(firstPoint.x + ',' + firstPoint.y + ',' + fourthPoint.x + ',' + fourthPoint.y); + moreLabel(): string { + return this.solution.showAll ? 'Show fewer' : 'Show all ' + this.solution.candidates().length; + } - // NewGridComponent.sendNotification(trialCoord.x+','+trialCoord.y); + toggleAllCandidates(): void { + this.solution.showAll = !this.solution.showAll; + } - // NewGridComponent.sendNotification(quality[0]+';'); + pickCandidate(key: string): void { + this.solution.pick(key); + } - //now check if there is 999 in the quality. Count 999 and say which position matches + hoverCandidate(key: string | null): void { + this.solution.setHover(key); + } - let whichPositionMatches = this.checkQuality(quality); + // --- the chosen solution ------------------------------------------------- - // NewGridComponent.sendNotification( - // 'Position Matches:' + - // whichPositionMatches[0] + - // ',' + - // whichPositionMatches[1] + - // ',' + - // whichPositionMatches[2] - // ); + get hasSolution(): boolean { + return this.showResults && this.solution.chosen() !== null; } - checkQuality(quality: number[]) { - let positionMatches: string[] = ['Position 1', 'Position 2', 'Position 3']; - if (quality[0] >= 0.09 || quality[1] >= 0.09) { - positionMatches[0] = 'No Match'; - this.synthesisBuilder.poses[1].status = SynthesisStatus.INVALID; - } else { - this.synthesisBuilder.poses[1].status = SynthesisStatus.VALID; - } - if (quality[3] >= 0.09 || quality[4] >= 0.09) { - positionMatches[1] = 'No Match'; - this.synthesisBuilder.poses[2].status = SynthesisStatus.INVALID; - } else { - this.synthesisBuilder.poses[2].status = SynthesisStatus.VALID; - } - if (quality[6] >= 0.09 || quality[7] >= 0.09) { - positionMatches[2] = 'No Match'; - this.synthesisBuilder.poses[3].status = SynthesisStatus.INVALID; - } else { - this.synthesisBuilder.poses[3].status = SynthesisStatus.VALID; - } + get solutionName(): string { + return this.solution.chosen()?.name ?? '—'; + } - return positionMatches; + 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 ?? '', + }; + }); } - compareTheQualityofSynthesis(jointValues: Joint[][], posCoords: Coord[], qualityOfSyn: number) { - //get position analysis data - //joint B, Joint C, - //compare that with poses + pickBranch(key: string): void { + if (key) this.solution.pick(key); + } + + 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 }, + ]; + } + + setPin(far: boolean): void { + this.solution.setDriveOnFarPin(far); + } - let quality1_b: number = 999; - let quality2_b: number = 999; - let quality3_b: number = 999; + /** + * 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 quality1_c: number = 999; - let quality2_c: number = 999; - let quality3_c: number = 999; + /** Whether the driver is both wanted and possible. */ + get driverOn(): boolean { + return this.solution.driverWanted && !this.driverRefusal; + } - let pos1TimeStep: number = 999; - let pos2TimeStep: number = 999; - let pos3TimeStep: number = 999; + toggleDriver(): void { + if (this.driverRefusal) return; + this.solution.toggleDriver(); + } - //compare Joint B with pose 1, pose2, and pose3; + toggleDimensions(): void { + this.solution.dimensionsOpen = !this.solution.dimensionsOpen; + } - let index: number = 1; + 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; + } - 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) - ); + 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; + } - // NewGridComponent.sendNotification('JointCoord;' + jointValues[val][1].x + ';'); - // NewGridComponent.sendNotification('PosCoord;' + posCoords[0].x + ';'); - // NewGridComponent.sendNotification('val:' + val); + // --- previewing the motion ----------------------------------------------- - 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) - ); + private direction = 1; - //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 < qualityOfSyn && pos1Value_c < qualityOfSyn && index == 1) { - quality1_b = pos1Value_b; - quality1_c = pos1Value_c; - pos1TimeStep = index; - } else if (pos1Value_b < qualityOfSyn && pos1Value_c < qualityOfSyn && index > 1) { - quality1_b = pos1Value_b; - quality1_c = pos1Value_c; - pos1TimeStep = index; - } else if (pos2Value_b < qualityOfSyn && pos2Value_c < qualityOfSyn && index == 1) { - quality2_b = pos2Value_b; - quality2_c = pos2Value_c; - pos2TimeStep = index; - } else if (pos2Value_b < qualityOfSyn && pos2Value_c < qualityOfSyn && index > 1) { - quality2_b = pos2Value_b; - quality2_c = pos2Value_c; - pos2TimeStep = index; - } else if (pos3Value_b < qualityOfSyn && pos3Value_c < qualityOfSyn && index == 1) { - quality3_b = pos3Value_b; - quality3_c = pos3Value_c; - pos3TimeStep = index; - } else if (pos3Value_b < qualityOfSyn && pos3Value_c < qualityOfSyn && 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; - - // NewGridComponent.sendNotification("Midpoint;" + val+';'+index); - - 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) - ); - - // NewGridComponent.sendNotification('JointCoord;' + jointValues[val][1].x + ';'); - // NewGridComponent.sendNotification('PosCoord;' + posCoords[0].x + ';'); - // NewGridComponent.sendNotification('val:' + val); - - 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 < qualityOfSyn && pos1Value_c < qualityOfSyn) { - quality1_b = pos1Value_b; - quality1_c = pos1Value_c; - pos1TimeStep = index - 0.5; - } else if (pos2Value_b < qualityOfSyn && pos2Value_c < qualityOfSyn) { - quality2_b = pos2Value_b; - quality2_c = pos2Value_c; - pos2TimeStep = index - 0.5; - } else if (pos3Value_b < qualityOfSyn && pos3Value_c < qualityOfSyn) { - quality3_b = pos3Value_b; - quality3_c = pos3Value_c; - pos3TimeStep = index - 0.5; - } - } - } + togglePlay(): void { + this.solution.playing = !this.solution.playing; + if (this.solution.playing) this.step(); + } - index = index + 1; + flipDirection(): void { + this.solution.clockwise = !this.solution.clockwise; + } - // NewGridComponent.sendNotification('index:' + index); + /** + * 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)); } - // NewGridComponent.sendNotification('quality3c:' + quality3_c); - - //now compile quality array and then pass it back - - let qualityCompilation: number[]; - - qualityCompilation = [ - quality1_b, - quality1_c, - pos1TimeStep, - quality2_b, - quality2_c, - pos2TimeStep, - quality3_b, - quality3_c, - pos3TimeStep, - ]; + this.solution.phase = phase; + this.solution.changed.next(); + this.frame = requestAnimationFrame(this.step); + }; - // NewGridComponent.sendNotification(";" + qualityCompilation); + scrubMin(): number { + return Math.round(this.solution.drivenRange().from); + } - return qualityCompilation; + scrubMax(): number { + return Math.round(this.solution.drivenRange().to); } - 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)); + scrubValue(): number { + return Math.round(this.solution.currentPhase()); + } - //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 - ); + setScrub(event: Event): void { + this.solution.setPhase(Number((event.target as HTMLInputElement).value)); + } - //intercept - let c1 = midpoint_line1.y + slope1 * midpoint_line1.x; - let c2 = midpoint_line2.y + slope2 * midpoint_line2.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) + '%'; + } - //intersection point - let x1 = (c1 - c2) / (-slope2 + slope1); - let y1 = -slope1 * x1 + c1; + /** 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] }; + }); + } - return new Coord(x1, y1); + angleLabel(): string { + const phase = this.solution.currentPhase(); + return Math.round(((phase % 360) + 360) % 360) + '°'; } - 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)); + 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)}°`; + } - //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 - ); + // --- 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(); + } - //intercept - let c1 = midpoint_line1.y + slope1 * midpoint_line1.x; - let c2 = midpoint_line2.y + slope2 * midpoint_line2.x; + /** + * 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); + } - //intersection point - let x1 = (c1 - c2) / (-slope2 + slope1); - let y1 = -slope1 * x1 + c1; + /** Whether this panel has been left, so nothing deferred acts on it. */ + private gone = false; + + /** Whether a wind-back is running, so a second press cannot start another. */ + private windingBack = false; + private windBackFrame: number | undefined; + + /** + * 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/component/toolbar/toolbar.component.html b/src/app/component/toolbar/toolbar.component.html deleted file mode 100644 index 1846f910..00000000 --- a/src/app/component/toolbar/toolbar.component.html +++ /dev/null @@ -1,101 +0,0 @@ -<nav class="navBar" id="{{ isDevMode() ? 'devModeColor' : '' }}"> - <img - class="pmksImage" - alt="PMKS+" - src="assets/PMKS_logo.png" - onClick="window.location.reload()" - /> - <!-- A real handler rather than an onclick attribute. In a DOM attribute - `this` is the button and `logEvent` is a global that does not exist, so - every click on New threw an uncaught ReferenceError -- after opening the - tab, which is why the button looked like it worked. --> - <button - mat-button - (click)="newProject()" - matTooltip="Open a new tab with a blank project" - matTooltipShowDelay="1000" - > - <mat-icon>note_add</mat-icon> - New - </button> - <input #csvInput style="display: none" type="file" accept="text" (change)="upload($event)" /> - <button - mat-button - (click)="csvInput.click()" - matTooltip="Open a linkage from a file on your device" - matTooltipShowDelay="1000" - > - <mat-icon>file_open</mat-icon> - Open - </button> - <button - mat-button - (click)="openTemplates()" - matTooltip="Show the library of available linkages" - matTooltipShowDelay="1000" - id="templatesButton" - > - <mat-icon>apps</mat-icon> - Templates - </button> - <button - mat-button - (click)="downloadLinkage()" - matTooltip="Save this linkage to your device" - matTooltipShowDelay="1000" - > - <mat-icon>save</mat-icon> - Save - </button> - <button - mat-button - (click)="copyURL()" - matTooltip="Copy a link to this mechanism to the clipboard" - matTooltipShowDelay="1000" - > - <mat-icon>link</mat-icon> - Share Project - </button> - <div class="spacer"></div> - @if (isDevMode()) { - <ng-container - matTooltip="This message and the debug panel will disappear" - matTooltipShowDelay="1000" - > - <p class="devModeNotice"> - You are in Development Mode. The color will return to normal when you build with the - production flag. - </p> - </ng-container> - } - @if (isDevMode()) { - <button - mat-button - (click)="openRightPanelDebug()" - matTooltip="This button will not show up in prod" - matTooltipShowDelay="1000" - > - <mat-icon>bug_report</mat-icon> - Debug - </button> - } - <button - mat-button - (click)="openRightPanelSettings()" - matTooltip="Change Linkage Settings" - matTooltipShowDelay="1000" - > - <mat-icon>settings</mat-icon> - Settings - </button> - <button - mat-button - (click)="openRightPanelHelp()" - matTooltip="Get Support from our team" - matTooltipShowDelay="1000" - id="helpButton" - > - <mat-icon>help</mat-icon> - Help / Feedback - </button> -</nav> diff --git a/src/app/component/toolbar/toolbar.component.scss b/src/app/component/toolbar/toolbar.component.scss deleted file mode 100644 index 21e907a7..00000000 --- a/src/app/component/toolbar/toolbar.component.scss +++ /dev/null @@ -1,107 +0,0 @@ -@use 'sass:map'; -@use '@angular/material' as mat; - -@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'); - $typography-config: mat.m2-get-typography-config($theme); - $background: map.get($theme, background); - $foreground: map.get($theme, foreground); - - #devModeColor { - background-color: #ef9a9a; - } - - .navBar { - z-index: 2; - @include mat.elevation(2); - //Check here for 2nd parameter options: node_modules/@angular/material/core/theming/_palette.scss: Line 674 - // background-color: mat.get-color-from-palette($background,'raised-button'); - background-color: mat.m2-get-color-from-palette($primary-palette, 50); - - position: absolute; - display: flex; - - height: 30px; - width: 100%; - top: 0; - left: 0; - - // The buttons never wrap or shrink, so on a narrow viewport they used to - // run past the window and drag the whole document sideways — panning the - // grid and panels off-screen. Keep that overflow inside the bar: it scrolls - // itself and every button stays reachable. The scrollbar is hidden because - // a classic one would eat half of the 30px strip. - overflow-x: auto; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } - - .mat-mdc-button-touch-target { - height: 100% !important; - } - - .spacer { - flex: 1 1 auto; - } - - .mat-icon { - vertical-align: middle; - //color: mat.get-color-from-palette($foreground, 'icon'); - } - - .mdc-button__label { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - white-space: nowrap; - } - - button { - @include mat.m2-typography-level($typography-config, 'body-2'); - height: 100% !important; - flex: 0 0 auto; - white-space: nowrap; - } - - p { - line-height: 30px; - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - // Wrap onto two short lines inside the 30px bar and take only the width the - // text needs, so it is clipped only when the toolbar really is too narrow. - .devModeNotice { - align-self: center; - flex: 0 1 auto; - min-width: 140px; - padding: 0 8px; - font-size: 10px; - line-height: 12px; - pointer-events: none; - text-align: center; - white-space: normal; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - - // Below this the remaining strip is too small to read; a sliver of text - // is worse than no notice at all. - @media (max-width: 1100px) { - display: none; - } - } - - img { - margin-left: 5px; - } - } -} diff --git a/src/app/component/toolbar/toolbar.component.ts b/src/app/component/toolbar/toolbar.component.ts deleted file mode 100644 index ba8262a0..00000000 --- a/src/app/component/toolbar/toolbar.component.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { - AfterViewInit, - Component, - EventEmitter, - inject, - Inject, - Input, - isDevMode, - OnInit, - Output, - ChangeDetectionStrategy, -} from '@angular/core'; -import { Joint, PrisJoint, RealJoint, RevJoint } from '../../model/joint'; -import { Bound, Link, SliderBlock, RealLink } from '../../model/link'; -import { Force } from '../../model/force'; -import { Mechanism } from '../../model/mechanism/mechanism'; -import { - AngleUnit, - ForceUnit, - GlobalUnit, - LengthUnit, - roundNumber, - stringToBoolean, - stringToFloat, - stringToShape, -} from '../../model/utils'; -import { ForceSolver } from '../../model/mechanism/force-solver'; -import { AnimationBarComponent } from '../animation-bar/animation-bar.component'; -import { LinkageTableComponent } from '../linkage-table/linkage-table.component'; -import { KinematicsSolver } from '../../model/mechanism/kinematic-solver'; -import { Coord } from '../../model/coord'; -// import { TemplatesPopupComponent } from '../templates-popup/templates-popup.component'; - -import { ActiveObjService } from 'src/app/services/active-obj.service'; -import { RightPanelComponent } from '../right-panel/right-panel.component'; -import { MechanismService } from '../../services/mechanism.service'; -import { NewGridComponent } from '../new-grid/new-grid.component'; -import { AnalyticsService } from '../../services/analytics.service'; -import { UrlProcessorService } from '../../services/url-processor.service'; -import { MatDialog } from '@angular/material/dialog'; -import { TemplatesComponent } from '../MODALS/templates/templates.component'; -import { StringTranscoder } from 'src/app/services/transcoding/string-transcoder'; -import { - ForceData, - JOINT_TYPE, - JointData, - LINK_TYPE, - LinkData, -} from 'src/app/services/transcoding/transcoder-data'; -import { SettingsService } from 'src/app/services/settings.service'; -import { - BoolSetting, - DecimalSetting, - EnumSetting, - IntSetting, -} from 'src/app/services/transcoding/stored-settings'; -import { UrlGenerationService } from 'src/app/services/url-generation.service'; -import { NOT_BUILT_YET } from '../../ui-text'; - -@Component({ - selector: 'app-toolbar', - templateUrl: './toolbar.component.html', - styleUrls: ['./toolbar.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, -}) -export class ToolbarComponent implements OnInit, AfterViewInit { - private analytics: AnalyticsService = inject(AnalyticsService); - - openRightPanelEquations() { - RightPanelComponent.tabClicked(2); - } - - openRightPanelHelp() { - RightPanelComponent.tabClicked(3); - } - - openRightPanelSettings() { - RightPanelComponent.tabClicked(1); - } - - openRightPanelDebug() { - RightPanelComponent.tabClicked(4); - } - - animate: boolean = false; - - // static inputAngularVelocity: number = 10; - // static clockwise: boolean = false; - // static gravity: boolean = true; //Kohmei set this to true for testing, normally false - private static fileButton: SVGElement; - static analysisButton: SVGElement; - static loopButton: SVGElement; - static forceButton: SVGElement; - static stressButton: SVGElement; - static kinematicButton: SVGElement; - private static settingsButton: SVGElement; - private static helpButton: SVGElement; - // static unit = 'cm'; - // TODO: If possible, change this to static variable... - url: any; - static instance: ToolbarComponent; - - constructor( - private activeObjService: ActiveObjService, - private mechanismService: MechanismService, - private urlGenerationService: UrlGenerationService, - private urlProcessorService: UrlProcessorService, - public dialog: MatDialog, - public settingsService: SettingsService - ) { - ToolbarComponent.instance = this; - } - - //Create a static method to get an instance of the toolbar component - - openTemplates() { - this.dialog.open(TemplatesComponent, { - height: '90%', - width: '90%', - autoFocus: false, - }); - } - - ngOnInit(): void { - //This will need to move to settings service - // const settingsPropsString = splitURLInfo('&s='); - // if (!(typeof settingsPropsString === 'string')) { - // return; - // } - // const settingsPropsArray = settingsPropsString.split(','); - // if (settingsPropsArray.length === 0) { - // return; - // } - // const input_speed_mag = stringToFloat(settingsPropsArray[0]); - // const clockwise = stringToBoolean(settingsPropsArray[1]); - // const gravity = stringToBoolean(settingsPropsArray[2]); - // const unit = settingsPropsArray[3]; - // ToolbarComponent.inputAngularVelocity = input_speed_mag; - // ToolbarComponent.clockwise = clockwise; - // AnimationBarComponent.direction = ToolbarComponent.clockwise ? 'cw' : 'ccw'; - // // ToolbarComponent.gravity = gravity; Temp commneted out by Kohmei for testing - // this.localUnit.selectedUnit = unit; - // ToolbarComponent.unit = unit; - // this.mechanismService.updateMechanism(); - } - - ngAfterViewInit() { - ToolbarComponent.fileButton = document.getElementById('fileButton') as unknown as SVGElement; - ToolbarComponent.analysisButton = document.getElementById( - 'analysisButton' - ) as unknown as SVGElement; - ToolbarComponent.loopButton = document.getElementById('loopButton') as unknown as SVGElement; - ToolbarComponent.forceButton = document.getElementById('forceButton') as unknown as SVGElement; - ToolbarComponent.stressButton = document.getElementById( - 'stressButton' - ) as unknown as SVGElement; - ToolbarComponent.kinematicButton = document.getElementById( - 'kinematicButton' - ) as unknown as SVGElement; - ToolbarComponent.settingsButton = document.getElementById( - 'settingsButton' - ) as unknown as SVGElement; - ToolbarComponent.helpButton = document.getElementById('helpButton') as unknown as SVGElement; - } - - // popUpTemplates() { - // TemplatesPopupComponent.showTemplates(); - // this.analytics.logEvent('open_templates'); - // } - - /** Open a blank project in a new tab, leaving this one as it is. */ - newProject(): void { - this.analytics.logEvent('new_file'); - window.open(window.location.origin, '_blank'); - } - - upload($event: any) { - console.log('upload'); - this.analytics.logEvent('upload_file'); - const input = $event.target; - if (input.files.length !== 1) { - console.log('No file selected', input.files.length); - NewGridComponent.sendNotification('No file selected'); - return; - } - const reader = new FileReader(); - - reader.onload = () => { - const data = reader.result as string; - console.log('open', data); - NewGridComponent.sendNotification('Loaded Mechanism from File'); - - this.urlProcessorService.updateFromURL(data); - - //TODO for Ansel - Clear the history - - //Reset the input so that the same file can be uploaded again - input.value = ''; - }; - - // actually read the file to call the onload callback above - reader.readAsText(input.files[0]); - } - /* - * Copy the URL of the current mechanism to the clipboard - */ - copyURL() { - this.analytics.logEvent('copyURL'); - - let url = this.urlGenerationService.generateFullUrl(); - - // fake a text area to exec copy - const toolman = document.createElement('textarea'); - document.body.appendChild(toolman); - toolman.value = url; - toolman.textContent = url; - toolman.select(); - document.execCommand('copy'); - document.body.removeChild(toolman); - - NewGridComponent.sendNotification( - 'Mechanism URL copied. If you make additional changes, copy the URL again.' - ); - } - - alertNotAvailable() { - //Use this.mechanismService.sendNotification() instead - NewGridComponent.sendNotification(NOT_BUILT_YET); - } - - downloadLinkage() { - this.analytics.logEvent('download_linkage'); - // TODO: Believe this should be this.unit.selectedUnit - const content = this.urlGenerationService.generateUrlQuery(); - - const blob = new Blob([content], { type: 'text;charset=utf-8;' }); - const fileName = `PMKS+_${new Date().toISOString()}.pmks`; - // if (navigator.msSaveBlob) { // IE 10+ - // navigator.msSaveBlob(blob, fileName); - // } else { - const link = document.createElement('a'); - if (link.download !== undefined) { - // feature detection - // Browsers that support HTML5 download attribute - // fake an <a> to click on - const url = URL.createObjectURL(blob); - link.setAttribute('href', url); - link.setAttribute('download', fileName); - link.style.visibility = 'hidden'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } - } - - openURL(url: string) { - // fake an <a> to click on - const toolman = document.createElement('a'); - toolman.setAttribute('href', url); - toolman.setAttribute('target', '_blank'); - toolman.style.display = 'none'; - document.body.appendChild(toolman); - toolman.click(); - document.body.removeChild(toolman); - } - - isDevMode() { - //Used to change the color of the topbar when not running prod - return isDevMode(); - } -} diff --git a/src/app/component/top-bar/top-bar.component.html b/src/app/component/top-bar/top-bar.component.html new file mode 100644 index 00000000..d3ca2e75 --- /dev/null +++ b/src/app/component/top-bar/top-bar.component.html @@ -0,0 +1,203 @@ +<div data-canvas-inset="top" class="topStrip" #strip [class]="'topStrip fit' + labelLevel" [class.devMode]="isDevMode()"> + <div class="card brandCard"> + <button + class="iconButton" + [class.on]="menuOpen" + (click)="toggleMenu()" + matTooltip="Project menu" + matTooltipShowDelay="600" + aria-label="Project menu" + > + <mat-icon>menu</mat-icon> + </button> + <img class="logo" alt="PMKS+" src="assets/PMKS_logo.png" /> + </div> + + <div class="card tabCard"> + <div class="tabStrip" #tabStrip> + <!-- One highlight for all four modes, so it slides between them instead + of blinking from one to the next. --> + <div + class="activeTabPill" + [class.hidden]="!highlight.visible" + [style.transform]="'translateX(' + highlight.left + 'px)'" + [style.width.px]="highlight.width" + ></div> + + <button + class="tabButton" + [class.active]="isActive(TabID.SYNTHESIZE)" + (click)="select(TabID.SYNTHESIZE)" + aria-label="Synthesis" + > + <mat-icon + [matTooltip]="shortcuts.tip('Synthesis', 'mode.synthesis')" + matTooltipShowDelay="600" + svgIcon="synthesis" + ></mat-icon> + <span class="tabLabel">Synthesis</span> + </button> + + <button + class="tabButton" + [class.active]="isActive(TabID.EDIT)" + (click)="select(TabID.EDIT)" + aria-label="Edit" + > + <mat-icon + [matTooltip]="shortcuts.tip('Edit', 'mode.edit')" + matTooltipShowDelay="600" + svgIcon="edit_outline" + ></mat-icon> + <span class="tabLabel">Edit</span> + </button> + + <div class="modeDivider"></div> + + <button + class="tabButton" + [class.active]="isActive(TabID.ANALYZE)" + [class.notReady]="!canAnalyse(TabID.ANALYZE)" + (click)="select(TabID.ANALYZE)" + aria-label="Kinematic Analysis" + > + <mat-icon + [matTooltip]="shortcuts.tip('Kinematic Analysis', 'mode.kinematic')" + matTooltipShowDelay="600" + >query_stats</mat-icon + > + <span class="tabLabel long">Kinematic Analysis</span> + <span class="tabLabel short">Kinematic</span> + <!-- Nothing drawn, nothing to be ready for. --> + @if (hasStatus()) { + <span + class="chip" + role="button" + [class.ok]="statusOf(TabID.ANALYZE).ready" + [attr.aria-label]="'Kinematic Analysis setup: ' + statusOf(TabID.ANALYZE).text" + (click)="openSetupFor(TabID.ANALYZE, $event)" + [matTooltip]="'What this mode needs'" + matTooltipShowDelay="600" + >{{ statusOf(TabID.ANALYZE).text }}</span + > + } + </button> + + <button + class="tabButton" + [class.active]="isActive(TabID.FORCE)" + [class.notReady]="!canAnalyse(TabID.FORCE)" + (click)="select(TabID.FORCE)" + aria-label="Force Analysis" + > + <mat-icon + [matTooltip]="shortcuts.tip('Force Analysis', 'mode.force')" + matTooltipShowDelay="600" + >balance</mat-icon + > + <span class="tabLabel long">Force Analysis</span> + <span class="tabLabel short">Force</span> + <!-- Nothing drawn, nothing to be ready for. --> + @if (hasStatus()) { + <span + class="chip" + role="button" + [class.ok]="statusOf(TabID.FORCE).ready" + [attr.aria-label]="'Force Analysis setup: ' + statusOf(TabID.FORCE).text" + (click)="openSetupFor(TabID.FORCE, $event)" + [matTooltip]="'What this mode needs'" + matTooltipShowDelay="600" + >{{ statusOf(TabID.FORCE).text }}</span + > + } + </button> + </div> + </div> + + <!-- The same corner in every mode, holding whatever that mode's verb is: + taking an edit back where edits can be made, and taking the numbers away + where they can only be read. One card that changes hands rather than two + that appear and disappear in the same place. --> + <div class="card historyCard"> + @if (!tabs.isAnalysisMode()) { + <div class="cardFace" @swapFace> + <button + class="historyButton" + (click)="undo()" + [disabled]="!canUndo()" + [matTooltip]="shortcuts.tip('Undo', 'history.undo')" + matTooltipShowDelay="600" + aria-label="Undo" + > + <mat-icon>undo</mat-icon> + <span class="historyLabel">Undo</span> + </button> + <button + class="historyButton" + (click)="redo()" + [disabled]="!canRedo()" + [matTooltip]="shortcuts.tip('Redo', 'history.redo')" + matTooltipShowDelay="600" + aria-label="Redo" + > + <mat-icon>redo</mat-icon> + <span class="historyLabel">Redo</span> + </button> + </div> + } @else { + <div class="cardFace" @swapFace> + <button + class="historyButton" + (click)="exportData()" + [disabled]="!canExport()" + [matTooltip]="exportTooltip()" + matTooltipShowDelay="600" + aria-label="Export data" + > + <mat-icon>download</mat-icon> + <span class="historyLabel">Export Data</span> + </button> + </div> + } + </div> +</div> + +@if (menuOpen) { + <div class="menuScrim" (click)="closeMenu()"></div> + <div class="projectMenu" @menuOpen> + <button class="menuItem" (click)="newProject()"> + <mat-icon>note_add</mat-icon>New Project + </button> + <input #fileInput hidden type="file" accept="text" (change)="upload($event)" /> + <button class="menuItem" (click)="fileInput.click()"><mat-icon>file_open</mat-icon>Open</button> + <button class="menuItem" id="templatesButton" (click)="openTemplates()"> + <mat-icon>apps</mat-icon>Templates + </button> + <button class="menuItem" (click)="downloadLinkage()"><mat-icon>save</mat-icon>Save</button> + <button class="menuItem" (click)="copyURL()"><mat-icon>link</mat-icon>Share Project</button> + <div class="menuDivider"></div> + <button + class="menuItem" + (click)="openSettings()" + [matTooltip]="shortcuts.tip('Settings', 'app.settings')" + matTooltipShowDelay="600" + > + <mat-icon>settings</mat-icon>Settings + </button> + <button class="menuItem" id="tutorialButton" (click)="openTutorial()"> + <mat-icon>school</mat-icon>Tutorial + </button> + <button + class="menuItem" + id="helpButton" + (click)="openHelp()" + [matTooltip]="shortcuts.tip('Help', 'app.help')" + matTooltipShowDelay="600" + > + <mat-icon>help</mat-icon>Help / Feedback + </button> + @if (isDevMode()) { + <button class="menuItem" (click)="openDebug()"><mat-icon>bug_report</mat-icon>Debug</button> + } + </div> +} diff --git a/src/app/component/top-bar/top-bar.component.scss b/src/app/component/top-bar/top-bar.component.scss new file mode 100644 index 00000000..48579027 --- /dev/null +++ b/src/app/component/top-bar/top-bar.component.scss @@ -0,0 +1,373 @@ +@use 'sass:map'; +@use '@angular/material' as mat; +@use '../left-tabs/left-tabs.vars' as nav; + +@mixin css($theme) { + $color-config: mat.m2-get-color-config($theme); + $primary-palette: map.get($color-config, 'primary'); + $typography-config: mat.m2-get-typography-config($theme); + + .topStrip { + position: absolute; + top: nav.$card-inset; + left: nav.$card-inset; + right: nav.$card-inset; + right: 12px; + display: flex; + align-items: flex-start; + gap: var(--card-gap); + z-index: 6; + pointer-events: none; + + .card { + display: flex; + align-items: center; + height: 48px; + background: var(--card-surface); + border-radius: var(--card-radius); + box-shadow: var(--card-shadow); + pointer-events: auto; + box-sizing: border-box; + } + + // 48px card, 36px controls, 6px padding: the same inset above, below and + // to either side of whatever the card holds. The buttons were 34px and 40px + // tall in an 8px-padded card, so the blue behind them sat at two different + // heights and never at the same distance from the edge as it was from the + // top. + .brandCard { + gap: 6px; + padding: 0 6px; + flex: 0 0 auto; + white-space: nowrap; + } + + .logo { + height: 26px; + width: auto; + flex: 0 0 auto; + display: block; + } + + // The tab card is the one thing allowed to shrink: on a narrow window the + // labels go before the modes do. + .tabCard { + flex: 0 1 auto; + min-width: 0; + padding: 0 6px; + overflow: hidden; + } + + // Against the right edge, with the slack between it and the modes. The + // status chips change width as a mechanism becomes ready, and with this + // card packed against the modes that made Undo and Redo shuffle sideways + // every time. The gap is what moves now. + .historyCard { + position: relative; + gap: 6px; + padding: 0 6px; + flex: 0 0 auto; + margin-left: auto; + } + + // The two faces of that card, one leaving as the other arrives. The one on + // its way out is taken out of flow by the animation itself, so the card is + // sized by the one arriving rather than by both at once. + .cardFace { + display: flex; + align-items: center; + gap: 6px; + } + } + + .tabStrip { + position: relative; + display: flex; + align-items: center; + gap: 2px; + height: 100%; + min-width: 0; + } + + // Carried over from the vertical rail this replaces: translateX is + // GPU-composited, so the highlight slides between modes without laying the + // strip out again, and nothing per-frame in JS can leave it stranded. + .activeTabPill { + position: absolute; + top: 6px; + left: 0; + height: 36px; + border-radius: 6px; + background: mat.m2-get-color-from-palette($primary-palette, 50); + border: 1.5px solid mat.m2-get-color-from-palette($primary-palette, 500); + box-sizing: border-box; + transition: + transform 200ms cubic-bezier(0.4, 0, 0.2, 1), + width 200ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 150ms ease; + will-change: transform, width; + pointer-events: none; + + &.hidden { + opacity: 0; + } + } + + .tabButton { + position: relative; + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + height: 36px; + padding: 0 12px; + border: none; + border-radius: 6px; + background: transparent; + font-family: inherit; + font-size: 15px; + color: #3c4043; + cursor: pointer; + white-space: nowrap; + + mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; + flex: 0 0 auto; + } + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + &.active { + color: mat.m2-get-color-from-palette($primary-palette, 500); + font-weight: 500; + background: transparent; + } + + // Deliberately *not* a disabled look. The mode cannot be entered yet, but + // the button still does something: it opens the list of what is missing. + &.notReady .tabLabel { + color: rgba(0, 0, 0, 0.55); + } + } + + // The short spelling only appears once the long one has gone. + .tabButton .tabLabel.short { + display: none; + } + + // One ladder of states, each strictly smaller than the one above it, and the + // strip takes the highest that fits. Measured rather than set at a window + // width: a width has to be chosen for the widest the strip ever gets -- four + // modes, two status chips, everything spelled out -- so it gives up words + // long before this particular strip has run out of room for them. + // + // fit3 full labels, chips, Undo and Redo spelled out + // fit2 short labels for the two analysis modes + // fit1 icons only, chips kept + // fit0 icons only, no chips, no wordmark + // + // Strictly smaller matters: when the chips lived in a media query, crossing + // it freed enough room to bring the labels *back*, so shrinking the window + // made the strip grow. + .topStrip.fit2 { + .tabButton .tabLabel.long { + display: none; + } + + .tabButton .tabLabel.short { + display: inline; + } + } + + .topStrip.fit1, + .topStrip.fit0 { + .tabButton .tabLabel, + .tabButton .tabLabel.short { + display: none; + } + + .historyCard .historyLabel { + display: none; + } + } + + .topStrip.fit0 { + .tabButton .chip { + display: none; + } + + .logo { + display: none; + } + + .tabButton { + padding: 0 8px; + } + + .modeDivider { + margin: 0 4px; + } + + .historyButton { + padding: 0 8px; + } + } + + .modeDivider { + flex: 0 0 1px; + width: 1px; + height: 26px; + background: #e2e4ec; + margin: 0 8px; + } + + .chip { + cursor: pointer; + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 10px; + background: #eef0f4; + color: #5f6368; + font-size: 12px; + font-weight: 400; + + &.ok { + background: #e6f4ea; + color: #137333; + } + } + + .iconButton { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 6px; + background: transparent; + color: #3c4043; + cursor: pointer; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + &.on { + background: mat.m2-get-color-from-palette($primary-palette, 50); + color: mat.m2-get-color-from-palette($primary-palette, 500); + } + } + + // The same button the view controls use: no outline, a hover tint, and the + // icon carrying it. An outlined blue button in the top strip read as the one + // emphatic thing in a row of quiet ones, which undo is not. + .historyButton { + display: flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 10px; + border: none; + border-radius: 6px; + background: transparent; + color: #5f6368; + font-family: inherit; + font-size: 14px; + cursor: pointer; + + mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; + } + + &:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.04); + } + + &:disabled { + color: #c4c7d0; + cursor: default; + background: transparent; + } + } + + .menuScrim { + position: fixed; + inset: 0; + z-index: 19; + } + + // Under the card that opened it, the same distance below it as that card is + // from the top of the window and from the card beside it. + .projectMenu { + position: absolute; + top: nav.$panel-top + nav.$shadow-room; + left: nav.$card-inset; + width: 188px; + padding: 4px 0; + background: var(--card-surface); + border-radius: var(--border-radius); + z-index: 20; + box-shadow: var(--card-shadow); + } + + .menuItem { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 34px; + padding: 0 14px; + border: none; + background: transparent; + font-family: inherit; + font-size: 14px; + color: #2c2c2c; + cursor: pointer; + text-align: left; + + mat-icon { + width: 21px; + height: 21px; + font-size: 21px; + line-height: 21px; + color: #5f6368; + flex: 0 0 auto; + } + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + } + + .menuDivider { + height: 1px; + background: #e6e7ee; + margin: 6px 14px; + } + + // The last resort, and only that: if even the smallest state does not fit, + // the tab card scrolls rather than putting a mode off the edge of the window. + @media (max-width: 380px) { + .topStrip .tabCard { + flex: 1 1 auto; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + } + + .topStrip .tabCard::-webkit-scrollbar { + display: none; + } + } +} diff --git a/src/app/component/top-bar/top-bar.component.ts b/src/app/component/top-bar/top-bar.component.ts new file mode 100644 index 00000000..b6731122 --- /dev/null +++ b/src/app/component/top-bar/top-bar.component.ts @@ -0,0 +1,508 @@ +import { + AfterViewChecked, + AfterViewInit, + Component, + ElementRef, + OnDestroy, + ChangeDetectionStrategy, + ChangeDetectorRef, + NgZone, + inject, + isDevMode, + viewChild, +} from '@angular/core'; +import { animate, style, transition, trigger } from '@angular/animations'; +import { MatDialog } from '@angular/material/dialog'; +import { SelectedTabService, TabID } from '../../selected-tab.service'; +import { MechanismService } from '../../services/mechanism.service'; +import { SaveHistoryService } from '../../services/save-history.service'; +import { AnalyticsService } from '../../services/analytics.service'; +import { UrlGenerationService } from '../../services/url-generation.service'; +import { UrlProcessorService } from '../../services/url-processor.service'; +import { RightPanelComponent } from '../right-panel/right-panel.component'; +import { ExportFlowService } from '../../services/export/export-flow.service'; +import { TemplatesComponent } from '../MODALS/templates/templates.component'; +import { NotificationService } from '../../services/notification.service'; +import { TutorialService } from '../../services/tutorial.service'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatIcon } from '@angular/material/icon'; +import { KeyboardShortcutsService, ShortcutId } from '../../services/keyboard-shortcuts.service'; + +/** A mode's chip: whether that analysis can be entered, and what is missing. */ +interface TabStatus { + text: string; + ready: boolean; +} + +/** + * The strip across the top: what may be done to the mechanism, and whether it + * is ready to be analysed. + * + * It replaces a horizontal file toolbar and a vertical mode rail which between + * them took a whole edge and corner of the window. Opening and saving is not + * what anyone is doing most of the time, so it folds into one menu and the + * modes take the space instead. + * + * Both analysis tabs stay pressable even when they cannot be entered. A greyed + * tab provokes exactly the question "why not?" and is the one control unable to + * answer it; pressing these opens the setup list, which is the answer. + */ +@Component({ + selector: 'app-top-bar', + templateUrl: './top-bar.component.html', + styleUrls: ['./top-bar.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + animations: [ + // Undo/Redo giving the corner over to Export Data, and back. The one on its + // way out leaves the flow, so the card takes the width of the one arriving + // instead of holding both. + trigger('swapFace', [ + transition(':enter', [ + style({ opacity: 0, transform: 'translateY(7px)' }), + animate('180ms cubic-bezier(0.4, 0, 0.2, 1)', style({ opacity: 1, transform: 'none' })), + ]), + transition(':leave', [ + style({ position: 'absolute', top: 0, right: '6px', bottom: 0 }), + animate( + '140ms cubic-bezier(0.4, 0, 0.2, 1)', + style({ opacity: 0, transform: 'translateY(-7px)' }) + ), + ]), + ]), + // The same 200ms and easing the mode highlight slides on, so the menu and + // everything else in this strip move as one piece of machinery. + trigger('menuOpen', [ + transition(':enter', [ + style({ opacity: 0, transform: 'translateY(-6px)' }), + animate('200ms cubic-bezier(0.4, 0, 0.2, 1)', style({ opacity: 1, transform: 'none' })), + ]), + transition(':leave', [ + animate( + '150ms cubic-bezier(0.4, 0, 0.2, 1)', + style({ opacity: 0, transform: 'translateY(-6px)' }) + ), + ]), + ]), + ], + imports: [MatTooltip, MatIcon], +}) +export class TopBarComponent implements AfterViewInit, AfterViewChecked, OnDestroy { + tabs = inject(SelectedTabService); + mechanism = inject(MechanismService); + private history = inject(SaveHistoryService); + private urlGeneration = inject(UrlGenerationService); + private urlProcessor = inject(UrlProcessorService); + private dialog = inject(MatDialog); + private zone = inject(NgZone); + private changes = inject(ChangeDetectorRef); + private exportFlow = inject(ExportFlowService); + private notify = inject(NotificationService); + private tutorial = inject(TutorialService); + + TabID = TabID; + menuOpen = false; + + private analytics: AnalyticsService = inject(AnalyticsService); + shortcuts = inject(KeyboardShortcutsService); + + readonly tabStrip = viewChild<ElementRef<HTMLElement>>('tabStrip'); + readonly strip = viewChild<ElementRef<HTMLElement>>('strip'); + + /** + * How much of each label the strip has room for: 2 full, 1 short, 0 icons. + * + * Measured rather than guessed at a width. Fixed breakpoints have to be set + * for the widest the strip ever gets -- four modes, two status chips, both + * spelled out -- so they throw words away long before the window has run out + * of room for them. This asks the strip. + */ + labelLevel = 3; + /** + * The room and the labels the current level was chosen for. + * + * Nothing else is remembered between fits. The natural widths depend on the + * chips, the font and the layout being finished, and a set measured before + * any of that settled says the strip is small enough for anything -- which + * is how a phone came to show four full labels in a card a fifth of the + * width they need. + */ + private lastFit = ''; + + isDevMode(): boolean { + return isDevMode(); + } + + isActive(tab: TabID): boolean { + return this.tabs.getCurrentTab() === tab && this.tabs.isTabVisible(); + } + + /** + * Where the sliding highlight sits, measured off the active button. + * + * Measured rather than computed from an index because the tabs are different + * widths, the divider takes space of its own, and the labels disappear on a + * narrow window. Held as plain numbers and applied once per tab change: a + * single CSS transition then eases the highlight across, so nothing runs per + * frame that could stutter or strand it midway. + * + * Measured off a frame rather than read during change detection, because the + * `active` class this looks for is applied by the same pass that would read + * it back. + */ + highlight = { left: 0, width: 0, visible: false }; + private pendingFrame = 0; + private readonly onResize = () => this.scheduleHighlight(); + + ngAfterViewInit(): void { + this.scheduleHighlight(); + window.addEventListener('resize', this.onResize); + document.fonts?.ready.then(() => { + this.lastFit = ''; + this.zone.run(() => this.fitLabels()); + }); + } + + // After the pass, not during it: the `active` class this measures off is + // written by the very pass that would read it back, so measuring inside one + // reports where the highlight used to be and it trails a tab behind. + ngAfterViewChecked(): void { + this.watchCard(); + this.fitLabels(); + this.scheduleHighlight(); + } + + /** + * Drop a level of label only once the level above will not fit. + * + * The three natural widths are measured once, by laying the strip out at each + * level and reading what it wanted. After that it is arithmetic against the + * space available, which is cheap enough to do on every checked pass. + */ + private fitLabels(): void { + const strip = this.strip()?.nativeElement; + if (!strip || strip.clientWidth === 0) return; + + // Re-fit when the question changes, not on every pass: four class toggles + // and four layout reads is cheap once and dear sixty times a second. The + // chips are part of the question -- "Ready" is half of "3 to set". + const question = [ + strip.clientWidth, + this.statusOf(TabID.ANALYZE).text, + this.statusOf(TabID.FORCE).text, + this.tabs.getCurrentTab(), + ].join('|'); + if (question === this.lastFit) return; + this.lastFit = question; + + // What the strip wants at each level: its cards at their natural widths, + // plus the gaps between them. Their laid-out widths would only tell us how + // far they have already been squeezed. + const gaps = 12 * Math.max(strip.children.length - 1, 0); + const restore = strip.className; + const wants = [0, 1, 2, 3].map((level) => { + strip.classList.remove('fit0', 'fit1', 'fit2', 'fit3'); + strip.classList.add(`fit${level}`); + return [...strip.children].reduce((total, card) => total + card.scrollWidth, 0) + gaps; + }); + strip.className = restore; + if (!(wants[3] > 0)) { + // Nothing has been laid out yet. Ask again rather than remembering that. + this.lastFit = ''; + return; + } + + const level = wants.reduce( + (best, want, at) => (want <= strip.clientWidth ? Math.max(best, at) : best), + 0 + ); + if (level !== this.labelLevel) { + this.labelLevel = level; + this.changes.detectChanges(); + } + } + + /** + * The keys that pick a mode go through `select`, exactly as the buttons do, + * so a mode that is not ready opens its setup drawer rather than half + * switching -- the gate is the mode's, not the button's. + */ + private readonly keyed: Partial<Record<ShortcutId, () => void>> = { + 'mode.synthesis': () => this.select(TabID.SYNTHESIZE), + 'mode.edit': () => this.select(TabID.EDIT), + 'mode.kinematic': () => this.select(TabID.ANALYZE), + 'mode.force': () => this.select(TabID.FORCE), + 'app.settings': () => this.openSettings(), + 'app.help': () => this.openHelp(), + }; + + private keySub = this.shortcuts.pressed.subscribe((id) => this.keyed[id]?.()); + + ngOnDestroy(): void { + this.keySub.unsubscribe(); + if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame); + window.removeEventListener('resize', this.onResize); + this.cardWatch?.disconnect(); + } + + private cardWatch?: ResizeObserver; + + /** + * Watch the card, not just the window. + * + * The card can be squeezed without the window changing -- a chip growing, a + * drawer opening beside it -- and a squeeze nothing asks a question about is + * one the reader sees as clipped text. + */ + private watchCard(): void { + const card = this.strip()?.nativeElement; + if (!card || this.cardWatch || typeof ResizeObserver === 'undefined') return; + this.cardWatch = new ResizeObserver(() => + this.zone.run(() => { + this.lastFit = ''; + this.fitLabels(); + }) + ); + this.cardWatch.observe(card); + } + + private scheduleHighlight(): void { + // Coalesce rather than restart. Cancelling the pending frame on every + // checked pass means a busy app -- playback runs change detection every + // frame -- cancels the measurement before it can ever run, and the + // highlight sits on whichever tab was active last time things went quiet. + if (this.pendingFrame) return; + this.pendingFrame = requestAnimationFrame(() => { + this.pendingFrame = 0; + const active = this.tabStrip()?.nativeElement.querySelector<HTMLElement>('.tabButton.active'); + const next = active + ? { left: active.offsetLeft, width: active.offsetWidth, visible: this.tabs.isTabVisible() } + : { ...this.highlight, visible: false }; + // Only on a real change, so the measure-every-pass above settles instead + // of asking for another pass forever. + if ( + next.left === this.highlight.left && + next.width === this.highlight.width && + next.visible === this.highlight.visible + ) { + return; + } + // Back inside Angular to assign it. An animation frame runs outside, so + // the new position would sit in the field unrendered until something else + // happened to trigger a pass -- which is the next tab change, leaving the + // highlight permanently one behind. + this.zone.run(() => { + this.highlight = next; + // And re-check this view explicitly. The assignment happens after the + // pass that would have rendered it, so without this the new position + // waits for whatever causes the *next* pass -- which is the next tab + // change, leaving the highlight permanently one behind. + this.changes.detectChanges(); + }); + }); + } + + select(tab: TabID): void { + this.menuOpen = false; + if (this.tabs.isAnalysisMode(tab) && !this.canAnalyse(tab)) { + // The setup for the mode that was pressed, not the other one's -- and + // never a toggle: a reader pressing a mode that will not open is asking + // why, and closing the answer is not one. + RightPanelComponent.insistOn( + tab === TabID.FORCE + ? RightPanelComponent.FORCE_SETUP_TAB + : RightPanelComponent.KINEMATIC_SETUP_TAB + ); + return; + } + this.tabs.setTab(tab); + } + + /** Kinematics needs one mechanism that runs. Force analysis needs more. */ + canAnalyse(tab: TabID): boolean { + return tab === TabID.FORCE + ? this.mechanism.forceAnalysisReady() + : this.mechanism.oneValidMechanismExists(); + } + + /** + * An empty grid has nothing to be ready for. + * + * "Ready" over a blank canvas is a promise about a mechanism that has not + * been drawn: nothing is stopping analysis because there is nothing to + * analyse. + */ + hasStatus(): boolean { + return this.mechanism.joints.length > 0 || this.mechanism.links.length > 0; + } + + statusOf(tab: TabID): TabStatus { + if (tab === TabID.FORCE) { + // Warnings do not count: they do not stop the analysis running. + const missing = this.mechanism + .forceAnalysisRequirements() + .filter((r) => !r.met && !r.warning).length; + return missing === 0 + ? { text: 'Ready', ready: true } + : { text: `${missing} to set`, ready: false }; + } + const blockers = this.mechanism.blockerCount(); + return blockers === 0 + ? { text: 'Ready', ready: true } + : { text: `${blockers} ${blockers === 1 ? 'fix' : 'fixes'}`, ready: false }; + } + + // Not while the mechanism is running: undo replays a URL, and replacing the + // drawing underneath a playing animation is not something anyone asked for. + canUndo(): boolean { + return !this.mechanism.isAnimating() && this.history.canUndo(); + } + + canRedo(): boolean { + return !this.mechanism.isAnimating() && this.history.canRedo(); + } + + /** + * Whether there is anything to take away. + * + * A solved mechanism, rather than a selected part: the drawer offers every + * part of every machine, so a reader who has selected nothing still has an + * export to make -- which is exactly the case the old one-click command could + * not serve. + */ + canExport(): boolean { + return this.mechanism.oneValidMechanismExists(); + } + + exportTooltip(): string { + return this.canExport() + ? 'Choose parts, columns and a file format' + : 'Nothing has been solved yet, so there are no numbers to export'; + } + + exportData(): void { + this.exportFlow.reset(); + RightPanelComponent.insistOn(RightPanelComponent.EXPORT_TAB); + } + + undo(): void { + this.history.undo(); + } + + redo(): void { + this.history.redo(); + } + + toggleMenu(): void { + this.menuOpen = !this.menuOpen; + } + + closeMenu(): void { + this.menuOpen = false; + } + + newProject(): void { + this.closeMenu(); + this.analytics.logEvent('new_project'); + window.open(window.location.origin + window.location.pathname, '_blank'); + } + + openTemplates(): void { + this.closeMenu(); + this.dialog.open(TemplatesComponent, { height: '90%', width: '90%', autoFocus: false }); + } + + openSettings(): void { + this.closeMenu(); + RightPanelComponent.tabClicked(1); + } + + openHelp(): void { + this.closeMenu(); + RightPanelComponent.tabClicked(3); + } + + /** + * The way back to the tutorial once its offer has been dismissed for good. + * + * It opens at whichever step the drawing has not satisfied, so this works on + * a mechanism already on the grid as well as on a bare one. + */ + openTutorial(): void { + this.closeMenu(); + this.tutorial.start(); + } + + openDebug(): void { + this.closeMenu(); + RightPanelComponent.tabClicked(4); + } + + /** + * The chip opens that mode's setup, whether or not the mode can be entered. + * + * Otherwise the list is reachable only by being refused, and a drawing where + * one machine is ready and another is not can be *entered* -- leaving the + * reader with a chip that counts problems and no way to read them. + */ + openSetupFor(tab: TabID, event: Event): void { + event.stopPropagation(); + this.menuOpen = false; + RightPanelComponent.tabClicked( + tab === TabID.FORCE + ? RightPanelComponent.FORCE_SETUP_TAB + : RightPanelComponent.KINEMATIC_SETUP_TAB + ); + } + + openSetup(): void { + RightPanelComponent.tabClicked( + this.tabs.getCurrentTab() === TabID.FORCE + ? RightPanelComponent.FORCE_SETUP_TAB + : RightPanelComponent.KINEMATIC_SETUP_TAB + ); + } + + upload($event: Event): void { + this.analytics.logEvent('upload_file'); + this.closeMenu(); + const input = $event.target as HTMLInputElement; + // Silently. This fires when the picker is dismissed, and cancelling a + // dialogue is not a thing that went wrong -- the reader closed it knowing + // full well that they had chosen nothing. + if (!input.files || input.files.length !== 1) { + return; + } + const reader = new FileReader(); + reader.onload = () => { + this.notify.success('file.loaded', 'Mechanism loaded.'); + this.urlProcessor.updateFromURL(reader.result as string); + // Reset the input so the same file can be opened again. + input.value = ''; + }; + reader.readAsText(input.files[0]); + } + + downloadLinkage(): void { + this.closeMenu(); + this.analytics.logEvent('download_linkage'); + const blob = new Blob([this.urlGeneration.generateUrlQuery()], { + type: 'text;charset=utf-8;', + }); + const link = document.createElement('a'); + if (link.download === undefined) return; + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `PMKS+_${new Date().toISOString()}.pmks`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + copyURL(): void { + this.closeMenu(); + this.analytics.logEvent('copyURL'); + this.urlGeneration.copyFullUrl(); + this.notify.success('share.copied', 'Link copied. Copy again after your next change.'); + } +} diff --git a/src/app/component/tutorial-panel/tutorial-panel.component.html b/src/app/component/tutorial-panel/tutorial-panel.component.html new file mode 100644 index 00000000..b95ccc12 --- /dev/null +++ b/src/app/component/tutorial-panel/tutorial-panel.component.html @@ -0,0 +1,183 @@ +<!-- Two faces of one page: the step being worked on, and the result. They are + never both true, so the drawer holds one card either way. --> +@if (!tutorial.done) { + <div class="tutorialCard" [class.settling]="tutorial.settling"> + <button class="closeCard" (click)="close()" aria-label="Close tutorial" title="Close"> + <mat-icon>close</mat-icon> + </button> + <div class="cardTitle">Tutorial</div> + <p class="cardLead">{{ lead }}</p> + + <!-- The measure, and the way back through it. Each segment is the step it + stands for, so a student who wants to re-read step two presses step + two rather than the back arrow twice. --> + <div class="stepBars"> + <button + class="stepArrow" + (click)="tutorial.goBack()" + [disabled]="!tutorial.canGoBack()" + aria-label="Previous step" + title="Previous step" + > + <mat-icon>chevron_left</mat-icon> + </button> + @for (bar of bars; track $index) { + <button + class="stepBar" + [class.done]="bar.done" + [class.here]="bar.here" + [disabled]="!bar.reachable" + (click)="tutorial.goToStep($index + 1)" + [attr.aria-label]="'Step ' + ($index + 1)" + ></button> + } + <button + class="stepArrow" + (click)="tutorial.goForward()" + [disabled]="!tutorial.canGoForward()" + aria-label="Next step" + title="Next step" + > + <mat-icon>chevron_right</mat-icon> + </button> + </div> + + <!-- Keyed on the step, so moving between them is a movement rather than a + repaint, and the direction of travel is the direction it slides. --> + <div class="stepBody" [@stepSwap]="tutorial.viewedStep"> + @if (tutorial.viewedProgress().achieved; as achieved) { + <div class="achieved"> + <mat-icon>check_circle</mat-icon> + <span>{{ achieved }}</span> + </div> + } + + <div class="stepTitle"> + <span>{{ tutorial.copy().title }}</span> + @if (tutorial.viewingCompleted()) { + <mat-icon class="stepTick" [class.justNow]="tutorial.settling">check_circle</mat-icon> + } + </div> + <p class="stepText">{{ tutorial.copy().body }}</p> + + @if (tutorial.copy().hint; as hint) { + <div class="hint"> + <mat-icon>{{ tutorial.copy().hintGlyph }}</mat-icon> + <span>{{ hint }}</span> + </div> + } + + <!-- The one lesson about the app rather than about linkages, and it is a + quotation: the chip says this whether or not the tutorial is running, + so from here on it answers "why can't I?" on its own. --> + @if (showsChipHint) { + <div class="hint chipHint"> + <span class="chip">{{ chipText }}</span> + <span> + is the Kinematic Analysis chip counting this step. Click it whenever a mode will not run + and it lists what is missing. + </span> + </div> + } + </div> + + <div class="cardRule"></div> + <div class="cardActions"> + <!-- Offered only where there is something outstanding to offer. On a step + already behind the student it would be a button to do something that + is done. --> + @if (!tutorial.viewingCompleted()) { + <button class="doItButton" (click)="doStepForMe()">Do This Step For Me</button> + } @else { + <span class="doneNote">{{ tutorial.settling ? 'Done' : 'Already done' }}</span> + } + @if (tutorial.canGoForward()) { + <button class="nextButton" (click)="tutorial.goForward()"> + Next step<mat-icon>chevron_right</mat-icon> + </button> + } + </div> + </div> +} @else { + <div class="tutorialCard doneCard"> + <div class="doneHead"> + <mat-icon class="doneMark">check_circle</mat-icon> + <span class="doneTitle">Tutorial complete</span> + </div> + + @if (tutorial.reading; as reading) { + <p class="doneLine"> + You built a four-bar and read joint {{ reading.joint }} at + <strong>{{ reading.magnitude }} {{ reading.unit }}</strong + >, {{ reading.time }} into the cycle. + </p> + } + + <div class="cardRule"></div> + <div class="doorsHeading">What next</div> + + <!-- Each row is the control it names, not a description of where to find + it: the tutorial ends by handing the student to the app. --> + <div class="doors"> + <button class="door" (click)="openExport()"> + <mat-icon>download</mat-icon> + <span class="doorText"> + <span class="doorName">Export Data</span> + <span class="doorWhy"> + Every timestep of position, velocity and acceleration, as a file you can attach to a + report. + </span> + </span> + </button> + <button class="door" (click)="shareProject()"> + <mat-icon>link</mat-icon> + <span class="doorText"> + <span class="doorName">Share Project</span> + <span class="doorWhy"> + A link that opens this exact mechanism. It is how a professor hands one out, and how you + hand one in. + </span> + </span> + </button> + <button class="door" (click)="openTemplates()"> + <mat-icon>apps</mat-icon> + <span class="doorText"> + <span class="doorName">Templates</span> + <span class="doorWhy"> + Worked mechanisms to take apart: a Watt six-bar, a scotch yoke, a Jansen leg, a + pumpjack. + </span> + </span> + </button> + </div> + + <!-- One way out of a finished tutorial, not two. The card used to carry an + × of its own here as well as this button, which put two of them in the + same corner: this one and the drawer's. --> + <div class="cardActions doneActions"> + <button class="primaryButton" (click)="close()">Close</button> + <button class="quietButton" (click)="restart()">Run Tutorial Again</button> + </div> + </div> +} + +<!-- Running it again clears the grid, which is the one thing the tutorial does + that cannot be shrugged off. Closing it costs nothing and says so in a + snackbar instead; this asks, because there is work to lose. --> +<ng-template #confirmRestart> + <div class="modal"> + <h1 mat-dialog-title>Start the tutorial again?</h1> + <div mat-dialog-content> + <p> + This clears the grid and begins from step one, so the mechanism you have built will be + deleted. Undo will bring it back. + </p> + </div> + <div mat-dialog-actions align="end"> + <button mat-button mat-dialog-close>Keep my mechanism</button> + <button mat-button cdkFocusInitial [mat-dialog-close]="'restart'"> + Clear and start again + </button> + </div> + </div> +</ng-template> diff --git a/src/app/component/tutorial-panel/tutorial-panel.component.scss b/src/app/component/tutorial-panel/tutorial-panel.component.scss new file mode 100644 index 00000000..cb0e5cbe --- /dev/null +++ b/src/app/component/tutorial-panel/tutorial-panel.component.scss @@ -0,0 +1,476 @@ +@use 'sass:map'; +@use '@angular/material' as mat; + +@mixin css($theme) { + $color-config: mat.m2-get-color-config($theme); + $primary-palette: map.get($color-config, 'primary'); + $indigo: mat.m2-get-color-from-palette($primary-palette, 500); + + // One card, like every other drawer page. It shrinks to its content rather + // than filling the drawer: the tutorial is a paragraph and two buttons, and + // a card stretched to the window height reads as a page that failed to load. + .tutorialCard { + position: relative; + box-sizing: border-box; + width: 100%; + padding: 12px 15px 14px; + background: var(--card-surface); + border-top: 5px solid $indigo; + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + } + + // Material sizes every icon box at 24px and hides what overflows it, from a + // bare `.mat-icon` -- which a bare class in here cannot outrank, whatever the + // source order says, because those styles are injected at runtime. So each + // icon below sets its font-size and got a 24px box anyway, and a 16px glyph + // drawn at the top of a 24px box reads as sitting high off its own line. + // + // Naming the element as well as the class is enough to take the box back, and + // 1em ties it to whatever size each icon was actually asked for. + .tutorialCard mat-icon { + width: 1em; + height: 1em; + line-height: 1; + overflow: visible; + } + + .closeCard { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: rgba(0, 0, 0, 0.54); + cursor: pointer; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + // In the completion card it shares a line with the title rather than + // floating over it, so it gives up its corner. + &.flush { + position: static; + flex: 0 0 auto; + width: 28px; + height: 28px; + } + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + line-height: 20px; + } + } + + .cardTitle { + padding-right: 40px; + font-size: 20px; + font-weight: 500; + line-height: 32px; + color: rgba(0, 0, 0, 0.87); + } + + .cardLead { + margin: 0; + font-size: 14px; + line-height: 18px; + color: rgba(0, 0, 0, 0.6); + } + + // The measure and the way back through it on one line: an arrow at each end + // and a segment per step between them. + .stepBars { + display: flex; + align-items: center; + gap: 4px; + margin: 10px 0 14px; + } + + .stepArrow { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: rgba(0, 0, 0, 0.54); + cursor: pointer; + + &:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.05); + } + + &:disabled { + color: rgba(0, 0, 0, 0.18); + cursor: default; + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + } + } + + // Behind, here, ahead — three weights of the one colour rather than three + // colours, because they are one measure being read left to right. Each is + // also the way to that step, so the bar is a control and has the hit height + // to be one without the line itself getting any thicker. + .stepBar { + flex: 1 1 0; + height: 14px; + padding: 0; + border: none; + background: none; + cursor: pointer; + + &::after { + content: ''; + display: block; + height: 3px; + border-radius: 2px; + background: #eceef5; + transition: background 0.18s ease-in-out; + } + + &.done::after { + background: #c5cae9; + } + + &.here::after { + background: $indigo; + } + + &:disabled { + cursor: default; + } + + &:hover:not(:disabled):not(.here)::after { + background: #aeb4d8; + } + } + + .achieved { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 12px; + + mat-icon { + flex: 0 0 auto; + font-size: 18px; + width: 18px; + height: 18px; + line-height: 19px; + color: #137333; + } + + span { + font-size: 13px; + line-height: 19px; + color: rgba(0, 0, 0, 0.45); + } + } + + // A row rather than an inline run, so the tick sits on the title's own centre + // line whatever the two are sized at. Baseline-aligning a 16px glyph against + // 14px text put it a third of a line high. + .stepTitle { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + color: rgba(0, 0, 0, 0.87); + } + + .stepText { + margin: 4px 0 0; + font-size: 13px; + line-height: 19px; + color: rgba(0, 0, 0, 0.6); + text-wrap: pretty; + } + + // The tick beside a step that is behind the student. On the step they have + // just this second finished it arrives with a beat of its own, which is the + // whole point of holding the card there before moving on. + .stepTick { + flex: 0 0 auto; + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + color: #137333; + + &.justNow { + animation: tutorialTick 0.42s cubic-bezier(0.22, 1, 0.36, 1); + } + } + + @keyframes tutorialTick { + 0% { + opacity: 0; + transform: scale(0.4); + } + 60% { + opacity: 1; + transform: scale(1.18); + } + 100% { + opacity: 1; + transform: scale(1); + } + } + + // While a finished step is being held up to be read. A tint rather than a + // banner: the words have not changed, and the card is about to move on by + // itself, so this says "that one is done" without taking a line to say it. + .settling .stepTitle { + color: #137333; + } + + .doneNote { + font-size: 13px; + font-weight: 500; + color: #137333; + } + + .nextButton { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 0 2px; + border: none; + background: transparent; + font-family: inherit; + font-size: 13px; + font-weight: 500; + color: $indigo; + cursor: pointer; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + } + } + + .quietButton { + padding: 0 2px; + border: none; + background: transparent; + font-family: inherit; + font-size: 13px; + color: rgba(0, 0, 0, 0.45); + cursor: pointer; + } + + .hint { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; + padding: 9px 11px; + border-radius: 4px; + background: #f5f6fa; + + mat-icon { + flex: 0 0 auto; + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + color: rgba(0, 0, 0, 0.54); + } + + span { + font-size: 12px; + line-height: 17px; + color: rgba(0, 0, 0, 0.6); + } + } + + // The quoted chip sits above its sentence's first line rather than centred + // against a paragraph it is two lines shorter than. + .chipHint { + align-items: flex-start; + gap: 9px; + padding: 10px 11px; + } + + .chip { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 10px; + background: #eef0f4; + color: rgba(0, 0, 0, 0.54); + font-size: 12px; + white-space: nowrap; + } + + .cardRule { + height: 1px; + margin: 14px 0 10px; + background: #eceef5; + } + + .cardActions { + display: flex; + align-items: center; + justify-content: space-between; + } + + .doItButton, + .exitButton, + .primaryButton { + padding: 0 2px; + border: none; + background: transparent; + font-family: inherit; + font-size: 13px; + cursor: pointer; + } + + .doItButton { + font-weight: 500; + color: $indigo; + } + + .exitButton { + color: rgba(0, 0, 0, 0.45); + } + + // ---------- the completion card ---------- + + .doneCard { + border-top-color: #137333; + } + + .doneHead { + display: flex; + align-items: center; + gap: 9px; + } + + .doneMark { + flex: 0 0 auto; + font-size: 20px; + width: 20px; + height: 20px; + line-height: 20px; + color: #137333; + } + + .doneTitle { + flex: 1 1 auto; + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + } + + .doneLine { + margin: 9px 0 0; + font-size: 13px; + line-height: 19px; + color: rgba(0, 0, 0, 0.7); + text-wrap: pretty; + } + + .doorsHeading { + margin-bottom: 10px; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.45); + } + + .doors { + display: flex; + flex-direction: column; + gap: 8px; + } + + .door { + display: flex; + align-items: flex-start; + gap: 11px; + width: 100%; + padding: 10px 12px; + border: 1px solid #dfe1ea; + border-radius: 4px; + background: var(--card-surface); + font-family: inherit; + text-align: left; + cursor: pointer; + + &:hover { + background: #f5f6fa; + } + + mat-icon { + flex: 0 0 auto; + font-size: 20px; + width: 20px; + height: 20px; + line-height: 20px; + color: $indigo; + } + } + + .doorText { + flex: 1 1 auto; + min-width: 0; + } + + .doorName { + display: block; + font-size: 13px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + } + + .doorWhy { + display: block; + margin-top: 2px; + font-size: 12px; + line-height: 17px; + color: rgba(0, 0, 0, 0.6); + } + + .doneActions { + justify-content: flex-start; + gap: 8px; + margin-top: 14px; + } + + .primaryButton { + height: 32px; + padding: 0 14px; + border-radius: 4px; + background: $indigo; + color: #fff; + font-weight: 500; + } +} diff --git a/src/app/component/tutorial-panel/tutorial-panel.component.ts b/src/app/component/tutorial-panel/tutorial-panel.component.ts new file mode 100644 index 00000000..53e77f4b --- /dev/null +++ b/src/app/component/tutorial-panel/tutorial-panel.component.ts @@ -0,0 +1,184 @@ +import { + ChangeDetectionStrategy, + Component, + DoCheck, + TemplateRef, + inject, + viewChild, +} from '@angular/core'; +import { animate, style, transition, trigger } from '@angular/animations'; +import { + MatDialog, + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogTitle, +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { TutorialService } from '../../services/tutorial.service'; +import { MechanismService } from '../../services/mechanism.service'; +import { UrlGenerationService } from '../../services/url-generation.service'; +import { NotificationService } from '../../services/notification.service'; +import { ExportFlowService } from '../../services/export/export-flow.service'; +import { TemplatesComponent } from '../MODALS/templates/templates.component'; +import { RightPanelComponent } from '../right-panel/right-panel.component'; + +/** + * The tutorial, as a drawer page. + * + * In the drawer rather than the left column because the left column is already + * that mode's own panel — it says what the selected part is, and the analysis + * page fills it with graph cards the last step is pointing at. The drawer is + * where the app already keeps things that run alongside the drawing instead of + * describing a selection. + */ +@Component({ + selector: 'app-tutorial-panel', + templateUrl: './tutorial-panel.component.html', + styleUrls: ['./tutorial-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + animations: [ + // One step leaving as the next arrives, in the direction of travel. The + // card used to cut from one step to the next in a single frame, at the + // moment the student finished the move it was describing -- so the thing + // they were reading was replaced by a thing they had not asked for, and + // the only cue that anything had happened was that the words were now + // different. + trigger('stepSwap', [ + transition(':increment', arrive(26)), + transition(':decrement', arrive(-26)), + ]), + ], + imports: [MatIcon, MatButton, MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose], +}) +export class TutorialPanelComponent implements DoCheck { + tutorial = inject(TutorialService); + private mechanism = inject(MechanismService); + private urlGeneration = inject(UrlGenerationService); + private notify = inject(NotificationService); + private exportFlow = inject(ExportFlowService); + private dialog = inject(MatDialog); + + readonly confirmRestart = viewChild.required<TemplateRef<unknown>>('confirmRestart'); + + /** + * Ask the drawing whether the step has changed, once per cycle. + * + * A subscription would be tidier, and there is nothing to subscribe to: an + * edit ends in `updateMechanism`, which publishes on nothing this could + * listen to. Change detection runs after every gesture that could move the + * tutorial on, which makes it the honest place to ask. + */ + ngDoCheck(): void { + this.tutorial.noticeStep(); + } + + /** + * One segment per step: filled behind the student, hollow ahead, and only + * pressable as far as the drawing has actually got. + */ + get bars(): { done: boolean; here: boolean; reachable: boolean }[] { + const viewed = this.tutorial.viewedStep; + const reached = this.tutorial.step(); + return Array.from({ length: this.tutorial.stepCount() }, (_, index) => ({ + done: index + 1 < reached, + here: index + 1 === viewed, + reachable: index + 1 <= reached, + })); + } + + get lead(): string { + return `Step ${this.tutorial.viewedStep} of ${this.tutorial.stepCount()}. Build a four-bar and read a velocity.`; + } + + /** + * What the Kinematic Analysis chip says right now, quoted rather than + * retyped. + * + * The mock spelled it "1 to set", which is the *Force* chip's wording — the + * kinematic one counts fixes. Reading the real number is the only way the + * sentence cannot drift from the control it is teaching. + */ + get chipText(): string { + const blockers = this.mechanism.blockerCount(); + return blockers === 0 ? 'Ready' : `${blockers} ${blockers === 1 ? 'fix' : 'fixes'}`; + } + + /** Step four is the one lesson about the app rather than about linkages. */ + get showsChipHint(): boolean { + return this.tutorial.viewedStep === 4; + } + + /** + * Closing happens, and then says where the tutorial went. + * + * A dialog asking permission first was too heavy for a thing that costs + * nothing: the drawing is untouched either way, and the only real question — + * "how do I get it back?" — is answered better after the fact than as a + * question the reader has to dismiss before they can act. + */ + close(): void { + this.tutorial.exit(); + this.notify.success( + 'tutorial.closed', + 'Tutorial closed. It is in the project menu, at the top left, whenever you want it back.' + ); + } + + /** + * Starting again clears the grid, so it asks — but only where there is + * something to lose. A confirmation over an empty drawing is a question with + * one answer. + */ + restart(): void { + if (!this.tutorial.restartWouldDiscard()) { + this.tutorial.restart(); + return; + } + this.dialog + // Sized, because a Material dialog left to itself takes most of the + // window and two sentences do not need it. + .open(this.confirmRestart(), { width: '440px', autoFocus: false }) + .afterClosed() + .subscribe((choice) => { + if (choice === 'restart') this.tutorial.restart(); + }); + } + + doStepForMe(): void { + this.tutorial.doStepForMe(); + } + + // ---------- the three doors ---------- + + openExport(): void { + this.exportFlow.reset(); + RightPanelComponent.insistOn(RightPanelComponent.EXPORT_TAB); + } + + shareProject(): void { + this.urlGeneration.copyFullUrl(); + this.notify.success('share.copied', 'Link copied. It opens this exact mechanism.'); + } + + openTemplates(): void { + this.dialog.open(TemplatesComponent, { height: '90%', width: '90%', autoFocus: false }); + } +} + +/** + * The step that has just arrived comes in from the side it came from: forwards + * from the right, backwards from the left. + * + * The element itself is animated rather than the two steps being crossfaded + * past each other. A crossfade needs both on screen at once, which means either + * the card jumping to the height of two steps stacked or the outgoing one being + * torn out of the layout — and neither is worth it for a movement this short. + */ +function arrive(offset: number) { + return [ + style({ opacity: 0, transform: `translateX(${offset}px)` }), + animate('220ms cubic-bezier(0.2, 0, 0, 1)', style({ opacity: 1, transform: 'none' })), + ]; +} diff --git a/src/app/component/view-controls/view-button.component.html b/src/app/component/view-controls/view-button.component.html new file mode 100644 index 00000000..07758a55 --- /dev/null +++ b/src/app/component/view-controls/view-button.component.html @@ -0,0 +1,17 @@ +<button + class="viewButton" + [class.on]="shown()" + [disabled]="disabled()" + (click)="pressed.emit()" + [matTooltip]="tip()" + matTooltipPosition="above" + [matTooltipShowDelay]="600" + [attr.aria-label]="label()" + [attr.aria-pressed]="noun() ? shown() : null" +> + @if (icon(); as ligature) { + <mat-icon>{{ ligature }}</mat-icon> + } @else { + <mat-icon [svgIcon]="glyph()"></mat-icon> + } +</button> diff --git a/src/app/component/view-controls/view-button.component.scss b/src/app/component/view-controls/view-button.component.scss new file mode 100644 index 00000000..577afdd7 --- /dev/null +++ b/src/app/component/view-controls/view-button.component.scss @@ -0,0 +1,56 @@ +@mixin css($theme) { + // The host is only a wrapper; the button is what the row lays out. + view-button { + display: flex; + flex: 0 0 auto; + } + + .viewButton { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex: 0 0 auto; + border: none; + border-radius: 6px; + background: transparent; + color: #5f6368; + cursor: pointer; + padding: 0; + + mat-icon { + width: 22px; + height: 22px; + font-size: 22px; + line-height: 22px; + margin: 0; + // Every glyph takes the button's ink, whether it is a Material ligature + // or one of the app's own SVGs. Two of those SVGs used to paint + // themselves black, so they stayed black while the button around them + // greyed out -- one switch in a row of three that would not go quiet. + color: inherit; + fill: currentColor; + } + + &:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.04); + } + + // On means a tint behind the glyph, and nothing else. The glyph itself + // stays the same grey in both states: it is drawing what is on the grid, + // so having it change colour as well made two signals out of one, and the + // three switches disagreed about which of them carried the state. + &.on { + background: #eef1fc; + } + + // Last, so it beats the tint: a switch with nothing to act on is not an + // on switch, whatever its setting says. + &:disabled { + color: #c4c7d0; + cursor: default; + background: transparent; + } + } +} diff --git a/src/app/component/view-controls/view-button.component.ts b/src/app/component/view-controls/view-button.component.ts new file mode 100644 index 00000000..8b66a2b8 --- /dev/null +++ b/src/app/component/view-controls/view-button.component.ts @@ -0,0 +1,69 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { KeyboardShortcutsService, ShortcutId } from '../../services/keyboard-shortcuts.service'; + +/** + * One button in the view controls, in both kinds it comes in: a switch that + * says whether something is on the grid, and a plain action like zooming. + * + * One component for all six because the six were written out six times, and + * they drifted: the switches disagreed about whether the glyph showed the + * state or offered the other one, about whether the tint or the ink carried + * it, and about what "nothing to do" meant. Every one of those rules is here + * now, once. A caller says what its switch is about; it does not get to say + * how a switch looks. + */ +@Component({ + selector: 'view-button', + templateUrl: './view-button.component.html', + styleUrls: ['./view-button.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatIcon, MatTooltip], +}) +export class ViewButtonComponent { + private shortcuts = inject(KeyboardShortcutsService); + + /** What a switch puts on the grid: "Center of Mass", "traced paths". */ + readonly noun = input<string>(); + /** The glyph for each state. The `shown` one is the plain, uncrossed glyph. */ + readonly shownIcon = input<string>(); + readonly hiddenIcon = input<string>(); + /** Whether that thing is on the grid right now. */ + readonly shown = input(false); + + /** A plain action instead: a Material ligature and the one name it goes by. */ + readonly icon = input<string>(); + readonly tooltip = input<string>(); + + /** The shortcut this button doubles, if it has one: its keys go in the tip. */ + readonly shortcut = input<ShortcutId>(); + + /** True when pressing this would change nothing on the grid. */ + readonly disabled = input(false); + readonly pressed = output<void>(); + + /** + * The control's own name, which does not change with its state -- a reader + * looking for the traced-paths switch is looking for the same button whether + * or not paths are showing, and so is a test. + * + * A switch also carries `aria-pressed`, so the state a sighted reader gets + * from the tint reaches everyone else: the label alone always says "Show", + * which left a screen reader with no way to tell an on switch from an off + * one. The plain actions have no state and no such attribute. + */ + readonly label = computed(() => this.tooltip() ?? `Show ${this.noun()}`); + + /** The tooltip names what pressing it would do, which is the other state. */ + readonly tip = computed(() => { + const name = this.noun() + ? `${this.shown() ? 'Hide' : 'Show'} ${this.noun()}` + : (this.tooltip() ?? ''); + const id = this.shortcut(); + return id ? this.shortcuts.tip(name, id) : name; + }); + + /** The glyph draws the grid as it is: the crossed-out one means hidden. */ + readonly glyph = computed(() => (this.shown() ? this.shownIcon() : this.hiddenIcon()) ?? ''); +} diff --git a/src/app/component/view-controls/view-controls.component.html b/src/app/component/view-controls/view-controls.component.html index 2f4aba12..a1fe5e34 100644 --- a/src/app/component/view-controls/view-controls.component.html +++ b/src/app/component/view-controls/view-controls.component.html @@ -1,64 +1,57 @@ -<div class="viewControls"> - <button - class="mini-buttons blue" - mat-stroked-button - color="primary" - [disabled]="noLinkExists()" - (click)="showCenterOfMass()" - matTooltip="Show Center of Mass" - matTooltipPosition="right" - [matTooltipShowDelay]="1000" - > - <mat-icon svgIcon="{{ comIconName() }}"></mat-icon> - CoM - </button> - <button - class="mini-buttons blue" - mat-stroked-button - color="primary" +<div data-canvas-inset="bottom" class="viewControls"> + <!-- The switches. Each says what it is about; how a switch looks and reads + is view-button's business, so the three cannot drift apart again. --> + <view-button + noun="Center of Mass" + shownIcon="com" + hiddenIcon="com_off" + [shown]="isShowingCoM()" + [disabled]="noMassiveLink()" + shortcut="view.centerOfMass" + (pressed)="showCenterOfMass()" + /> + <view-button + noun="joint IDs" + shownIcon="abc" + hiddenIcon="abc_off" + [shown]="isShowingIDs()" [disabled]="noJointExists()" - (click)="onShowIDPressed()" - matTooltip="Show ID Labels" - matTooltipPosition="right" - [matTooltipShowDelay]="1000" - > - <mat-icon svgIcon="{{ idLabelIconName() }}"></mat-icon> - Label - </button> - <button - class="mini-buttons blue" - mat-stroked-button - color="primary" - (click)="onReframePressed()" - matTooltip="Reset View" - matTooltipPosition="right" - [matTooltipShowDelay]="1000" - > - <mat-icon>crop_free</mat-icon> - View - </button> - <div class="zoomRow"> - <button - class="mini-buttons blue zoomButton" - mat-stroked-button - color="primary" - (click)="onZoomOutPressed()" - matTooltip="Zoom Out" - matTooltipPosition="right" - [matTooltipShowDelay]="1000" - > - <mat-icon>zoom_out</mat-icon> - </button> - <button - class="mini-buttons blue zoomButton" - mat-stroked-button - color="primary" - (click)="onZoomInPressed()" - matTooltip="Zoom In" - matTooltipPosition="right" - [matTooltipShowDelay]="1000" - > - <mat-icon>zoom_in</mat-icon> - </button> - </div> + shortcut="view.jointIds" + (pressed)="onShowIDPressed()" + /> + <!-- The app's own curve glyph, which is a traced point with its path behind + it. A line chart reads as another kind of analysis. --> + <view-button + noun="traced paths" + shownIcon="show_path" + hiddenIcon="hide_path" + [shown]="isShowingTraces()" + [disabled]="noTracedJoint()" + shortcut="view.paths" + (pressed)="onShowTracesPressed()" + /> + <!-- The switches above say what the drawing shows and stay pressed; the + actions below do something once and come back up. Two kinds of button + in one row, so the row says where one kind ends. --> + <div class="viewDivider" aria-hidden="true"></div> + <!-- The actions, which are always available: there is a view to zoom + whether or not there is anything drawn in it. --> + <view-button + icon="zoom_out" + tooltip="Zoom Out" + shortcut="view.zoomOut" + (pressed)="onZoomOutPressed()" + /> + <view-button + icon="zoom_in" + tooltip="Zoom In" + shortcut="view.zoomIn" + (pressed)="onZoomInPressed()" + /> + <view-button + icon="crop_free" + tooltip="Reset View" + shortcut="view.reset" + (pressed)="onReframePressed()" + /> </div> diff --git a/src/app/component/view-controls/view-controls.component.scss b/src/app/component/view-controls/view-controls.component.scss index 6030da44..c6c052a5 100644 --- a/src/app/component/view-controls/view-controls.component.scss +++ b/src/app/component/view-controls/view-controls.component.scss @@ -1,34 +1,43 @@ @use '../BLOCKS/blocks.common' as blocks; @mixin css($theme) { + // A floating card at the bottom right, beside the transport rather than + // stacked in a rail: these say what you can see, the transport says what is + // moving, and both belong at the edge nearest the grid they act on. .viewControls { display: flex; - flex-direction: column; + flex-direction: row; + align-items: center; gap: 6px; - padding: 8px; + padding: 0 12px; + height: 60px; + pointer-events: auto; - // Same button look as the edit panel; the container prefix keeps its - // border-color winning over Material's default outline. - @include blocks.mini-buttons($theme); - - .mini-buttons { - justify-content: flex-start; + // One place, in every mode. It used to carry an extra 16px in Edit that it + // gave up in analysis, so the card slid sideways on the way into a mode + // whose controls have nothing to do with it. + @media (max-width: 720px) { + padding: 0 6px; + gap: 2px; } + background: var(--card-surface); + border-radius: var(--card-radius); + z-index: 8; + box-sizing: border-box; + box-shadow: var(--card-shadow); + } - .zoomRow { - display: flex; - gap: 6px; - - .zoomButton { - flex: 1 1 0; - min-width: 0; - padding: 0; - justify-content: center; + // Between the switches and the actions. Shorter than the buttons, so it + // reads as a seam in the row rather than as a wall across the card, and it + // takes the row's own gap on each side rather than adding one of its own. + .viewDivider { + flex: 0 0 auto; + width: 1px; + height: 24px; + background: rgba(0, 0, 0, 0.12); - .mat-icon { - margin: 0; - } - } + @media (max-width: 720px) { + height: 20px; } } } diff --git a/src/app/component/view-controls/view-controls.component.ts b/src/app/component/view-controls/view-controls.component.ts index 51670390..cf6f1703 100644 --- a/src/app/component/view-controls/view-controls.component.ts +++ b/src/app/component/view-controls/view-controls.component.ts @@ -1,7 +1,20 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + inject, +} from '@angular/core'; import { MechanismService } from '../../services/mechanism.service'; -import { SettingsService } from '../../services/settings.service'; +import { SettingsService, writeStoredFlag } from '../../services/settings.service'; import { SvgGridService } from '../../services/svg-grid.service'; +import { ViewButtonComponent } from './view-button.component'; +import { RealLink } from '../../model/link'; +import { KeyboardShortcutsService, ShortcutId } from '../../services/keyboard-shortcuts.service'; + +/** The one gap a card keeps from its neighbour (left-tabs.vars.scss). */ +const CARD_GAP = 12; /** * View toggles that apply in every mode, so they sit at the foot of the nav @@ -12,33 +25,137 @@ import { SvgGridService } from '../../services/svg-grid.service'; templateUrl: './view-controls.component.html', styleUrls: ['./view-controls.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, - standalone: false, + imports: [ViewButtonComponent], }) -export class ViewControlsComponent { - constructor( - public svgGrid: SvgGridService, - public mechanismService: MechanismService, - public settingsService: SettingsService - ) {} +export class ViewControlsComponent implements AfterViewInit, OnDestroy { + svgGrid = inject(SvgGridService); + mechanismService = inject(MechanismService); + settingsService = inject(SettingsService); + private host = inject<ElementRef<HTMLElement>>(ElementRef); + private shortcuts = inject(KeyboardShortcutsService); + + /** + * The keys reach the same methods the buttons do, greyed state and all: a + * switch that would change nothing on the grid is one the key must not + * change either, or the shortcut and the button would disagree about what + * the drawing can show. + */ + private readonly keyed: Partial<Record<ShortcutId, () => void>> = { + 'view.zoomIn': () => this.onZoomInPressed(), + 'view.zoomOut': () => this.onZoomOutPressed(), + 'view.reset': () => this.onReframePressed(), + 'view.centerOfMass': () => !this.noMassiveLink() && this.showCenterOfMass(), + 'view.jointIds': () => !this.noJointExists() && this.onShowIDPressed(), + 'view.paths': () => !this.noTracedJoint() && this.onShowTracesPressed(), + }; + + private keySub = this.shortcuts.pressed.subscribe((id) => this.keyed[id]?.()); + + /** + * Publish where this card is, for the drawer that stands over it. + * + * The setup drawer is meant to line up with these controls and stop one gap + * above them, and both numbers are this card's own -- its width is however + * many buttons it ends up carrying, and its top edge is where the drawer has + * to stop. Measured here rather than written down twice and left to drift. + * + * The drawer used to measure the whole transport row instead, which grows a + * row per machine while unsynced: three machines pushed the drawer 92px up + * the window, away from controls that had not moved. + */ + ngAfterViewInit(): void { + const card = this.host.nativeElement.querySelector('.viewControls') as HTMLElement | null; + if (!card || typeof ResizeObserver === 'undefined') return; + this.publishGeometry(card); + this.geometryWatch = new ResizeObserver(() => this.publishGeometry(card)); + this.geometryWatch.observe(card); + // The card is anchored to the bottom of the window, so a resize moves it + // without changing its size -- which a ResizeObserver alone would miss. + this.onWindowResize = () => this.publishGeometry(card); + window.addEventListener('resize', this.onWindowResize); + } + + private geometryWatch?: ResizeObserver; + private onWindowResize?: () => void; + + private publishGeometry(card: HTMLElement): void { + const box = card.getBoundingClientRect(); + const style = document.documentElement.style; + if (box.width > 0) { + style.setProperty('--view-controls-width', `${Math.round(box.width)}px`); + } + if (box.height > 0) { + // From the bottom of the window to the top of this card, plus the gap a + // card keeps from its neighbour. + const clearance = Math.round(window.innerHeight - box.top) + CARD_GAP; + style.setProperty('--view-controls-clearance', `${clearance}px`); + } + } + + ngOnDestroy(): void { + this.keySub.unsubscribe(); + this.geometryWatch?.disconnect(); + if (this.onWindowResize) window.removeEventListener('resize', this.onWindowResize); + document.documentElement.style.removeProperty('--view-controls-width'); + document.documentElement.style.removeProperty('--view-controls-clearance'); + } + + /** + * Whether the thing this button switches on is on screen right now. + * + * The three switches say it the same way: a tint behind the button for on, + * and a glyph that draws what is on the grid — crossed out only when the + * thing is not there. The glyph used to offer the *other* state, so a switch + * that was on wore the icon for off and the tint for on at the same time. + */ + isShowingCoM(): boolean { + return this.settingsService.isShowCOM.value; + } + + isShowingIDs(): boolean { + return this.settingsService.isShowID.value; + } + isShowingTraces(): boolean { + return this.settingsService.isShowTraces.value; + } + + /** + * Nothing on the grid would change, so the switch is greyed. + * + * Greyed rather than hidden: a control that comes and goes as the drawing + * does is a control nobody learns the position of. Each button asks about + * exactly what its own toggle draws — labels want a joint to name, the + * centre-of-mass mark wants a link with a mass to have a centre of, and a + * path wants a joint that has been asked to trace one. + */ noJointExists(): boolean { return this.mechanismService.joints.length === 0; } - noLinkExists(): boolean { - return this.mechanismService.links.length === 0; + noMassiveLink(): boolean { + // The same test the canvas applies (NewGridComponent.showsCoM): a body with + // a mass, which a slider block is not -- it has a mass and no mark, so a + // drawing whose only weight sat on one offered a switch that did nothing. + return !this.mechanismService.links.some((link) => link instanceof RealLink && link.mass > 0); } - showCenterOfMass(): void { - this.settingsService.isShowCOM.next(!this.settingsService.isShowCOM.value); + noTracedJoint(): boolean { + return !this.mechanismService.joints.some( + (joint) => (joint as { showCurve?: boolean }).showCurve + ); } - comIconName(): string { - return this.settingsService.isShowCOM.value ? 'com_off' : 'com'; + onShowTracesPressed(): void { + this.settingsService.isShowTraces.next(!this.settingsService.isShowTraces.value); } - idLabelIconName(): string { - return this.settingsService.isShowID.value ? 'abc_off' : 'abc'; + showCenterOfMass(): void { + const on = !this.settingsService.isShowCOM.value; + this.settingsService.isShowCOM.next(on); + // A display preference, remembered on this machine rather than in the URL + // (see SettingsService.isShowCOM). + writeStoredFlag('showCoM', on); } onShowIDPressed(): void { diff --git a/src/app/focus-on-show.directive.ts b/src/app/focus-on-show.directive.ts index 75a14062..d731af5c 100644 --- a/src/app/focus-on-show.directive.ts +++ b/src/app/focus-on-show.directive.ts @@ -1,11 +1,12 @@ -import { Directive, ElementRef, OnInit } from '@angular/core'; +import { Directive, ElementRef, OnInit, inject } from '@angular/core'; -@Directive({ - selector: '[appPrefixFocusAndSelect]', - standalone: false, -}) +@Directive({ selector: '[appPrefixFocusAndSelect]' }) export class FocusOnShowDirective implements OnInit { - constructor(private el: ElementRef) { + private el = inject(ElementRef); + + constructor() { + const el = this.el; + if (!el.nativeElement['focus']) { throw new Error('Element does not accept focus.'); } diff --git a/src/app/material/material.module.ts b/src/app/material/material.module.ts deleted file mode 100644 index a9100598..00000000 --- a/src/app/material/material.module.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -// Material Form Controls -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -// import { MatDatepickerModule } from '@angular/material/datepicker'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSliderModule } from '@angular/material/slider'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -// Material Navigation -import { MatMenuModule } from '@angular/material/menu'; -import { MatSidenavModule } from '@angular/material/sidenav'; -import { MatToolbarModule } from '@angular/material/toolbar'; -// Material Layout -import { MatCardModule } from '@angular/material/card'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatGridListModule } from '@angular/material/grid-list'; -import { MatListModule } from '@angular/material/list'; -import { MatStepperModule } from '@angular/material/stepper'; -import { MatTabsModule } from '@angular/material/tabs'; -import { MatTreeModule } from '@angular/material/tree'; -// Material Buttons & Indicators -import { MatButtonModule } from '@angular/material/button'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { MatBadgeModule } from '@angular/material/badge'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatIconModule } from '@angular/material/icon'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatRippleModule } from '@angular/material/core'; -// Material Popups & Modals -import { MatBottomSheetModule } from '@angular/material/bottom-sheet'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; -// Material Data tables -import { MatPaginatorModule } from '@angular/material/paginator'; -import { MatSortModule } from '@angular/material/sort'; -import { MatTableModule } from '@angular/material/table'; - -@NgModule({ - declarations: [], - imports: [ - CommonModule, - MatAutocompleteModule, - MatCheckboxModule, - // MatDatepickerModule, - MatFormFieldModule, - MatInputModule, - MatRadioModule, - MatSelectModule, - MatSliderModule, - MatSlideToggleModule, - MatMenuModule, - MatSidenavModule, - MatToolbarModule, - MatCardModule, - MatDividerModule, - MatExpansionModule, - MatGridListModule, - MatListModule, - MatStepperModule, - MatTabsModule, - MatTreeModule, - MatButtonModule, - MatButtonToggleModule, - MatBadgeModule, - MatChipsModule, - MatIconModule, - MatProgressSpinnerModule, - MatProgressBarModule, - MatRippleModule, - MatBottomSheetModule, - MatDialogModule, - MatSnackBarModule, - MatTooltipModule, - MatPaginatorModule, - MatSortModule, - MatTableModule, - ], - exports: [ - MatAutocompleteModule, - MatCheckboxModule, - // MatDatepickerModule, - MatFormFieldModule, - MatInputModule, - MatRadioModule, - MatSelectModule, - MatSliderModule, - MatSlideToggleModule, - MatMenuModule, - MatSidenavModule, - MatToolbarModule, - MatCardModule, - MatDividerModule, - MatExpansionModule, - MatGridListModule, - MatListModule, - MatStepperModule, - MatTabsModule, - MatTreeModule, - MatButtonModule, - MatButtonToggleModule, - MatBadgeModule, - MatChipsModule, - MatIconModule, - MatProgressSpinnerModule, - MatProgressBarModule, - MatRippleModule, - MatBottomSheetModule, - MatDialogModule, - MatSnackBarModule, - MatTooltipModule, - MatPaginatorModule, - MatSortModule, - MatTableModule, - ], -}) -export class MaterialModule {} diff --git a/src/app/model/actuator.ts b/src/app/model/actuator.ts index e92a4c6d..4e26c35c 100644 --- a/src/app/model/actuator.ts +++ b/src/app/model/actuator.ts @@ -116,6 +116,27 @@ export function describeActuator(joint: Joint): Actuator | string { return actuator; } +/** + * A driven-joint refusal in two lengths: a few words for a menu row's + * right-hand slot, and the model's own sentence for the hover behind it. + * + * One source, two lengths. `describeActuator` already writes the sentence the + * setup panel shows; the short form is derived from the same branch rather + * than written again somewhere else, so a menu and a panel cannot end up + * disagreeing about why a joint will not take an input. + */ +export function describeActuatorRefusal(joint: Joint): { short: string; long: string } | undefined { + const found = describeActuator(joint); + if (typeof found !== 'string') return undefined; + const long = found; + if (!(joint instanceof RealJoint)) return { short: 'not a joint', long }; + if (joint.isWelded) return { short: 'welded, no freedom', long }; + const bodies = incidentBodies(joint).length; + if (bodies < 2) return { short: 'needs 2 bodies', long }; + if (bodies > 2) return { short: `${bodies} bodies meet`, long }; + return { short: 'no angle here', long }; +} + /** The actuator this joint would be, or nothing. */ export function resolveActuator(joint: Joint): Actuator | undefined { const found = describeActuator(joint); diff --git a/src/app/model/analysis-series.ts b/src/app/model/analysis-series.ts new file mode 100644 index 00000000..caefd8a6 --- /dev/null +++ b/src/app/model/analysis-series.ts @@ -0,0 +1,55 @@ +import { AngleUnit } from './unit-enums'; + +/** + * The colour of each analysis series, in one place. + * + * A graph, its legend and the collapsed header that previews its values all + * have to agree about which colour means X, so the answer lives beside the + * model rather than inside whichever component happened to need it first. + */ +export const ANALYSIS_SERIES_COLORS = { + X: '#313aa7', + Y: '#ea2b29', + Z: '#fdb50e', +} as const; + +/** + * A solved value as a reader should see it. + * + * Two decimals, which is what the readout row above every plot uses and what + * the axes beside it are lettered in. The values themselves leave the solver at + * full precision, so a label printing one straight ran to seventeen digits -- + * "2.6179937801901527" hanging off the plot beside a readout of "2.62" for the + * same instant. + * + * Below the point where two decimals would round to nothing, two significant + * figures instead, so a small reaction reads as small rather than as zero. + */ +export function formatAnalysisValue(value: number): string { + if (!Number.isFinite(value)) return ''; + if (value === 0) return '0.00'; + return Math.abs(value) < 0.005 ? Number(value.toPrecision(2)).toString() : value.toFixed(2); +} + +/** + * What an angular series has to be multiplied by to be in the unit it is labelled with. + * + * The solver does not hand these out in one unit. A link's angle is converted + * to degrees as it is recorded; its angular velocity and acceleration are left + * in radians, because that is what the equations are written in. So the panel + * has always converted one or the other on its way to the screen, depending on + * which unit the reader has chosen — and an export that skipped it wrote + * radians per second under a head saying degrees per second. + * + * Stated once, here, because a graph and a file of the same series that + * disagree are worse than either being wrong on its own. + */ +export function angularScale(mechProp: string, unit: AngleUnit): number { + if (mechProp === 'Angular Link Pos') { + return unit === AngleUnit.RADIAN ? Math.PI / 180 : 1; + } + if (mechProp === 'Angular Link Vel' || mechProp === 'Angular Link Acc') { + return unit === AngleUnit.DEGREE ? 180 / Math.PI : 1; + } + return 1; +} diff --git a/src/app/model/body-label.ts b/src/app/model/body-label.ts new file mode 100644 index 00000000..908b66f3 --- /dev/null +++ b/src/app/model/body-label.ts @@ -0,0 +1,42 @@ +import { Cylinder } from './cylinder'; +import { PrisJoint } from './joint'; +import { Link, RealLink, SliderBlock } from './link'; + +/** + * What the panels call a body. + * + * A link's id is the letters of its joints, which is a fine key and a poor + * name: a cylinder's rod is named after the pin buried inside it, and a slider + * block after the sliding joint underneath it. Neither of those joints has a + * marker, a hitbox, or a row in any panel — so a label built from the id + * offered the reader a part they had never been shown and could not find. + * + * Always a complete noun phrase, so a caller can drop it into a sentence + * without knowing which kind of body came back. + */ +export function labelForBody(body: Link, cylinder: Cylinder | undefined): string { + if (cylinder) { + // By identity, with no catch-all: a compound that merely *contains* a + // cylinder part is a welded body of its own, not another Piston. + const role = + body === cylinder.block + ? 'Piston' + : body === cylinder.barrel + ? 'Barrel' + : body === cylinder.rod + ? 'Rod' + : undefined; + if (role) { + const name = + (cylinder.barrelFar.name || cylinder.barrelFar.id) + + (cylinder.rodFar.name || cylinder.rodFar.id); + return `${role} ${name}`; + } + } + // A block by the joint a reader can see and click, not by its pair. + if (body instanceof SliderBlock) { + const pin = body.joints.find((joint) => !(joint instanceof PrisJoint)); + return pin ? `Block at ${pin.name || pin.id}` : `Block ${body.id}`; + } + return `Link ${(body as RealLink).name || body.id}`; +} diff --git a/src/app/model/chrome-motion.ts b/src/app/model/chrome-motion.ts new file mode 100644 index 00000000..c0698be7 --- /dev/null +++ b/src/app/model/chrome-motion.ts @@ -0,0 +1,15 @@ +import { Subject } from 'rxjs'; + +/** + * Announced by a card that has begun moving over the canvas. + * + * The companion to `data-canvas-inset`: the attribute says which edge a card + * takes, and this says when it has started taking a different amount of it, so + * the drawing can move out from under it. A leaf module with no other imports, + * because the chrome that announces and the canvas that listens sit on opposite + * sides of the service graph and neither should be importing the other. + * + * Not needed for the panel beside the canvas, whose mode the canvas already + * follows, nor for the window, which has an event of its own. + */ +export const CHROME_MOVED = new Subject<void>(); diff --git a/src/app/model/constructionlines_syn.ts b/src/app/model/constructionlines_syn.ts deleted file mode 100644 index 2427d71d..00000000 --- a/src/app/model/constructionlines_syn.ts +++ /dev/null @@ -1,28 +0,0 @@ -// A class to represent Construction lines -//will contain x1,y1, x2,y2 of construction lines -import { Coord } from './coord'; - -export class ConstrucLines { - private _coord1: Coord; - private _coord2: Coord; - - constructor(coord1: Coord, coord2: Coord) { - this._coord1 = coord1; - this._coord2 = coord2; - } - - get coord1(): Coord { - return this._coord1; - } - - set coord1(value: Coord) { - this._coord1 = value; - } - get coord2(): Coord { - return this._coord2; - } - - set coord2(value: Coord) { - this._coord2 = value; - } -} diff --git a/src/app/model/contrast.ts b/src/app/model/contrast.ts new file mode 100644 index 00000000..5a98d1d5 --- /dev/null +++ b/src/app/model/contrast.ts @@ -0,0 +1,17 @@ +/** + * Whether a mark drawn on a colour should be dark or light. + * + * A leaf with no imports, because both the canvas and the swatch picker need + * the same answer and neither should be reaching into the other for it. + */ + +/** Rough perceived brightness of a `#rrggbb`, between 0 and 1. */ +export function luminanceOf(color: string): number { + const hex = color.replace('#', ''); + if (hex.length !== 6) return 1; + const [r, g, b] = [0, 2, 4].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** Above this, a colour takes dark ink; below it, light. */ +export const INK_FLIPS_AT = 0.55; diff --git a/src/app/model/coord.ts b/src/app/model/coord.ts index de23e87d..94ba1a7e 100644 --- a/src/app/model/coord.ts +++ b/src/app/model/coord.ts @@ -1,6 +1,6 @@ // A class to represent an X and Y coordinate -import { SettingsService } from '../services/settings.service'; +import { OBJECT_SCALE } from './object-scale'; export class Coord { private _x: number; @@ -42,11 +42,11 @@ export class Coord { } equals(coord: Coord) { - return this.getDistanceTo(coord) < 0.0001 * SettingsService.objectScale; + return this.getDistanceTo(coord) < 0.0001 * OBJECT_SCALE.value; } looselyEquals(coord: Coord) { - return this.getDistanceTo(coord) < 0.04 * SettingsService.objectScale; + return this.getDistanceTo(coord) < 0.04 * OBJECT_SCALE.value; } add(vector: Coord) { diff --git a/src/app/model/cylinder.ts b/src/app/model/cylinder.ts index 1b7ef718..78213a8b 100644 --- a/src/app/model/cylinder.ts +++ b/src/app/model/cylinder.ts @@ -539,6 +539,50 @@ export function normalizedCylinderPose( }; } +/** + * Re-lay a ram between two mounts that have moved, resizing it to reach. + * + * The straightener above holds the size it finds, which is right when the + * question is "put this back on its axis" and wrong when the mounts themselves + * have been carried apart: the head lands as far outside the barrel as the + * stretch, joined to it by nothing, and the part is drawn in two pieces. + * + * Two rams can share a mount -- the first's rod end is the second's barrel end + * -- so moving one moves the other's mount without the other being asked. This + * is what asks it. Both halves grow or shrink together, exactly as a drag on a + * ram's own mount resizes it past its stops, so both of its ends move; and both + * mounts are held, because they belong to whatever moved them. + * + * Only called where a mount is known to have moved. Applied blindly it could + * not tell that from a barrel some other write had shortened, and would quietly + * repair a starved ram into a working one. + */ +export function stretchedCylinderPose( + barrelMount: { x: number; y: number }, + rodMount: { x: number; y: number }, + barrelLength: number, + r: number +): CylinderPose | undefined { + const dx = rodMount.x - barrelMount.x; + const dy = rodMount.y - barrelMount.y; + const distance = Math.hypot(dx, dy); + if (distance < 1e-9 || !(barrelLength > 1e-9)) return undefined; + const ux = dx / distance; + const uy = dy / distance; + const flex = cylinderSpanLayout(distance, cylinderStroke(barrelLength, r), r); + const at = (along: number) => ({ + x: barrelMount.x + along * ux, + y: barrelMount.y + along * uy, + }); + return { + barrelFar: { x: barrelMount.x, y: barrelMount.y }, + barrelNear: at(flex.barrel), + pin: at(flex.pinAlong), + rodFar: { x: rodMount.x, y: rodMount.y }, + atMinimum: flex.atMinimum, + }; +} + /** Where each joint of a re-posed cylinder lands. */ export interface CylinderPose { /** True when the layout had to hold the ram at its shortest. */ diff --git a/src/app/model/force.ts b/src/app/model/force.ts index e0a8be46..64678f79 100644 --- a/src/app/model/force.ts +++ b/src/app/model/force.ts @@ -23,6 +23,17 @@ export class Force { private _angleRad: number; private _showHighlight: boolean = false; + /** Locked in place while editing: neither handle nor the body may be dragged. */ + public locked: boolean = false; + /** + * What this one force is drawn in, or '' for the colour they all share. + * + * From the same six the links use, because a force belongs to a link and + * reading a drawing means pairing them up. Per force rather than per drawing + * so that two loads on one linkage can be told apart -- which is the whole + * question a force diagram with more than one arrow in it asks. + */ + public color: string = ''; isEndSelected: boolean = false; isStartSelected: boolean = false; visualWidth: number = Force.DEFAULT_VISUAL_WIDTH; diff --git a/src/app/model/joint-colors.ts b/src/app/model/joint-colors.ts new file mode 100644 index 00000000..1f121a01 --- /dev/null +++ b/src/app/model/joint-colors.ts @@ -0,0 +1,74 @@ +/** + * The colours a part can be drawn in. + * + * A leaf module with no imports of its own, like the object scale beside it: + * the URL codec and the canvas both need these, and neither should be reaching + * through the service graph for a list of hex values. + */ + +/** + * What links are drawn in, and what forces are offered. + * + * One palette for both, because a force belongs to a link and reading a drawing + * means pairing them up -- a force in a colour no link could be would say it + * belonged to something that is not there. + */ +export const PART_COLORS = [ + '#c5cae9', + '#303e9f', + '#0d125a', + '#B2DFDB', + '#26A69A', + '#00695C', +] as const; + +/** + * The colour a force is drawn in until somebody chooses another. + * + * The darkest of the six rather than a seventh colour of its own, so the picker + * always has one swatch ticked and there is nothing in the list that is not + * also a link colour. + */ +export const DEFAULT_FORCE_COLOR = '#0d125a'; + +/** + * One joint colour: what it is drawn in at rest, under the cursor, and picked. + * + * `id` is what travels in the URL, and the first family's is empty -- a joint + * that has never been given a colour says nothing, so a drawing where nobody + * chose one encodes to the bytes it always did. + */ +export interface JointFamily { + id: string; + name: string; + normal: string; + hover: string; + selected: string; +} + +/** + * Amber through brown, warm the whole way, none of it a link colour. + * + * Amber is exactly what every joint has always been drawn in and does not move. + * The other three sit a step further up their own ramps than amber does on + * hers -- at the palest they were four off-whites a hand's width apart, which + * read as one material but left a coloured joint indistinguishable from an + * uncoloured one until somebody pointed at it. The hover shade moves with the + * resting one, or the two would be the same colour. + */ +export const JOINT_FAMILIES: readonly JointFamily[] = [ + { id: '', name: 'Amber', normal: '#fff8e1', hover: '#ffecb3', selected: '#ffca28' }, + { id: 'o', name: 'Orange', normal: '#ffe0b2', hover: '#ffcc80', selected: '#fb8c00' }, + { id: 'd', name: 'Deep orange', normal: '#ffccbc', hover: '#ffab91', selected: '#f4511e' }, + { id: 'b', name: 'Brown', normal: '#d7ccc8', hover: '#bcaaa4', selected: '#6d4c41' }, +]; + +/** + * What a selection looks like whichever family a joint is drawn in. + * + * Amber is what "picked" means everywhere in the app -- the outline of a + * selected link, every pressed control -- so a joint in another family wears it + * as a ring inside its own edge rather than giving up its own colour. The fill + * says which joint this is; the ring says it is the one that is selected. + */ +export const SELECTION_RING = '#ffca28'; diff --git a/src/app/model/joint-marks.ts b/src/app/model/joint-marks.ts index 00bd7d75..5121f94b 100644 --- a/src/app/model/joint-marks.ts +++ b/src/app/model/joint-marks.ts @@ -709,11 +709,6 @@ export function straightArrowPaths( }); } -/** A filled triangular head, tip at (x, y), pointing along `angle`. */ -function arrowHead(r: number, x: number, y: number, angle: number): string { - return arrowHeadAt(x, y, angle, MARK.arrowHeadLength * r, MARK.arrowHeadHalf * r); -} - function arrowHeadAt(x: number, y: number, angle: number, back: number, half: number): string { const c = Math.cos(angle); const s = Math.sin(angle); @@ -721,10 +716,6 @@ function arrowHeadAt(x: number, y: number, angle: number, back: number, half: nu return `M ${at(0, 0)} L ${at(-back, -half)} L ${at(-back, half)} Z`; } -function angleOnCircle(radius: number, angle: number): { x: number; y: number } { - return { x: radius * Math.cos(angle), y: radius * Math.sin(angle) }; -} - function roundedRect(x: number, y: number, w: number, h: number, k: number): string { const c = Math.min(k, w / 2, h / 2); return ( diff --git a/src/app/model/joint.ts b/src/app/model/joint.ts index e504ada5..5816befc 100644 --- a/src/app/model/joint.ts +++ b/src/app/model/joint.ts @@ -1,10 +1,8 @@ -import { AppConstants } from './app-constants'; // Type-only: a runtime import here closes the joint -> link -> joint module // cycle, which breaks class initialization when a test entry point loads the // model modules in a different order than the app does. import type { Link } from './link'; import { Coord } from './coord'; -import { SettingsService } from '../services/settings.service'; export class Joint extends Coord { private _id: string; @@ -35,6 +33,18 @@ export class Joint extends Coord { this._name = value; } + /** + * Which colour family this one joint is drawn in -- see JOINT_FAMILIES -- + * or '' for the amber every joint shares. + * + * Per joint rather than per drawing because the point of it is to tell one + * pin apart from the fifteen around it: the coupler point being traced, the + * joint a force is about, the one a reader is being asked to look at. A + * family rather than a fill because a joint is drawn resting, pointed at and + * picked, and those three only read as one object if they come from one set. + */ + public colorFamily: string = ''; + get showHighlight(): boolean { return this._showHighlight; } @@ -53,6 +63,27 @@ export class RealJoint extends Joint { private _connectedJoints: Joint[]; public showCurve: boolean; public isWelded: boolean = false; + /** + * Locked in place while editing: no drag, direct or carried, may move this + * joint. An editing constraint only — the solvers never read it, so a locked + * coupler still animates. Ground is the simulation-time cousin; the two are + * deliberately independent axes. + */ + public locked: boolean = false; + /** + * How fast this joint drives its own mechanism, signed for direction, in the + * units its kind of drive is measured in: rpm for a pin, length per second + * for a slider. + * + * Lives on the joint because a drawing can hold several mechanisms and each + * is driven at its own speed. The driven joint is the natural place to keep + * it -- one per mechanism, already saved, and it is where the panel has + * always shown the setting anyway. + * + * Zero means "follow the document-wide default", which is what every joint + * loaded from a URL written before this existed says. + */ + public driveSpeed: number = 0; constructor( id: string, diff --git a/src/app/model/line.ts b/src/app/model/line.ts index e4a9ebdd..4f38591b 100644 --- a/src/app/model/line.ts +++ b/src/app/model/line.ts @@ -1,9 +1,7 @@ -import { Joint, RealJoint } from './joint'; +import { Joint } from './joint'; import { Coord } from './coord'; import { arc_arc_intersect, line_arc_intersect, line_line_intersect } from './utils'; import { RealLink } from './link'; -import { SettingsService } from '../services/settings.service'; -import { center } from 'svg-pan-zoom'; export class Line { startPosition: Coord; @@ -98,7 +96,6 @@ export class Line { splitAt(coord: Coord): Line | undefined { if (this.startPosition.equals(coord) || this.endPosition.equals(coord)) { - // console.log('Cannot split at start or end position', coord, this); return; } else { let newLine = new Line(coord, this.endPosition); @@ -199,7 +196,6 @@ export class Arc extends Line { override splitAt(coord: Coord): Line | undefined { if (this.startPosition.equals(coord) || this.endPosition.equals(coord)) { - // console.log('Cannot split at start or end position', coord, this); return; } else { let newArc = new Arc(coord, this.endPosition, this.center); diff --git a/src/app/model/link.circular.spec.ts b/src/app/model/link.circular.spec.ts new file mode 100644 index 00000000..1e21f3ca --- /dev/null +++ b/src/app/model/link.circular.spec.ts @@ -0,0 +1,139 @@ +import { RealLink } from './link'; +import { PrisJoint, RevJoint } from './joint'; +import { SettingsService } from '../services/settings.service'; + +/** + * A crank drawn as the disc it sweeps. + * + * The rule the drawing rests on is that a disc needs a centre, and the only + * centre a link can offer without being asked is the one pin it turns about. + * Everything here is about that rule holding when the mechanism changes under + * it -- the flag outlives a ground being removed, so the answer has to come + * from the joints every time rather than from what was true when it was set. + */ + +/** A grounded crank: pin at the origin, throw out to (3, 4) -- a reach of 5. */ +function crank(): { link: RealLink; ground: RevJoint; throwPin: RevJoint } { + const ground = new RevJoint('A', 0, 0, false, true); + const throwPin = new RevJoint('B', 3, 4); + return { link: new RealLink('AB', [ground, throwPin]), ground, throwPin }; +} + +/** Every number in a path string, in the order it is written. */ +function numbersIn(path: string): number[] { + return (path.match(/-?\d+(\.\d+)?/g) ?? []).map(Number); +} + +describe('a link drawn as a circle', () => { + beforeEach(() => { + SettingsService._objectScale.next(1); + }); + + it('is offered on a grounded crank and nowhere else', () => { + expect(crank().link.canBeCircular()).toBe(true); + + // No ground: a coupler has no fixed centre, so no disc to draw about. + const floating = new RealLink('AB', [new RevJoint('A', 0, 0), new RevJoint('B', 3, 4)]); + expect(floating.canBeCircular()).toBe(false); + + // Two grounds: a frame, which does not turn at all. + const frame = new RealLink('AB', [ + new RevJoint('A', 0, 0, false, true), + new RevJoint('B', 3, 4, false, true), + ]); + expect(frame.canBeCircular()).toBe(false); + + // A grounded slot anchors a slide, not a pivot. + const slotted = new RealLink('AB', [ + new PrisJoint('A', 0, 0, false, true), + new RevJoint('B', 3, 4), + ]); + expect(slotted.canBeCircular()).toBe(false); + }); + + it('draws a disc on the ground pin, wide enough to hold every joint', () => { + const { link, ground } = crank(); + link.isCircle = true; + link.reComputeDPath(); + + expect(link.d).toMatch(/^M /); + expect(link.d).not.toMatch(/NaN|Infinity/); + + // Two semicircle arcs of one radius: reach plus the half-width every bar's + // end cap is already drawn with, so the disc covers what the bar covered. + const radius = 5 + SettingsService.objectScale / 4; + const numbers = numbersIn(link.d); + expect(numbers[0]).toBeCloseTo(ground.x - radius, 6); + expect(numbers[1]).toBeCloseTo(ground.y, 6); + // Two arcs and no straight edge. The arc count alone proves nothing — an + // ordinary two-joint bar is also drawn with exactly two, one per end cap — + // so what separates a disc from a bar is that a disc has no sides. + expect(link.d.match(/A /g)?.length).toBe(2); + expect(link.d).not.toMatch(/ L /); + + // A disc has no edges, so there is nothing to attach a joint along. + expect(link.externalLines).toEqual([]); + expect(link.initialExternalLines).toEqual([]); + }); + + it('grows to cover a tracer point added past the throw', () => { + const { link, ground } = crank(); + link.isCircle = true; + link.joints.push(new RevJoint('C', 0, 9)); + link.reComputeDPath(); + + const radius = 9 + SettingsService.objectScale / 4; + expect(numbersIn(link.d)[0]).toBeCloseTo(ground.x - radius, 6); + }); + + it('comes back as a bar when the ground it was centred on is removed', () => { + const { link, ground } = crank(); + link.isCircle = true; + link.reComputeDPath(); + const asDisc = link.d; + + ground.ground = false; + link.reComputeDPath(); + expect(link.canBeCircular()).toBe(false); + // A bar, stated as geometry rather than as "not the string it was": it has + // sides, which a disc never does, and it says so itself. + expect(link.d).toMatch(/ L /); + expect(link.drawnAsDisc).toBe(false); + // The bar has edges again, which is how the rest of the app knows it can + // hang a joint on one. + expect(link.externalLines.length).toBeGreaterThan(0); + + // The choice was not thrown away, only ignored: grounding it again is + // enough to get the disc back, without having to ask for it a second time. + ground.ground = true; + link.reComputeDPath(); + expect(link.d).toBe(asDisc); + }); + + it('is inherited by the copy of a link, so every solved frame is a disc too', () => { + const { link } = crank(); + link.isCircle = true; + link.reComputeDPath(); + + // What Mechanism does per timestep: rebuild the link against moved joints, + // handing the previous one in as the source of its visual geometry. + const moved = new RealLink( + 'AB', + [new RevJoint('A', 0, 0, false, true), new RevJoint('B', -4, 3)], + undefined, + undefined, + undefined, + undefined, + link + ); + expect(moved.isCircle).toBe(true); + + // The same circle, though not the same string: the copy is the original + // rigidly rotated, so the path now starts at a different point of the same + // rim. Centre and radius are what "the same disc" means. + const turned = numbersIn(moved.d); + const radius = 5 + SettingsService.objectScale / 4; + expect(turned[2]).toBeCloseTo(radius, 6); + expect(Math.hypot(turned[0], turned[1])).toBeCloseTo(radius, 6); + }); +}); diff --git a/src/app/model/link.com-anchor.spec.ts b/src/app/model/link.com-anchor.spec.ts new file mode 100644 index 00000000..115d4c99 --- /dev/null +++ b/src/app/model/link.com-anchor.spec.ts @@ -0,0 +1,153 @@ +import { RealLink } from './link'; +import { RevJoint } from './joint'; +import { Coord } from './coord'; +import { SettingsService } from '../services/settings.service'; + +/** + * What a hand-placed centre of mass is held against while the mechanism is + * being edited. + * + * The three answers differ only when the link moves, so every test here places + * the point, moves something, and asks where the point went. `customCoMFromOffset` + * is what MechanismService consults on each update, so it stands in for "the + * user dragged something" throughout. + */ + +/** A bar from (0,0) to (10,0), with its centre of mass placed at (4,3). */ +function bar(): { link: RealLink; a: RevJoint; b: RevJoint } { + const a = new RevJoint('A', 0, 0); + const b = new RevJoint('B', 10, 0); + const link = new RealLink('AB', [a, b], 1); + link.placeCustomCoM({ x: 4, y: 3 }); + return { link, a, b }; +} + +/** + * What MechanismService does at the end of every update: resolve the point + * against its anchor and write it back. Without this the link's own CoM is the + * one from before the move, and `captureComOffset` -- which measures from + * wherever the CoM currently is -- would capture a stale point. Every move + * below ends here, because in the app every move does. + */ +function settle(link: RealLink): void { + const placed = link.customCoMFromOffset(); + if (placed) link.CoM = placed; +} + +/** Slide the whole link by (dx, dy), as dragging its body would. */ +function shift(link: RealLink, dx: number, dy: number): void { + link.joints.forEach((joint) => { + joint.x += dx; + joint.y += dy; + }); + settle(link); +} + +/** Turn the link about joint A, which leaves A itself where it is. */ +function turnAboutA(link: RealLink, degrees: number): void { + const [pivot] = link.joints; + const angle = (degrees * Math.PI) / 180; + const origin = new Coord(pivot.x, pivot.y); + link.joints.forEach((joint) => { + const dx = joint.x - origin.x; + const dy = joint.y - origin.y; + joint.x = origin.x + dx * Math.cos(angle) - dy * Math.sin(angle); + joint.y = origin.y + dx * Math.sin(angle) + dy * Math.cos(angle); + }); + settle(link); +} + +describe('what a placed center of mass is held against', () => { + beforeEach(() => { + SettingsService._objectScale.next(1); + }); + + it('rides the link when held against the link, which is the standing default', () => { + const { link } = bar(); + expect(link.comAnchor).toBe('centroid'); + + shift(link, 5, 2); + const moved = link.customCoMFromOffset()!; + expect(moved.x).toBeCloseTo(9, 6); + expect(moved.y).toBeCloseTo(5, 6); + + // And turns with it: the point is a mark on the body. + const { link: second } = bar(); + turnAboutA(second, 90); + const turned = second.customCoMFromOffset()!; + expect(turned.x).toBeCloseTo(-3, 6); + expect(turned.y).toBeCloseTo(4, 6); + }); + + it('stays on the drawing when held against the grid', () => { + const { link } = bar(); + link.comAnchor = 'grid'; + link.captureComOffset(); + + shift(link, 5, 2); + const afterDrag = link.customCoMFromOffset()!; + expect(afterDrag.x).toBeCloseTo(4, 6); + expect(afterDrag.y).toBeCloseTo(3, 6); + + turnAboutA(link, 90); + const afterTurn = link.customCoMFromOffset()!; + expect(afterTurn.x).toBeCloseTo(4, 6); + expect(afterTurn.y).toBeCloseTo(3, 6); + }); + + it('follows one pin, and only that pin, when held against a joint', () => { + const { link, a, b } = bar(); + link.comAnchor = { joint: 'A' }; + link.captureComOffset(); + + // The far pin moving is not that pin moving. + b.x = 40; + settle(link); + const afterFarPin = link.customCoMFromOffset()!; + expect(afterFarPin.x).toBeCloseTo(4, 6); + expect(afterFarPin.y).toBeCloseTo(3, 6); + + // Turning about A does not move A, so it does not move the point either. + turnAboutA(link, 90); + const afterTurn = link.customCoMFromOffset()!; + expect(afterTurn.x).toBeCloseTo(4, 6); + expect(afterTurn.y).toBeCloseTo(3, 6); + + // Moving A carries it, keeping the same offset from the pin. + a.x += 1; + a.y -= 2; + settle(link); + const afterPin = link.customCoMFromOffset()!; + expect(afterPin.x).toBeCloseTo(5, 6); + expect(afterPin.y).toBeCloseTo(1, 6); + }); + + it('re-describes the point where it is rather than moving it, on switching anchor', () => { + const { link } = bar(); + shift(link, 5, 2); + const before = link.customCoMFromOffset()!; + + link.comAnchor = 'grid'; + link.captureComOffset(); + const after = link.customCoMFromOffset()!; + + expect(after.x).toBeCloseTo(before.x, 6); + expect(after.y).toBeCloseTo(before.y, 6); + }); + + it('hands the point back to the link when its pin leaves', () => { + const { link } = bar(); + link.comAnchor = { joint: 'B' }; + link.captureComOffset(); + + // B is deleted or merged away. The point is still where it was put, so it + // stays there and goes back to riding the link -- not to a pin that is no + // longer on it, and not to wherever the missing pin last was. + link.joints = link.joints.filter((joint) => joint.id !== 'B'); + settle(link); + const rescued = link.customCoMFromOffset()!; + expect(rescued.x).toBeCloseTo(4, 6); + expect(rescued.y).toBeCloseTo(3, 6); + expect(link.comAnchor).toBe('centroid'); + }); +}); diff --git a/src/app/model/link.ts b/src/app/model/link.ts index a1657ee7..beb3bc46 100644 --- a/src/app/model/link.ts +++ b/src/app/model/link.ts @@ -1,6 +1,6 @@ import { Joint, PrisJoint, RealJoint } from './joint'; +import { uniformBodyOf } from './uniform-body'; import { Coord } from './coord'; -import { AppConstants } from './app-constants'; import { Force } from './force'; import { degToRad, determineSlope, getAngle, getDistance, radToDeg } from './utils'; import hull from 'hull.js'; @@ -30,6 +30,11 @@ export enum Shape { customShape = 'customShape', } +/** + * What a hand-placed centre of mass is held against. See RealLink.comAnchor. + */ +export type ComAnchor = 'centroid' | 'grid' | { joint: string }; + export interface Bound { b1: Coord; b2: Coord; @@ -53,7 +58,10 @@ export class Link { constructor(id: string, joints: Joint[], mass?: number) { this._id = id; this._joints = joints; - this._mass = mass !== undefined ? mass : 1; + // Nothing weighs anything until someone says so. A link that arrives with + // a mass of 1 g invites a force analysis to be run on a number nobody + // chose, and reports it as though it meant something. + this._mass = mass !== undefined ? mass : 0; joints.forEach((j) => { this.fixedLocations.push({ id: j.id, label: j.id }); }); @@ -140,8 +148,193 @@ export class RealLink extends Link { // TODO: Have an optional argument of forces - public static debugDesiredJointsIDs: any; + public static debugDesiredJointsIDs: unknown; public lastSelectedSublink: Link | null = null; + /** + * Whether the author chose this link's moment of inertia / centre of mass, + * or left them to follow the geometry (a uniform body over the joints, + * re-derived at every update). Custom values hold still; auto values move + * with the mechanism. Old URLs decode as custom-everything, which preserves + * exactly what they have always meant. + */ + public moiIsCustom = false; + public comIsCustom = false; + /** + * Draw this link as the disc it sweeps rather than as a bar between its + * joints — the crank of an engine, drawn the way an engine draws it. + * + * A drawing choice and nothing more. Mass properties keep coming from the + * joint skeleton (see uniform-body.ts), so a link's moment of inertia does + * not change because someone changed its picture; the Edit panel says so + * where the numbers are, rather than leaving the difference to be guessed. + * + * Only a link with exactly one ground pin can honour it, because the disc + * is centred on the pin the link turns about and a link with no such pin + * has no centre to offer. `canBeCircular` is that question; the flag is + * simply ignored while the answer is no, so a link that loses its ground + * comes back as a bar and returns to a disc if it is grounded again. + */ + public isCircle = false; + + /** + * Whether the outline currently held in `d` is a disc. + * + * `isCircle` is what was asked for; this is what was drawn. They part company + * whenever the link stops or starts qualifying — a ground removed, a ground + * put back — and since a path is only rebuilt when something asks it to, the + * difference between the two is exactly the signal that it needs rebuilding. + */ + public drawnAsDisc = false; + /** + * A hand-placed centre of mass, held against the link's own frame: along + * and across the unit direction joints[0]→joints[1], measured from the + * uniform-body centroid. "Stored against the centroid" is what lets a + * placed point ride the link through drags, rotations and deformations — + * a world coordinate goes stale the moment the mechanism moves. The URL + * still carries the global coordinate; this is re-captured at decode. + */ + public comOffset?: { along: number; across: number; frame: [string, string] }; + + /** + * What a hand-placed centre of mass is held against while the mechanism is + * being edited. + * + * 'centroid' the link itself — the point rides every drag, turn and + * deformation, which is `comOffset` above; + * 'grid' the drawing — the point keeps its world coordinate, so moving + * the link out from under it leaves it where it was put; + * {joint} one pin — the point follows that pin's position and nothing + * else, so turning the link about it does not move it. + * + * Editing only. Once the mechanism runs, the centre of mass is a point of the + * body and rides it like any other: the solved timesteps carry it rigidly + * (Mechanism.transportPoint), which is what makes it a centre of mass at all + * rather than a mark on the page that inertia would be wrong about. + */ + public comAnchor: ComAnchor = 'centroid'; + + /** + * Where the point sits relative to whatever `comAnchor` names, in world axes. + * Unused by the centroid anchor, which has `comOffset` to express the same + * thing in the link's own frame — the difference between the two is the whole + * point, so they cannot share one representation. + */ + public comAnchorOffset?: { dx: number; dy: number }; + + /** The fixed point a non-centroid anchor measures from, if it still exists. */ + private anchorPoint(): Coord | undefined { + // Read once into a local: inside the callback below, `this.comAnchor` is a + // mutable property again and narrowing does not reach it. + const anchor = this.comAnchor; + if (anchor === 'grid') return new Coord(0, 0); + if (anchor === 'centroid') return undefined; + const pin = this.joints.find((joint) => joint.id === anchor.joint); + return pin ? new Coord(pin.x, pin.y) : undefined; + } + + /** + * The link's own frame: origin at the uniform centroid, x̂ along the two + * *named* joints — the farthest pair at capture time, so the axis is never + * degenerate by accident and never silently reinterpreted when the joints + * array reorders. `ok` is false when a named joint has left the link, which + * tells the caller to rebase rather than trust a different pair. + */ + private comFrame(pair?: [string, string]): { + origin: Coord; + ux: number; + uy: number; + pair: [string, string]; + ok: boolean; + } { + const origin = uniformBodyOf(this.joints).centroid; + let a = pair ? this.joints.find((joint) => joint.id === pair[0]) : undefined; + let b = pair ? this.joints.find((joint) => joint.id === pair[1]) : undefined; + const named = !!(a && b); + if (!named) { + // The farthest pair: the most stable axis the link has. + let longest = -1; + for (let i = 0; i < this.joints.length; i++) { + for (let j = i + 1; j < this.joints.length; j++) { + const span = + (this.joints[i].x - this.joints[j].x) ** 2 + + (this.joints[i].y - this.joints[j].y) ** 2; + if (span > longest) { + longest = span; + a = this.joints[i]; + b = this.joints[j]; + } + } + } + } + const span = a && b ? Math.hypot(b.x - a.x, b.y - a.y) : 0; + if (!a || !b || !(span > 1e-9)) { + return { origin, ux: 1, uy: 0, pair: [a?.id ?? '', b?.id ?? ''], ok: named }; + } + return { + origin, + ux: (b.x - a.x) / span, + uy: (b.y - a.y) / span, + pair: [a.id, b.id], + ok: !pair || named, + }; + } + + /** Place the centre of mass by hand: flags it custom and captures the offset. */ + placeCustomCoM(point: { x: number; y: number }): void { + this.comIsCustom = true; + this._CoM = new Coord(point.x, point.y); + this.captureComOffset(); + this.updateCoMDs(); + } + + /** Re-read the local offset from wherever the CoM currently is. */ + captureComOffset(): void { + // Both are captured every time, whichever anchor is in force: switching + // anchor in the panel must not need the point re-placed, and the anchor a + // link is not currently using is the one it will be using next. + const frame = this.comFrame(); + const dx = this._CoM.x - frame.origin.x; + const dy = this._CoM.y - frame.origin.y; + this.comOffset = { + along: dx * frame.ux + dy * frame.uy, + across: -dx * frame.uy + dy * frame.ux, + frame: frame.pair, + }; + const anchor = this.anchorPoint(); + this.comAnchorOffset = anchor + ? { dx: this._CoM.x - anchor.x, dy: this._CoM.y - anchor.y } + : undefined; + } + + /** Where the captured offset lands in today's geometry. */ + customCoMFromOffset(): Coord | undefined { + if (this.comAnchor !== 'centroid') { + const anchor = this.anchorPoint(); + if (anchor && this.comAnchorOffset) { + return new Coord(anchor.x + this.comAnchorOffset.dx, anchor.y + this.comAnchorOffset.dy); + } + // The pin this was held against has left the link. Keep the point where + // it is and hand it back to the link, which is the one anchor every link + // always has -- rather than let it snap to a pin that is not there. + this.comAnchor = 'centroid'; + this.captureComOffset(); + return new Coord(this._CoM.x, this._CoM.y); + } + if (!this.comOffset) return undefined; + const frame = this.comFrame(this.comOffset.frame); + if (!frame.ok) { + // A frame joint left the link (deleted, merged away). The point itself + // is still where it was, so rebase: keep the CoM, re-anchor the offset + // to the pair the link has now, rather than silently reading the old + // numbers against a different axis. + this.captureComOffset(); + return new Coord(this._CoM.x, this._CoM.y); + } + return new Coord( + frame.origin.x + this.comOffset.along * frame.ux - this.comOffset.across * frame.uy, + frame.origin.y + this.comOffset.along * frame.uy + this.comOffset.across * frame.ux + ); + } constructor( id: string, @@ -154,27 +347,26 @@ export class RealLink extends Link { ) { super(id, joints, mass); - // SettingsService.objectScale.subscribe((value) => { - // this._d = RealLink.getD(this.joints); - // //TODO: Unsubsribe from this when link gets deleted - // }); - // console.log('new subscription'); - // this._mass = mass !== undefined ? mass : 1; - this._massMoI = massMoI !== undefined ? massMoI : 1; - // this._shape = shape !== undefined ? shape : Shape.line; - // this._fill = '#' + (0x1000000 + Math.random() * 0xffffff).toString(16).substr(1, 6); - // this._fill = RealLink.colorOptions[Math.floor(Math.random() * RealLink.colorOptions.length)]; - // setTimeout(() => { - // this._fill = ColorService.instance.getNextLinkColor(); - // }); + // Zero, like the mass above it: a link that arrives with a moment of + // inertia of 1 resists angular acceleration in every dynamic analysis of a + // drawing whose author never chose a number — and it made "massless links + // are skipped by inertia" a lie. URLs carry each link's stored value, so + // only freshly drawn links land here. + this._massMoI = massMoI !== undefined ? massMoI : 0; this._fill = '#555555'; //Set later if (subSet === undefined || subSet.length === 0) { - // this.subset = []; } else { this.subset = subSet; } this._CoM = CoM !== undefined ? CoM : RealLink.determineCenterOfMass(joints); + // Before the path is built, because being drawn as a disc is what decides + // what that path is. `visualSource` is the link this one is a copy of, so + // every solved timestep inherits the choice without each cloning site + // having to remember it. + if (visualSource !== undefined) { + this.isCircle = visualSource.isCircle; + } if ( visualSource?.isVisualGeometryCurrent && visualSource.joints.length >= 2 && @@ -265,10 +457,11 @@ export class RealLink extends Link { updateLengthAndAngle() { this._length = getDistance(this.joints[0], this.joints[1]); this._angle = getAngle(this.joints[0], this.joints[1]); - // console.warn(this._length, this._angle); } getCompoundPathString(): string { + // A compound is drawn from the outlines of its parts, never as a disc. + this.drawnAsDisc = false; // A sealed cylinder's rod welded into this compound is drawn by the skin, // above the block; the compound repeating it drew the same bar twice, one // copy on the wrong side of the block. The leaf is recognised through its @@ -325,7 +518,7 @@ export class RealLink extends Link { const hullPoints = hull(points, Infinity) as number[][]; let hullContainsPoint = false; - hullPoints.forEach((point: any) => { + hullPoints.forEach((point: number[]) => { if (point[0] === x && point[1] === y) { hullContainsPoint = true; } @@ -363,8 +556,50 @@ export class RealLink extends Link { ); } + /** + * The pin a circular link turns about, or nothing when it has no single one. + * + * Exactly one ground, and a revolute one: two grounds make a frame that does + * not turn at all, none makes a coupler with no fixed centre to draw about, + * and a prismatic ground anchors a slot rather than a pivot. Each of those + * would need a different answer to "centred where?", and a disc drawn about + * a guess is worse than the bar it replaced. + */ + groundPivot(): Joint | undefined { + const pivots = this.joints.filter( + (joint) => joint instanceof RealJoint && joint.ground && !(joint instanceof PrisJoint) + ); + return pivots.length === 1 ? pivots[0] : undefined; + } + + /** Whether Make Circular has anything to act on. */ + canBeCircular(): boolean { + return this.subset.length === 0 && this.groundPivot() !== undefined; + } + + /** + * The disc a circular link is drawn as, or nothing when it is not one. + * + * Centred on the ground pin, and wide enough to reach the outermost joint's + * end cap — the same half-width every bar is drawn with — so the disc covers + * exactly the ground the bar covered and no pin ends up outside its own link. + */ + private circularOutline(): string | undefined { + if (!this.isCircle) return undefined; + const centre = this.groundPivot(); + if (centre === undefined) return undefined; + const reach = this.joints.reduce((far, joint) => Math.max(far, getDistance(centre, joint)), 0); + const radius = reach + SettingsService.objectScale / 4; + const { x, y } = centre; + return ( + `M ${x - radius} ${y} A ${radius} ${radius} 0 0 1 ${x + radius} ${y} ` + + `A ${radius} ${radius} 0 0 1 ${x - radius} ${y} Z ` + ); + } + getSimplePathString(): string { this.externalLines = []; + this.drawnAsDisc = false; let l = this; // Draw link given the desiredJointIDs const allJoints = l.joints; @@ -377,17 +612,33 @@ export class RealLink extends Link { this.initialExternalLines = []; return collapsed; } + // A disc has no edges, so there is nothing for a joint to be attached + // along and nothing for the edit-hover to measure — the same empty answer + // the collapsed bar above gives, for the same reason. + const disc = this.circularOutline(); + if (disc) { + this.externalLines = []; + this.initialExternalLines = []; + this.drawnAsDisc = true; + return disc; + } const hullPoints = hull(points, Infinity) as number[][]; //Hull points find the convex hull (largest fence) - //Match resuling x,y points to joints - let desiredJointsIDs: string = ''; - hullPoints.forEach((point: any) => { + // Match resulting x,y points to joints. + // + // A list rather than one packed string. The ids used to be concatenated and + // then read back a character at a time, which quietly required every joint + // id to be exactly one character -- so the two-letter names a drawing gets + // past its fifty-second joint, and the numbered names inside a cylinder, + // both came apart here into characters that name no joint at all. + let desiredJointsIDs: string[] = []; + hullPoints.forEach((point: number[]) => { const joint = allJoints.find((j) => j.x === point[0] && j.y === point[1]); - if (joint) desiredJointsIDs += joint.id; + if (joint) desiredJointsIDs.push(joint.id); }); //Cut off the last once since it is the same as the first - desiredJointsIDs = desiredJointsIDs.substring(0, desiredJointsIDs.length - 1); + desiredJointsIDs = desiredJointsIDs.slice(0, -1); //This is just for debugging display // l.debugDesiredJointsIDs = desiredJointsIDs; @@ -441,8 +692,6 @@ export class RealLink extends Link { } } - const splitPath = d.split(' '); - //Get the final joint const finalJoint = allJoints[jointIDtoIndex.get(desiredJointsIDs[j!])!]; let lastPos = this.externalLines[this.externalLines.length - 1].endPosition; @@ -464,7 +713,6 @@ export class RealLink extends Link { this.externalLines.push(new Arc(lastPos, startPos, finalJoint)); if (!RealLink.isClockwise(this.externalLines[0], this.CoM)) { - // console.log('Link is not clockwise'); this.externalLines.reverse(); //If the link is not clockwise, reverse the order of the external lines for (let i = 0; i < this.externalLines.length; i++) { @@ -601,23 +849,6 @@ export class RealLink extends Link { return crossProduct > 0; } - // static getLineIntersection(thisLine: Line, nextLine: Line): Coord | null { - // const [x, y] = line_intersect( - // thisLine.startPosition.x, - // thisLine.startPosition.y, - // thisLine.endPosition.x, - // thisLine.endPosition.y, - // nextLine.startPosition.x, - // nextLine.startPosition.y, - // nextLine.endPosition.x, - // nextLine.endPosition.y - // ); - // if (x === null || y === null) { - // return null; - // } - // return new Coord(x, y); - // } - static determineCenterOfMass(joints: Joint[]) { let com_x = 0; let com_y = 0; @@ -724,7 +955,9 @@ export class RealLink extends Link { updateCoMDs() { //This is such a bad way of doing this. Just import the SVG file from the assets folder and use that instead of constructing the exact same thing every time. - const radius = SettingsService.objectScale * 0.2; + // Small. The mark says where the centre of mass is; at the size it was, on + // a drawing with several links, it was the loudest thing on the canvas. + const radius = SettingsService.objectScale * 0.11; this._CoM_d1 = 'M' + this.CoM.x + diff --git a/src/app/model/lock-set.spec.ts b/src/app/model/lock-set.spec.ts new file mode 100644 index 00000000..beb4eb90 --- /dev/null +++ b/src/app/model/lock-set.spec.ts @@ -0,0 +1,155 @@ +import './joint'; +import { Coord } from './coord'; +import { PrisJoint, RevJoint } from './joint'; +import { RealLink, SliderBlock } from './link'; +import { cylinderHeadHalf, cylinderSpanLayoutFrom, HEAD_CLEARANCE_R } from './cylinder'; +import { frozenJointIds, locksHolding } from './lock-set'; +import { MODEL_SCALE } from './render-scale'; + +const S = MODEL_SCALE; + +/** + * What a Lock mark holds still is a set of joints, and this file pins the + * translation: marks spread along consequence, not membership. The directed + * cases — a mount that stays free while an interior joint seals the part, a + * floating pin that holds its own place without holding its channel — are + * exactly the ones a symmetric "travelling group" closure gets wrong. + */ + +function fourBar() { + const a = new RevJoint('A', 0, 0, true, true); + const b = new RevJoint('B', 0, 2 * S); + const c = new RevJoint('C', 3 * S, 2 * S); + const d = new RevJoint('D', 3 * S, 0, false, true); + const ab = new RealLink('AB', [a, b], 1, 1); + const bc = new RealLink('BC', [b, c], 1, 1); + const cd = new RealLink('CD', [c, d], 1, 1); + [a, b].forEach((joint) => joint.links.push(ab)); + [b, c].forEach((joint) => joint.links.push(bc)); + [c, d].forEach((joint) => joint.links.push(cd)); + return { joints: [a, b, c, d], links: [ab, bc, cd], a, b, c, d, ab, bc, cd }; +} + +/** The sealed-cylinder shape url-sealed-cylinder.spec builds, minus the codec. */ +function sealedCylinder() { + const angle = 0.31; + const at = (along: number) => new Coord(along * Math.cos(angle) * S, along * Math.sin(angle) * S); + const CLEARANCE = HEAD_CLEARANCE_R * 0.15; + const { stroke } = cylinderSpanLayoutFrom(8, 0.5, 0.15); + const HEAD_HALF = cylinderHeadHalf(stroke + CLEARANCE, 0.15); + const buried = -4 + stroke + CLEARANCE; + const pinAlong = -4 + CLEARANCE + HEAD_HALF + stroke * 0.5; + + const a = new RevJoint('A', at(-4).x, at(-4).y, false, true); + const b = new RevJoint('B', at(buried).x, at(buried).y); + const c = new RevJoint('C', at(pinAlong).x, at(pinAlong).y); + const d = new RevJoint('D', at(4).x, at(4).y); + const slider = new PrisJoint('P', c.x, c.y); + slider.isSealed = true; + + const barrel = new RealLink('AB', [a, b], 1, 1); + const rod = new RealLink('CD', [c, d], 1, 1); + const block = new SliderBlock('CP', [c, slider], 1); + [a, b].forEach((joint) => joint.links.push(barrel)); + [c, d].forEach((joint) => joint.links.push(rod)); + c.links.push(block); + slider.links.push(block); + c.isWelded = true; + slider.slideOn(barrel, a, b); + + return { + joints: [a, b, c, d, slider], + links: [barrel, rod, block], + mountA: a, + buried: b, + pin: c, + mountD: d, + slider, + }; +} + +describe('what a Lock mark holds still', () => { + it('a locked joint holds itself and nothing else', () => { + const { joints, links, b } = fourBar(); + b.locked = true; + + expect(frozenJointIds(joints, links)).toEqual(new Set(['B'])); + }); + + it('one lock layer: freeing one joint of a marked pair frees exactly it', () => { + // Locking a link is a shortcut that marks each of its joints — so this is + // what "lock link BC, then unlock joint B" leaves behind. + const { joints, links, b, c } = fourBar(); + b.locked = true; + c.locked = true; + expect(frozenJointIds(joints, links)).toEqual(new Set(['B', 'C'])); + + b.locked = false; + expect(frozenJointIds(joints, links)).toEqual(new Set(['C'])); + }); + + it('a slider pin and its block joint hold each other — they are coincident', () => { + const pin = new RevJoint('A', 0, 0); + const slider = new PrisJoint('P', 0, 0); + const block = new SliderBlock('AP', [pin, slider], 1); + pin.links.push(block); + slider.links.push(block); + pin.locked = true; + + expect(frozenJointIds([pin, slider], [block])).toEqual(new Set(['A', 'P'])); + }); + + it('a locked interior joint seals the whole cylinder', () => { + const part = sealedCylinder(); + part.slider.locked = true; + + expect(frozenJointIds(part.joints, part.links)).toEqual(new Set(['A', 'B', 'C', 'D', 'P'])); + }); + + it('a locked mount holds only the mount — the ram still re-poses and swings about it', () => { + const part = sealedCylinder(); + part.mountA.locked = true; + + expect(frozenJointIds(part.joints, part.links)).toEqual(new Set(['A'])); + }); + + it('a locked floating pin holds its own block and leaves its channel free', () => { + // The block's mark is parametric: it spends the one freedom the block has, + // which is where it sits along the slot. The two joints that cut the slot + // keep moving, and the reseat takes the block with them. + const { joints, links, a, b, ab } = fourBar(); + const slider = new PrisJoint('P', 1 * S, 1 * S); + const rider = new RevJoint('E', 1 * S, 1 * S); + const block = new SliderBlock('EP', [rider, slider], 1); + rider.links.push(block); + slider.links.push(block); + slider.slideOn(ab, a, b); + const withSlot = { joints: [...joints, slider, rider], links: [...links, block] }; + + slider.locked = true; + expect(frozenJointIds(withSlot.joints, withSlot.links)).toEqual(new Set(['P', 'E'])); + + slider.locked = false; + a.locked = true; + expect(frozenJointIds(withSlot.joints, withSlot.links)).toEqual(new Set(['A'])); + }); + + it('keeps holding through a weld: marks live on joints, which no weld restructures', () => { + const { joints, b, c, d, bc, cd } = fourBar(); + b.locked = true; + c.locked = true; + const compound = new RealLink('BCD', [b, c, d], 1, 1, undefined, [bc, cd]); + const links = [compound]; + + expect(frozenJointIds(joints, links)).toEqual(new Set(['B', 'C'])); + }); + + it('names the mark that holds a joint, so Unlock can clear exactly it', () => { + const { joints, links, b, c } = fourBar(); + b.locked = true; + + expect(locksHolding('B', joints, links)).toEqual([b]); + expect(locksHolding('A', joints, links)).toEqual([]); + expect(locksHolding(c.id, joints, links)).toEqual([]); + }); +}); diff --git a/src/app/model/lock-set.ts b/src/app/model/lock-set.ts new file mode 100644 index 00000000..1ebc81ab --- /dev/null +++ b/src/app/model/lock-set.ts @@ -0,0 +1,106 @@ +import { Joint, RealJoint } from './joint'; +import { Link, RealLink, SliderBlock } from './link'; +import { Force } from './force'; +import { cylinderJoints, sealedCylinderStructures } from './cylinder'; + +/** + * Which joints the current Lock marks hold still. + * + * There is exactly one kind of positional lock: a mark on a *joint*. Locking + * a link is a shortcut that marks all of its joints, and unlocking one of + * those joints afterwards frees exactly that joint — there is no second, + * link-level ledger to keep in agreement with the first. (It also means a + * weld can never hide a lock: welding restructures links, and the marks do + * not live on links.) Forces carry their own mark, having no joints. + * + * What a drag has to respect is still a *set* of held joints, because some + * joints travel together whatever the drag asked. The closure is a set of + * one-way implications — "held" spreads along consequence, not membership: + * + * - A slider's block joint is coincident with its pin, so holding either + * holds both — the one symmetric case. + * - A sealed cylinder's interior joints only ever move as the whole part + * moves, so holding one holds all five. A held *mount* stays a held mount: + * dragging the other mount anchors it, and a body drag can still swing the + * part about it, so the implication deliberately does not run outward. + * + * A floating slider is the one mark that is not about a point on the drawing. + * It has exactly one freedom — where it sits along its slot — and that is what + * its mark spends: the channel stays free, and the reseat carries the block + * along at the offset it was locked at. So the mark reaches its own block + * joint and stops. Holding the pair that cuts the channel, which is what a + * world-position lock would have to do, froze two joints the reader never + * marked and had no way to predict from the one they did. + */ +export type Lockable = RealJoint | Force; + +interface Implication { + ifAnyOf: string[]; + freeze: string[]; +} + +/** + * Every body, welded or free: the roots and the leaves inside each compound. + * The closure walks them all so a slider block buried by a weld still binds + * its coincident pair. + */ +function allBodies(links: Link[]): Link[] { + return links.flatMap((link) => [link, ...(link instanceof RealLink ? link.subset : [])]); +} + +/** The ids of every joint the current Lock marks hold still. */ +export function frozenJointIds(joints: Joint[], links: Link[]): Set<string> { + const frozen = new Set<string>(); + joints.forEach((joint) => { + if (joint instanceof RealJoint && joint.locked) frozen.add(joint.id); + }); + return closeOverConsequences(frozen, joints, links); +} + +/** + * Grow a held set until every implication is satisfied. Iterated to a fixed + * point — freezing a block joint can seal a cylinder's interior, which holds + * its mounts — and it terminates because each pass only adds. + */ +function closeOverConsequences(frozen: Set<string>, joints: Joint[], links: Link[]): Set<string> { + const rules: Implication[] = []; + + allBodies(links).forEach((link) => { + if (!(link instanceof SliderBlock)) return; + const pair = link.joints.map((joint) => joint.id); + rules.push({ ifAnyOf: pair, freeze: pair }); + }); + + sealedCylinderStructures(joints).forEach((sealed) => { + rules.push({ + ifAnyOf: [sealed.pin.id, sealed.slider.id, sealed.barrelNear.id], + freeze: cylinderJoints(sealed).map((joint) => joint.id), + }); + }); + + let grew = true; + while (grew) { + grew = false; + for (const rule of rules) { + if (!rule.ifAnyOf.some((id) => frozen.has(id))) continue; + for (const id of rule.freeze) { + if (!frozen.has(id)) { + frozen.add(id); + grew = true; + } + } + } + } + return frozen; +} + +/** + * The marks that hold this joint still — what an Unlock action has to clear + * for the joint to move again. Each marked joint is asked alone: the closure + * of just that mark either reaches the joint or it does not. + */ +export function locksHolding(jointId: string, joints: Joint[], links: Link[]): Lockable[] { + return joints + .filter((joint): joint is RealJoint => joint instanceof RealJoint && joint.locked) + .filter((locked) => closeOverConsequences(new Set([locked.id]), joints, links).has(jointId)); +} diff --git a/src/app/model/mechanism/bodies.ts b/src/app/model/mechanism/bodies.ts new file mode 100644 index 00000000..fa0d1004 --- /dev/null +++ b/src/app/model/mechanism/bodies.ts @@ -0,0 +1,79 @@ +import { Joint, PrisJoint, RealJoint } from '../joint'; +import { Link } from '../link'; +import { groupRigidBodies } from '../rigid-bodies'; +import { assemblyBodyIds, slideAssemblies } from '../slide-assembly'; + +/** The world: one body, however many anchored bars are drawn on top of it. */ +export const WORLD = 'ground-body'; + +export interface BodyAssignment { + /** The rigid body a link belongs to — WORLD for anything pinned down everywhere. */ + bodyOf(link: Link): string; + /** Every distinct body meeting at a joint, the world among them. */ + bodiesAt(joint: RealJoint): Set<string>; + /** Every body that can move: the drawing's bodies, less the world. */ + movingBodies: Set<string>; +} + +/** + * Which rigid body each link belongs to, and which bodies meet at each joint. + * + * Mobility counting and the split into separate mechanisms are two questions + * asked of the same structure, and they have to agree: a mechanism whose + * mobility was counted over one notion of "body" and whose extent was decided + * by another would report a degree of freedom for a machine that is not the one + * on the screen. So both read this, and there is one definition to be wrong. + */ +export function assignBodies(joints: Joint[], links: Link[]): BodyAssignment { + // A Slide's rider and block share one joint, so the shared-joint rule cannot + // see the weld that makes them one body. Links pinned to ground at every + // joint are merged for the same reason: they cannot move, so they are one + // body — the world's, as settled below where the world exists. + const anchored = links + .filter((link) => link.joints.length > 0 && link.joints.every((j) => (j as RealJoint).ground)) + .map((link) => link.id); + const rigidBody = groupRigidBodies(links, [ + ...slideAssemblies(joints).map(assemblyBodyIds), + anchored, + ]); + + const groupOf = (link: Link) => rigidBody.get(link.id) ?? link.id; + // A group every one of whose links is pinned down at every joint *is* the + // world. Deciding it per group rather than per link keeps a rail that has + // been merged with something movable out of the world by mistake. + const anchoredGroups = new Set( + [...new Set(links.map(groupOf))].filter((group) => + links + .filter((link) => groupOf(link) === group) + .every( + (link) => link.joints.length > 0 && link.joints.every((j) => (j as RealJoint).ground) + ) + ) + ); + const bodyOf = (link: Link) => (anchoredGroups.has(groupOf(link)) ? WORLD : groupOf(link)); + + /** + * One rule for both joint types, because it is one question. A pin joins the + * bodies of its links, plus the world if it is grounded; a sliding joint + * joins its block to whatever the slot is cut into — the world for a fixed + * guide, the carrier for a floating one. + */ + const bodiesAt = (joint: RealJoint): Set<string> => { + const bodies = new Set(joint.links.map(bodyOf)); + if (joint.ground) { + bodies.add(WORLD); + } + if (joint instanceof PrisJoint && !joint.ground) { + // A slider with no carrier and no ground slides against nothing. It is an + // invalid mechanism either way; naming the absent body keeps the reported + // number the one this case has always reported. + bodies.add(joint.carrier ? bodyOf(joint.carrier) : 'dangling-slot' + joint.id); + } + return bodies; + }; + + const movingBodies = new Set(links.map(bodyOf)); + movingBodies.delete(WORLD); + + return { bodyOf, bodiesAt, movingBodies }; +} diff --git a/src/app/model/mechanism/cylinder-mass-migration.spec.ts b/src/app/model/mechanism/cylinder-mass-migration.spec.ts new file mode 100644 index 00000000..ec399786 --- /dev/null +++ b/src/app/model/mechanism/cylinder-mass-migration.spec.ts @@ -0,0 +1,34 @@ +import { Mechanism } from './mechanism'; +import { TEMPLATE_LINKAGES } from '../../component/MODALS/templates/template-linkages'; +import { buildMechanismFixture } from '../../../tests/fixtures/mechanism-fixtures'; +import { RealLink } from '../link'; +import { sealedCylinderStructures } from '../cylinder'; + +// Imports mirror force-solver.fixture.spec.ts exactly: the model modules form +// a cycle that only initializes cleanly when entered in the order the app +// enters it, and this file's entry point is the fixture builder. +void Mechanism; + +describe('cylinder parts arriving from a URL', () => { + it("migrate to auto: their inertia was never anybody's choice", () => { + // Nothing that shipped ever offered a field for a sealed part's inertia + // or centre — the values in circulating URLs are fixture defaults. The + // decoder hands the parts back to their shapes; masses stay as stored, + // because mass carries no flag and is always somebody's choice. + const { service } = buildMechanismFixture(TEMPLATE_LINKAGES['Cylinder_Boom']); + const sealed = sealedCylinderStructures(service.joints); + expect(sealed.length).toBe(1); + for (const part of [sealed[0].barrel, sealed[0].rod]) { + expect(part instanceof RealLink).toBe(true); + expect((part as RealLink).moiIsCustom).toBe(false); + expect((part as RealLink).comIsCustom).toBe(false); + } + // The ordinary link in the same URL keeps its legacy frozen values. + const boom = service.links.find( + (link: unknown) => + link instanceof RealLink && !sealed.some((cyl) => cyl.barrel === link || cyl.rod === link) + ) as RealLink; + expect(boom.moiIsCustom).toBe(true); + expect(boom.comIsCustom).toBe(true); + }); +}); diff --git a/src/app/model/mechanism/drive-profile.spec.ts b/src/app/model/mechanism/drive-profile.spec.ts new file mode 100644 index 00000000..af559313 --- /dev/null +++ b/src/app/model/mechanism/drive-profile.spec.ts @@ -0,0 +1,162 @@ +import { Injector } from '@angular/core'; +import { createMechanismHarness } from '../../../test-utils/mechanism-harness'; +import { DriveProfile, driveProfileOf, fractionalSampleAlong, sampleAlong } from './drive-profile'; +import { ActiveObjService } from '../../services/active-obj.service'; +import { ColorService } from '../../services/color.service'; +import { DragStateService } from '../../services/drag-state.service'; +import { GridUtilsService } from '../../services/grid-utils.service'; +import { MechanismService } from '../../services/mechanism.service'; +import { NumberUnitParserService } from '../../services/number-unit-parser.service'; +import { SettingsService } from '../../services/settings.service'; +import { SvgGridService } from '../../services/svg-grid.service'; +import { SynthesisBuilderService } from '../../services/synthesis/synthesis-builder.service'; +import { MechanismBuilder } from '../../services/transcoding/mechanism-builder'; +import { StringTranscoder } from '../../services/transcoding/string-transcoder'; +import { TemplateID, TEMPLATE_LINKAGES } from '../../component/MODALS/templates/template-linkages'; +import { silentNotifications } from '../../../test-utils/notification-stub'; +import { buildMechanism } from '../../../test-utils/verification/fixture'; +import { squareRodSliderCrankFixture } from '../../../test-utils/verification/slot-fixtures'; + +/** The solved first mechanism of a template, and what its input does. */ +function profileOf(template: TemplateID): DriveProfile { + const { service, settings, active } = createMechanismHarness(); + + const decoder = new StringTranscoder(); + decoder.decodeURL(TEMPLATE_LINKAGES[template]); + new MechanismBuilder(service, decoder, settings, active).build(true); + service.updateMechanism(); + + const profile = driveProfileOf(service.mechanisms[0]); + expect(profile, `${template} has a drive profile`).toBeDefined(); + return profile!; +} + +describe('Where a machine says its input is', () => { + beforeEach(() => vi.spyOn(console, 'log').mockImplementation(() => undefined)); + afterEach(() => vi.restoreAllMocks()); + + it('sweeps a crank that goes all the way round once across the track', () => { + const profile = profileOf('4-Bar'); + + expect(profile.continuous, 'the crank turns all the way round').toBe(true); + expect(profile.linear).toBe(false); + // Zero is the pose the drawing was authored in, and it climbs from there. + expect(profile.along[0]).toBeCloseTo(0, 6); + expect(profile.along[Math.floor(profile.along.length / 2)]).toBeCloseTo(0.5, 2); + expect(profile.along.at(-1)!).toBeGreaterThan(0.99); + // Never off the end: a turn is the whole track, not more than it. + expect(profile.along.every((value) => value >= 0 && value <= 1)).toBe(true); + }); + + it('runs a clockwise crank left to right', () => { + // The app stores a clockwise drive as a negative speed, which is the + // opposite of the direction a reader expects the handle to travel. + const profile = profileOf('4-Bar'); + expect(profile.along[1]).toBeGreaterThan(profile.along[0]); + }); + + it('spans a branch-swapping two-revolution cycle once, so a drag cannot flicker', () => { + // The tangency slider-crank closes only after two crank revolutions. + // Wrapped to one turn, sample t and t+360 shared a handle position, and + // the scrubber flickered between the two assembly branches while + // dragging, float noise picking the winner on each event. + const { mechanism } = buildMechanism(squareRodSliderCrankFixture()); + const profile = driveProfileOf(mechanism)!; + + expect(profile.continuous).toBe(true); + expect(profile.along).toHaveLength(721); + // Monotone: every handle position names exactly one pose. + for (let i = 1; i < profile.along.length; i++) { + expect(profile.along[i]).toBeGreaterThan(profile.along[i - 1]); + } + expect(profile.along[0]).toBeCloseTo(0, 9); + expect(profile.along.at(-1)!).toBeCloseTo(1, 9); + + // A simulated drag across the whole track lands on nearby samples in + // order, never across the cycle to the other branch. Distance is measured + // round the loop: the right edge of the track is the left edge, and both + // name the same (closed) pose -- a real branch flicker is a ~360-sample + // hop, which no seam can excuse. + const last = profile.along.length - 1; + let previous = 0; + for (let i = 0; i <= 200; i++) { + const sample = sampleAlong(profile, i / 200, previous); + const hop = Math.abs(sample - previous); + expect(Math.min(hop, last - hop)).toBeLessThanOrEqual(8); + previous = sample; + } + }); + + it('measures a ram between the ends of its own stroke, not its clock', () => { + const profile = profileOf('Cylinder_Boom'); + + expect(profile.linear, 'the input is a ram').toBe(true); + expect(profile.continuous, 'a ram runs out of stroke and comes back').toBe(false); + // Both ends of the track are reached, and only at the ends of the stroke. + expect(Math.min(...profile.along)).toBeCloseTo(0, 6); + expect(Math.max(...profile.along)).toBeCloseTo(1, 6); + // And it comes back: the last sample is not the far end. + expect(profile.along.at(-1)!).toBeLessThan(1); + }); + + it('starts a ram wherever the drawing was authored, not at zero', () => { + // The whole point of measuring position rather than time: a ram drawn half + // extended starts the handle half way along, because that is where it is. + const profile = profileOf('Cylinder_Boom'); + expect(profile.along[0]).toBeGreaterThan(0.05); + expect(profile.along[0]).toBeLessThan(0.95); + }); + + it('reads a place on the track back to the sample that is at it', () => { + const profile = profileOf('Cylinder_Boom'); + const sample = sampleAlong(profile, profile.along[40], 40); + expect(profile.along[sample]).toBeCloseTo(profile.along[40], 6); + }); + + it('keeps a drag on the leg the machine is already on', () => { + // A ram passes every position twice, once each way. Pulling the handle back + // a little should walk back along the way it came, not jump to the return + // leg at the same extension. + const profile = profileOf('Cylinder_Boom'); + const last = profile.along.length - 1; + const outbound = Math.round(last * 0.25); + const returning = Math.round(last * 0.85); + + expect(sampleAlong(profile, profile.along[outbound], outbound)).toBeLessThan(last / 2); + expect(sampleAlong(profile, profile.along[returning], returning)).toBeGreaterThan(last / 2); + }); + + it('answers between samples, so a drag is not a series of small jumps', () => { + // A degree of crank is a couple of pixels of track. Snapping to the nearest + // sample held the drawing still for those two pixels and then jumped it, + // which is what a reader sees as stutter. + const profile = profileOf('4-Bar'); + const half = (profile.along[10] + profile.along[11]) / 2; + const at = fractionalSampleAlong(profile, half, 10); + expect(at).toBeGreaterThan(10); + expect(at).toBeLessThan(11); + // And still lands exactly on a sample when that is what was asked for. + expect(fractionalSampleAlong(profile, profile.along[10], 10)).toBe(10); + }); + + it('treats the two ends of a loop as the same place', () => { + // For a crank that goes all the way round the right edge of the track is + // the left edge; dragging past the end comes back to the start rather than + // stopping dead against it. + const profile = profileOf('4-Bar'); + expect(sampleAlong(profile, 1, 0)).toBe(0); + }); + + it('lands a drag TO the right edge on the last sample, not sample zero', () => { + // The two ends of the loop are the same pose, but not the same readout: a + // drag arriving at the right edge should read as the end of the cycle + // (24.00 s, 720 degrees), and it used to snap to 0.00 s because the tie + // between the two ends went to whichever sample the scan met first. + const { mechanism } = buildMechanism(squareRodSliderCrankFixture()); + const profile = driveProfileOf(mechanism)!; + const last = profile.along.length - 1; + expect(sampleAlong(profile, 1, last - 4)).toBe(last); + // And from rest at the start, the same place is still the start. + expect(sampleAlong(profile, 1, 0)).toBe(0); + }); +}); diff --git a/src/app/model/mechanism/drive-profile.ts b/src/app/model/mechanism/drive-profile.ts new file mode 100644 index 00000000..bbf94dd4 --- /dev/null +++ b/src/app/model/mechanism/drive-profile.ts @@ -0,0 +1,258 @@ +import { PrisJoint, RealJoint } from '../joint'; +import { Mechanism } from './mechanism'; + +/** + * Where a machine's input sits, as a fraction of everything it can do. + * + * The transport used to be a clock: the handle ran left to right as time + * passed, and everything the reader could ask it was really a question about + * time. That works for a crank and breaks for anything else. A ram spends the + * second half of its cycle undoing the first, so a handle following the clock + * carried on to the right while the ram came back; a rocker never leaves a + * forty-degree arc, and the handle claimed the whole track for it. + * + * So the handle measures the input instead: + * + * - A ram or slider runs from one end of its stroke to the other. + * - A rocking crank runs from one angular limit to the other. + * - A crank that goes all the way round has no limits to run between, so the + * whole cycle spans the track -- one turn usually, two for a rod that swaps + * assembly branch at a slot tangency -- with zero at the pose the drawing + * was authored in. + * + * Time is still what the machine is *at*, and still what the readout shows. It + * is just no longer what the handle means. + */ +export interface DriveProfile { + /** 0..1 for each solved sample: where the input is, out of everything it does. */ + readonly along: number[]; + /** True when the input goes all the way round rather than turning back. */ + readonly continuous: boolean; + /** A slider or ram, which extends; otherwise a crank, which turns. */ + readonly linear: boolean; + /** End to end of the input's travel, in model units. Zero for a crank. */ + readonly span: number; +} + +/** The two joints a ram's length is measured between, when the input is one. */ +export interface RamEnds { + readonly from: string; + readonly to: string; +} + +/** + * Read the input's own coordinate out of a solved cycle. + * + * Sample by sample, from the drawn positions rather than from a stored angle: a + * slot's rail, a ram's bore and a pin's crank are described three different + * ways in the model and are all just a moving joint here. + */ +export function driveProfileOf(mechanism: Mechanism, ram?: RamEnds): DriveProfile | undefined { + const frames = mechanism.joints; + if (!mechanism.isMechanismValid() || frames.length < 2) { + return undefined; + } + const at = frames[0].findIndex((joint) => (joint as RealJoint).input); + if (at === -1) { + return undefined; + } + + const linear = frames[0][at] instanceof PrisJoint; + // A ram is measured by how far out its rod is, not by how far its slider has + // moved from wherever the drawing put it: the two run opposite ways as often + // as not, and the second one has no name a reader would recognise. Anything + // else linear has no extension to speak of, so it is measured along the line + // it slides on. + const raw = linear ? ((ram && lengthOf(frames, ram)) ?? strokeOf(frames, at)) : turnOf(mechanism); + if (!raw) { + return undefined; + } + + // A cycle whose input never turns back has no two ends to measure between -- + // it is a loop, and the only honest thing to show is how far round it is, + // from the pose the drawing was authored in. + // + // Round the WHOLE cycle, however many turns that is: a rod that swaps + // assembly branch at a slot tangency closes after two, and a track wrapped + // to one turn made every handle position name two different poses -- the + // scrubber flickered between them, float noise picking the winner. + // + // Signed, and taken from the angle rather than from the sample index: the + // crank is at the angle it is at whichever way it is being driven, so turning + // the drive round must leave the handle where it is. Off the sample index it + // would jump to the mirror image of itself. + const continuous = !turnsBack(raw); + if (continuous) { + const total = raw[raw.length - 1] - raw[0]; + const denominator = Math.abs(total) > 1e-9 ? total : 1; + return { + along: raw.map((value) => (value - raw[0]) / denominator), + continuous, + linear, + span: 0, + }; + } + + const low = Math.min(...raw); + const span = Math.max(...raw) - low; + if (!(span > 0)) { + return undefined; + } + return { along: raw.map((value) => (value - low) / span), continuous, linear, span }; +} + +/** How far the ram's rod is out, sample by sample. */ +function lengthOf(frames: Mechanism['joints'], ram: RamEnds): number[] | undefined { + const from = frames[0].findIndex((joint) => joint.id === ram.from); + const to = frames[0].findIndex((joint) => joint.id === ram.to); + if (from === -1 || to === -1) { + return undefined; + } + return frames.map((frame) => + Math.hypot(frame[to].x - frame[from].x, frame[to].y - frame[from].y) + ); +} + +/** How far the input has moved along the line it slides on, sample by sample. */ +function strokeOf(frames: Mechanism['joints'], at: number): number[] | undefined { + const start = frames[0][at]; + // The two ends of the path it actually takes give its direction; a stored + // angle would only agree with them for one of the ways a slide is built. + const far = frames.reduce( + (best, frame) => { + const distance = Math.hypot(frame[at].x - start.x, frame[at].y - start.y); + return distance > best.distance ? { distance, x: frame[at].x, y: frame[at].y } : best; + }, + { distance: 0, x: start.x, y: start.y } + ); + if (!(far.distance > 0)) { + return undefined; + } + const ux = (far.x - start.x) / far.distance; + const uy = (far.y - start.y) / far.distance; + return frames.map((frame) => (frame[at].x - start.x) * ux + (frame[at].y - start.y) * uy); +} + +/** + * How far the crank has turned, sample by sample, unwrapped. + * + * Unwrapped so that going round twice reads as twice as far rather than as + * back where it started, which is what tells a full revolution apart from a + * rocker that happens to end where it began. + */ +function turnOf(mechanism: Mechanism): number[] | undefined { + const speeds = mechanism.inputAngularVelocities; + const times = mechanism.timeNum ?? []; + if (speeds.length === 0 || times.length !== speeds.length) { + return undefined; + } + // Negated, because the app stores a clockwise drive as a negative speed and + // the reader expects a clockwise input to run the handle left to right. + // + // Against the frames, not against the drive. This walks the samples adding up + // how far the crank has turned between them, so it has to follow the order + // the samples are in. Turning the drive round leaves those samples alone -- + // the crank is at the same angle at each of them -- and reading the drive's + // new sign here instead mirrored the whole track, so the handle jumped to the + // far end of a machine that had not moved. + const sense = mechanism.framesRunBackwards ? -1 : 1; + let angle = 0; + return times.map((time, i) => { + if (i > 0) { + angle -= sense * speeds[i - 1] * (time - times[i - 1]); + } + return angle; + }); +} + +/** Does the input reverse anywhere in the cycle? */ +function turnsBack(raw: number[]): boolean { + let rising = false; + let falling = false; + for (let i = 1; i < raw.length; i++) { + const step = raw[i] - raw[i - 1]; + if (step > 1e-9) rising = true; + if (step < -1e-9) falling = true; + if (rising && falling) return true; + } + return false; +} + +/** + * The sample at a given place along the track. + * + * Searched rather than inverted: the mapping is a sampled curve, and on a + * machine that turns back it passes every place twice. `near` is the sample the + * machine is at now, and ties go to whichever leg that is on -- so pulling the + * handle backwards does not jump it to the other half of the cycle. + */ +export function sampleAlong(profile: DriveProfile, along: number, near: number): number { + return Math.round(fractionalSampleAlong(profile, along, near)); +} + +/** + * The same answer, between samples. + * + * A drag is continuous and the samples are not: a degree of crank is a couple + * of pixels of track, so snapping to the nearest one holds the drawing still + * for two pixels and then jumps it. Interpolating between the sample found and + * its neighbour on the side the reader is pointing lets the drawing follow the + * hand. + */ +export function fractionalSampleAlong(profile: DriveProfile, along: number, near: number): number { + const at = nearestSample(profile, along, near); + const last = profile.along.length - 1; + if (last <= 0) return 0; + const here = profile.along[at]; + // Toward whichever neighbour lies on the side the reader asked for. + const step = along > here ? 1 : -1; + const next = at + step; + if (next < 0 || next > last) return at; + const span = profile.along[next] - here; + if (Math.abs(span) < 1e-9) return at; + const share = (along - here) / span; + // Only between the two. Off the end of a loop the nearest sample is the one + // at the far end -- the right edge of the track being the left edge -- and + // interpolating away from it would walk back the way the drag came. + if (!(share > 0 && share < 1)) return at; + return at + step * share; +} + +function nearestSample(profile: DriveProfile, along: number, near: number): number { + const last = profile.along.length - 1; + if (last <= 0) { + return 0; + } + if (profile.continuous) { + // Round the loop: the place next to 0 is 1, not the far side of the track. + // The seam makes the track's two ends the same place, so distance to the + // sample the machine is at breaks that tie -- enough to keep a drag on its + // own end of the track, never enough to reach a place the reader did not + // point at. + let best = 0; + let bestCost = Infinity; + profile.along.forEach((value, sample) => { + const gap = Math.abs(value - along); + const cost = Math.min(gap, 1 - gap) + (Math.abs(sample - near) / last) * 1e-6; + if (cost < bestCost) { + bestCost = cost; + best = sample; + } + }); + return best; + } + const nearReturning = near > last / 2; + let best = 0; + let bestCost = Infinity; + profile.along.forEach((value, sample) => { + const returning = sample > last / 2; + // Enough to break a tie, never enough to reach a place the reader did not + // point at. + const cost = Math.abs(value - along) + (returning === nearReturning ? 0 : 1e-3); + if (cost < bestCost) { + bestCost = cost; + best = sample; + } + }); + return best; +} diff --git a/src/app/model/mechanism/force-solver.fixture.spec.ts b/src/app/model/mechanism/force-solver.fixture.spec.ts index 0bdc260a..ff91dfd5 100644 --- a/src/app/model/mechanism/force-solver.fixture.spec.ts +++ b/src/app/model/mechanism/force-solver.fixture.spec.ts @@ -27,6 +27,10 @@ function expectForceAnalysis(mechanism: Mechanism, mode: ForceAnalysisMode) { expect(frame.rank).toBeGreaterThan(0); expect(Number.isFinite(frame.residual)).toBe(true); expect(frame.residual).toBeLessThanOrEqual(1e-8); + // Healthy solves stay an order of magnitude above the singularity + // tolerance (corpus-wide minimum measured 3.4e-3 against the 1e-4 line), + // so the cross-browser determinism guard cannot refuse real frames. + expect(frame.minPivot).toBeGreaterThan(1e-3); expect(frame.inputEffort).toBeDefined(); expect(Number.isFinite(frame.inputEffort!.valueSI)).toBe(true); frame.jointReactionsByLink.forEach((byLink) => diff --git a/src/app/model/mechanism/force-solver.ts b/src/app/model/mechanism/force-solver.ts index 47b868ed..22f1025c 100644 --- a/src/app/model/mechanism/force-solver.ts +++ b/src/app/model/mechanism/force-solver.ts @@ -27,9 +27,17 @@ export interface ForceAnalysisFrame { jointReactionsByLink: Map<string, Map<string, ForceVector>>; /** Compatibility/default view: the resultant on the joint's first root body. */ jointReactions: Map<string, ForceVector>; + /** + * The couple each welded guide applies to its rider, keyed by the sliding + * joint's id. N·m, counterclockwise-positive on the rider. Only welded + * prismatic pairs carry one; a free-turning block cannot transmit a moment. + */ + guideCouples: Map<string, number>; inputEffort?: ForceAnalysisEffort; rank: number; residual: number; + /** Smallest scaled pivot of the solve — how far this pose sat from singular. */ + minPivot?: number; message?: string; } @@ -86,14 +94,51 @@ interface ReactionUnknown { column: number; } +/** + * The couple a welded guide transmits (docs/phase-3-slide-spec.md §3.8, §9). + * + * A free-turning block exchanges only a normal force with its slot. Welding a + * rider to the block stops the pair rotating, so the guide must also supply a + * moment — the unknown the count guard used to come up one short by. The block + * is zero-length and has no moment equation, so the couple passes through it + * untouched and can couple the rider to the guide directly. + */ +interface GuideCouple { + /** The sliding joint whose guide supplies the couple. */ + slider: PrisJoint; + /** The root body the weld holds rigid with the block; +1 in its moment row. */ + rider: RealLink; + /** The slot's carrier when it is cut into a moving body; −1 in its moment row. */ + carrier?: Link; + column: number; +} + interface LinearSolution { values: number[]; rank: number; residual: number; + /** Smallest scaled pivot the elimination met — distance from singular. */ + minPivot: number; } const GRAVITY = 9.80665; const MAX_NORMALIZED_RESIDUAL = 1e-8; +/** + * The smallest scaled pivot the elimination accepts before calling the pose + * singular. + * + * A toggle pose reached through exact arithmetic pivots at exactly zero, but + * the same pose reached through floating-point trig lands within round-off of + * zero — and *which* side of zero differs by browser, because engines round + * transcendentals differently at the last bit. Chrome refused the square-rod + * tangency frame while Safari "solved" it into 8.5e5 N reactions from a 10 N + * load. Refusing anything below this line makes the call deterministic. The + * smallest scaled pivot of any healthy solve across the whole fixture corpus + * is 3.4e-3, 34x above the line (force-solver.fixture.spec.ts holds every + * frame above 1e-3), so frames the sampler lands merely *near* a toggle still + * solve, as designed. + */ +const SINGULAR_PIVOT_TOLERANCE = 1e-4; /** * Free-body force analysis for the current root topology. @@ -212,9 +257,11 @@ export class ForceSolver { frames, successfulFrames, reactionIndex: this.buildReactionIndex(mechanism.joints[0] ?? [], mechanism.links[0] ?? []), + // The frame's own message first: it names the joint or the count, where + // the status alone can only say "topology". diagnostic: successfulFrames === 0 - ? this.statusMessage(frames[0]?.status ?? 'unsupported-topology') + ? (frames[0]?.message ?? this.statusMessage(frames[0]?.status ?? 'unsupported-topology')) : undefined, }; } @@ -244,27 +291,15 @@ export class ForceSolver { timeSeconds, jointReactionsByLink: new Map(), jointReactions: new Map(), + guideCouples: new Map(), rank, residual, message, }); if (bodies.length === 0) return empty('unsupported-topology'); - // A welded slide assembly is refused by name rather than by arithmetic - // (docs/phase-3-slide-spec.md §3.8). The equation count catches it too -- - // it comes out one unknown short, because a prismatic pair welded to a body - // with a moment equation transmits a couple the model has no column for -- - // but a count that happens to be wrong is not a reason, and it would go - // quiet the moment some other change made the numbers balance. - const welded = slideAssemblies(joints); - if (welded.length > 0) { - return empty( - 'unsupported-topology', - `Joint ${welded[0].weldJoint.id} welds ${welded[0].riders[0].id} rigidly to its slider. ` + - 'A prismatic pair carrying a moment has no force solution here yet.' - ); - } - if (!this.propertiesAreValid(bodies, units)) return empty('invalid-properties'); + const badProperty = this.invalidProperty(bodies, units); + if (badProperty) return empty('invalid-properties', badProperty); if (mode === 'dynamic' && !this.kinematicsAreComplete(bodies, kinematics)) { return empty('missing-kinematics'); } @@ -279,6 +314,40 @@ export class ForceSolver { const { reactions, incidentByJoint } = this.enumerateReactions(joints, bodies); + // One couple unknown per welded slide (docs/phase-3-slide-spec.md §9). + // Shapes the couple cannot be written for are refused by name rather than + // left to the count guard, which would only catch them by coincidence. + const couples: GuideCouple[] = []; + for (const assembly of slideAssemblies(joints)) { + // A dangling guide exerts nothing, so it owes no couple either. + if (!assembly.slider.ground && !assembly.slider.isFloating) continue; + // A settled weld has fused every rider into one compound. Mid-edit, the + // riders can still be several distinct bodies, and one couple cannot + // speak for all of them — refuse rather than pick a favourite. + const riderRoots = new Set(assembly.riders.map((leaf) => this.rootBody(bodies, leaf))); + const rider = riderRoots.size === 1 ? [...riderRoots][0] : undefined; + const carrier = assembly.slider.isFloating + ? this.rootBody(bodies, assembly.slider.carrier) + : undefined; + if ( + !(rider instanceof RealLink) || + rider === carrier || + (carrier !== undefined && !(carrier instanceof RealLink)) + ) { + return empty( + 'unsupported-topology', + `Joint ${assembly.weldJoint.id} welds ${assembly.riders[0]?.id ?? 'a link'} rigidly ` + + 'to its slider, and that shape has no force model.' + ); + } + couples.push({ + slider: assembly.slider, + rider, + carrier, + column: reactions.length + couples.length, + }); + } + const inputJoint = joints.find( (joint): joint is RealJoint => joint instanceof RealJoint && joint.input ); @@ -299,11 +368,17 @@ export class ForceSolver { } } - const unknownCount = reactions.length + (inputBody && inputKind ? 1 : 0); + const unknownCount = reactions.length + couples.length + (inputBody && inputKind ? 1 : 0); if (unknownCount !== rowCount) { + // Cause first, arithmetic second: "10 equations, 9 unknowns" is the + // solver talking to itself. What a reader can act on is which way the + // count is off — too many supports, or a body with none. + const counts = `(${rowCount} equilibrium equations, ${unknownCount} unknowns)`; return empty( 'unsupported-topology', - `Force equilibrium has ${rowCount} equations and ${unknownCount} unknowns.` + unknownCount > rowCount + ? `This linkage has more supports than equilibrium can determine — a redundant support, link, or weld leaves the load's split between them ambiguous. Remove one ${counts}.` + : `A body here has nothing to react against, so its equilibrium cannot be written. Check for a part carrying load with no support ${counts}.` ); } @@ -346,7 +421,15 @@ export class ForceSolver { } } - const inputColumn = reactions.length; + // A couple has no force resultant: it enters moment rows alone. + for (const couple of couples) { + A[bodyRows.get(couple.rider.id)!.start + 2][couple.column] += 1; + if (couple.carrier) { + A[bodyRows.get(couple.carrier.id)!.start + 2][couple.column] -= 1; + } + } + + const inputColumn = reactions.length + couples.length; if (inputBody && inputKind) { const rows = bodyRows.get(inputBody.id)!; if (inputKind === 'torque' && inputBody instanceof RealLink) { @@ -354,6 +437,17 @@ export class ForceSolver { } else if (inputKind === 'force') { A[rows.start][inputColumn] = inputDirection[0]; A[rows.start + 1][inputColumn] = inputDirection[1]; + // An actuator pushes against something. On a grounded guide that is + // the world; in a sealed cylinder it is the barrel, and leaving the + // reaction off turns the drive into an outside hand pushing the block + // through space — which breaks the oldest property a cylinder has, of + // being a two-force member between its mounts. + if (inputJoint instanceof PrisJoint && inputJoint.isFloating) { + const carrier = this.rootBody(bodies, inputJoint.carrier); + if (carrier) { + addForceCoefficient(carrier, inputJoint, inputDirection, inputColumn, -1); + } + } } } @@ -427,6 +521,11 @@ export class ForceSolver { if (value) jointReactions.set(jointId, value); } + const guideCouples = new Map<string, number>(); + for (const couple of couples) { + guideCouples.set(couple.slider.id, solution.values[couple.column]); + } + const inputEffort = inputJoint && inputKind ? { @@ -442,12 +541,29 @@ export class ForceSolver { timeSeconds, jointReactionsByLink, jointReactions, + guideCouples, inputEffort, rank: solution.rank, residual: solution.residual, + minPivot: solution.minPivot, }; } + /** + * The root body in `bodies` that `leaf` belongs to. + * + * A weld fuses links into a compound whose members survive as `subset` + * leaves, so the link a slide assembly names is not always the body the + * equilibrium rows are written for. + */ + private static rootBody(bodies: Link[], leaf: Link | undefined): Link | undefined { + if (!leaf) return undefined; + const contains = (link: Link): boolean => + link.id === leaf.id || + (link instanceof RealLink && link.subset.some((member) => contains(member))); + return bodies.find(contains); + } + static statusMessage(status: ForceAnalysisStatus): string { switch (status) { case 'singular': @@ -493,10 +609,9 @@ export class ForceSolver { // A grounded slot pushes against the world, which needs no equation of // its own. A floating one pushes against the carrier, and that reaction // has to appear in the carrier's equilibrium as well or the slot - // transmits force out of nowhere. - const carrier = candidate.isFloating - ? bodies.find((body) => body.id === candidate.carrier?.id) - : undefined; + // transmits force out of nowhere. Resolved through compounds: a weld + // may have folded the carrier into a root whose id is not its own. + const carrier = candidate.isFloating ? this.rootBody(bodies, candidate.carrier) : undefined; if (piston && (candidate.ground || carrier)) { reactions.push({ joint: candidate, @@ -587,27 +702,42 @@ export class ForceSolver { // `joints`, so without this the body on the far side of the slot is simply // missing from the joint's incidence. if (joint instanceof PrisJoint && joint.isFloating) { - add(bodies.find((body) => body.id === joint.carrier?.id)); + add(this.rootBody(bodies, joint.carrier)); } return ordered; } - private static propertiesAreValid(bodies: Link[], units: UnitFactors): boolean { - if (!Object.values(units).every(Number.isFinite)) return false; - return bodies.every((body) => { - if (!Number.isFinite(body.mass) || body.mass < 0) return false; + /** + * The first invalid mass, inertia, or force property, described by name — + * or undefined when everything is a usable number. The panel shows this + * sentence verbatim, so it has to say which part to go and fix. + */ + private static invalidProperty(bodies: Link[], units: UnitFactors): string | undefined { + if (!Object.values(units).every(Number.isFinite)) { + return 'The unit conversion is invalid — reselect the global units.'; + } + const nameOf = (body: Link): string => ('name' in body && body.name) || body.id; + for (const body of bodies) { + if (!Number.isFinite(body.mass) || body.mass < 0) { + return `Link ${nameOf(body)} has a mass that is not a usable number. Set Link Mass in Mass Settings.`; + } if (body instanceof RealLink) { - if (!Number.isFinite(body.massMoI) || body.massMoI < 0) return false; - return body.forces.every( - (force) => - Number.isFinite(force.mag) && - Number.isFinite(force.angleRad) && - Number.isFinite(force.startCoord.x) && - Number.isFinite(force.startCoord.y) - ); + if (!Number.isFinite(body.massMoI) || body.massMoI < 0) { + return `Link ${nameOf(body)} has a moment of inertia that is not a usable number. Set it in Mass Settings.`; + } + for (const force of body.forces) { + if ( + !Number.isFinite(force.mag) || + !Number.isFinite(force.angleRad) || + !Number.isFinite(force.startCoord.x) || + !Number.isFinite(force.startCoord.y) + ) { + return `The force on link ${nameOf(body)} has an invalid magnitude or position. Select it and re-enter its values.`; + } + } } - return true; - }); + } + return undefined; } private static kinematicsAreComplete( @@ -783,6 +913,7 @@ export class ForceSolver { const matrix = A.map((row, index) => [...row, b[index]]); const scales = A.map((row) => Math.max(...row.map(Math.abs), 0)); let rank = 0; + let minPivot = Infinity; for (let column = 0; column < n; column++) { let pivotRow = -1; @@ -794,16 +925,18 @@ export class ForceSolver { pivotRow = row; } } - // Near-toggle frames legitimately have extremely small pivots and very - // large reactions. Solve them, then let the normalized residual decide - // whether the result is usable; reject only an exact/non-finite pivot. + // Near-toggle frames legitimately have small pivots and large reactions; + // they solve, and the normalized residual judges the result. But a pivot + // within SINGULAR_PIVOT_TOLERANCE of zero is a toggle pose seen through + // round-off, and which browser's round-off must not decide the answer. if ( pivotRow < 0 || !Number.isFinite(matrix[pivotRow][column]) || - matrix[pivotRow][column] === 0 + pivotScore < SINGULAR_PIVOT_TOLERANCE ) { return undefined; } + minPivot = Math.min(minPivot, pivotScore); if (pivotRow !== column) { [matrix[column], matrix[pivotRow]] = [matrix[pivotRow], matrix[column]]; [scales[column], scales[pivotRow]] = [scales[pivotRow], scales[column]]; @@ -846,6 +979,6 @@ export class ForceSolver { solutionNorm = Math.max(solutionNorm, Math.abs(values[row])); } const residual = residualNorm / Math.max(1, matrixNorm * solutionNorm + rhsNorm); - return { values, rank, residual }; + return { values, rank, residual, minPivot }; } } diff --git a/src/app/model/mechanism/ic-solver.ts b/src/app/model/mechanism/ic-solver.ts deleted file mode 100644 index 98d0294b..00000000 --- a/src/app/model/mechanism/ic-solver.ts +++ /dev/null @@ -1,874 +0,0 @@ -import { Joint, PrisJoint, RealJoint } from '../joint'; -import { Link } from '../link'; -import { - FixedInstantCenter, - InstantCenter, - PermanentInstantCenter, - PrimaryInstantCenter, - SecondaryInstantCenter, -} from '../instant-center'; -import { crossProduct } from '../utils'; - -export class ICSolver { - private static usedJoints = Array<Boolean>(); - private static primary_ic_index_map = new Map<string, number>(); - private static link_index_map = new Map<string, number>(); - private static joint_loop_index_map = new Map<string, number>(); - private static link_index_to_primary_ic_index_map = new Map<number, Array<number>>(); - private static link_index_to_secondary_ic_index_map = new Map<number, Array<number>>(); - private static letter_map = new Map<string, string>(); - private static secondary_ic_index_map = new Map<number, Array<number>>(); - private static joint_ic_index_map = new Map<string, number>(); - private static ic_index_map = new Map<string, number>(); - - static linksAngVelMap = new Map<string, number>(); - static jointVelMap = new Map<string, [number, number]>(); - private static determineKinSetup = false; - - // Utilized for determining Kinematics for ICs - private static indexAndLoopToLinkIndexMap = new Map<string, number>(); - private static desiredJointIC = new Map<number, number>(); - private static prevLinkIndex = new Map<number, number>(); - private static desiredIC = new Map<number, number>(); - private static indexAndLoopToFirstICIndex = new Map<string, number>(); - private static IndexAndLoopToSecondICIndices = new Map<string, Array<Array<number>>>(); - private static prevJointIndex = new Map<number, number>(); - static instantCenterIndexMap = new Map<string, number>(); - - static resetVariables() { - this.usedJoints = Array<Boolean>(); - this.primary_ic_index_map = new Map<string, number>(); - this.link_index_map = new Map<string, number>(); - this.joint_loop_index_map = new Map<string, number>(); - this.link_index_to_primary_ic_index_map = new Map<number, Array<number>>(); - this.link_index_to_secondary_ic_index_map = new Map<number, Array<number>>(); - this.letter_map = new Map<string, string>(); - this.secondary_ic_index_map = new Map<number, Array<number>>(); - this.joint_ic_index_map = new Map<string, number>(); - this.ic_index_map = new Map<string, number>(); - this.determineKinSetup = false; - } - - static createICs(simJoints: Joint[], simLinks: Link[], requiredLoops: string[]) { - // First, find all primary instant centers - const primaryICs: Array<PrimaryInstantCenter> = []; - if (requiredLoops.length < 1) { - return []; - } - requiredLoops.forEach((loop) => { - // store all joint indices within map - for (let index = 0; index < loop.length; index++) { - if (!this.joint_loop_index_map.has(loop[index - 1])) { - this.setJointLoopIndexMap(loop, simJoints, index - 1); - } - if (!this.joint_loop_index_map.has(loop[index])) { - this.setJointLoopIndexMap(loop, simJoints, index); - } - if (!this.joint_loop_index_map.has(loop[index + 1])) { - this.setJointLoopIndexMap(loop, simJoints, index + 1); - } - } - for (let index = 1; index < loop.length - 1; index++) { - // joint from loop has already been accounted for within primary instant centers map - if (this.primary_ic_index_map.has(loop[index])) { - continue; - } - - const joint1 = simJoints[this.joint_loop_index_map.get(loop[index - 1])!]; - const joint2 = simJoints[this.joint_loop_index_map.get(loop[index])!]; - const joint3 = simJoints[this.joint_loop_index_map.get(loop[index + 1])!]; - - // store all link indices given by joint1 and joint2 id - if (!this.link_index_map.has(joint1.id + joint2.id)) { - this.setLinkIndexMap(joint1.id, joint2.id, simLinks); - } - if (!this.link_index_map.has(joint2.id + joint3.id)) { - this.setLinkIndexMap(joint2.id, joint3.id, simLinks); - } - const firstLinkIndex = this.link_index_map.get(joint1.id + joint2.id)!; - const secondLinkIndex = this.link_index_map.get(joint2.id + joint3.id)!; - let curr_ic: PrimaryInstantCenter; - if (!(joint2 instanceof RealJoint)) { - return; - } - // create a newly created instant center - if (joint2.ground) { - // FixedInstantCenter - switch (joint2.constructor) { - case RealJoint: - curr_ic = new FixedInstantCenter( - joint2.x, - joint2.y, - firstLinkIndex.toString() + ',' + secondLinkIndex.toString(), - [], - joint2.id - ); - break; - case PrisJoint: - let desired_angle: number; - if (joint1 instanceof PrisJoint) { - desired_angle = joint1.slotAngle; - } else if (joint3 instanceof PrisJoint) { - desired_angle = joint3.slotAngle; - } else { - return; - } - // TODO: should be infinity, infinity - curr_ic = new FixedInstantCenter( - Number.POSITIVE_INFINITY, - Number.POSITIVE_INFINITY, - firstLinkIndex.toString() + ',' + secondLinkIndex.toString(), - [], - joint2.id, - desired_angle - ); - break; - default: - return; - } - } else { - // PermanentInstantCenter - curr_ic = new PermanentInstantCenter( - joint2.x, - joint2.y, - firstLinkIndex.toString() + ',' + secondLinkIndex.toString(), - [], - joint2.id - ); - } - // if no IC has this link index, create an empty IC - if (!this.link_index_to_primary_ic_index_map.has(firstLinkIndex)) { - this.setLinkIndexToPrimaryICIndexMap(firstLinkIndex, []); - } - if (!this.link_index_to_primary_ic_index_map.has(secondLinkIndex)) { - this.setLinkIndexToPrimaryICIndexMap(secondLinkIndex, []); - } - // call all instant centers that utilize the desired link index - const all_i_primary_IC_indices = - this.link_index_to_primary_ic_index_map.get(firstLinkIndex)!; - const all_j_primary_IC_indices = - this.link_index_to_primary_ic_index_map.get(secondLinkIndex)!; - // set ics with common link ic as its neighbor - this.connectAdjacentICs(all_i_primary_IC_indices, primaryICs, curr_ic); - this.connectAdjacentICs(all_j_primary_IC_indices, primaryICs, curr_ic); - // include this primary ICs index into stored primary ics containing desired link - all_i_primary_IC_indices.push(primaryICs.length); - all_j_primary_IC_indices.push(primaryICs.length); - this.setLinkIndexToPrimaryICIndexMap(firstLinkIndex, all_i_primary_IC_indices); - this.setLinkIndexToPrimaryICIndexMap(secondLinkIndex, all_j_primary_IC_indices); - this.primary_ic_index_map.set(loop[index], primaryICs.length); - primaryICs.push(curr_ic); - } - }); - - // now determine all secondaryICs - const unknownSecondaryICArray: Array<Array<number>> = []; - for (let i = 1; i <= simLinks.length; i++) { - for (let j = i + 1; j <= simLinks.length + 1; j++) { - for (let k = 0; k < primaryICs.length; k++) { - if (primaryICs[k].id.includes(i.toString()) && primaryICs[k].id.includes(j.toString())) { - break; - } - if (k === primaryICs.length - 1) { - unknownSecondaryICArray.push([i, j]); - if (!this.link_index_to_secondary_ic_index_map.has(i)) { - this.setLinkIndexToSecondaryICIndexMap(i, []); - } - if (!this.link_index_to_secondary_ic_index_map.has(j)) { - this.setLinkIndexToSecondaryICIndexMap(j, []); - } - } - } - } - } - const unknownSecondaryIC_num = - ((simLinks.length + 1) * simLinks.length) / 2 - primaryICs.length; - const secondaryICs: Array<InstantCenter> = []; - let count = 0; - let more_determinable_secondary_ICs = true; - while (unknownSecondaryICArray.length !== 0 && more_determinable_secondary_ICs) { - for (let unknownIndex = 0; unknownIndex < unknownSecondaryICArray.length; unknownIndex++) { - // get unknown link indices - const [first_link_index, second_link_index] = unknownSecondaryICArray[unknownIndex]; - const all_ics_common_link_1: Array<InstantCenter> = []; - const all_ics_common_link_2: Array<InstantCenter> = []; - // get all primary ICs that utilize common link and store their index within all_ics_common_link - this.link_index_to_primary_ic_index_map - .get(first_link_index)! - .forEach((ic_index) => all_ics_common_link_1.push(primaryICs[ic_index])); - this.link_index_to_primary_ic_index_map - .get(second_link_index)! - .forEach((ic_index) => all_ics_common_link_2.push(primaryICs[ic_index])); - // get all secondary ICs that utilize common link and store their index within all_ics_common_link (if link index within secondary - // ic exist) - this.link_index_to_secondary_ic_index_map.get(first_link_index)!.forEach((ic_index) => { - const secICs = secondaryICs[ic_index]; - if (secICs === undefined) { - return; - } - all_ics_common_link_1.push(secondaryICs[ic_index]); - }); - this.link_index_to_secondary_ic_index_map - .get(second_link_index)! - .forEach((ic_index) => all_ics_common_link_2.push(secondaryICs[ic_index])); - - let firstCommonPath = ''; - let secondCommonPath = ''; - for (let k = 0; k < all_ics_common_link_1.length; k++) { - if ( - !this.letter_map.has(all_ics_common_link_1[k].id + ',' + first_link_index.toString()) - ) { - let link_index = all_ics_common_link_1[k].id; - link_index = link_index.replace(first_link_index.toString(), ''); - link_index = link_index.replace(',', ''); - this.letter_map.set( - all_ics_common_link_1[k].id + ',' + first_link_index.toString(), - link_index - ); - } - const letter = this.letter_map.get( - all_ics_common_link_1[k].id + ',' + first_link_index.toString() - )!; - for (let l = 0; l < all_ics_common_link_2.length; l++) { - if (!all_ics_common_link_2[l].id.includes(letter)) { - continue; - } - if (firstCommonPath === '') { - firstCommonPath += letter; - } else { - secondCommonPath += letter; - } - break; - } - if (secondCommonPath !== '') { - break; - } - } - if (secondCommonPath === '') { - // check here to see if there are no more possible secondary instant centers that can be determined - if (unknownIndex === unknownSecondaryICArray.length - 1) { - more_determinable_secondary_ICs = false; - break; - } - continue; - } - const ic1 = this.findIC(primaryICs, secondaryICs, firstCommonPath, first_link_index); - const ic2 = this.findIC(primaryICs, secondaryICs, firstCommonPath, second_link_index); - const ic3 = this.findIC(primaryICs, secondaryICs, secondCommonPath, first_link_index); - const ic4 = this.findIC(primaryICs, secondaryICs, secondCommonPath, second_link_index); - const [m1, b1] = this.determine_m_b(ic1, ic2); - const [m2, b2] = this.determine_m_b(ic3, ic4); - let x: number; - let y: number; - if (Math.abs(m1) > 9999999) { - x = ic1.x !== null ? ic1.x : ic2.x; - y = m2 * x + b2; - } else if (Math.abs(m2) > 9999999) { - x = ic3.x !== null ? ic3.x : ic4.x; - y = m1 * x + b1; - } else if (Math.abs(m1) < 0.001) { - y = ic1.y !== null ? ic1.y : ic2.y; - x = (y - b2) / m2; - } else if (Math.abs(m2) < 0.001) { - y = ic3.y !== null ? ic3.y : ic4.y; - x = (y - b1) / m1; - } else { - x = (b2 - b1) / (m1 - m2); - y = m1 * x + b1; - } - const newSecondaryIC = new SecondaryInstantCenter( - x, - y, - first_link_index.toString() + ',' + second_link_index.toString(), - [], - [ - [ic1.id, ic2.id], - [ic3.id, ic4.id], - ] - ); - const all_i: Array<InstantCenter> = []; - const all_j: Array<InstantCenter> = []; - // possible to include all this within certain hashmap ? - all_ics_common_link_1.forEach((ic) => { - all_i.push(ic); - ic.connectedICs.push(newSecondaryIC); - }); - all_ics_common_link_2.forEach((ic) => { - all_j.push(ic); - ic.connectedICs.push(newSecondaryIC); - }); - // - if (!this.secondary_ic_index_map.has(first_link_index)) { - this.secondary_ic_index_map.set(first_link_index, []); - } - if (!this.secondary_ic_index_map.has(second_link_index)) { - this.secondary_ic_index_map.set(second_link_index, []); - } - // TODO: Rethink and check to see if this is necessary - // const all_i_secondary = this.secondary_ic_index_map.get(first_link_index)!; - // const all_j_secondary = this.secondary_ic_index_map.get(second_link_index)!; - // all_i_secondary.forEach(ic => all_i.push(ic)); - // all_j_secondary.forEach(ic => all_j.push(ic)); - all_i.forEach((ic) => { - newSecondaryIC.connectedICs.push(ic); - }); - all_j.forEach((ic) => { - newSecondaryIC.connectedICs.push(ic); - }); - const all_i_secondary_IC_indices = - this.link_index_to_secondary_ic_index_map.get(first_link_index)!; - const all_j_secondary_IC_indices = - this.link_index_to_secondary_ic_index_map.get(second_link_index)!; - all_i_secondary_IC_indices.forEach((ic_index) => { - newSecondaryIC.connectedICs.push(secondaryICs[ic_index]); - secondaryICs[ic_index].connectedICs.push(newSecondaryIC); - }); - all_j_secondary_IC_indices.forEach((ic_index) => { - newSecondaryIC.connectedICs.push(secondaryICs[ic_index]); - secondaryICs[ic_index].connectedICs.push(newSecondaryIC); - }); - let curr_ic_indices: Array<number>; - curr_ic_indices = this.link_index_to_secondary_ic_index_map.get(first_link_index)!; - curr_ic_indices.push(secondaryICs.length); - this.setLinkIndexToSecondaryICIndexMap(first_link_index, curr_ic_indices); - curr_ic_indices = this.link_index_to_secondary_ic_index_map.get(second_link_index)!; - curr_ic_indices.push(secondaryICs.length); - this.setLinkIndexToSecondaryICIndexMap(second_link_index, curr_ic_indices); - - const unk_sec_index = unknownSecondaryICArray.findIndex( - (ic) => ic === unknownSecondaryICArray[unknownIndex] - ); - secondaryICs.push(newSecondaryIC); - unknownSecondaryICArray.splice(unk_sec_index, 1); - count++; - break; - } - } - const ICs: Array<InstantCenter> = []; - primaryICs.forEach((ic) => { - ICs.push(ic); - this.instantCenterIndexMap.set(ic.id, ICs.length - 1); - }); - secondaryICs.forEach((ic) => { - ICs.push(ic); - this.instantCenterIndexMap.set(ic.id, ICs.length - 1); - }); - return ICs; - } - - private static determine_m_b(ic1: any, ic2: any) { - const ninetyDegreesToRad = (90 * Math.PI) / 180; - let m: number; - if (ic1.x === null) { - m = Math.tan(ninetyDegreesToRad - ic1.angle); - } else if (ic2.x === null) { - m = Math.tan(ninetyDegreesToRad - ic2.angle); - } else { - m = (ic2.y - ic1.y) / (ic2.x - ic1.x); - } - const b = ic1.y - ic1.x * m; - return [m, b]; - } - - private static findIC(primaryICs: any[], secondaryICs: any[], l1: any, l2: any) { - const ic1 = primaryICs.find((ic) => ic.id.includes(l1) && ic.id.includes(l2)); - if (ic1 !== undefined) { - return ic1; - } - return secondaryICs.find((ic) => ic.id.includes(l1) && ic.id.includes(l2)); - } - - static determineICPos(instantCenters: any[], simJoints: Joint[]) { - const secondaryICs: Array<SecondaryInstantCenter> = []; - instantCenters.forEach((ic) => { - // do not want to do this for FixedInstantCenter with an angle (slider) - if ( - ic instanceof PermanentInstantCenter || - (ic instanceof FixedInstantCenter && ic.x !== null) - ) { - if (!this.joint_ic_index_map.has(ic.jointID)) { - this.joint_ic_index_map.set( - ic.jointID, - simJoints.findIndex((j) => j.id === ic.jointID) - ); - } - const joint = simJoints[this.joint_ic_index_map.get(ic.jointID)!]; - ic.x = joint.x; - ic.y = joint.y; - } else if (ic instanceof SecondaryInstantCenter) { - // instance of SecondaryInstantCenters - if (!this.ic_index_map.has(ic.desired_ICs[0][0])) { - this.ic_index_map.set( - ic.desired_ICs[0][0], - instantCenters.findIndex((curr_ic) => curr_ic.id.includes(ic.desired_ICs[0][0])) - ); - } - if (!this.ic_index_map.has(ic.desired_ICs[0][1])) { - this.ic_index_map.set( - ic.desired_ICs[0][1], - instantCenters.findIndex((curr_ic) => curr_ic.id.includes(ic.desired_ICs[0][1])) - ); - } - if (!this.ic_index_map.has(ic.desired_ICs[1][0])) { - this.ic_index_map.set( - ic.desired_ICs[1][0], - instantCenters.findIndex((curr_ic) => curr_ic.id.includes(ic.desired_ICs[1][0])) - ); - } - if (!this.ic_index_map.has(ic.desired_ICs[1][1])) { - this.ic_index_map.set( - ic.desired_ICs[1][1], - instantCenters.findIndex((curr_ic) => curr_ic.id.includes(ic.desired_ICs[1][1])) - ); - } - const ic1 = instantCenters[this.ic_index_map.get(ic.desired_ICs[0][0])!]; - const ic2 = instantCenters[this.ic_index_map.get(ic.desired_ICs[0][1])!]; - const ic3 = instantCenters[this.ic_index_map.get(ic.desired_ICs[1][0])!]; - const ic4 = instantCenters[this.ic_index_map.get(ic.desired_ICs[1][1])!]; - const [m1, b1] = this.determine_m_b(ic1, ic2); - const [m2, b2] = this.determine_m_b(ic3, ic4); - const x = (b2 - b1) / (m1 - m2); - const y = m1 * x + b1; - ic.x = x; - ic.y = y; - secondaryICs.push(ic); - } - }); - return secondaryICs; - } - - private static setJointLoopIndexMap(loop: string, simJoints: Joint[], number: number) { - this.joint_loop_index_map.set( - loop[number], - simJoints.findIndex((j) => j.id === loop[number]) - ); - } - - private static setLinkIndexToPrimaryICIndexMap(linkIndex: number, primaryICs: any[]) { - this.link_index_to_primary_ic_index_map.set(linkIndex, primaryICs); - } - - private static setLinkIndexToSecondaryICIndexMap(linkIndex: number, secondaryICs: any[]) { - this.link_index_to_secondary_ic_index_map.set(linkIndex, secondaryICs); - } - - private static setLinkIndexMap(joint1_id: string, joint2_id2: string, simLinks: Link[]) { - this.link_index_map.set( - joint1_id + joint2_id2, - simLinks.findIndex((l) => l.id.includes(joint1_id) && l.id.includes(joint2_id2)) + 2 - ); - } - - private static connectAdjacentICs( - all_num_primary_IC_indices: Array<number>, - primaryICs: any[], - curr_ic: PrimaryInstantCenter - ) { - all_num_primary_IC_indices.forEach((ic_index) => { - curr_ic.connectedICs.push(primaryICs[ic_index]); - primaryICs[ic_index].connectedICs.push(curr_ic); - }); - // is this needed?? - // return [curr_ic, primaryICs]; - } - - static determineKinDataICs( - SimulationJoints: Joint[], - SimulationLinks: Link[], - SimulationInstantCenters: InstantCenter[], - requiredLoops: string[], - inputAngularVelocity: number - ) { - if (!this.determineKinSetup) { - // const utilizedLinksIndexMap = new Map<string, number>(); - const simICNumToIcIndexMap = new Map<number, number>(); - for (let i = 2; i <= SimulationLinks.length + 1; i++) { - const ic_index = SimulationInstantCenters.findIndex( - (ic) => ic.id.includes('1') && ic.id.includes(i.toString()) - ); - simICNumToIcIndexMap.set(i, ic_index); - } - const twoJointIDsDeterminedMap = new Map<string, number>(); - const linkIDToLinkIndexMap = new Map<string, number>(); - // Determine linear velocity - for (let i = 0; i < SimulationJoints.length; i++) { - const joint = SimulationJoints[i]; - if (!(joint instanceof RealJoint)) { - return; - } - if (joint.ground) { - this.jointVelMap.set(joint.id, [0, 0]); - } - } - // 1. first set input link's angular velocity = inputVelocity - requiredLoops.forEach((loop) => { - // let temp_counter = 1; - for (let index = 1; index < loop.length - 1; index++) { - const first_joint_id = loop[index - 1]; - const second_joint_id = loop[index]; - if (twoJointIDsDeterminedMap.has(first_joint_id + second_joint_id)) { - // link has already been accounted for - this.indexAndLoopToFirstICIndex.set(index + loop, -1); - continue; // should have no need be done here - } - const link_index = SimulationLinks.findIndex( - (l) => l.id.includes(first_joint_id) && l.id.includes(second_joint_id) - ); - twoJointIDsDeterminedMap.set(first_joint_id + second_joint_id, link_index); - if (linkIDToLinkIndexMap.has(SimulationLinks[link_index].id)) { - // link has already been accounted for - this.indexAndLoopToFirstICIndex.set(index + loop, -1); - continue; // should have no need be done here - } - linkIDToLinkIndexMap.set(SimulationLinks[link_index].id, link_index); - this.indexAndLoopToLinkIndexMap.set(index + loop, link_index); - // determine the first instant center - let first_instant_center_index: number; - if (index !== loop.length - 2) { - first_instant_center_index = SimulationInstantCenters.findIndex((ic) => { - if (!(ic instanceof PrimaryInstantCenter)) { - return; - } - return ic.jointID === first_joint_id; - }); - } else { - first_instant_center_index = SimulationInstantCenters.findIndex((ic) => { - if (!(ic instanceof PrimaryInstantCenter)) { - return; - } - return ic.jointID === second_joint_id; - }); - } - this.indexAndLoopToFirstICIndex.set(index + loop, first_instant_center_index); - - // determine the second set of instant centers to utilize - // const utilized_joint_indices = Array<Array<number>>(); - const utilized_instant_centers_indices = []; - let second_initial_instant_center_index: number; - switch (index) { - case 1: - second_initial_instant_center_index = SimulationInstantCenters.findIndex((ic) => { - if (!(ic instanceof PrimaryInstantCenter)) { - return; - } - return ic.jointID === second_joint_id; - }); - utilized_instant_centers_indices.push([second_initial_instant_center_index]); - // determine if there are any other joints to the input link (check if joint is connected to two links) - SimulationLinks[link_index].joints.forEach((jt) => { - // if (jt.id !== first_joint_id && jt.id !== second_joint_id && jt.connectedLinks.length > 1) { - if (jt.id !== first_joint_id && jt.id !== second_joint_id) { - const additional_instant_center_index = SimulationInstantCenters.findIndex( - (ic) => { - if (!(ic instanceof PrimaryInstantCenter)) { - return; - } - return ic.jointID === jt.id; - } - ); - utilized_instant_centers_indices.push([additional_instant_center_index]); - } - }); - this.IndexAndLoopToSecondICIndices.set( - index + loop, - utilized_instant_centers_indices - ); - break; - case loop.length - 2: // last link - second_initial_instant_center_index = SimulationInstantCenters.findIndex((ic) => { - if (!(ic instanceof PrimaryInstantCenter)) { - return; - } - return ic.jointID === first_joint_id; - }); - utilized_instant_centers_indices.push([second_initial_instant_center_index]); - // determine if there are any other joints to the link - SimulationLinks[link_index].joints.forEach((jt) => { - // if (jt.id !== first_joint_id && jt.id !== second_joint_id && jt.connectedLinks.length > 1) { - if (jt.id !== first_joint_id && jt.id !== second_joint_id) { - const additional_instant_center_index = SimulationInstantCenters.findIndex( - (ic) => { - if (!(ic instanceof PrimaryInstantCenter)) { - return; - } - { - return ic.jointID === jt.id; - } - } - ); - utilized_instant_centers_indices.push([additional_instant_center_index]); - } - }); - this.IndexAndLoopToSecondICIndices.set( - index + loop, - utilized_instant_centers_indices - ); - break; - default: { - second_initial_instant_center_index = SimulationInstantCenters.findIndex((ic) => { - if (!(ic instanceof PrimaryInstantCenter)) { - return; - } - return ic.jointID === second_joint_id; - }); - let similar_instant_center_index: number; - let similar_ic_num: number; - // get the instant center that the first two instant centers have in common - if ( - SimulationInstantCenters[second_initial_instant_center_index].id.includes( - SimulationInstantCenters[first_instant_center_index].id[0] - ) - ) { - similar_ic_num = Number(SimulationInstantCenters[first_instant_center_index].id[0]); - } else { - similar_ic_num = Number(SimulationInstantCenters[first_instant_center_index].id[2]); - } - similar_instant_center_index = simICNumToIcIndexMap.get(similar_ic_num)!; - utilized_instant_centers_indices.push([ - second_initial_instant_center_index, - similar_instant_center_index, - ]); - // determine if there are any other joints to the input link (check if joint is connected to two links) - SimulationLinks[link_index].joints.forEach((jt) => { - // if (jt.id !== first_joint_id && jt.id !== second_joint_id && jt.connectedLinks.length > 1) { - if (jt.id !== first_joint_id && jt.id !== second_joint_id) { - const additional_instant_center_index = SimulationInstantCenters.findIndex( - (ic) => { - if (!(ic instanceof PrimaryInstantCenter)) { - return; - } // this won't account for tracer joints! - return ic.jointID === jt.id; - } - ); - // get the instant center that the first two instant centers have in common - // - if ( - SimulationInstantCenters[additional_instant_center_index].id[0] === - SimulationInstantCenters[first_instant_center_index].id[0] - ) { - similar_ic_num = Number( - SimulationInstantCenters[first_instant_center_index].id[0] - ); - } else { - similar_ic_num = Number( - SimulationInstantCenters[first_instant_center_index].id[2] - ); - } - similar_instant_center_index = simICNumToIcIndexMap.get(similar_ic_num)!; - utilized_instant_centers_indices.push([ - additional_instant_center_index, - similar_instant_center_index, - ]); - // utilized_instant_centers_indices.push([additional_instant_center_index, 0]); // doesn't matter what 2nd argument is - } - }); - this.IndexAndLoopToSecondICIndices.set( - index + loop, - utilized_instant_centers_indices - ); - break; - } - } - } - }); - this.determineKinSetup = true; - } - - requiredLoops.forEach((loop) => { - for (let counter = 1; counter < loop.length - 1; counter++) { - const first_ic_index = this.indexAndLoopToFirstICIndex.get(counter + loop)!; - const first_ic = SimulationInstantCenters[first_ic_index]; - if (first_ic_index === -1) { - continue; - } - const second_ics_indices = this.IndexAndLoopToSecondICIndices.get(counter + loop)!; - const desired_link_index = this.indexAndLoopToLinkIndexMap.get(counter + loop)!; - const desired_link = SimulationLinks[desired_link_index]; - switch (counter) { - case 1: - this.linksAngVelMap.set(desired_link.id, inputAngularVelocity); - // utilize input angular velocity to determine velocity of the other links - second_ics_indices.forEach((second_ic_index) => { - const vel = crossProduct( - [0, 0, inputAngularVelocity], - [ - SimulationInstantCenters[second_ic_index[0]].x - first_ic.x, - SimulationInstantCenters[second_ic_index[0]].y - first_ic.y, - 0, - ] - ); - const second_ic_loop = SimulationInstantCenters[ - second_ic_index[0] - ] as PrimaryInstantCenter; - this.jointVelMap.set(second_ic_loop.jointID, [vel[0], vel[1]]); - // this.jointVelMap.set(SimulationJoints[0].id, [vel[0], vel[1]]); - }); - break; - case loop.length - 2: - // get the velocity of the first joint and utilize that to determine angular velocity of the link - // const second_ic = SimulationInstantCenters[second_ics_indices[0][0]] as PrimaryInstantCenter; - const second_ic = SimulationInstantCenters[ - second_ics_indices[0][0] - ] as PrimaryInstantCenter; - const determined_vel = this.jointVelMap.get(second_ic.jointID)!; - // This equation is gotten based from the cross product v = w X R - const angular_vel = -determined_vel[0] / (second_ic.y - first_ic.y); - this.linksAngVelMap.set(desired_link.id, angular_vel); - // utilize the angular velocity determined to determine the velocity of the other joints attached to the link - second_ics_indices.forEach((ic_index_array) => { - if (ic_index_array === second_ics_indices[0]) { - return; - } - const connecting_ic = SimulationInstantCenters[ - ic_index_array[0] - ] as PrimaryInstantCenter; - const vel = crossProduct( - [0, 0, inputAngularVelocity], - [connecting_ic.x - first_ic.x, connecting_ic.y - first_ic.y, 0] - ); - this.jointVelMap.set(connecting_ic.jointID, [vel[0], vel[1]]); - // const joint_index = SimulationJoints.findIndex(jt => jt.id === connecting_ic.jointID); - // this.jointVelMap.set(SimulationJoints[joint_index].id, [vel[0], vel[1]]); - }); - break; - default: - // get the velocity of the first joint and utilize that to determine angular velocity of the link - const second_ic_helpful_secondary_ic = - SimulationInstantCenters[second_ics_indices[0][1]]; - if (!(first_ic instanceof PrimaryInstantCenter)) { - return; - } - const utilized_vel = this.jointVelMap.get(first_ic.jointID)!; - // This equation is gotten based from the cross product v = w X R - // first_ic is ground while second_useful one is a joint where we know the velocity - const angular_vel_ = -utilized_vel[0] / (first_ic.y - second_ic_helpful_secondary_ic.y); - this.linksAngVelMap.set(desired_link.id, angular_vel_); - // utilize the angular velocity determined to determine the velocity of the other joints attached to the link - // TODO: Account for tracer joints within all cases (probably copy and paste logic below) - const determined_tracer_joints: Array<string> = []; - second_ics_indices.forEach((ic_index_array) => { - // if (ic_index_array === second_ics_indices[0]) { - // return; - // } - if (ic_index_array[0] === -1) { - // find the tracer joints and determine their velocities - const undetermined_tracer_joint = desired_link.joints.find((jt) => { - if (!(jt instanceof RealJoint)) { - return; - } - // TODO: change the naming so it's obvious that j is a string and not a joint - return ( - jt.links.length !== 2 && - determined_tracer_joints.findIndex((j) => { - return j === jt.id; - }) === -1 - ); - }); - if (undetermined_tracer_joint === undefined) { - return; - } - determined_tracer_joints.push(undetermined_tracer_joint.id); - const vel = crossProduct( - [0, 0, angular_vel_], - [ - undetermined_tracer_joint.x - first_ic.x, - undetermined_tracer_joint.y - first_ic.y, - 0, - ] - ); - // const joint_index = SimulationJoints.findIndex(jt => jt.id === undetermined_tracer_joint.id); - this.jointVelMap.set(undetermined_tracer_joint.id, [vel[0], vel[1]]); - } else { - const second_ic_joint = SimulationInstantCenters[ - ic_index_array[0] - ] as PrimaryInstantCenter; - const second_ic_common_ic = SimulationInstantCenters[ - ic_index_array[1] - ] as SecondaryInstantCenter; - const vel = crossProduct( - [0, 0, angular_vel_], - [ - second_ic_joint.x - second_ic_common_ic.x, - second_ic_joint.y - second_ic_common_ic.y, - 0, - ] - ); - // const vel = cross([0, 0, angular_vel_], [second_ic_joint.x - first_ic.x, second_ic_joint.y - first_ic.y, 0]); - this.jointVelMap.set(second_ic_joint.jointID, [vel[0], vel[1]]); - } - // else { - // const ic_sec = SimulationInstantCenters[ic_index_array[1]] as SecondaryInstantCenter; - // const ic_prim = SimulationInstantCenters[ic_index_array[0]] as PrimaryInstantCenter; - // const vel = cross([0, 0, angular_vel_], [first_ic.x - ic_sec.x, first_ic.y - ic_sec.y, 0]); - // const joint_index = SimulationJoints.findIndex(jt => jt.id === ic_prim.jointID); - // this.jointVelMap.set(SimulationJoints[joint_index].id, [vel[0], vel[1]]); - // } - }); - break; - } - } - // determine angular velocity of link - // set up angular velocity of link connected to angular velocity - // first, determine link's angular velocity connected to input joint and determine connecting joint's velocities - // const input_joint_id = loop[0]; - // const connected_joint_id = loop[1]; - // const input_joint = SimulationJoints.find(j => j.id.includes(input_joint_id)); - // const connected_joint = SimulationJoints.find(j => j.id.includes(connected_joint_id)); - // const connected_link_index = SimulationLinks.findIndex(l => l.id.includes(input_joint_id) && l.id.includes(connected_joint_id)); - // this.linksAngVelMap.set(SimulationLinks[connected_link_index].id, inputAngularVelocity); - // const vel_input = cross([0, 0, inputAngularVelocity], [connected_joint.x - input_joint.x, - // connected_joint.y - input_joint.y, 0]); - // this.jointVelMap.set(SimulationJoints[connected_link_index].id, [vel_input[0], vel_input[1]]); - // - // const num_unknowns = SimulationLinks.length - 1; - // let counter = 1; - // while (counter <= num_unknowns) { - // const indexAndLoopToLinkIndexMap = this.indexAndLoopToLinkIndexMap.get(counter); - // const prev_link_index = this.prevLinkIndex.get(counter); - // const desired_ic_index = this.desiredIC.get(counter); - // // const desired_ic_index = this.desiredJointIC.get(counter); - // const first_ic_index = this.indexAndLoopToFirstICIndex.get(counter); - // const second_ic_index = this.IndexAndLoopToSecondICIndices.get(counter); - // - // const prev_link = SimulationLinks[prev_link_index]; - // const desired_ic = SimulationInstantCenters[desired_ic_index]; - // // const desired_joint = SimulationJoints.find(jt => jt.id === desired_ic.id); - // - // // const desired_joint = SimulationJoints.find(jt => jt.id === desired_ic.jointID); - // const first_ic = SimulationInstantCenters[first_ic_index]; - // // const known_joint = SimulationJoints.find(jt => jt.id === first_ic.id); - // // const known_joint = SimulationJoints.find(jt => jt.id === first_ic.jointID); - // const second_ic = SimulationInstantCenters[second_ic_index]; - // - // const prev_link_ang_vel = this.linksAngVelMap.get(prev_link.id); - // const ang_vel = this.determineAngVelIC(prev_link_ang_vel, desired_ic, first_ic, second_ic); - // this.linksAngVelMap.set(SimulationLinks[indexAndLoopToLinkIndexMap].id, ang_vel); - // const used_first_ic = first_ic as PrimaryInstantCenter; - // const known_joint = SimulationJoints.find(jt => jt.id === used_first_ic.jointID); - // const vel = cross([0, 0, ang_vel], [desired_ic.x - first_ic.x, desired_ic.y - first_ic.y, 0]); - // const known_joint_vel_x = this.jointVelMap.get(known_joint.id)[0]; - // const known_joint_vel_y = this.jointVelMap.get(known_joint.id)[1]; - // this.jointVelMap.set(SimulationJoints[0].id, [vel[0] + known_joint_vel_x, vel[1] + known_joint_vel_y]); - // - // counter++; - // } - }); - } - - private static determineAngVelIC( - w: number, - IC1: InstantCenter, - IC2: InstantCenter, - IC3: InstantCenter - ) { - // utilize cross product for w and IC2 - IC1 - const vel1 = crossProduct([0, 0, w], [IC2.x - IC1.x, IC2.y - IC1.y, 0]); - // utilize cross product for w and IC3 - IC2 - const vel2 = crossProduct([0, 0, 1], [IC3.x - IC2.x, IC3.y - IC2.y, 0]); - // eq = vel1(1) == vel2(1); - // utilize math.js to determine unknown angular velocity - return (-1 * vel1[0]) / vel2[0]; - // return lusolve(vel2[0], -1 * vel1[0]); - // return value - } -} diff --git a/src/app/model/mechanism/kinematic-solver.ts b/src/app/model/mechanism/kinematic-solver.ts index 5ad89c5e..edda926e 100644 --- a/src/app/model/mechanism/kinematic-solver.ts +++ b/src/app/model/mechanism/kinematic-solver.ts @@ -672,7 +672,6 @@ export class KinematicsSolver { X = matLinearSystem(this.A_matrix_AngAcc, this.B_matrix_AngAcc); break; } - // const X = lusolve(this.A_matrix_AngVel, this.B_matrix_AngVel); // 3rd, store unknown values to respected links for (let i = 0; i < X.length; i++) { const linkOrJoint = unknownLinks[i]; @@ -720,7 +719,6 @@ export class KinematicsSolver { } private static determineLin(simJoints: Joint[], simLinks: Link[]) { - // this.setUpLinkAndJointIndexMap(simJoints, simLinks, requiredLoops); const desired_links_used: Array<string> = []; this.requiredLoops.forEach((loop) => { for (const edge of loop.edges) { @@ -748,10 +746,17 @@ export class KinematicsSolver { } const firstJoint = simJoints[this.jointIndexMap.get(edge.fromId)!]; // determine the velocity/accel of each link's joint that is not the first joint - for (let index = 0; index < desiredLink.id.length; index++) { - const joint_id = desiredLink.id[index]; + // + // Over the link's own joints, not over the characters of its id. A link + // id is its joints' names run together, so reading it back a character + // at a time asked for a joint called "A" and one called "1" where the + // link holds "A1" -- and then dereferenced whatever came back. Every id + // longer than a character reaches this: the ones inside a cylinder, and + // the two-letter names a drawing is given past its fifty-second joint. + for (const linkJoint of desiredLink.joints) { + const joint_id = linkJoint.id; const desiredJoint = simJoints[this.jointIndexMap.get(joint_id)!]; - if (joint_id === firstJoint.id) { + if (joint_id === firstJoint.id || !desiredJoint) { continue; } // determine the distance for the left side of the equation @@ -932,8 +937,8 @@ export class KinematicsSolver { simJoints: Joint[], simLinks: Link[], analysisType: string - ): any[] { - const unknownLinksOrJoints: Array<any> = []; + ): Array<Link | Joint> { + const unknownLinksOrJoints: Array<Link | Joint> = []; // first, determine variable locations (X) this.requiredLoops.forEach((loop) => { for (const edge of loop.edges) { @@ -1068,12 +1073,8 @@ export class KinematicsSolver { break; case SliderBlock: const realJoint = simJoints[this.realJointIndexMap.get(link.id)!]; - // if (!(realJoint instanceof PrisJoint)) { - // return; - // } const desiredAngle = this.desiredAngleMap.get(realJoint.id)!; arr = [-Math.cos(desiredAngle), -Math.sin(desiredAngle), 0]; - // arr = [-Math.cos(realJoint.slotAngle), -Math.sin(realJoint.slotAngle), 0]; colIndex = this.unknownLinkIndexMap.get(realJoint.id)!; break; default: @@ -1141,11 +1142,7 @@ export class KinematicsSolver { break; case SliderBlock: const realJoint = simJoints[this.realJointIndexMap.get(link.id)!]; - // if (!(realJoint instanceof PrisJoint)) { - // return; - // } const desiredAngle = this.desiredAngleMap.get(realJoint.id)!; - // arr = [-Math.cos(desiredAngle), -Math.sin(desiredAngle), 0]; sol = [-Math.cos(desiredAngle), -Math.sin(desiredAngle)]; colIndex = this.unknownLinkIndexMap.get(realJoint.id)!; break; @@ -1196,7 +1193,7 @@ export class KinematicsSolver { return rate ? [-rate[0], -rate[1], 0] : [Number.NaN, Number.NaN, 0]; } - private static addTwoArrays(first_array: any, second_array: any) { + private static addTwoArrays(first_array: number[], second_array: number[]) { return first_array.map(function (num: number, idx: number) { // may have to caste num and second_array[idx] return num + second_array[idx]; diff --git a/src/app/model/mechanism/loop-solver.ts b/src/app/model/mechanism/loop-solver.ts index 5bf1b9b0..802d5f7a 100644 --- a/src/app/model/mechanism/loop-solver.ts +++ b/src/app/model/mechanism/loop-solver.ts @@ -92,23 +92,25 @@ export class LoopSolver { } }); + const neighbourCache = new Map<string, Neighbour[]>(); while (groundJoints.length >= 2) { const desiredGround = groundJoints.shift()!; if (!(desiredGround instanceof RealJoint)) { continue; } - this.neighboursOf(desiredGround, slotNeighbours).forEach((next) => { + this.neighboursOf(desiredGround, slotNeighbours, neighbourCache).forEach((next) => { this.findGround( next.joint, groundJoints, - [next.joint.id], + new Set([next.joint.id]), [ { jointId: desiredGround.id }, { jointId: next.joint.id, viaSliderId: next.viaSliderId }, ], loops, links, - slotNeighbours + slotNeighbours, + neighbourCache ); }); } @@ -243,14 +245,27 @@ export class LoopSolver { return adjacency; } + /** + * Who this joint is connected to, worked out once per enumeration. + * + * The walk below visits the same joint on thousands of different paths, and + * this answer is the same every time -- it depends only on the drawing. Built + * fresh, it was a fifth of the time spent enumerating a forty-five joint + * linkage's loops, and most of its garbage. + */ private static neighboursOf( joint: RealJoint, - slotNeighbours: Map<string, Neighbour[]> + slotNeighbours: Map<string, Neighbour[]>, + cache?: Map<string, Neighbour[]> ): Neighbour[] { + const known = cache?.get(joint.id); + if (known) return known; const linked = joint.connectedJoints .filter((candidate): candidate is RealJoint => candidate instanceof RealJoint) .map((candidate) => ({ joint: candidate }) as Neighbour); - return [...linked, ...(slotNeighbours.get(joint.id) ?? [])]; + const all = [...linked, ...(slotNeighbours.get(joint.id) ?? [])]; + cache?.set(joint.id, all); + return all; } /** @@ -283,22 +298,31 @@ export class LoopSolver { ); } - /** Walk outward until another ground joint is reached. */ + /** + * Walk outward until another ground joint is reached. + * + * The walk backtracks in place: `visited` and `path` are one set and one + * array pushed and popped as it descends, rather than a fresh copy of each + * per branch. On a linkage the size of a running horse this recursion visits + * hundreds of thousands of nodes, and copying two growing arrays at every one + * of them was most of the cost of opening the drawing at all. + */ private static findGround( joint: Joint, groundJoints: Joint[], - visited: string[], + visited: Set<string>, path: PathStep[], loops: Loop[], links: Link[], - slotNeighbours: Map<string, Neighbour[]> + slotNeighbours: Map<string, Neighbour[]>, + neighbourCache: Map<string, Neighbour[]> ): void { if (!(joint instanceof RealJoint)) { return; } - for (const next of this.neighboursOf(joint, slotNeighbours)) { + for (const next of this.neighboursOf(joint, slotNeighbours, neighbourCache)) { const j = next.joint; - if (visited.includes(j.id)) { + if (visited.has(j.id)) { continue; } const step: PathStep = { jointId: j.id, viaSliderId: next.viaSliderId }; @@ -306,20 +330,27 @@ export class LoopSolver { if (groundJoints.indexOf(j) === -1) { continue; } - const edges = this.edgesAlong([...path, step], links); + path.push(step); + const edges = this.edgesAlong(path, links); + path.pop(); if (edges) { loops.push({ id: loopId(edges), edges }); } } else { + visited.add(j.id); + path.push(step); this.findGround( j, groundJoints, - [...visited, j.id], - [...path, step], + visited, + path, loops, links, - slotNeighbours + slotNeighbours, + neighbourCache ); + path.pop(); + visited.delete(j.id); } } } diff --git a/src/app/model/mechanism/mechanism-partition.ts b/src/app/model/mechanism/mechanism-partition.ts new file mode 100644 index 00000000..8ec67be4 --- /dev/null +++ b/src/app/model/mechanism/mechanism-partition.ts @@ -0,0 +1,252 @@ +import { Joint, PrisJoint, RealJoint } from '../joint'; +import { Link } from '../link'; +import { Force } from '../force'; +import { assignBodies, WORLD } from './bodies'; + +/** One independently solvable machine within the drawing. */ +export interface MechanismPartition { + /** Derived, never stored: M1, M2… in a stable order. See `partitionMechanisms`. */ + id: string; + /** Everything the solver has to be handed, frame pieces included. */ + joints: Joint[]; + /** + * The joints this mechanism is actually made of. + * + * A subset of `joints`: a machine has to be solved against frame it shares + * with others — a fixed bar between two frames, a rail a slider runs along — + * and the far ends of those pieces belong to whatever else is bolted to them. + * Ask this, not `joints`, whenever the question is "which of these is *mine*" + * — which input drives me, which joint do I own. + */ + ownJoints: Joint[]; + links: Link[]; + forces: Force[]; +} + +/** + * Geometry that belongs to no mechanism, and why. + * + * Split by cause rather than lumped together, because the two have different + * ways out: a floating chain needs grounding, a loose joint needs connecting. + */ +export interface UnassignedGeometry { + /** Chains of real bodies that never reach ground, so they have no solvable position. */ + floatingChains: { joints: Joint[]; links: Link[] }[]; + /** Joints with no link at all. */ + looseJoints: Joint[]; + /** Bars pinned down at every joint: part of the frame, and part of no machine. */ + fixedLinks: Link[]; +} + +export interface Partitioning { + mechanisms: MechanismPartition[]; + unassigned: UnassignedGeometry; +} + +/** Sort ids the way the alphabet does, with longer ids after the letters they extend. */ +function byJointId(a: string, b: string): number { + return a.length === b.length ? a.localeCompare(b) : a.length - b.length; +} + +/** + * Split a drawing into the machines it actually contains. + * + * Two bodies belong to the same mechanism when a joint holds them to each + * other. Ground is not such a joint: it anchors what meets it without joining + * those things to one another, so two cranks pinned to the same fixed point + * turn independently and are two mechanisms, not one. Were ground allowed to + * connect, every grounded chain in the drawing would collapse into a single + * component and the whole distinction would be lost. + * + * A component that reaches ground is a mechanism — even one whose mobility is + * wrong, because "your mechanism is 2-DoF" is a far more useful thing to say + * than "you have no mechanism". A component that never reaches ground has no + * solvable position at all, so it is not a mechanism yet and is reported as + * unassigned instead. + */ +export function partitionMechanisms( + joints: Joint[], + links: Link[], + forces: Force[] = [] +): Partitioning { + const { bodyOf, bodiesAt, movingBodies } = assignBodies(joints, links); + + const parent = new Map<string, string>(); + movingBodies.forEach((body) => parent.set(body, body)); + const find = (id: string): string => { + let root = id; + while (parent.get(root) !== root) { + root = parent.get(root)!; + } + return root; + }; + const union = (left: string, right: string) => parent.set(find(left), find(right)); + + const realJoints = joints.filter((joint): joint is RealJoint => joint instanceof RealJoint); + + // A grounded joint anchors; it does not connect. Everything else joins the + // moving bodies that meet at it into one machine. + realJoints + .filter((joint) => !joint.ground) + .forEach((joint) => { + const moving = [...bodiesAt(joint)].filter((body) => parent.has(body)); + moving.slice(1).forEach((body) => union(moving[0], body)); + }); + + const groundedComponents = new Set<string>(); + realJoints.forEach((joint) => { + const bodies = bodiesAt(joint); + if (!bodies.has(WORLD)) { + return; + } + bodies.forEach((body) => { + if (parent.has(body)) { + groundedComponents.add(find(body)); + } + }); + }); + + // `joints` is everything the solver has to be handed; `owned` is the subset + // this component is actually made of. They differ because a mechanism needs + // frame pieces -- an anchored bar its slider runs along, a fixed link between + // two frames -- whose far ends belong to some other machine entirely. Without + // the distinction those far ends look like this component's own joints, and a + // second linkage's driven joint gets picked up as this one's input. + const members = new Map<string, { joints: Set<Joint>; owned: Set<Joint>; links: Set<Link> }>(); + const memberOf = (component: string) => { + let entry = members.get(component); + if (!entry) { + entry = { joints: new Set(), owned: new Set(), links: new Set() }; + members.set(component, entry); + } + return entry; + }; + + const worldLinks: Link[] = []; + links.forEach((link) => { + const body = bodyOf(link); + if (body === WORLD) { + worldLinks.push(link); + return; + } + const entry = memberOf(find(body)); + entry.links.add(link); + link.joints.forEach((joint) => { + entry.joints.add(joint); + entry.owned.add(joint); + }); + }); + + // An anchored bar is part of the world, which every machine shares. It is + // handed to each component it touches rather than to one of them, so a + // mechanism is never solved against a frame that is missing a piece. + worldLinks.forEach((link) => { + members.forEach((entry) => { + if (link.joints.some((joint) => entry.joints.has(joint))) { + entry.links.add(link); + link.joints.forEach((joint) => entry.joints.add(joint)); + } + }); + }); + + // A slot's carrier and the two joints that draw its line live outside + // `links` and `connectedJoints` (§2.3 Option A), so nothing above reaches + // them. When the carrier is a *moving* link the slider's own body edge has + // already pulled it in, but a slot cut into an anchored bar is part of the + // world and has no edge -- and a mechanism handed its sliders without the + // rails they run on does not fail loudly. It solves something else: the + // gripper's arms wandered off and never closed their cycle. + // + // Repeated to a fixed point because a carrier can itself carry a slider. + for (let settled = false; !settled;) { + settled = true; + members.forEach((entry) => { + [...entry.joints].forEach((joint) => { + if (!(joint instanceof PrisJoint)) { + return; + } + const rails: (Link | Joint | undefined)[] = [ + joint.carrier, + joint.slotJointA, + joint.slotJointB, + ]; + rails.forEach((part) => { + if (!part) return; + if (part instanceof Joint) { + if (!entry.joints.has(part)) { + entry.joints.add(part); + settled = false; + } + return; + } + if (!entry.links.has(part)) { + entry.links.add(part); + settled = false; + } + part.joints.forEach((railJoint) => { + if (!entry.joints.has(railJoint)) { + entry.joints.add(railJoint); + settled = false; + } + }); + }); + }); + }); + } + + // A grounded joint sits on the frame, so it belongs to every machine bolted + // to it — added by body above, and by name here for a joint whose links all + // turned out to be world. + realJoints + .filter((joint) => joint.ground) + .forEach((joint) => { + joint.links.forEach((link) => { + const body = bodyOf(link); + if (body !== WORLD && members.has(find(body))) { + memberOf(find(body)).joints.add(joint); + memberOf(find(body)).owned.add(joint); + } + }); + }); + + const componentIds = [...members.keys()]; + const lowestJointId = (component: string) => + [...memberOf(component).joints].map((joint) => joint.id).sort(byJointId)[0] ?? ''; + + const grounded = componentIds + .filter((component) => groundedComponents.has(component)) + .sort((a, b) => byJointId(lowestJointId(a), lowestJointId(b))); + + const mechanisms: MechanismPartition[] = grounded.map((component, index) => { + const entry = memberOf(component); + const partitionLinks = [...entry.links]; + return { + id: `M${index + 1}`, + joints: [...entry.joints], + ownJoints: [...entry.owned], + links: partitionLinks, + forces: forces.filter((force) => partitionLinks.some((link) => link.id === force.link?.id)), + }; + }); + + const floatingChains = componentIds + .filter((component) => !groundedComponents.has(component)) + .sort((a, b) => byJointId(lowestJointId(a), lowestJointId(b))) + .map((component) => { + const entry = memberOf(component); + return { joints: [...entry.joints], links: [...entry.links] }; + }); + + const placed = new Set<Joint>(); + members.forEach((entry) => entry.joints.forEach((joint) => placed.add(joint))); + // Loose means *no link*, which is a different situation from a bar fixed at + // both ends: that has links, cannot move, and belongs to no machine because + // there is no machine to belong to. Calling those joints loose told the + // reader to attach a link to something that already had one. + const looseJoints = joints.filter( + (joint) => !placed.has(joint) && (!(joint instanceof RealJoint) || joint.links.length === 0) + ); + const fixedLinks = links.filter((link) => !placed.has(link.joints[0]) && link.joints.length > 0); + + return { mechanisms, unassigned: { floatingChains, looseJoints, fixedLinks } }; +} diff --git a/src/app/model/mechanism/mechanism.ts b/src/app/model/mechanism/mechanism.ts index 9e91ad44..892a4586 100644 --- a/src/app/model/mechanism/mechanism.ts +++ b/src/app/model/mechanism/mechanism.ts @@ -1,11 +1,8 @@ import { Joint, PrisJoint, RealJoint, RevJoint } from '../joint'; -import { Link, SliderBlock, RealLink, Shape } from '../link'; -import { groupRigidBodies } from '../rigid-bodies'; -import { assemblyBodyIds, slideAssemblies } from '../slide-assembly'; +import { Link, SliderBlock, RealLink } from '../link'; +import { assignBodies, WORLD } from './bodies'; import { Force } from '../force'; -// import {LoopSolver} from "./loop-solver"; -import { PositionSolver, PRISMATIC_INPUT_STEP } from './position-solver'; -// import {IcSolver} from "./ic-solver"; +import { PositionSolver, PositionSolverDriveState, PRISMATIC_INPUT_STEP } from './position-solver'; import { InstantCenter } from '../instant-center'; import { Loop, LoopSolver } from './loop-solver'; import { Coord } from '../coord'; @@ -13,8 +10,32 @@ import { KinematicsSolver } from './kinematic-solver'; import { ForceAnalysisMode, ForceAnalysisSeries, ForceSolver } from './force-solver'; import { roundNumber } from '../utils'; import { LBF_IN_PER_NEWTON_METER, LBF_PER_NEWTON } from '../unit-conversions'; +import { MODEL_SCALE } from '../render-scale'; + +/** + * Why a mechanism will not run. One of these, not a boolean, because the ways a + * linkage fails call for quite different things to be done about them. + */ +export type MechanismFailure = + | 'dangling-slider' + | 'mobility' + | 'not-driven' + | 'nothing-can-move' + | 'dead-position' + | 'cycle-never-closes' + | 'cylinder-has-no-travel'; export class Mechanism { + private _failure: MechanismFailure | undefined; + private _unusableCylinder: string | undefined; + /** Joints the position ordering never reached, captured at the failure. */ + private _unreachableJoints: string[] = []; + /** + * How near the cycle came to closing before the solver gave up, in user + * units. Captured at the failure so the panel can say whether the loop only + * just misses or wanders wide. + */ + private _cycleGap: number | undefined; private _joints: Joint[][] = [[]]; private _links: Link[][] = [[]]; private _forces: Force[][] = [[]]; @@ -28,7 +49,17 @@ export class Mechanism { private _dof: number; private _inputAngularVelocities: number[] = []; private _requiredLoops: Loop[] = []; + private _driveState?: PositionSolverDriveState; private mechanismValid = true; + /** + * Whether the drive now runs against the order the frames were solved in. + * + * Set by `withReversedDrive`, which turns the drive round without touching + * the frames. Anything reading a direction has to know: the frames still + * climb the way they always did, so a crank angle taken off them is the same + * angle, while the machine walking them is now going the other way. + */ + private _framesRunBackwards = false; constructor( joints: Joint[], @@ -37,7 +68,14 @@ export class Mechanism { ics: InstantCenter[], gravity: boolean, unit: string, - inputAngVel: number + inputAngVel: number, + // 'adaptive' cuts a rocking input's small arc into a full cycle's worth of + // samples; 'degree' holds the crank's classical one-per-degree grid. The + // MATLAB verification harness pins 'degree', because its truth tables are + // stated row-per-degree and compared one to one -- the solver is the same + // either way, so the values it vouches for are the values 'adaptive' + // interpolates between. + private readonly sampling: 'adaptive' | 'degree' = 'adaptive' ) { joints.forEach((j) => { this._joints[0].push(this.cloneJointAt(j, j.x, j.y)); @@ -101,21 +139,35 @@ export class Mechanism { const dangling = this._joints[0].some( (joint) => joint instanceof PrisJoint && joint.isDangling ); - if ( - //If DOF is 1 and at least one joint is an input joint - !dangling && - this._dof === 1 && - this._joints[0].findIndex((j) => { - if (!(j instanceof RealJoint)) { - return; - } - return j.input; - }) !== -1 - ) { + const driven = this._joints[0].some((j) => j instanceof RealJoint && j.input); + // Ordered the way the fixes depend on each other, because this is also the + // order the panel reports them in: a slider with nothing to slide along has + // no mobility worth counting, and adding an input to a linkage whose + // mobility is wrong will not make it run. + if (dangling) { + this.setMechanismInvalid('dangling-slider'); + } else if (this._dof !== 1) { + this.setMechanismInvalid('mobility'); + } else if (!driven) { + this.setMechanismInvalid('not-driven'); + } else { this._requiredLoops = LoopSolver.determineLoops(this._joints[0], this._links[0]); this.findFullMovementPos(inputAngVel); - } else { - this.setMechanismInvalid(); + // The solver's static holds what *this* build found; read it now, before + // the next mechanism's build resets and overwrites it. + this._unusableCylinder = PositionSolver.unusableCylinderDrive; + // For the same reason, and for anything that differentiates this + // mechanism after every other one has been solved over the top of it. + // Built here rather than on demand: it is derived from a dozen more of + // the same statics, and by the time a graph asks they belong elsewhere. + PositionSolver.ensureSimultaneousSystem(this._joints[0], this._links[0]); + this._driveState = PositionSolver.captureDriveState(); + // A sealed cylinder with no stroke emits no steps, so the failure above + // is already recorded -- as "nothing can move", which is true but says + // nothing a student can act on. Name the ram instead. + if (this._unusableCylinder !== undefined && !this.mechanismValid) { + this._failure = 'cylinder-has-no-travel'; + } } } @@ -282,73 +334,20 @@ export class Mechanism { * refuses to simulate. Collapsing such links into one body before counting * removes the paradox. */ - private determineRigidBodies(): Map<string, string> { - // A Slide's rider and block share one joint, so the shared-joint rule above - // cannot see the weld that makes them one body. Left uncounted, the extra - // freedom is real to Gruebler and a Scotch yoke reports DOF 2. - // - // Links pinned to ground at every joint are merged with each other for the - // same reason: they cannot move, so they are one body — the world's. Which - // body that is gets settled in the count below, where the world exists. - const anchored = this.links[0] - .filter((link) => link.joints.length > 0 && link.joints.every((j) => (j as RealJoint).ground)) - .map((link) => link.id); - return groupRigidBodies(this.links[0], [ - ...slideAssemblies(this.joints[0]).map(assemblyBodyIds), - anchored, - ]); - } - /** * steps to determine DOF (Gruebler's Criteron with Exceptions): 1.determine number of links + ground - 1a. Links sharing two or more joints are one rigid body (see determineRigidBodies) + 1a. Links sharing two or more joints are one rigid body (see assignBodies) 2.determine number of ground joints 3.determine number of slider joints + * + * What a joint *costs* is one less than the number of bodies it holds + * together, which is what makes a bar pinned to ground at both ends cost + * nothing: only one body meets at each of its ends, and that body is the + * world. */ determineDegreesOfFreedom() { - const rigidBody = this.determineRigidBodies(); - /** The world: one body, however many anchored bars are drawn on top of it. */ - const WORLD = 'ground-body'; - const groupOf = (link: Link) => rigidBody.get(link.id) ?? link.id; - // A group every one of whose links is pinned down at every joint *is* the - // world. Deciding it per group rather than per link keeps a rail that has - // been merged with something movable out of the world by mistake. - const anchoredGroups = new Set( - [...new Set(this.links[0].map(groupOf))].filter((group) => - this.links[0] - .filter((link) => groupOf(link) === group) - .every( - (link) => link.joints.length > 0 && link.joints.every((j) => (j as RealJoint).ground) - ) - ) - ); - const bodyOf = (link: Link) => (anchoredGroups.has(groupOf(link)) ? WORLD : groupOf(link)); - - /** - * Every distinct body meeting at a joint, the world among them. - * - * One rule for both joint types, because it is one question. A pin joins - * the bodies of its links, plus the world if it is grounded; a sliding - * joint joins its block to whatever the slot is cut into — the world for a - * fixed guide, the carrier for a floating one. What a joint *costs* is then - * always one less than the number of bodies it holds together, which is - * what makes a bar pinned to ground at both ends cost nothing: only one - * body meets at each of its ends, and that body is the world. - */ - const bodiesAt = (joint: RealJoint): number => { - const bodies = new Set(joint.links.map(bodyOf)); - if (joint.ground) { - bodies.add(WORLD); - } - if (joint instanceof PrisJoint && !joint.ground) { - // A slider with no carrier and no ground slides against nothing. It is - // an invalid mechanism either way; naming the absent body keeps the - // reported number the one this case has always reported. - bodies.add(joint.carrier ? bodyOf(joint.carrier) : 'dangling-slot' + joint.id); - } - return bodies.size; - }; + const { bodyOf, bodiesAt } = assignBodies(this.joints[0], this.links[0]); const hasGround = this.joints[0].some((j) => j instanceof RealJoint && j.ground); if (!hasGround) { @@ -364,12 +363,18 @@ export class Mechanism { if (!(j instanceof RealJoint)) { return; } - J1 += Math.max(bodiesAt(j) - 1, 0); + J1 += Math.max(bodiesAt(j).size - 1, 0); }); return 3 * (N - 1) - 2 * J1 - J2; } - private findFullMovementPos(inputAngVel: number) { + /** One refinement per build, and never again after its fallback re-solve. */ + private refineAttempted = false; + + private findFullMovementPos(inputAngVel: number, revoluteStep: number = Math.PI / 180) { + // The loop below flips inputAngVel at each reversal; a re-solve has to + // start from the speed that was asked for, not the one the loop ended on. + const requestedAngVel = inputAngVel; let simForward = true; let falseTwice = 0; let inputAngVelDirection = inputAngVel > 0; @@ -388,6 +393,8 @@ export class Mechanism { let reversals = 0; const angularSpeed = Math.abs(inputAngVel); PositionSolver.resetStaticVariables(); + // After the reset, which is what puts the default back. + PositionSolver.revoluteSampleStep = revoluteStep; PositionSolver.determineJointOrder(this.joints[0], this.links[0]); // Before anything is measured from t = 0, put t = 0 on its own constraints. PositionSolver.settleInitialPose(this.joints[0]); @@ -405,7 +412,7 @@ export class Mechanism { // frames and a long one in six hundred. Anything else prismatic keeps the // fixed step, having no end of travel to divide. const sampleStep = revoluteInput - ? Math.PI / 180 + ? revoluteStep : (PositionSolver.drivenSampleStep ?? PRISMATIC_INPUT_STEP); // Time always moves forward, including across a rocking mechanism's // direction reversal. At zero speed retain finite sample coordinates so @@ -415,7 +422,6 @@ export class Mechanism { const connectedJointMapIndices = new Map<string, number[]>(); this.links[0].forEach((l) => { const numArray: number[] = []; - // if (!(l instanceof RealLink)) {return} l.joints.forEach((j) => { const jointIndex = this.joints[0].findIndex((jt) => jt.id === j.id); numArray.push(jointIndex); @@ -430,7 +436,8 @@ export class Mechanism { // move. Returning quietly left it *reporting itself valid* with a single // frame: the play button enabled, nothing happening, and the panel with // nothing to say about why. - this.setMechanismInvalid(); + this._unreachableJoints = [...PositionSolver.unsolvableJoints]; + this.setMechanismInvalid('nothing-can-move'); return; } const desiredJoint = this.joints[0][desiredJointIndex]; @@ -446,9 +453,20 @@ export class Mechanism { // fraction of its stroke happened to lie on one side of where it was drawn. // The cycle is closed only once both limits have been reached and the // mechanism is home again. + // One revolution closes most cycles, but not every one: a rod that passes + // through tangency with its slot comes back on the other assembly branch, + // so after a full turn of the crank the block is across the slot from + // where it started. Its true period is two revolutions — out on one + // branch, home on the other. Watching only the reference joint missed + // this (the crank pin is home; the block is not), and the animation + // teleported at the wrap. + let revolutionsToClose = 1; + // One-shot: an abandoned extension rewinds to the very pose that asked for + // it, and without this it would ask again forever. + let extensionAbandoned = false; const cycleIncomplete = () => revoluteInput && reversals === 0 - ? currentTimeStamp < STEPS_PER_REVOLUTION + ? currentTimeStamp < STEPS_PER_REVOLUTION * revolutionsToClose : reversals < 2 || xDiff > TOLERANCE || yDiff > TOLERANCE; while (!simForward || currentTimeStamp === 0 || cycleIncomplete()) { @@ -482,17 +500,6 @@ export class Mechanism { if (!(l instanceof RealLink)) { return; } - // connectedJointIndices = connectedJointMapIndices.get(l.id)!; - // connectedJointIndices.forEach((ji: number) => { - // connectedJoints.push(this._joints[currentTimeStamp + 1][ji]); - // }); - // const connectedJoints: Joint[] = []; - // TODO: think of possible way to reduce this if there is time - // l.joints.forEach(j => { - // const joint = this._joints[currentTimeStamp + 1].find(jt => jt.id === j.id); - // if (joint === undefined) {return} - // connectedJoints.push(joint); - // }); const pushLink = new RealLink( l.id, connectedJoints, @@ -511,43 +518,11 @@ export class Mechanism { if (!(l instanceof SliderBlock)) { return; } - // connectedJointIndices = connectedJointMapIndices.get(l.id)!; - // connectedJointIndices.forEach((ji: number) => { - // connectedJoints.push(this._joints[currentTimeStamp + 1][ji]); - // }); const newLink = new SliderBlock(l.id, connectedJoints, l.mass); newLink.name = l.name; this._links[currentTimeStamp + 1].push(newLink); break; } - // if (!(l instanceof RealLink)) {return} - // const connectedJointIndices = connectedJointMapIndices.get(l.id)!; - // const connectedJoints: Joint[] = []; - // connectedJointIndices.forEach((ji: number) => { - // connectedJoints.push(this._joints[currentTimeStamp + 1][ji]); - // }); - // // const connectedJoints: Joint[] = []; - // // TODO: think of possible way to reduce this if there is time - // // l.joints.forEach(j => { - // // const joint = this._joints[currentTimeStamp + 1].find(jt => jt.id === j.id); - // // if (joint === undefined) {return} - // // connectedJoints.push(joint); - // // }); - // const pushLink = new RealLink(l.id, connectedJoints); - // pushLink.shape = l.shape; - // // TODO: Update this to just one function later... - // if (pushLink.shape === 'line') { - // pushLink.bound = RealLink.getBounds(new Coord(connectedJoints[0].x, connectedJoints[0].y), - // new Coord(connectedJoints[1].x, connectedJoints[1].y), l.shape); - // } else { - // pushLink.bound = RealLink.rotateBounds(l.joints[0], l.joints[1], new Coord(connectedJoints[0].x, connectedJoints[0].y), - // new Coord(connectedJoints[1].x, connectedJoints[1].y), l.bound); - // } - // pushLink.d = RealLink.getPointsFromBounds(pushLink.bound, pushLink.shape); - // // TODO: When you insert a joint onto a link, be sure to utilize this function call - // pushLink.CoM = RealLink.determineCenterOfMass(pushLink.joints); - // pushLink.forces = l.forces; - // this._links[currentTimeStamp + 1].push(pushLink); }); this.wireJointGraph(currentTimeStamp + 1, this.joints[0]); // TODO: If forces are a part of links, is all of this info needed? Or just the positions? @@ -569,23 +544,6 @@ export class Mechanism { }); Force.normalizeVisualWidths(this._forces[currentTimeStamp + 1]); this.attachForcesToLinks(currentTimeStamp + 1); - // this.links[0].forEach(l => { - // if (l instanceof RealLink) { - // l.determineCenterOfMass(l.joints, 'x'); - // l.determineCenterOfMass(l.joints, 'y'); - // this._links[currentTimeStamp + 1].push(l as Link); - // // this.determineLinkCoMLocations(links); - // } - // }); - // for (const entry of desiredMap.entries()) { - // const joint_index = jointIDToJointIndexMap.get(entry[0]); - // if (joint_index === undefined) {return} - // const joint = joints[joint_index]; - // // const joint = this.SimulationJoints.find(j => j.id === entry[0]); - // joint.x = entry[1][0]; - // joint.y = entry[1][1]; - // this._joints[currentTimeStamp + 1].push(joint as Joint); - // } falseTwice = 0; currentTimeStamp++; if (curTimeNum + timeNumIncrement <= 0) { @@ -594,15 +552,19 @@ export class Mechanism { curTimeNum = curTimeNum + timeNumIncrement; this._timeNum.push(curTimeNum); this._inputAngularVelocities.push(inputAngVel); - // TODO: Create own function to determine this. Probably just utilize tracer joint logic - // const ffs = this.calculateForces(this.SimulationLinks, this.SimulationForces); - // adjust for linkAngle (for center of mass) - // TODO: arguments passed in has to come from mechanism ICs and not from IC - // const determined_ics = IcSolver.determineICPositions(ics, joints); + } else if (revolutionsToClose === 2 && currentTimeStamp >= STEPS_PER_REVOLUTION) { + // The second revolution could not be solved through. Fall back to the + // one-revolution cycle and its warned seam rather than losing a + // mechanism the old behavior kept. + this.trimToSample(STEPS_PER_REVOLUTION); + currentTimeStamp = STEPS_PER_REVOLUTION; + curTimeNum = this._timeNum[currentTimeStamp]; + revolutionsToClose = 1; + extensionAbandoned = true; } else { if ((!simForward && currentTimeStamp === 0) || falseTwice === 2) { //If we are here, the mechnism is in a toggle point - this.setMechanismInvalid(); + this.setMechanismInvalid('dead-position'); return; } falseTwice += 1; @@ -620,46 +582,239 @@ export class Mechanism { yDiff = Math.abs( startingPositionY - roundNumber(this._joints[currentTimeStamp][desiredJointIndex].y, 2) ); + // The whole drawing has to be home, not just the reference joint, before + // the first revolution is allowed to be the whole cycle. + if ( + revoluteInput && + reversals === 0 && + revolutionsToClose === 1 && + !extensionAbandoned && + currentTimeStamp === STEPS_PER_REVOLUTION && + this.seamGapAt(currentTimeStamp) > this.seamTolerance() + ) { + revolutionsToClose = 2; + } if (currentTimeStamp === 750) { - this.setMechanismInvalid(); + // How close it ever came to its starting pose, skipping the first few + // frames where it is trivially still there. setMechanismInvalid wipes + // the frames, so this is the last chance to measure. + let nearest = Infinity; + for (let frame = 30; frame < this._joints.length; frame++) { + const joint = this._joints[frame]?.[desiredJointIndex]; + if (!joint) continue; + nearest = Math.min( + nearest, + Math.hypot(joint.x - startingPositionX, joint.y - startingPositionY) + ); + } + this._cycleGap = Number.isFinite(nearest) ? nearest / MODEL_SCALE : undefined; + this.setMechanismInvalid('cycle-never-closes'); return; } } - // Pin the closing sample to the analytic period 2*pi/|w| rather than to 360 - // accumulated float additions, so the reported cycle time scales exactly with - // input speed and the last sample lines up with the first. + // A rocking input covers only its own arc, and a degree per sample cuts a + // small arc into almost nothing: a linkage that swings eight degrees out + // and back was a cycle of some thirty frames, so the scrubber jumped and + // the graphs were drawn between a handful of points. The arc is only known + // once it has been walked, so walk it at crank spacing first, then cut the + // same arc into the sample count a full crank turn would have had and walk + // it again. One refinement, never a coarsening, and never for a crank: + // a full revolution already has its 360, and the MATLAB verification + // tables are stated at exactly that spacing. + if ( + this.sampling === 'adaptive' && + !this.refineAttempted && + revoluteInput && + // Only when the crank spacing is what this mechanism moves by: a driven + // pin steps by its own field, so re-solving it with a finer crank + // spacing repeats the identical arc under a 58-times-faster clock. + PositionSolver.stepsByRevoluteSampleStep && + reversals >= 2 + ) { + const samples = this._joints.length - 1; + const TARGET_SAMPLES = 360; + // Sixty-four cuts per degree matches the boundary solver's own halving + // cap, and keeps every sample's motion far above the four decimals a + // solved position is held to. + const FINEST = Math.PI / 180 / 64; + if (samples > 0 && samples < TARGET_SAMPLES * (2 / 3)) { + this.refineAttempted = true; + const refined = Math.max((revoluteStep * samples) / TARGET_SAMPLES, FINEST); + // The editable pose and what the caller derived from it, because a + // failed attempt rewinds all the way to here. The fine pass is not + // guaranteed to walk the same cycle: a limit is wherever the solver + // failed, and a failure at one degree is sometimes a fold too sharp + // for that seed rather than a place the linkage cannot pass. A finer + // step walks through such a fold and out into arc the coarse pass + // never saw -- the honest response to which is not to wander until + // the frame cap calls the whole mechanism broken, but to keep the + // cycle that did close. + const start = { + joints: this._joints[0], + links: this._links[0], + forces: this._forces[0], + // setMechanismInvalid clears the loops with the frames, and the + // loops were found by the caller before any of this began. + loops: this._requiredLoops.slice(), + speed: this._inputAngularVelocities[0], + }; + this._joints.length = 1; + this._links.length = 1; + this._forces.length = 1; + this._timeNum.length = 0; + // Back to its seed entry, not to nothing: the first speed is pushed by + // the constructor before this function ever runs, so it is the one + // entry a re-entry will not put back -- and without it the array runs + // one short of the frames, and the last sample of every refined cycle + // solves against an undefined speed. + this._inputAngularVelocities.length = 1; + this.findFullMovementPos(requestedAngVel, refined); + if (!this.mechanismValid) { + // Walk the coarse arc again rather than pasting its frames back. + // Restoring arrays alone left the position solver's statics -- the + // joint map, the motion history, the force anchors -- describing the + // pose the abandoned fine pass died in, and everything that runs + // after a build reads those statics. A re-solve of twenty-odd + // samples is cheap, deterministic, and leaves the solver in exactly + // the state the coarse pass always left it in. + this._joints = [start.joints]; + this._links = [start.links]; + this._forces = [start.forces]; + this._timeNum = []; + this._inputAngularVelocities = [start.speed]; + this._requiredLoops = start.loops; + this.mechanismValid = true; + this._failure = undefined; + this._cycleGap = undefined; + this.findFullMovementPos(requestedAngVel); + } + return; + } + } + + // An extension that ran its full second revolution and is still not home + // bought nothing: keep the one-revolution cycle the old behavior kept. + if ( + revoluteInput && + reversals === 0 && + revolutionsToClose === 2 && + this.seamGapAt(currentTimeStamp) > this.seamTolerance() + ) { + this.trimToSample(STEPS_PER_REVOLUTION); + currentTimeStamp = STEPS_PER_REVOLUTION; + revolutionsToClose = 1; + } + + // Pin the closing sample to the analytic period rather than to hundreds of + // accumulated float additions, so the reported cycle time scales exactly + // with input speed and the last sample lines up with the first. if (revoluteInput && reversals === 0 && angularSpeed > Number.EPSILON) { - this._timeNum[this._timeNum.length - 1] = (2 * Math.PI) / angularSpeed; - // Closing at a fixed 360 steps assumes assembly-mode tracking held all the - // way around; if it did not, the cycle no longer ends where it began and - // the seam would otherwise be silent. - if (xDiff > TOLERANCE || yDiff > TOLERANCE) { + this._timeNum[this._timeNum.length - 1] = (revolutionsToClose * 2 * Math.PI) / angularSpeed; + // Closing at a fixed step count assumes assembly-mode tracking held all + // the way around; if it did not, the cycle no longer ends where it began + // and the seam would otherwise be silent. + const seam = this.seamGapAt(currentTimeStamp); + if (seam > this.seamTolerance()) { console.warn( - `Cycle did not close: after one input revolution the reference joint is off by (${xDiff}, ${yDiff})` + `Cycle did not close: after ${revolutionsToClose} input revolution(s) the drawing is off by ${seam}` ); } } } + /** Drop precomputed samples past `lastSample`, keeping 0..lastSample. */ + private trimToSample(lastSample: number): void { + this._joints.length = lastSample + 1; + this._links.length = lastSample + 1; + this._forces.length = lastSample + 1; + this._timeNum.length = lastSample + 1; + this._inputAngularVelocities.length = lastSample + 1; + } + + /** How far the furthest joint stands from its starting position at `sample`. */ + private seamGapAt(sample: number): number { + const start = this._joints[0]; + const now = this._joints[sample]; + if (!now || now.length !== start.length) return Infinity; + let gap = 0; + for (let index = 0; index < start.length; index++) { + gap = Math.max(gap, Math.hypot(now[index].x - start[index].x, now[index].y - start[index].y)); + } + return gap; + } + + /** + * A seam wide enough to mean "different pose", not solver noise. + * + * Relative to the drawing's own size because coordinates arrive at whatever + * scale the caller drew in: solved positions are held to four decimals, so + * noise is orders below a thousandth of the drawing while a branch swap is + * on the order of a link length. + */ + private seamTolerance(): number { + const start = this._joints[0]; + let extent = 0; + for (const joint of start) { + extent = Math.max(extent, Math.abs(joint.x), Math.abs(joint.y)); + } + return Math.max(extent, 1) * 1e-3; + } + /** Seconds spanned by one full traversal of the precomputed motion. */ get cyclePeriod(): number { return this._timeNum.length > 1 ? this._timeNum[this._timeNum.length - 1] : 0; } - private setMechanismInvalid() { + private setMechanismInvalid(cause: MechanismFailure) { // TODO: Set all of the joints, links, force, instant center positions as empty this.joints = [[]]; this.links = [[]]; this.forces = [[]]; this.requiredLoops = []; this.mechanismValid = false; + this._failure = cause; } public isMechanismValid(): boolean { return this.mechanismValid; } + /** + * Why this mechanism will not run, or undefined if it will. + * + * Four quite different situations used to arrive at the same false: a shape + * nothing can move, a linkage locked at a dead position, a cycle that never + * comes back to where it started, and a mobility that is simply not one. The + * panel can only tell a student which of those they are looking at -- and so + * what to do about it -- if the solver says which one it hit. + */ + get failure(): MechanismFailure | undefined { + return this._failure; + } + + /** + * The cylinder whose barrel is too short to slide in at all, if any. + * + * Captured here rather than read from the solver's static afterwards: with + * several mechanisms built one after another, that static holds whatever the + * last one found, so a good linkage would inherit the complaint of a bad one + * built after it. + */ + get unusableCylinder(): string | undefined { + return this._unusableCylinder; + } + + /** Joints the position ordering never reached, when nothing-can-move. */ + get unreachableJoints(): string[] { + return this._unreachableJoints; + } + + /** Nearest return to the starting pose in user units, when the cycle never closes. */ + get cycleGap(): number | undefined { + return this._cycleGap; + } + get internalTriangleSimLinkMap(): Map<string, number[]> { return this._internalTriangleSimLinkMap; } @@ -692,6 +847,64 @@ export class Mechanism { this._unit = value; } + /** + * Put the shared solvers back on this mechanism's own constraints. + * + * `KinematicsSolver` and `PositionSolver` both keep their working state in + * statics, which was fine when a drawing held one mechanism. It holds as many + * as are drawn now, and every one of them is solved over the top of the last + * -- so anything that comes back later to differentiate a particular one has + * to say which one it means first. Cheap: three assignments and a reference. + */ + prepareSolvers(): void { + PositionSolver.restoreDriveState(this._driveState); + KinematicsSolver.requiredLoops = this._requiredLoops; + } + + /** + * The same cycle, driven the other way round it. + * + * A fully rotating input traverses one closed loop of poses, so reversing it + * changes nothing about the loop -- only which way the machine walks it, and + * how fast each part is going as it does. So the frames are kept exactly as + * they are and only the signed input speed is turned round. + * + * That is what keeps a reader's place. Re-solving, or even mirroring the + * frames, moves every pose to a different time and the whole curve slides + * end for end under the playhead: the peak someone was reading jumps to the + * other side of the chart for a machine that has not moved. Here the curve + * stays where it is and the playhead turns round, which is what actually + * happened. + * + * Rates are not stored per frame. Every velocity and acceleration is derived + * from the pose and this one signed number, so negating it is the whole of + * the change: velocities turn round, accelerations do not (they go as the + * square of the speed), and the force analysis is untouched at every pose -- + * which is right, because what a part has to carry at a pose does not depend + * on which way it arrived there. + */ + withReversedDrive(): Mechanism | undefined { + if (!this.mechanismValid || this._joints.length < 2) return undefined; + const reversed: Mechanism = Object.create(Mechanism.prototype); + Object.assign(reversed, this); + reversed._inputAngularVelocities = this._inputAngularVelocities.map((speed) => -speed); + reversed._framesRunBackwards = !this._framesRunBackwards; + // A new object rather than a mutation: the panels and graphs cache what + // they have drawn against the mechanism they drew it from, and identity is + // how they know to look again. + // + // The cache holds the same answers -- reversing changes no force at any + // pose -- but it is keyed by nothing else, so it is cleared rather than + // carried into an object claiming a different drive. + reversed.forceAnalysisCache = new Map(); + return reversed; + } + + /** See `_framesRunBackwards`. */ + get framesRunBackwards(): boolean { + return this._framesRunBackwards; + } + get requiredLoops(): Loop[] { return this._requiredLoops; } @@ -783,39 +996,7 @@ export class Mechanism { break; default: return; - // case 'km': - // posUnit = 'km'; - // forceUnit = 'N'; - // torqueUnit = 'N*km'; - // accUnit = 'km/s^2'; - // break; - // case 'in': - // posUnit = 'in'; - // forceUnit = 'lbf'; - // torqueUnit = 'lbf*in'; - // accUnit = 'in/s^2'; - // break; - // case 'ft': - // posUnit = 'ft'; - // forceUnit = 'lbf'; - // torqueUnit = 'lbf*ft'; - // accUnit = 'ft/s^2'; - // break; } - // switch (this._unit) { - // case 'Metric': - // posUnit = 'cm'; - // forceUnit = 'N'; - // torqueUnit = 'N*cm'; - // accUnit = 'cm/s^2'; - // break; - // case 'English': - // posUnit = 'ft'; - // forceUnit = 'lbm*ft/s^2'; - // torqueUnit = 'lbm*ft^2/s^2'; - // accUnit = 'in/s^2'; - // break; - // } for (const joint of this.joints[0].filter((candidate) => this.isForceAnalysisJoint(candidate) )) { @@ -862,7 +1043,6 @@ export class Mechanism { if (l instanceof SliderBlock) { return; } - // forceTitleRow.push('Link ' + l.id + ' angPos ' + angAccUnit); forceTitleRow.push('Link ' + l.id + ' angPos ' + angPosUnit); forceTitleRow.push('Link ' + l.id + ' angVel ' + angVelUnit); forceTitleRow.push('Link ' + l.id + ' angAcc ' + angAccUnit); @@ -870,14 +1050,6 @@ export class Mechanism { break; } - // this.SimulationLinks.forEach(l => { - // if (l instanceof ImagLink) { - // return; - // } - // forceTitleRow.push('Link ' + l.id + ' CoM x ' + posUnit); - // forceTitleRow.push('Link ' + l.id + ' CoM y ' + posUnit); - // }); - return forceTitleRow; } @@ -916,38 +1088,8 @@ export class Mechanism { velUnit = 'in/s'; accUnit = 'in/s^2'; break; - // case 'km': - // posUnit = 'km'; - // velUnit = 'km/s'; - // accUnit = 'km/s^2'; - // break; - // case 'in': - // posUnit = 'in'; - // velUnit = 'in/s'; - // accUnit = 'in/s^2'; - // break; - // case 'ft': - // posUnit = 'ft'; - // velUnit = 'ft/s'; - // accUnit = 'ft/s^2'; - // break; } - // switch (this._unit) { - // case 'Metric': - // posUnit = 'cm'; - // velUnit = 'cm/s'; - // accUnit = 'cm/s^2'; - // break; - // case 'English': - // posUnit = 'in'; - // velUnit = 'in/s'; - // accUnit = 'in/s^2'; - // break; - // } this.joints[0].forEach((j) => { - // if (j instanceof PrisJoint) { - // return; - // } kinematicTitleRow.push('Joint ' + j.id + ' x ' + posUnit); kinematicTitleRow.push('Joint ' + j.id + ' y ' + posUnit); kinematicTitleRow.push('Joint ' + j.id + ' vx ' + velUnit); @@ -960,9 +1102,6 @@ export class Mechanism { if (l instanceof SliderBlock) { return; } - // if (l instanceof ImagLink) { - // return; - // } kinematicTitleRow.push('Link ' + l.id + ' CoM ' + 'x ' + posUnit); kinematicTitleRow.push('Link ' + l.id + ' CoM ' + 'y ' + posUnit); kinematicTitleRow.push('Link ' + l.id + ' CoM ' + 'vx ' + velUnit); @@ -975,9 +1114,6 @@ export class Mechanism { if (l instanceof SliderBlock) { return; } - // if (l instanceof ImagLink) { - // return; - // } kinematicTitleRow.push('Link ' + l.id + ' angle ' + angPosUnit); kinematicTitleRow.push('Link ' + l.id + ' angVel ' + angVelUnit); kinematicTitleRow.push('Link ' + l.id + ' angAcc ' + angAccUnit); @@ -1014,49 +1150,19 @@ export class Mechanism { velUnitConversion = 1; accUnitConversion = 1; break; - // case 'km': - // forceUnitConversion = 1; // convert from newtons -> newton - // torqueUnitConversion = 1; // convert from newton_meter -> newton_centimeter - // accUnitConversion = 1; // cm/s^2 - // break; - // case 'in': - // forceUnitConversion = 1; // convert from newtons -> newton - // torqueUnitConversion = 1; // convert from newton_meter -> newton_centimeter - // accUnitConversion = 1; // cm/s^2 - // break; - // case 'ft': - // forceUnitConversion = 1; // convert from newtons -> newton - // torqueUnitConversion = 1; // convert from newton_meter -> newton_centimeter - // accUnitConversion = 1; // cm/s^2 - // break; } - // switch (this._unit) { - // case 'Metric': - // forceUnitConversion = 1; // convert from newtons -> newton - // torqueUnitConversion = 1; // convert from newton_meter -> newton_centimeter - // accUnitConversion = 1; // cm/s^2 - // break; - // case 'English': - // forceUnitConversion = 0.2248089431; // convert from newton -> - // torqueUnitConversion = 0.73756214728 ; // convert from newton_meter -> - // // change this since conversion is starting at cm instead of m - // accUnitConversion = 3.2808399; - // break; - // } ForceSolver.resetVariables(); ForceSolver.determineDesiredLoopLettersForce(this.requiredLoops); if (analysisType === 'dynamics') { KinematicsSolver.resetVariables(); - KinematicsSolver.requiredLoops = this.requiredLoops; + // The inertia terms come from differentiating this mechanism, and the + // solvers have been used by every other mechanism in the drawing since. + this.prepareSolvers(); } // Go through each step within the mechanism this.joints.forEach((_, index) => { - // this.insertNewJointPos(tsl); const force_row = Array<string>(); - // const A_row = Array<Array<string>>(); // unknown array - // const B_row = Array<string>(); // known array force_row.push(this.timeNum[index].toString()); - // force_row.push((index * tsl.angular_velocity * Math.PI / 180).toString()); if (analysisType === 'dynamics') { // determine kinematic analysis KinematicsSolver.determineKinematics( @@ -1093,29 +1199,9 @@ export class Mechanism { roundNumber(ForceSolver.unknownVariableTorque * torqueUnitConversion, 4).toString() ); force_row.push(' '); - // this.SimulationJoints.forEach(j => { - // // force_row.push('(' + j.x.toString() + ',' + j.y.toString() + ')'); - // force_row.push(j.x.toString()); - // force_row.push(j.y.toString()); - // }); - // force_row.push(' '); - // this.SimulationLinks.forEach(l => { - // if (l instanceof ImagLink) { - // return; - // } - // const cur_link = l as RealLink; - // force_row.push(Simulator.roundToHundredThousandth(cur_link.CoM_x).toString()); - // force_row.push(Simulator.roundToHundredThousandth(cur_link.CoM_y).toString()); - // // force_row.push(Simulator.roundToHundredThousandth(KinematicsSolver.linkCoMMap.get(l.id)[0]).toString()); - // // force_row.push(Simulator.roundToHundredThousandth(KinematicsSolver.linkCoMMap.get(l.id)[1]).toString()); - // // force_row.push('(' + Simulator.roundToHundredThousandth(KinematicsSolver.linkAccMap.get(l.id)[0] * accUnitConversion) + - // // ',' + Simulator.roundToHundredThousandth(KinematicsSolver.linkAccMap.get(l.id)[1] * accUnitConversion) + ')'); - // }); this.forces[index].forEach((f) => { force_row.push(roundNumber(f.startCoord.x, 4).toString()); force_row.push(roundNumber(f.startCoord.y, 4).toString()); - // force_row.push(Simulator.roundToHundredThousandth(f.xLocOfForceOnLink).toString()); - // force_row.push(Simulator.roundToHundredThousandth(f.yLocOfForceOnLink).toString()); }); force_row.push(' '); switch (analysisType) { @@ -1123,26 +1209,12 @@ export class Mechanism { this.joints[index].forEach((j) => { force_row.push(roundNumber(j.x, 4).toString()); force_row.push(roundNumber(j.y, 4).toString()); - // force_row.push(Simulator.roundToHundredThousandth(KinematicsSolver.jointIndexMap.get(j.id)[0] * accUnitConversion).toString()); - // force_row.push(Simulator.roundToHundredThousandth(KinematicsSolver.jointIndexMap.get(j.id)[1] * accUnitConversion).toString()); }); - // force_row.push(' '); - // this.SimulationLinks.forEach(l => { - // if (l instanceof ImagLink) { - // return; - // } - // force_row.push(Simulator.roundToHundredThousandth()) - // force_row.push(Simulator.roundToHundredThousandth(KinematicsSolver.linkCoMMap.get(l.id)[0] * accUnitConversion).toString()); - // force_row.push(Simulator.roundToHundredThousandth(KinematicsSolver.linkCoMMap.get(l.id)[1] * accUnitConversion).toString()); - // }); - // force_row.push(' '); break; case 'dynamics': this.joints[index].forEach((j) => { force_row.push(roundNumber(j.x, 4).toString()); force_row.push(roundNumber(j.y, 4).toString()); - // force_row.push(Simulator.roundToHundredThousandth(KinematicsSolver.jointIndexMap.get(j.id)[0] * accUnitConversion).toString()); - // force_row.push(Simulator.roundToHundredThousandth(KinematicsSolver.jointIndexMap.get(j.id)[1] * accUnitConversion).toString()); force_row.push( roundNumber( KinematicsSolver.jointVelMap.get(j.id)![0] * velUnitConversion, @@ -1167,8 +1239,6 @@ export class Mechanism { 4 ).toString() ); - // force_row.push('(' + Simulator.roundToHundredThousandth(KinematicsSolver.jointAccMap.get(j.id)[0] * accUnitConversion) + - // ',' + Simulator.roundToHundredThousandth(KinematicsSolver.jointAccMap.get(j.id)[1] * accUnitConversion) + ')'); }); force_row.push(' '); this.links[index].forEach((l) => { @@ -1211,8 +1281,6 @@ export class Mechanism { 4 ).toString() ); - // force_row.push('(' + Simulator.roundToHundredThousandth(KinematicsSolver.linkAccMap.get(l.id)[0] * accUnitConversion) + - // ',' + Simulator.roundToHundredThousandth(KinematicsSolver.linkAccMap.get(l.id)[1] * accUnitConversion) + ')'); }); force_row.push(' '); this.links[index].forEach((l) => { @@ -1233,9 +1301,8 @@ export class Mechanism { kinematicLoopAnalysis() { const kinematicAnalysis = Array<Array<string>>(); KinematicsSolver.resetVariables(); - KinematicsSolver.requiredLoops = this.requiredLoops; + this.prepareSolvers(); this.joints.forEach((_, index) => { - // const row = Array<number>(); const row = Array<string>(); row.push(this.timeNum[index].toString()); KinematicsSolver.determineKinematics( @@ -1257,46 +1324,9 @@ export class Mechanism { velUnitConversion = 1; // cm/s accUnitConversion = 1; // cm/s^2 break; - // case 'km': - // posUnitConversion = 1; // cm - // velUnitConversion = 1; // cm/s - // accUnitConversion = 1; // cm/s^2 - // break; - // case 'in': - // posUnitConversion = 1; // cm - // velUnitConversion = 1; // cm/s - // accUnitConversion = 1; // cm/s^2 - // break; - // case 'ft': - // posUnitConversion = 1; // cm - // velUnitConversion = 1; // cm/s - // accUnitConversion = 1; // cm/s^2 - // break; } - // switch (this._unit) { - // case 'Metric': - // posUnitConversion = 1; // cm - // velUnitConversion = 1; // cm/s - // accUnitConversion = 1; // cm/s^2 - // break; - // case 'English': - // // change this since conversion is starting at cm instead of m - // posUnitConversion = 0.3048; - // velUnitConversion = 3.28084; - // accUnitConversion = 3.2808399; - // break; - // } this.joints[0].forEach((j) => { - // if (j instanceof ImagJoint) { - // return; - // } - // row.push((this.SimulationJoints[KinematicsSolver.jointIndexMap.get(j.id)].x * posUnitConversion).toString()); - // row.push((this.SimulationJoints[KinematicsSolver.jointIndexMap.get(j.id)].y * posUnitConversion).toString()); - // row.push((KinematicsSolver.jointVelMap.get(j.id)[0] * velUnitConversion).toString()); - // row.push((KinematicsSolver.jointVelMap.get(j.id)[1] * velUnitConversion).toString()); - // row.push((KinematicsSolver.jointAccMap.get(j.id)[0] * accUnitConversion).toString()); - // row.push((KinematicsSolver.jointAccMap.get(j.id)[1] * accUnitConversion).toString()); row.push( roundNumber( this.joints[index][KinematicsSolver.jointIndexMap.get(j.id)!].x * posUnitConversion, @@ -1327,15 +1357,6 @@ export class Mechanism { if (l instanceof SliderBlock) { return; } - // if (l instanceof ImagLink) { - // return; - // } - // row.push((KinematicsSolver.linkCoMMap.get(l.id)[0] * posUnitConversion).toString()); - // row.push((KinematicsSolver.linkCoMMap.get(l.id)[1] * posUnitConversion).toString()); - // row.push((KinematicsSolver.linkVelMap.get(l.id)[0] * velUnitConversion).toString()); - // row.push((KinematicsSolver.linkVelMap.get(l.id)[1] * velUnitConversion).toString()); - // row.push((KinematicsSolver.linkAccMap.get(l.id)[0] * accUnitConversion).toString()); - // row.push((KinematicsSolver.linkAccMap.get(l.id)[1] * accUnitConversion).toString()); row.push( roundNumber(KinematicsSolver.linkCoMMap.get(l.id)![0] * posUnitConversion, 4).toString() ); @@ -1360,12 +1381,6 @@ export class Mechanism { if (l instanceof SliderBlock) { return; } - // if (l instanceof ImagLink) { - // return; - // } - // row.push((KinematicsSolver.linkAngPosMap.get(l.id)).toString()); - // row.push((KinematicsSolver.linkAngVelMap.get(l.id)).toString()); - // row.push((KinematicsSolver.linkAngAccMap.get(l.id)).toString()); row.push(roundNumber(KinematicsSolver.linkAngPosMap.get(l.id)!, 4).toString()); row.push(roundNumber(KinematicsSolver.linkAngVelMap.get(l.id)!, 4).toString()); row.push(roundNumber(KinematicsSolver.linkAngAccMap.get(l.id)!, 4).toString()); diff --git a/src/app/model/mechanism/position-solver.ts b/src/app/model/mechanism/position-solver.ts index 36c9f62d..55401eb2 100644 --- a/src/app/model/mechanism/position-solver.ts +++ b/src/app/model/mechanism/position-solver.ts @@ -10,7 +10,6 @@ import { import { Force } from '../force'; import { Coord } from '../coord'; import { assemblyBodyIds, SlideAssembly, slideAssemblies, slotOffset } from '../slide-assembly'; -import { core } from '@angular/compiler'; import { MODEL_SCALE } from '../render-scale'; import { Cylinder, @@ -228,6 +227,29 @@ interface CylinderDrive { step: number; } +/** + * A driven *pin* (§2.9, Phase 6): the actuator's two bodies, as the three + * points whose angle the drive prescribes. Held in the same shape as the + * cylinder's record so one stepping path serves both. + */ +interface PinDrive { + pivotId: string; + referenceId: string; + drivenId: string; + angle: number; + step: number; +} + +/** + * One mechanism's drive, taken out of this solver's statics so it can be put + * back later. See `PositionSolver.captureDriveState`. + */ +export interface PositionSolverDriveState { + cylinderDrive?: CylinderDrive; + pinDrive?: PinDrive; + simultaneousSystem?: SimultaneousSystem; +} + /** * Whether a joint's coordinates are something the rate system has to solve for. * @@ -287,13 +309,7 @@ export class PositionSolver { * points whose angle the drive prescribes. Held in the same shape as the * cylinder's record so one stepping path serves both. */ - private static pinDrive?: { - pivotId: string; - referenceId: string; - drivenId: string; - angle: number; - step: number; - }; + private static pinDrive?: PinDrive; /** Joints no chain of dyads can place, and what they have to satisfy (§2.7a). */ private static simultaneousSystem?: SimultaneousSystem; /** @@ -334,6 +350,25 @@ export class PositionSolver { * `SAMPLES_PER_STROKE`. Undefined when nothing prismatic is driving. */ static drivenSampleStep?: number; + /** + * Sample spacing for a revolute input, in radians. One degree, the spacing a + * crank has always had -- except when a rocking input turns out to cover + * only a few degrees of arc, and the mechanism asks for the same arc cut + * finer so its whole cycle is not six frames long (see + * Mechanism.findFullMovementPos). + */ + static revoluteSampleStep = Math.PI / 180; + + /** + * Whether the crank spacing above is the spacing this mechanism actually + * moves by. A driven pin and a driven cylinder each carry a step of their + * own, so changing the crank spacing under one of those changes nothing but + * the clock -- the arc keeps its old samples while every sample claims to + * span less time. + */ + static get stepsByRevoluteSampleStep(): boolean { + return this.pinDrive === undefined && this.cylinderDrive === undefined; + } private static internalTriangleValuesMap = new Map<string, number[]>(); private static desiredConnectedJointIndicesMap = new Map<string, number[]>(); private static desiredAnalysisJointMap = new Map<string, string>(); @@ -351,6 +386,58 @@ export class PositionSolver { static forcePositionMap = new Map<string, Coord>(); static forceMagnitudeMap = new Map<string, number>(); + /** + * Everything in this solver that describes one particular mechanism's drive. + * + * These three statics are written while a mechanism is being solved and read + * again, much later, whenever something differentiates it -- a velocity + * graph, a dynamic force analysis. With one mechanism in a drawing that was + * safe. With three, the last one solved owns them, and a graph asked about + * the first one was answered out of the third one's constraints: it found no + * drive it recognised, fell through to the loop formulation, and came back + * with nothing at all. Every velocity and acceleration graph of a + * cylinder-driven machine standing beside another machine was empty. + */ + /** + * Build this mechanism's constraint set now, while its own state is loaded. + * + * Differentiating a mechanism needs the set, and a mechanism whose positions + * the dyad walk solved never built one -- so it was built on demand, at the + * moment a graph was opened. By then a drawing with more than one machine in + * it has solved every other machine over the top of this one, and the set + * came out of the *last* machine's slot lines, distances and drive. Every + * velocity and acceleration graph of the first machine was empty. + * + * Only where the drive needs it: a plain crank is differentiated through the + * loop formulation, which asks nothing of this and is cheaper. + */ + static ensureSimultaneousSystem(joints: Joint[], links: Link[]): void { + if (this.simultaneousSystem || (!this.cylinderDrive && !this.pinDrive)) { + return; + } + this.simultaneousSystem = this.buildSimultaneousSystem( + joints, + links, + joints.filter(isRateUnknown).map((joint) => joint.id) + ); + } + + static captureDriveState(): PositionSolverDriveState { + return { + cylinderDrive: this.cylinderDrive, + pinDrive: this.pinDrive, + simultaneousSystem: this.simultaneousSystem, + }; + } + + /** Put this solver back on the constraints of the mechanism named by `state`. */ + static restoreDriveState(state: PositionSolverDriveState | undefined): void { + if (!state) return; + this.cylinderDrive = state.cylinderDrive; + this.pinDrive = state.pinDrive; + this.simultaneousSystem = state.simultaneousSystem; + } + static resetStaticVariables() { this.jointMapPositions = new Map<string, Array<number>>(); this.priorJointPositions = new Map<string, Array<number>>(); @@ -379,6 +466,7 @@ export class PositionSolver { this.solvedPoses = []; this.pendingSpan = undefined; this.drivenSampleStep = undefined; + this.revoluteSampleStep = Math.PI / 180; this.stepCount = 0; this.unsolvableJoints = []; this.unusableCylinderDrive = undefined; @@ -489,13 +577,9 @@ export class PositionSolver { if (!driven.has(j.id)) { return; } - // if (j.ground && j.constructor !== PrisJoint) { - // return; - // } // store the solved number this.jointNumOrderSolverMap.set(orderNum++, [j.id]); // store desired joints as input joint and current_joint - // const currentJointIndex = simJoints.findIndex(jt => jt.id === current_joint.id); this.desiredConnectedJointIndicesMap.set(j.id, [inputJointIndex]); // store the solve type from the input solver switch (inputJoint.constructor) { @@ -2145,12 +2229,9 @@ export class PositionSolver { return; } // tracer joint is not connected on the same link as prev joint and curr joint - // if (tracer_joint.connectedLinks.findIndex(ll => ll.id === desired_link.id) !== -1) { if (tracer_joint.links.findIndex((ll) => ll.id === desired_link.id) === -1) { return; } - // const tracer_joint_index = 0; - // this.desiredConnectedJointIndicesMap.set(tracer_joint.id, [cur_joint_index, known_joint_index]); this.desiredConnectedJointIndicesMap.set(tracer_joint.id, [ prev_joint_index, cur_joint_index, @@ -2184,7 +2265,6 @@ export class PositionSolver { return joint.connectedJoints.find((jt) => { const knownJointIndex = knownJointArray.findIndex((j_id) => j_id === jt.id); return knownJointIndex !== -1 && jt.id !== prev_joint.id; - // return knownJointArray.findIndex(j_id => j_id === jt.id) !== -1 && jt.id !== prev_joint.id; }); } @@ -2309,7 +2389,7 @@ export class PositionSolver { private static incrementRevInput(inputJoint: Joint, unknownJoint: Joint, angVelDir: boolean) { const r = this.jointDistMap.get(inputJoint.id + ',' + unknownJoint.id)!; - const increment = angVelDir ? Math.PI / 180.0 : -Math.PI / 180.0; + const increment = angVelDir ? this.revoluteSampleStep : -this.revoluteSampleStep; const angle = Math.atan2(unknownJoint.y - inputJoint.y, unknownJoint.x - inputJoint.x); const x = Math.cos(angle + increment) * r + inputJoint.x; const y = Math.sin(angle + increment) * r + inputJoint.y; diff --git a/src/app/model/mechanism/readiness.ts b/src/app/model/mechanism/readiness.ts new file mode 100644 index 00000000..f099bb74 --- /dev/null +++ b/src/app/model/mechanism/readiness.ts @@ -0,0 +1,354 @@ +import { Joint, PrisJoint, RealJoint } from '../joint'; +import { Link } from '../link'; +import { canDrive } from '../actuator'; +import { Mechanism } from './mechanism'; +import { MechanismPartition, UnassignedGeometry } from './mechanism-partition'; + +/** + * A blocker stops the mechanism running at all. A warning means it runs, and + * there is something about the result worth knowing before trusting it. + */ +export type CheckState = 'blocker' | 'warning'; + +export interface ReadinessCheck { + state: CheckState; + /** A short sentence-case phrase naming the situation. */ + title: string; + /** What is wrong and what to do about it. */ + body: string; + /** The part at fault, so the panel can offer to go to it. */ + at?: Joint | Link; + /** Title Case, because it labels a button. */ + action?: string; +} + +/** A named number about a mechanism, for the overview grid. */ +export interface MechanismFact { + label: string; + value: string; +} + +export interface MechanismReadiness { + id: string; + ready: boolean; + checks: ReadinessCheck[]; + /** What this mechanism *is*, for a reader whose question is not "why is it broken". */ + facts: MechanismFact[]; +} + +/** The two strings only the service can produce, passed in rather than reached for. */ +export interface ReadinessHelpers { + /** What to call a cylinder identified by its slider joint's id. */ + cylinderName(sliderId: string): string; + /** Why this mechanism's driven joint cannot be driven, if it cannot. */ + drivenRefusal(partition: MechanismPartition): string | undefined; + /** The cylinder-cannot-use-its-whole-stroke warning for this mechanism. */ + strokeWarning(partition: MechanismPartition): string | undefined; + /** This mechanism's input speed, in the units the panel shows it in. */ + describeSpeed(partition: MechanismPartition): string; +} + +const names = (joints: Joint[]): string => + joints.map((joint) => (joint as RealJoint).name || joint.id).join(', '); + +/** + * Everything standing between one mechanism and its animation, worst first. + * + * Ordered the way the fixes depend on one another rather than by severity, + * because a list a student works down should not send them to do something that + * cannot help yet: a slider with nothing to slide along has no mobility worth + * counting, and giving an input to a linkage whose mobility is wrong will not + * make it run. Each blocker names the way out, not just the wall. + */ +export function readinessOf( + partition: MechanismPartition, + mechanism: Mechanism, + helpers: ReadinessHelpers +): MechanismReadiness { + const checks: ReadinessCheck[] = []; + const add = (check: ReadinessCheck) => checks.push(check); + + switch (mechanism.failure) { + case 'dangling-slider': { + const dangling = partition.joints.filter( + (joint) => joint instanceof PrisJoint && joint.isDangling + ); + add({ + state: 'blocker', + title: 'A slider has nothing to slide along', + body: `Slider ${names(dangling)} has no slot and no ground, so there is no direction for it to move in. Drag it onto a link to cut a slot, or ground it to fix its direction.`, + at: dangling[0], + action: 'Go To Slider', + }); + break; + } + + case 'mobility': { + const dof = mechanism.dof; + if (Number.isNaN(dof)) { + add({ + state: 'blocker', + title: 'Nothing holds this mechanism in place', + body: 'It has no ground, so every part of it is free to drift. Ground a joint, or ground a slider’s guide.', + }); + } else if (dof > 1) { + // Point at the loose ends when there are any: a joint on one link with + // no ground is a freedom the reader can see. Only on a *binary* link, + // though — a third joint riding a link that already has two is a tracer + // point, and a tracer adds no freedom worth sending anyone to. + const freeEnds = partition.ownJoints.filter( + (joint) => + joint instanceof RealJoint && + !(joint instanceof PrisJoint) && + !joint.ground && + joint.links.length === 1 && + joint.links[0].joints.length <= 2 + ); + add({ + state: 'blocker', + title: `This mechanism has ${dof} degrees of freedom`, + body: + `One input can drive only one degree of freedom. Ground another joint, or connect a free joint to a second link, until this reads 1.` + + (freeEnds.length > 0 + ? ` ${freeEnds.length === 1 ? 'Joint' : 'Joints'} ${names(freeEnds)} ${ + freeEnds.length === 1 ? 'hangs' : 'hang' + } on only one link — free ends like that are where extra freedom usually lives.` + : ''), + at: freeEnds[0], + action: freeEnds.length > 0 ? 'Go To Joint' : undefined, + }); + } else { + const welded = partition.ownJoints.some( + (joint) => joint instanceof RealJoint && joint.isWelded + ); + add({ + state: 'blocker', + title: `This mechanism has ${dof} degrees of freedom`, + body: + 'It is over-constrained, so nothing can move at all. Remove a link, or unground a joint, until this reads 1.' + + (welded + ? ' A weld also removes freedom — unwelding a joint is another way out.' + : ''), + }); + } + break; + } + + case 'not-driven': { + // Point at a joint that could actually take the job, so the button is an + // answer rather than a place to start looking. + const candidate = partition.ownJoints.find( + (joint) => joint instanceof RealJoint && canDrive(joint) + ); + add({ + state: 'blocker', + title: 'Nothing drives this mechanism', + body: candidate + ? `There is no time to solve against until one joint is driven. Right-click joint ${(candidate as RealJoint).name || candidate.id} and switch on Driven Input.` + : 'There is no time to solve against until one joint is driven. Right-click a grounded joint and switch on Driven Input.', + at: candidate, + action: candidate ? 'Go To Joint' : undefined, + }); + break; + } + + case 'cylinder-has-no-travel': { + const id = mechanism.unusableCylinder; + const subject = id ? `Cylinder ${helpers.cylinderName(id)}` : 'This cylinder'; + add({ + state: 'blocker', + title: 'A cylinder has no travel', + body: `${subject} has a barrel too short for its rod to slide in at all. Lengthen the cylinder, or reduce Object Scale — a larger scale draws everything on the rod bigger without lengthening the barrel.`, + }); + break; + } + + case 'dead-position': + add({ + state: 'blocker', + title: 'This mechanism starts at a dead position', + body: 'The driven joint is at a limit of its travel and cannot turn away from it in either direction. Drag a joint to move the mechanism off the limit.', + }); + break; + + case 'cycle-never-closes': { + const gap = mechanism.cycleGap; + add({ + state: 'blocker', + title: 'The motion never repeats', + body: + 'This mechanism never comes back to the pose it started in, so there is no cycle to animate.' + + (gap !== undefined && Number.isFinite(gap) + ? gap < 0.5 + ? ` The closest it comes is ${gap.toFixed(2)} units away — a loop that only just fails to close usually has a link length slightly off.` + : ` The closest it comes is ${gap.toFixed(1)} units away — the motion wanders rather than repeating. Check the link lengths.` + : ' Check the link lengths — a loop that only just closes can wander instead of repeating.'), + }); + break; + } + + case 'nothing-can-move': { + const unreachable = partition.ownJoints.filter((joint) => + mechanism.unreachableJoints.includes(joint.id) + ); + add({ + state: 'blocker', + title: 'Nothing moves when the input turns', + body: + unreachable.length > 0 + ? `The solver never finds a position for ${ + unreachable.length === 1 ? 'joint' : 'joints' + } ${names(unreachable)} — the driven joint cannot reach ${ + unreachable.length === 1 ? 'it' : 'them' + } through the links. Check the connections between the input and ${ + unreachable.length === 1 ? 'that joint' : 'those joints' + }.` + : 'The driven joint cannot reach the rest of the mechanism, so no other joint has a position to solve for. Check that it is connected through links to the parts you expect it to move.', + at: unreachable[0], + action: unreachable.length > 0 ? 'Go To Joint' : undefined, + }); + break; + } + } + + // Asked even of a mechanism the solver accepted: the toggle refuses a joint + // it cannot describe, but nothing stops a later edit adding a third body to a + // joint that was legitimately driven when it was switched on. + const refusal = helpers.drivenRefusal(partition); + if (refusal) { + const driven = partition.ownJoints.find((joint) => joint instanceof RealJoint && joint.input); + add({ + state: 'blocker', + title: 'The driven joint cannot be driven', + body: refusal, + at: driven, + action: driven ? 'Go To Joint' : undefined, + }); + } + + const stroke = helpers.strokeWarning(partition); + if (stroke) { + add({ state: 'warning', title: 'A cylinder cannot use its whole stroke', body: stroke }); + } + + return { + id: partition.id, + ready: mechanism.isMechanismValid() && checks.every((check) => check.state !== 'blocker'), + checks, + facts: factsOf(partition, mechanism, helpers), + }; +} + +/** + * What a mechanism is, as opposed to what is wrong with it. + * + * A reader who has just been told their linkage is ready still has questions — + * which joint drives it, how long a cycle takes, whether it goes round or backs + * up — and until now the app answered none of them anywhere. + */ +function factsOf( + partition: MechanismPartition, + mechanism: Mechanism, + helpers: ReadinessHelpers +): MechanismFact[] { + const driven = partition.joints.find((joint) => joint instanceof RealJoint && joint.input) as + RealJoint | undefined; + const moving = partition.links.length; + const dof = mechanism.dof; + const facts: MechanismFact[] = [ + { label: 'Degrees of freedom', value: Number.isFinite(dof) ? String(dof) : '—' }, + { label: 'Links / joints', value: `${moving} / ${partition.ownJoints.length}` }, + { label: 'Driven joint', value: driven ? driven.name || driven.id : 'Not set' }, + ]; + if (mechanism.isMechanismValid()) { + facts.push({ label: 'Input speed', value: helpers.describeSpeed(partition) }); + facts.push({ label: 'Cycle time', value: `${mechanism.cyclePeriod.toFixed(2)} s` }); + facts.push({ + label: 'Motion', + value: reciprocates(mechanism) ? 'Reciprocating' : 'Continuous', + }); + } + return facts; +} + +/** + * Out and back, rather than round and round. + * + * Taken from the sign of the recorded input velocity, which the solver flips at + * each reversal — so a cycle containing both signs is one that turned around. + * + * An earlier version compared a joint's position at the start and the midpoint, + * which read every mechanism as reciprocating: the joint it sampled was the + * first in the frame, and the first joint is usually ground, which never moves. + */ +function reciprocates(mechanism: Mechanism): boolean { + const speeds = mechanism.inputAngularVelocities; + return speeds.some((speed) => speed > 0) && speeds.some((speed) => speed < 0); +} + +/** One condition force analysis needs, and whether the drawing meets it. */ +export interface ForceRequirement { + met: boolean; + /** + * Unmet-but-not-blocking: the analysis runs anyway, and the row is worth + * reading before trusting the numbers. Warnings do not gate readiness and + * are not counted by the "N to set" chips. + */ + warning?: boolean; + /** A short sentence-case phrase naming the condition. */ + title: string; + /** Met: what is true. Unmet: what is missing, and how to supply it. */ + body: string; + /** + * A fix the panel can carry out itself, where there is one. + * + * Turning gravity back on is the only one so far, and only where it settles + * the matter on its own. The other ways out of an unloaded drawing -- attach + * a force, give a body mass -- are a gesture on the canvas and a row in the + * table directly below, and a button here would stand in for neither. + */ + act?: 'gravity'; +} + +export interface UnassignedReport { + /** The part at fault, so the panel can offer to go to it. */ + at?: Joint; + title: string; + body: string; +} + +/** + * What to say about geometry that is in no mechanism. + * + * Split by cause, because the two have different ways out: a floating chain + * needs grounding, a joint on its own needs connecting. + */ +export function describeUnassigned(unassigned: UnassignedGeometry): UnassignedReport[] { + const reports: UnassignedReport[] = []; + + unassigned.floatingChains.forEach((chain) => { + const sorted = [...chain.joints].sort((a, b) => a.id.localeCompare(b.id)); + reports.push({ + at: sorted[0], + title: `Joints ${names(sorted)} never reach ground`, + body: 'Nothing anchors this chain, so there is nothing for it to move against and no position to solve for. Ground one of its joints to make it a mechanism.', + }); + }); + + unassigned.fixedLinks.forEach((link) => { + reports.push({ + title: `Link ${link.name || link.id} is fixed at both ends`, + body: 'Every joint on it is grounded, so it is part of the frame and nothing about it can move. Unground one of its joints to make it a mechanism, or leave it as a fixed reference.', + }); + }); + + unassigned.looseJoints.forEach((joint) => { + reports.push({ + at: joint, + title: `Joint ${(joint as RealJoint).name || joint.id} has no link`, + body: 'A joint on its own is not part of any mechanism and is skipped by analysis. Attach a link to it, or delete it.', + }); + }); + + return reports; +} diff --git a/src/app/model/mechanism/stress-solver.ts b/src/app/model/mechanism/stress-solver.ts deleted file mode 100644 index 157281ed..00000000 --- a/src/app/model/mechanism/stress-solver.ts +++ /dev/null @@ -1 +0,0 @@ -export class StressSolver {} diff --git a/src/app/model/object-scale.ts b/src/app/model/object-scale.ts new file mode 100644 index 00000000..992ceb2e --- /dev/null +++ b/src/app/model/object-scale.ts @@ -0,0 +1,20 @@ +import { BehaviorSubject } from 'rxjs'; +import { MODEL_SCALE } from './render-scale'; + +/** + * The size of the drawn marks — joints, ground hatching, arrows — as an + * internal length. It lives here, in a module with no other imports, rather + * than on SettingsService where its public face is: Coord's closeness + * thresholds scale with it, and a geometry leaf importing the service graph is + * the module cycle that left `Coord` undefined while `Joint extends Coord` + * was being evaluated. + * + * 0.7 rather than 1: joints, blocks and cylinder heads at full size crowd a + * linkage of ordinary proportions, and the first thing most people did was + * turn it down. A mechanism arriving from a URL carries its own scale, so + * this is what a new project starts at and what an older URL without the + * setting falls back to. + */ +export const DEFAULT_OBJECT_SCALE = 0.7 * MODEL_SCALE; + +export const OBJECT_SCALE = new BehaviorSubject(DEFAULT_OBJECT_SCALE); diff --git a/src/app/model/outline-winding.ts b/src/app/model/outline-winding.ts index 64ba3664..260e6816 100644 --- a/src/app/model/outline-winding.ts +++ b/src/app/model/outline-winding.ts @@ -38,11 +38,11 @@ interface PointLike { * see is removed on the same grounds as one that is exactly flat. */ export function withoutCollinearVertices( - order: string, + order: readonly string[], joints: PointLike[], indexOf: Map<string, number>, width: number -): string { +): string[] { let kept = [...order]; const flatness = width * 1e-3; for (let pass = 0; pass < kept.length && kept.length > 2; pass++) { @@ -67,16 +67,16 @@ export function withoutCollinearVertices( if (index < 0) break; kept.splice(index, 1); } - return kept.join(''); + return kept; } export function outlineSweepFlag( - order: string, + order: readonly string[], joints: PointLike[], indexOf: Map<string, number>, width: number ): '0' | '1' { - const outline = [...order].map((id) => joints[indexOf.get(id)!]).filter(Boolean); + const outline = order.map((id) => joints[indexOf.get(id)!]).filter(Boolean); if (outline.length < 2) return '1'; const area = outline.length > 2 ? signedArea(outline) : 0; // A hull with no area has no winding to read, and joints do fall exactly on diff --git a/src/app/model/tutorial-steps.spec.ts b/src/app/model/tutorial-steps.spec.ts new file mode 100644 index 00000000..942386ab --- /dev/null +++ b/src/app/model/tutorial-steps.spec.ts @@ -0,0 +1,144 @@ +import { RevJoint } from './joint'; +import { Coord } from './coord'; +import { RealLink } from './link'; +import { copyFor, endJoints, linksAreChained, progressFor } from './tutorial-steps'; + +/** One bar between two joints, wired the way the grid wires one. */ +function bar(a: RevJoint, b: RevJoint): RealLink { + const link = new RealLink(a.id + b.id, [a, b], 1, 1, new Coord((a.x + b.x) / 2, (a.y + b.y) / 2)); + a.links.push(link); + b.links.push(link); + a.connectedJoints.push(b); + b.connectedJoints.push(a); + return link; +} + +/** A─B─C─D, the chain the tutorial is walking the student towards. */ +function chain() { + const a = new RevJoint('A', -2, 0); + const b = new RevJoint('B', -2, 1); + const c = new RevJoint('C', 0.99, 2.82); + const d = new RevJoint('D', 2, 0); + const links = [bar(a, b), bar(b, c), bar(c, d)]; + return { joints: [a, b, c, d], links, a, b, c, d }; +} + +/** Three bars that never touch — what `Add Link` three times actually leaves. */ +function looseBars() { + const joints: RevJoint[] = []; + const links: RealLink[] = []; + for (const [one, two] of [ + ['A', 'B'], + ['C', 'D'], + ['E', 'F'], + ]) { + const p = new RevJoint(one, 0, 0); + const q = new RevJoint(two, 1, 1); + joints.push(p, q); + links.push(bar(p, q)); + } + return { joints, links }; +} + +describe('which step the drawing is on', () => { + it('starts at step 1 with nothing drawn', () => { + expect(progressFor([], []).step).toBe(1); + }); + + it('asks for the chain once one bar exists', () => { + const a = new RevJoint('A', 0, 0); + const b = new RevJoint('B', 1, 0); + const progress = progressFor([a, b], [bar(a, b)]); + expect(progress.step).toBe(2); + expect(progress.achieved).toBe('First bar drawn'); + }); + + /** + * The reason step 1 and step 2 are two steps rather than one. + * + * The prototype counted links, so three bars dropped on bare grid read as a + * finished chain and sent the student on to ground "the two end joints" of + * something with six of them. Connectivity is the question being asked. + */ + it('does not accept three bars that never touch as a chain', () => { + const { joints, links } = looseBars(); + expect(links.length).toBe(3); + expect(linksAreChained(links)).toBe(false); + expect(progressFor(joints, links).step).toBe(2); + }); + + it('moves to grounding once three links are joined up', () => { + const { joints, links } = chain(); + const progress = progressFor(joints, links); + expect(progress.step).toBe(3); + expect(progress.achieved).toBe('Three links chained'); + }); + + it('names and rings an end joint, not one in the middle', () => { + const { joints, links, a, d } = chain(); + expect(endJoints(joints).map((j) => j.id)).toEqual(['A', 'D']); + const progress = progressFor(joints, links); + expect([a, d]).toContain(progress.target); + expect(copyFor(progress).body).toContain('joint A'); + }); + + /** + * A drawing can arrive with one end already grounded — a shared link, or a + * student who did half of it. Naming the finished end sends them nowhere. + */ + it('skips past the end that is already grounded', () => { + const { joints, links, a, d } = chain(); + a.ground = true; + const progress = progressFor(joints, links); + expect(progress.step).toBe(3); + expect(progress.target).toBe(d); + expect(progress.alsoTarget).toBeUndefined(); + expect(copyFor(progress).body).not.toContain('at the other end'); + }); + + it('asks for an input once both ends are grounded', () => { + const { joints, links, a, d } = chain(); + a.ground = true; + d.ground = true; + const progress = progressFor(joints, links); + expect(progress.step).toBe(4); + expect(progress.achieved).toBe('Joints A and D grounded'); + expect(progress.target).toBe(a); + }); + + it('reaches the last step once a joint drives it', () => { + const { joints, links, a, d } = chain(); + a.ground = true; + d.ground = true; + a.input = true; + const progress = progressFor(joints, links); + expect(progress.step).toBe(5); + expect(progress.achieved).toBe('Joint A is the input'); + }); +}); + +describe('what the card says', () => { + /** + * The two gestures are genuinely different controls, and the mock had step + * one telling the student to use the wrong one three times. + */ + it('sends the student to Add first and Attach after', () => { + // The menu names the verb once, on the group, and the row below it is the + // bare noun -- so what the student is told to look for is the group. + expect(copyFor({ step: 1 }).body).toContain('under Add'); + expect(copyFor({ step: 1 }).body).not.toContain('under Attach'); + expect(copyFor({ step: 2 }).body).toContain('under Attach'); + }); + + it('names the switches the menu actually shows', () => { + // Not "Add Ground" and "Add Input": those were labels that flipped as they + // were used, and the menu writes states as states now. + expect(copyFor({ step: 3, target: { id: 'A' } as never }).body).toContain('Grounded'); + expect(copyFor({ step: 4, target: { id: 'A' } as never }).body).toContain('Driven Input'); + }); + + it('never tells the student to add three links from the grid', () => { + expect(copyFor({ step: 1 }).body).not.toMatch(/three times/i); + expect(copyFor({ step: 2 }).body).not.toMatch(/Right-click anywhere/i); + }); +}); diff --git a/src/app/model/tutorial-steps.ts b/src/app/model/tutorial-steps.ts new file mode 100644 index 00000000..6c7eacab --- /dev/null +++ b/src/app/model/tutorial-steps.ts @@ -0,0 +1,205 @@ +import { Joint, RealJoint } from './joint'; +import { Link } from './link'; +import { canDrive } from './actuator'; + +/** + * The five moves the tutorial teaches, and how to tell from the drawing which + * one is still outstanding. + * + * Read off the mechanism rather than counted by a Next button: a student who + * grounds the wrong joint simply stays where they are, and one who arrives on a + * half-built drawing starts at the first move it has not already made. That is + * also what makes the tutorial replayable on somebody else's linkage. + */ +export type TutorialStepId = 1 | 2 | 3 | 4 | 5; + +export const TUTORIAL_STEP_COUNT = 5; + +/** What the drawing has to look like for a step to be behind you. */ +export interface TutorialProgress { + /** The first step this drawing has not satisfied. */ + step: TutorialStepId; + /** The joint the card names and the grid rings, where a step has one. */ + target?: RealJoint; + /** The second joint step 3 still wants, so the copy can name both at once. */ + alsoTarget?: RealJoint; + /** What the *previous* step achieved, shown as the card's green line. */ + achieved?: string; +} + +const real = (joints: Joint[]): RealJoint[] => + joints.filter((joint): joint is RealJoint => joint instanceof RealJoint); + +/** + * The joints at the ends of a chain — the ones a four-bar grounds. + * + * Degree one in the link graph, which is what "the far end of the last bar" + * means once the student has stopped following along exactly. + */ +export function endJoints(joints: Joint[]): RealJoint[] { + return real(joints).filter((joint) => joint.links.length === 1); +} + +/** + * Whether the drawing holds three links that are actually joined up. + * + * Three separate bars are three links and are not a chain, and this is the + * whole reason step 1 and step 2 are two steps: Add on the bare grid makes a + * free-standing bar every time, so three of them never touch. Connectivity + * is the test rather than a joint count, because a student who builds their + * chain in a different order still built a chain. + */ +export function linksAreChained(links: Link[]): boolean { + if (links.length < 3) return false; + const reached = new Set<Link>([links[0]]); + const queue: Link[] = [links[0]]; + while (queue.length > 0) { + const link = queue.pop()!; + for (const joint of link.joints) { + if (!(joint instanceof RealJoint)) continue; + for (const neighbour of joint.links) { + if (!reached.has(neighbour)) { + reached.add(neighbour); + queue.push(neighbour); + } + } + } + } + return reached.size >= 3; +} + +const nameOf = (joint: RealJoint): string => joint.name || joint.id; + +/** + * Which step this drawing is on, and the joint that step is about. + * + * The target is resolved from the drawing before the copy that names it: a + * drawing that arrives with one end already grounded must ring the *other* + * end, or the sentence sends the student to the finished one. + */ +export function progressFor(joints: Joint[], links: Link[]): TutorialProgress { + return progressAt(joints, links, stepOf(joints, links)); +} + +/** The first of the five moves this drawing has not made. */ +export function stepOf(joints: Joint[], links: Link[]): TutorialStepId { + if (links.length < 1) return 1; + if (!linksAreChained(links)) return 2; + if (real(joints).filter((joint) => joint.ground).length < 2) return 3; + if (!real(joints).some((joint) => joint.input)) return 4; + return 5; +} + +/** + * One step's view of this drawing — including a step it is already past. + * + * The card can be turned back to re-read a step that is done, so every step has + * to be describable against a drawing that has moved on. Where the joint a step + * asks about has already been dealt with, the finished one is named instead of + * nothing: the sentence becomes a record of what was done rather than an + * instruction, and a record still has to say which joint it was about. + */ +export function progressAt(joints: Joint[], links: Link[], step: TutorialStepId): TutorialProgress { + const ends = endJoints(joints); + const grounded = real(joints).filter((joint) => joint.ground); + const input = real(joints).find((joint) => joint.input); + + switch (step) { + case 1: + return { step: 1 }; + case 2: + return { step: 2, achieved: 'First bar drawn' }; + case 3: { + const loose = ends.filter((joint) => !joint.ground); + const named = loose.length > 0 ? loose : ends; + return { step: 3, target: named[0], alsoTarget: named[1], achieved: 'Three links chained' }; + } + case 4: { + const drivable = grounded.filter((joint) => canDrive(joint)); + return { + step: 4, + target: input ?? drivable[0] ?? grounded[0], + achieved: + grounded.length > 0 + ? `${grounded.length > 1 ? 'Joints' : 'Joint'} ${grounded.map(nameOf).join(' and ')} grounded` + : undefined, + }; + } + default: + return { + step: 5, + achieved: input ? `Joint ${nameOf(input)} is the input` : undefined, + }; + } +} + +/** A joint worth reading a velocity off: one that moves. */ +export function readableJoints(joints: Joint[]): RealJoint[] { + return real(joints).filter((joint) => !joint.ground); +} + +/** The card's words for one step, with the joints of this drawing named in them. */ +export interface TutorialCopy { + title: string; + body: string; + hint?: string; + hintGlyph?: string; +} + +/** + * What the card says, given where the student is. + * + * Step 1 and step 2 name *different gestures*, which is the point of their + * being two steps. Add on the bare grid always makes a free-standing bar — + * doing it three times leaves three bars that never touch — so the chain is + * built by attaching to the far end of what is already there. + */ +export function copyFor(progress: TutorialProgress): TutorialCopy { + const target = progress.target ? nameOf(progress.target) : ''; + switch (progress.step) { + case 1: + return { + title: 'Draw the first bar', + // The gesture is a click, then a move, then a click -- not a drag. The + // bar's far end follows the pointer between the two, and describing it + // as "drag and release" had readers holding the button down through a + // move that does not want it held. + body: 'Right-click the empty grid and choose Link, under Add. One end is placed where you clicked and the other follows your pointer — move to where you want it and left-click to set it.', + hint: 'Right-click is how everything is added in PMKS+. The panel on the left edits whatever you then select.', + hintGlyph: 'mouse', + }; + case 2: + return { + title: 'Extend it into a chain of three', + body: 'Right-click the joint at the far end of the bar and choose Link, under Attach, then left-click where the new bar should end. Do that twice, each time from the newest end, so the three links make a chain.', + hint: 'On the grid the group is Add, and the new bar stands on its own. On a joint it is Attach, and the new bar joins the joint that is already there.', + hintGlyph: 'link', + }; + case 3: { + const also = progress.alsoTarget ? nameOf(progress.alsoTarget) : ''; + return { + title: 'Ground the two end joints', + body: + `Right-click joint ${target}, the ringed one, and switch on Grounded. A grounded joint is fixed to the frame.` + + (also ? ` Then do the same at joint ${also}, at the other end.` : ''), + hint: 'A mechanism with nothing grounded floats away.', + hintGlyph: 'push_pin', + }; + } + case 4: + return { + title: 'Make one joint the input', + body: `Right-click joint ${target}, the ringed one, and switch on Driven Input. The input is the joint that drives the mechanism. A mechanism needs exactly one.`, + }; + default: + return { + title: 'Play it and read a velocity', + // Named by where it is, not only by what it says: below about 900px the + // mode tabs give up their labels and a student is looking at four + // unexplained glyphs. + body: 'Switch to Kinematic Analysis — the third mode in the strip along the top — then press Play at the bottom of the window and click a moving joint on the grid.', + hint: 'The reading follows the pose, so scrubbing the handle moves the number.', + hintGlyph: 'speed', + }; + } +} diff --git a/src/app/model/uniform-body.spec.ts b/src/app/model/uniform-body.spec.ts new file mode 100644 index 00000000..3f6adfc6 --- /dev/null +++ b/src/app/model/uniform-body.spec.ts @@ -0,0 +1,78 @@ +import { uniformBodyOf } from './uniform-body'; + +// Checked against the closed forms every statics text carries, not against the +// implementation: a rod is mL²/12, a rectangular plate m(w²+h²)/12, a triangle +// m(a²+b²+c²)/36. Auto-derived mass properties are only worth having if they +// are the numbers the textbook would print. + +describe('a uniform body over a link skeleton', () => { + it('treats two joints as a slender rod', () => { + const body = uniformBodyOf([ + { x: 1, y: 2 }, + { x: 4, y: 6 }, + ]); + expect(body.centroid.x).toBeCloseTo(2.5, 12); + expect(body.centroid.y).toBeCloseTo(4, 12); + expect(body.gyrationSq).toBeCloseTo(25 / 12, 12); + }); + + it('treats four corners as a rectangular plate', () => { + const body = uniformBodyOf([ + { x: 0, y: 0 }, + { x: 6, y: 0 }, + { x: 6, y: 2 }, + { x: 0, y: 2 }, + ]); + expect(body.centroid.x).toBeCloseTo(3, 12); + expect(body.centroid.y).toBeCloseTo(1, 12); + expect(body.gyrationSq).toBeCloseTo((36 + 4) / 12, 12); + }); + + it('treats three joints as a triangular plate', () => { + const a = { x: 0, y: 0 }; + const b = { x: 4, y: 0 }; + const c = { x: 1, y: 3 }; + const body = uniformBodyOf([a, b, c]); + expect(body.centroid.x).toBeCloseTo((a.x + b.x + c.x) / 3, 12); + expect(body.centroid.y).toBeCloseTo((a.y + b.y + c.y) / 3, 12); + const side = (p: { x: number; y: number }, q: { x: number; y: number }) => + (p.x - q.x) ** 2 + (p.y - q.y) ** 2; + expect(body.gyrationSq).toBeCloseTo((side(a, b) + side(b, c) + side(c, a)) / 36, 12); + }); + + it('falls back to the rod when the joints are collinear', () => { + // A tracer on the bar's own axis must not turn the bar into a zero-area + // plate; it stays the rod between the farthest pair. + const body = uniformBodyOf([ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 5, y: 0 }, + ]); + expect(body.centroid.x).toBeCloseTo(2.5, 12); + expect(body.gyrationSq).toBeCloseTo(25 / 12, 12); + }); + + it('ignores a joint interior to the hull', () => { + // The plate is the drawn shape, and the drawn shape is the hull: a joint + // strictly inside it adds no material. + const withInterior = uniformBodyOf([ + { x: 0, y: 0 }, + { x: 6, y: 0 }, + { x: 6, y: 2 }, + { x: 0, y: 2 }, + { x: 3, y: 1 }, + ]); + expect(withInterior.gyrationSq).toBeCloseTo((36 + 4) / 12, 12); + }); + + it('shrugs at duplicates and degenerate input rather than dividing by zero', () => { + expect(uniformBodyOf([]).gyrationSq).toBe(0); + expect(uniformBodyOf([{ x: 1, y: 1 }]).gyrationSq).toBe(0); + const doubled = uniformBodyOf([ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 3, y: 0 }, + ]); + expect(doubled.gyrationSq).toBeCloseTo(9 / 12, 12); + }); +}); diff --git a/src/app/model/uniform-body.ts b/src/app/model/uniform-body.ts new file mode 100644 index 00000000..99d36e27 --- /dev/null +++ b/src/app/model/uniform-body.ts @@ -0,0 +1,148 @@ +import { Coord } from './coord'; + +/** + * Mass properties of a link modeled as a uniform body over its own joints. + * + * Derived from the joint skeleton alone, never from the drawn outline: bar + * width, pin radius and fillets all follow Object Scale, and a moment of + * inertia that changed when someone adjusted a display slider would be physics + * depending on cosmetics. Joint positions are real geometry; these numbers + * move only when the mechanism does. + * + * The idealization by shape: + * + * - two joints — a slender rod between them: centroid at the midpoint, + * k² = L²/12; + * - three or more — a uniform plate over the convex hull of the joints, + * which is the same hull the link is drawn as: polygon centroid, polygon + * second moment; + * - a degenerate hull (collinear joints, e.g. a tracer on the bar's own + * axis) — the rod again, between the two farthest joints. + * + * Everything is expressed per unit mass: `gyrationSq` is k², the squared + * radius of gyration, in squared model units. MoI = mass × k², converted to + * whatever unit the store keeps inertia in — mass stays the one number a + * person chooses, and these follow it. + */ +export interface UniformBody { + /** Centroid, in model coordinates. */ + centroid: Coord; + /** Squared radius of gyration about the centroid, in squared model units. */ + gyrationSq: number; +} + +export function uniformBodyOf(joints: { x: number; y: number }[]): UniformBody { + const points = dedupe(joints); + if (points.length === 0) { + return { centroid: new Coord(0, 0), gyrationSq: 0 }; + } + if (points.length === 1) { + return { centroid: new Coord(points[0].x, points[0].y), gyrationSq: 0 }; + } + if (points.length >= 3) { + const hull = convexHull(points); + if (hull.length >= 3) { + const plate = platedPolygon(hull); + if (plate) return plate; + } + } + return rod(points); +} + +/** The slender rod between the two farthest of the points. */ +function rod(points: { x: number; y: number }[]): UniformBody { + let a = points[0]; + let b = points[1] ?? points[0]; + let longest = -1; + for (let i = 0; i < points.length; i++) { + for (let j = i + 1; j < points.length; j++) { + const span = (points[i].x - points[j].x) ** 2 + (points[i].y - points[j].y) ** 2; + if (span > longest) { + longest = span; + a = points[i]; + b = points[j]; + } + } + } + return { + centroid: new Coord((a.x + b.x) / 2, (a.y + b.y) / 2), + gyrationSq: Math.max(longest, 0) / 12, + }; +} + +/** + * Uniform plate over a convex polygon: centroid and k² by the standard + * shoelace second-moment formulas. Returns undefined for a degenerate area, + * so the caller can fall back to the rod. + */ +function platedPolygon(points: { x: number; y: number }[]): UniformBody | undefined { + // In the first vertex's frame, not the world's: the polar terms below grow + // with distance⁴ from the origin, and a small link far from (0,0) loses its + // own second moment to cancellation. Translation changes neither area, nor + // centroid (shifted back at the end), nor the centroidal moment. + const origin = points[0]; + const hull = points.map((p) => ({ x: p.x - origin.x, y: p.y - origin.y })); + let area2 = 0; + let cx = 0; + let cy = 0; + let inertia = 0; // polar second moment about the origin, times 6/area2 pending + for (let i = 0; i < hull.length; i++) { + const p = hull[i]; + const q = hull[(i + 1) % hull.length]; + const cross = p.x * q.y - q.x * p.y; + area2 += cross; + cx += (p.x + q.x) * cross; + cy += (p.y + q.y) * cross; + inertia += cross * (p.x * p.x + p.x * q.x + q.x * q.x + p.y * p.y + p.y * q.y + q.y * q.y); + } + const area = area2 / 2; + const span = hull.reduce( + (widest, p) => Math.max(widest, Math.abs(p.x - hull[0].x), Math.abs(p.y - hull[0].y)), + 0 + ); + // Degenerate: the hull encloses next to nothing compared to its own reach. + if (Math.abs(area) < 1e-9 * Math.max(span * span, 1)) return undefined; + + const centroidX = cx / (3 * area2); + const centroidY = cy / (3 * area2); + // Polar second moment per unit mass about the origin, then moved to the + // centroid by the parallel-axis theorem. + const polarOverMass = inertia / (6 * area2); + const gyrationSq = polarOverMass - (centroidX * centroidX + centroidY * centroidY); + return { + centroid: new Coord(centroidX + origin.x, centroidY + origin.y), + gyrationSq: Math.max(gyrationSq, 0), + }; +} + +/** Andrew's monotone chain; returns the hull counterclockwise. */ +function convexHull(points: { x: number; y: number }[]): { x: number; y: number }[] { + const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y); + const cross = ( + o: { x: number; y: number }, + a: { x: number; y: number }, + b: { x: number; y: number } + ) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + const half = (list: { x: number; y: number }[]) => { + const chain: { x: number; y: number }[] = []; + for (const p of list) { + while (chain.length >= 2 && cross(chain[chain.length - 2], chain[chain.length - 1], p) <= 0) { + chain.pop(); + } + chain.push(p); + } + chain.pop(); + return chain; + }; + return [...half(sorted), ...half([...sorted].reverse())]; +} + +function dedupe(points: { x: number; y: number }[]): { x: number; y: number }[] { + const seen = new Set<string>(); + return points.filter((p) => { + const key = `${p.x},${p.y}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/app/model/unit-conversions.ts b/src/app/model/unit-conversions.ts index d73a71f2..1e960a13 100644 --- a/src/app/model/unit-conversions.ts +++ b/src/app/model/unit-conversions.ts @@ -68,6 +68,7 @@ export const INERTIA_TO_KG_M2: Record<number, number> = { [InertiaUnit.KG_CM2]: 0.0001, [InertiaUnit.KG_M2]: 1, [InertiaUnit.LBM_IN2]: KG_PER_LBM * METERS_PER_INCH * METERS_PER_INCH, + [InertiaUnit.G_CM2]: 0.0000001, }; /** Seconds per one of each time unit. */ diff --git a/src/app/model/unit-enums.ts b/src/app/model/unit-enums.ts index 54a39e16..65c8320c 100644 --- a/src/app/model/unit-enums.ts +++ b/src/app/model/unit-enums.ts @@ -35,6 +35,13 @@ export enum InertiaUnit { KG_CM2 = 51, KG_M2 = 52, NULL = 53, + /** + * The metric *display* unit: g pairs with g·cm², where g beside kg·cm² read + * as two different systems in one panel. Storage stays KG_CM2 — the solver's + * unit factors and every URL in circulation are written against it — so this + * unit exists only at the input/label boundary. + */ + G_CM2 = 54, } export enum TimeUnit { diff --git a/src/app/model/utils.ts b/src/app/model/utils.ts index 3b62a5c2..326321a1 100644 --- a/src/app/model/utils.ts +++ b/src/app/model/utils.ts @@ -1,15 +1,9 @@ -import { Joint, PrisJoint, RealJoint, RevJoint } from './joint'; +import { Joint } from './joint'; import { Coord } from './coord'; import { Shape } from './link'; -import { Pose } from './pose'; -import { ConstrucLines } from './constructionlines_syn'; -import { NewGridComponent } from '../component/new-grid/new-grid.component'; - -export class Utils {} // radToDeg export * from './unit-enums'; -import { AngleUnit, ForceUnit, GlobalUnit, LengthUnit } from './unit-enums'; // The possible states the program could be in. export enum gridStates { @@ -44,23 +38,6 @@ export enum forceStates { draggingBody, } -export enum shapeEditModes { - move, - resize, -} - -export enum createModes { - link, - force, -} - -export enum moveModes { - joint, - forceEndpoint, - threePosition, - pathPoint, -} - // degToRad export function roundNumber(num: number, scale: number): number { @@ -76,15 +53,6 @@ export function vecMake(n: number, val: number) { return result; } -export function vecInit(s: string) { - let vals = s.split(','); - let result = []; - for (let i = 0; i < vals.length; ++i) { - result[i] = parseFloat(vals[i]); - } - return result; -} - export function matMake(rows: number, cols: number, val: number) { let result: Array<Array<number>> = []; for (let i = 0; i < rows; ++i) { @@ -96,19 +64,6 @@ export function matMake(rows: number, cols: number, val: number) { return result; } -export function matInit(rows: number, cols: number, s: string) { - // ex: let m = matInit(2, 3, "1,2,3, 4,5,6"); - let result = matMake(rows, cols, 0.0); - let vals = s.split(','); - let k = 0; - for (let i = 0; i < rows; ++i) { - for (let j = 0; j < cols; ++j) { - result[i][j] = parseFloat(vals[k++]); - } - } - return result; -} - export function matProduct(ma: Array<Array<number>>, mb: Array<Array<number>>) { let aRows = ma.length; let aCols = ma[0].length; @@ -134,6 +89,34 @@ export function matProduct(ma: Array<Array<number>>, mb: Array<Array<number>>) { return result; } +export function reduce(lum: Array<Array<number>>, b: Array<number>) { + // helper + let n = lum.length; + let x = vecMake(n, 0.0); + for (let i = 0; i < n; ++i) { + x[i] = b[i]; + } + + for (let i = 1; i < n; ++i) { + let sum = x[i]; + for (let j = 0; j < i; ++j) { + sum -= lum[i][j] * x[j]; + } + x[i] = sum; + } + + x[n - 1] /= lum[n - 1][n - 1]; + for (let i = n - 2; i >= 0; --i) { + let sum = x[i]; + for (let j = i + 1; j < n; ++j) { + sum -= lum[i][j] * x[j]; + } + x[i] = sum / lum[i][i]; + } + + return x; +} // reduce + export function matInverse(m: Array<Array<number>>) { // assumes determinant is not 0 // that is, the matrix does have an inverse @@ -162,15 +145,6 @@ export function matInverse(m: Array<Array<number>>) { return result; } -export function matDeterminant(m: Array<Array<number>>) { - let n = m.length; - let lum = matMake(n, n, 0.0); - let perm = vecMake(n, 0.0); - let result = matDecompose(m, lum, perm); // -1 or +1 - for (let i = 0; i < n; ++i) result *= lum[i][i]; - return result; -} - export function matDecompose( m: Array<Array<number>>, lum: Array<Array<number>>, @@ -240,34 +214,6 @@ export function matDecompose( return toggle; // for determinant } // matDecompose -export function reduce(lum: Array<Array<number>>, b: Array<number>) { - // helper - let n = lum.length; - let x = vecMake(n, 0.0); - for (let i = 0; i < n; ++i) { - x[i] = b[i]; - } - - for (let i = 1; i < n; ++i) { - let sum = x[i]; - for (let j = 0; j < i; ++j) { - sum -= lum[i][j] * x[j]; - } - x[i] = sum; - } - - x[n - 1] /= lum[n - 1][n - 1]; - for (let i = n - 2; i >= 0; --i) { - let sum = x[i]; - for (let j = i + 1; j < n; ++j) { - sum -= lum[i][j] * x[j]; - } - x[i] = sum / lum[i][i]; - } - - return x; -} // reduce - export function matLinearSystem(A: Array<Array<number>>, B: Array<Array<number>>) { const inv_A = matInverse(A); return matProduct(inv_A, B); @@ -305,87 +251,10 @@ export function degToRad(deg: number) { return (deg * Math.PI) / 180.0; } -export function getXDistance(r: number, theta: number) { - return Math.cos(theta) * r; -} - -export function getYDistance(r: number, theta: number) { - return Math.sin(theta) * r; -} - export function determineSlope(x1: number, y1: number, x2: number, y2: number) { return (y2 - y1) / (x2 - x1); } -export function determineYIntersect(x: number, y: number, m: number) { - return y - m * x; -} - -export function determineX(m1: number, b1: number, m2: number, b2: number) { - return (b2 - b1) / (m1 - m2); -} - -export function determineY(x: number, m: number, b: number) { - return m * x + b; -} - -export function stringToFloat(str: string) { - const res = parseFloat(str); - if (Number.isNaN(res)) { - throw new Error('should be a number, file corrupted'); - } - return res; -} - -export function stringToBoolean(str: string) { - const lowerCaseString = str.toLowerCase(); - switch (lowerCaseString) { - case 't': - case 'true': - return true; - case 'f': - case 'false': - return false; - default: - throw new Error('should be a boolean, file corrupted'); - } -} - -export function stringToShape(str: string) { - switch (str) { - case 'line': - return Shape.line; - case 'bar': - return Shape.bar; - case 'eTriangle': - return Shape.eTriangle; - case 'rTriangle': - return Shape.rTriangle; - case 'rectangle': - return Shape.rectangle; - case 'square': - return Shape.square; - case 'circle': - return Shape.circle; - case 'cShape': - return Shape.cShape; - case 'tShape': - return Shape.tShape; - case 'lShape': - return Shape.lShape; - default: - return Shape.line; - // case Shape.horizontalLine: - // case Shape.verticalLine: - // case Shape.slantedLineForward: - // case Shape.slantedLineBackward: - // case Shape.beanShape: - // case Shape.infinityShape: - // case Shape.eightShape: - // case Shape.customShape: - } -} - // TODO: In future, can replace with this: https://www.gatevidyalay.com/2d-rotation-in-computer-graphics-definition-examples/ // Easier to understand, less variables to account for, and computationally faster export function determineUnknownJointUsingTriangulation( @@ -452,192 +321,6 @@ export function euclideanDistance(x1: number, y1: number, x2: number, y2: number return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)); } -export function findBiggestAngle(joint: RealJoint, allJoints: RealJoint[]) { - // TODO: check for condition that first condition is not met... (with imagJoints) - if (allJoints.length === 2) { - return [allJoints[0], allJoints[1]]; - } - const curJoint = allJoints.find((j) => joint.id === j.id) as RealJoint; - let biggestAngle = 0; - // TODO: Change this where desiredJoint1 and desiredJoint2 are not same - let desiredJoint1: Joint = curJoint; - let desiredJoint2: Joint = curJoint; - - const curJointIndex = allJoints.findIndex((j) => j.id === joint.id); - for (let i = 0; i < allJoints.length; i++) { - // console.log('Outer loop: ' + i); - if (i === curJointIndex) { - // console.log('Skipped'); - continue; - } - const joint1 = allJoints[i]; - for (let j = i + 1; j < allJoints.length; j++) { - // console.log('Inner loop: ' + j); - if (j === curJointIndex) { - // console.log('Skipped'); - continue; - } - const joint2 = allJoints[j]; - const angle = find_angle(allJoints[curJointIndex], joint1, joint2); - // console.log(angle); - if (angle > biggestAngle) { - // if (biggestAngle === 0 || angle > biggestAngle) { - biggestAngle = angle; - desiredJoint1 = joint1; - desiredJoint2 = joint2; - } - } - } - return [desiredJoint1, desiredJoint2]; -} - -// https://stackoverflow.com/questions/17763392/how-to-calculate-in-javascript-angle-between-3-points (wrong) -// http://phrogz.net/angle-between-three-points -export function find_angle(B: Coord, A: Coord, C: Coord) { - let AB = Math.sqrt(Math.pow(B.x - A.x, 2) + Math.pow(B.y - A.y, 2)); - let BC = Math.sqrt(Math.pow(B.x - C.x, 2) + Math.pow(B.y - C.y, 2)); - let AC = Math.sqrt(Math.pow(C.x - A.x, 2) + Math.pow(C.y - A.y, 2)); - return Math.acos((BC * BC + AB * AB - AC * AC) / (2 * BC * AB)); - // const a = Math.pow(p1.x-p0.x,2) + Math.pow(p1.y-p0.y,2), - // b = Math.pow(p1.x-p2.x,2) + Math.pow(p1.y-p2.y,2), - // c = Math.pow(p2.x-p0.x,2) + Math.pow(p2.y-p0.y,2); - // return Math.acos( (a+b-c) / Math.sqrt(4*a*b) ) * 180 / Math.PI; -} - -//added by Pradeep Mar 25th 2023 -//determine the end point of each pose -//we pass coord, length and angle of each pose to this function and store the end point under each pose -export function getCoordofeachPose(pose1: Pose) { - if (pose1.angle == 0) { - pose1.coord1.x = pose1.midpoint.x - 0.5 * length * Math.cos(degToRad(pose1.angle)); - pose1.coord1.y = pose1.midpoint.y - 0.5 * length * Math.sin(degToRad(pose1.angle)); - - pose1.coord2.x = pose1.midpoint.x + 0.5 * length * Math.cos(degToRad(pose1.angle)); - pose1.coord2.y = pose1.midpoint.y + 0.5 * length * Math.sin(degToRad(pose1.angle)); - } else if (pose1.angle > 0 && pose1.angle < 90) { - pose1.coord1.x = pose1.midpoint.x - 0.5 * length * Math.cos(degToRad(pose1.angle + 180)); - pose1.coord1.y = pose1.midpoint.y - 0.5 * length * Math.sin(degToRad(pose1.angle + 180)); - - pose1.coord2.x = pose1.midpoint.x + 0.5 * length * Math.cos(degToRad(pose1.angle)); - pose1.coord2.y = pose1.midpoint.y + 0.5 * length * Math.sin(degToRad(pose1.angle)); - } else if (pose1.angle < 0 && pose1.angle < -90) { - pose1.coord1.x = pose1.midpoint.x - 0.5 * length * Math.cos(degToRad(pose1.angle + 180)); - pose1.coord1.y = pose1.midpoint.y - 0.5 * length * Math.sin(degToRad(pose1.angle + 180)); - - pose1.coord2.x = pose1.midpoint.x + 0.5 * length * Math.cos(degToRad(pose1.angle)); - pose1.coord2.y = pose1.midpoint.y + 0.5 * length * Math.sin(degToRad(pose1.angle)); - } -} - -//added by Pradeep Mar 25th 2023 -//obtain end points of lines. This will help draw the construction lines -export function ConstructionLine_1(pose1: Pose, pose2: Pose) { - return new ConstrucLines(pose1.coord1, pose2.coord1); -} - -export function ConstructionLine_2(pose2: Pose, pose3: Pose) { - return new ConstrucLines(pose2.coord1, pose3.coord1); -} - -export function ConstructionLine_3(pose1: Pose, pose2: Pose) { - return new ConstrucLines(pose1.coord2, pose2.coord2); -} - -export function ConstructionLine_4(pose2: Pose, pose3: Pose) { - return new ConstrucLines(pose2.coord2, pose3.coord2); -} - -//added by Pradeep Mar 25th 2023 -//obtain the coordinates of intersection point -//return one intersection point -export function intersectionPoint_1(pose1: Pose, pose2: Pose, pose3: Pose) { - //slope of line 1 - //var slope1 = 1 / ((Pos2_end1(2) - Pos1_end1(2)) / (Pos2_end1(1) - Pos1_end1(1))); - //slope of line 2 - //slope2 = 1 / ((Pos3_end1(2) - Pos2_end1(2)) / (Pos3_end1(1) - Pos2_end1(1))); - //midpoints - //midpoint_line1 = (Pos1_end1 + Pos2_end1) / 2; - //midpoint_line2 = (Pos2_end1 + Pos3_end1) / 2; - //intercept - //c1 = midpoint_line1(2) + slope1 * midpoint_line1(1); - //c2 = midpoint_line2(2) + slope2 * midpoint_line2(1); - var slope1 = 1 / ((pose2.coord1.y - pose1.coord1.y) / (pose2.coord1.x - pose1.coord1.x)); - //slope of line 2 - var slope2 = 1 / ((pose3.coord1.y - pose2.coord1.y) / (pose3.coord1.x - pose2.coord1.x)); - - //midpoints of the above two lines - var midpoint_line1 = new Coord( - (pose1.coord1.x + pose2.coord1.x) / 2, - (pose1.coord1.y + pose2.coord1.y) / 2 - ); - var midpoint_line2 = new Coord( - (pose3.coord1.x + pose2.coord1.x) / 2, - (pose3.coord1.y + pose2.coord1.y) / 2 - ); - - //intercept - var c1 = midpoint_line1.y + slope1 * midpoint_line1.x; - var c2 = midpoint_line2.y + slope2 * midpoint_line2.x; - - //intersection point - var x1 = (c1 - c2) / (-slope2 + slope1); - var y1 = -slope1 * x1 + c1; - - return new Coord(x1, y1); -} - -//return second intersection point -export function intersectionPoint_2(pose1: Pose, pose2: Pose, pose3: Pose) { - //slope of line 3 - //slope3 = 1 / ((Pos2_end2(2) - Pos1_end2(2)) / (Pos2_end2(1) - Pos1_end2(1))); - //slope of line 4 - //slope4 = 1 / ((Pos3_end2(2) - Pos2_end2(2)) / (Pos3_end2(1) - Pos2_end2(1))); - //midpoints - // midpoint_line3 = (Pos1_end2 + Pos2_end2) / 2; - // midpoint_line4 = (Pos2_end2 + Pos3_end2) / 2; - //intercept - // c3 = midpoint_line3(2) + slope3 * midpoint_line3(1); - // c4 = midpoint_line4(2) + slope4 * midpoint_line4(1); - - //intersection point - //x2 = (c3 - c4) / (-slope4 + slope3); - //y2 = -slope3 * x2 + c3; - - var slope1 = 1 / ((pose2.coord2.y - pose1.coord2.y) / (pose2.coord2.x - pose1.coord2.x)); - //slope of line 2 - var slope2 = 1 / ((pose3.coord2.y - pose2.coord2.y) / (pose3.coord2.x - pose2.coord2.x)); - - //midpoints of the above two lines - var midpoint_line1 = new Coord( - (pose1.coord2.x + pose2.coord2.x) / 2, - (pose1.coord2.y + pose2.coord2.y) / 2 - ); - var midpoint_line2 = new Coord( - (pose3.coord2.x + pose2.coord2.x) / 2, - (pose3.coord2.y + pose2.coord2.y) / 2 - ); - - //intercept - var c1 = midpoint_line1.y + slope1 * midpoint_line1.x; - var c2 = midpoint_line2.y + slope2 * midpoint_line2.x; - - //intersection point - var x1 = (c1 - c2) / (-slope2 + slope1); - var y1 = -slope1 * x1 + c1; - - return new Coord(x1, y1); -} - -//organize the four coordinates -export function fourCoordinates(pose1: Pose, pose2: Pose, pose3: Pose) { - var coord_A = intersectionPoint_1(pose1, pose2, pose3); - var coord_B = pose1.coord1; - var coord_C = pose1.coord2; - var coord_D = intersectionPoint_1(pose1, pose2, pose3); - - //we have the four coordinates that make up the -} - // TODO: Should put this all over the code... export function find_slope(point1: Coord, point2: Coord) { return (point1.y - point2.y) / (point1.x - point2.x); @@ -648,21 +331,6 @@ export function find_y_intercept(point1: Coord, slope: number) { return point1.y - slope * point1.x; } -// https://stackoverflow.com/questions/14480345/how-to-get-the-nth-occurrence-in-a-string -export function getPosition(string: string, subString: string, index: number) { - return string.split(subString, index).join(subString).length; -} - -// https://stackoverflow.com/questions/4364881/inserting-string-at-position-x-of-another-string -export function insertStringWithinString(a: string, index: number, b: string) { - return [a.slice(0, index), b, a.slice(index)].join(''); -} - -// https://www.tutorialspoint.com/typescript/typescript_string_substr.htm -export function pullStringWithinString(a: string, firstIndex: number, secondIndex: number) { - return a.substring(firstIndex, secondIndex); -} - // https://stackoverflow.com/questions/13937782/calculating-the-point-of-intersection-of-two-lines // line intercept math by Paul Bourke http://paulbourke.net/geometry/pointlineplane/ // Determine the intersection point of two line segments @@ -750,7 +418,6 @@ function circle_circle_intersect( // Circles are coincident if (d === 0 && radius === radius2) { - // console.log('Circles are coincident'); return [undefined, true]; } @@ -766,62 +433,6 @@ function circle_circle_intersect( return [[new Coord(x4, y4), new Coord(x5, y5)], false]; } -export function determineCenter( - startPoint: Coord, - endPoint: Coord, - radius: number, - cw: string -): Coord { - // Find the center of the circle that the arc revolves around. - // The center will be 'radius' distance from the startPoint and endPoint. - - // Find the midpoint of the line segment - let mid = new Coord((startPoint.x + endPoint.x) / 2, (startPoint.y + endPoint.y) / 2); - // Find the vector from startPoint to endPoint - let vec = new Coord(endPoint.x - startPoint.x, endPoint.y - startPoint.y); - // Find the length of vec - let len = Math.sqrt(vec.x * vec.x + vec.y * vec.y); - // Check if len is zero - if (len === 0) { - // Return null or throw an error - throw new Error('len is zero'); - } - // Find the perpendicular vector to vec - let perp = new Coord(-vec.y / len, vec.x / len); - // Check if radius is too small - if (radius < len / 2) { - // Return null or throw an error - throw new Error('radius is too small'); - } - // Find the parameter t that satisfies the equation (c - mid)^2 = r^2 - let t = Math.sqrt(radius * radius - (len * len) / 4); - // Check if t is negative - if (t < 0) { - // Return null or throw an error - throw new Error('t is negative'); - } - // Find the center point c by adding mid and perp * t - let c = new Coord(mid.x + perp.x * t, mid.y + perp.y * t); - // Check if c is NaN - if (isNaN(c.x) || isNaN(c.y)) { - // Return null or throw an error - throw new Error('c is NaN'); - } - // Check if cw is '0' or '1' - if (cw === '0') { - // Reverse the direction of perp - c = new Coord(mid.x - perp.x * t, mid.y - perp.y * t); - } else if (cw === '1') { - // Keep the direction of perp - c = new Coord(mid.x + perp.x * t, mid.y + perp.y * t); - } else { - // Return null or throw an error - throw new Error('cw must be "0" or "1"'); - } - - return c; -} - export function arc_arc_intersect( startPosition: Coord, endPosition: Coord, @@ -855,11 +466,6 @@ export function arc_arc_intersect( allImportantIntersections.push(endPosition2); } - // console.log( - // 'Circles are coicident, here are all the endpoints that overalp', - // allImportantIntersections - // ); - //If there are no intersections, then the arcs do not intersect. if (allImportantIntersections.length === 0) { return; @@ -1041,17 +647,14 @@ function line_circle_intersect( const tolerance = 0.00001; if (discriminant < -tolerance) { // line doesn't touch circle - // console.log("line doesn't touch circle"); return; } else if (discriminant < tolerance) { - // console.log('line is tangent to circle'); // line is tangent to circle let x = -b / (2 * a); let y = slope * x + y_intercept; intersections.push(new Coord(x, y)); return intersections; } else { - // console.log('line intersects circle in two places'); // line intersects circle in two places let x1 = (-b + Math.sqrt(discriminant)) / (2 * a); let y1 = slope * x1 + y_intercept; @@ -1090,35 +693,6 @@ function isPointInArc( } } -// returns true if the line from (a,b)->(c,d) intersects with (p,q)->(r,s) -// function intersects(a,b,c,d,p,q,r,s) { -// var det, gamma, lambda; -// det = (c - a) * (s - q) - (r - p) * (d - b); -// if (det === 0) { -// return false; -// } else { -// lambda = ((s - q) * (r - a) + (p - r) * (s - b)) / det; -// gamma = ((b - d) * (r - a) + (c - a) * (s - b)) / det; -// return (0 < lambda && lambda < 1) && (0 < gamma && gamma < 1); -// } -// }; - -export function wrapAngle(angle: number) { - //Wrap between -pi and pi - angle = angle % (2 * Math.PI); - if (angle > Math.PI) { - angle -= 2 * Math.PI; - } - if (angle < -Math.PI) { - angle += 2 * Math.PI; - } - return angle; -} - -export function is_touch_enabled() { - return 'ontouchstart' in window || navigator.maxTouchPoints > 0; -} - // https://www.petercollingridge.co.uk/tutorials/computational-geometry/circle-circle-intersections/ export function circleCircleIntersection( x0: number, @@ -1148,13 +722,9 @@ export function circleCircleIntersection( return false; } - // const TOLERANCE = 0.001; if (d <= 0.001) { return false; } - // if (d === 0) { - // return false; - // } dx /= d; dy /= d; @@ -1220,40 +790,6 @@ export function circleLineIntersection( ); } -// https://dirask.com/posts/JavaScript-calculate-intersection-point-of-two-lines-for-given-4-points-VjvnAj -export function lineLineIntersection( - x1: number, - y1: number, - x2: number, - y2: number, - x3: number, - y3: number, - x4: number, - y4: number -) { - const c2x = x3 - x4; // (x3 - x4) - const c3x = x1 - x2; // (x1 - x2) - const c2y = y3 - y4; // (y3 - y4) - const c3y = y1 - y2; // (y1 - y2) - - // down part of intersection point formula - const d = c3x * c2y - c3y * c2x; - - if (d == 0) { - throw new Error('Number of intersection points is zero or infinity.'); - } - - // upper part of intersection point formula - const u1 = x1 * y2 - y1 * x2; // (x1 * y2 - y1 * x2) - const u4 = x3 * y4 - y3 * x4; // (x3 * y4 - y3 * x4) - - // intersection point formula - - const px = (u1 * c2x - c3x * u4) / d; - const py = (u1 * c2y - c3y * u4) / d; - - return new Coord(px, py); -} export function has_mouse_pointer() { return matchMedia('(pointer:fine)').matches; } @@ -1263,11 +799,6 @@ export function local_storage_available() { return typeof Storage !== 'undefined'; } -// https://stackoverflow.com/questions/1560492/how-to-tell-whether-a-point-is-to-the-right-or-left-side-of-a-line -export function isLeft(a: Coord, b: Coord, c: Coord) { - return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) > 0; -} - // Vector projection algorithm // Given (x, y), find the closest point on the line SEGMENT between (x1, y1) and (x2, y2) export function point_on_line_segment_closest_to_point( @@ -1300,741 +831,3 @@ export function point_on_line_segment_closest_to_point( export function distance_points(x1: number, y1: number, x2: number, y2: number): number { return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); } - -//hfz -var bezier3Type = 'bezier3'; -var lineType = 'line'; - -var mathAbs = Math.abs; -var mathAsin = Math.asin; -var mathCos = Math.cos; -var mathMax = Math.max; -var mathMin = Math.min; -var mathPi = Math.PI; -var mathPow = Math.pow; -var mathSin = Math.sin; -var mathSqrt = Math.sqrt; -var mathTan = Math.tan; - -var tolerance = 1e-6; -export function isInside(point: any, polygon: any) { - var segments; - if (polygon && Array.isArray(polygon)) { - segments = polygon; - } else { - segments = splitSegments(polygon); - } - - var minX = 0; - var minY = 0; - for (var s = 0; s < segments.length; s++) { - var coords = segments[s].coords; - for (var c = 0; c < coords.length; c++) { - var coord = coords[c]; - minX = Math.min(minX, x(coord)); - minY = Math.min(minY, y(coord)); - } - } - var zero = [minX - 10, minY - 10]; - - var intersections = []; - for (var i = 0; i < segments.length; i++) { - var newIntersections = getIntersections(zero, point, segments[i]); - for (var j = 0; j < newIntersections.length; j++) { - var seen = false; - var intersection = newIntersections[j]; - - for (var k = 0; k < intersections.length; k++) { - if (coordEqual(intersections[k], intersection)) { - seen = true; - break; - } - } - - if (!seen) { - intersections.push(intersection); - } - } - } - - return intersections.length % 2 === 1; -} - -export function x(p: any) { - return p[0]; -} - -export function y(p: any) { - return p[1]; -} - -// Unpack an SVG path string into different curves and lines -// -// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d -export function splitSegments(polygon: any) { - if (typeof polygon !== 'string') { - throw new Error('Polygon should be a path string'); - } - - let start: any; - let position: any; - let result: any = []; - let input = polygon; - - function stripWhitespace() { - polygon = polygon.trim(); - } - - function readCharSeq(n: any) { - var c = polygon.charCodeAt(n); - while (c >= 48 && c <= 57) { - n++; - c = polygon.charCodeAt(n); - } - return n; - } - - function readNumber() { - stripWhitespace(); - - var start = 0; - var end = 0; - if (polygon[start] === ',') { - start++; - end++; - } - - if (polygon[start] === '-' || polygon[start] === '.') { - end++; - } - - end = readCharSeq(end); - if (polygon[end] === '.' && polygon[start] !== '.') { - end++; - end = readCharSeq(end); - } - - var s = polygon.substring(start, end); - if (s !== '') { - var num = toFloat(s); - polygon = polygon.substring(end); - if (polygon.length && polygon[0].toLowerCase() === 'e') { - var f = 1; - var expEnd = 0; - if (polygon.length > 1 && polygon[1] === '-') { - f = -1; - expEnd = readCharSeq(2); - } else { - expEnd = readCharSeq(1); - } - var exp = toFloat(polygon.substring(1, expEnd)); - if (mathAbs(exp) > 0) { - num *= mathPow(10, exp); - } - polygon = polygon.substring(expEnd); - } - return num; - } else { - throw new Error('Expected number: ' + polygon); - } - } - - function readNumbers(n: any, fn: any) { - stripWhitespace(); - var index = 0; - var c = polygon.charCodeAt(0); - while ((c >= 48 && c <= 57) || c === 44 || c === 45 || c === 46) { - var numbers = []; - for (var i = 0; i < n; i++) { - numbers.push(readNumber()); - } - fn(numbers, index); - - stripWhitespace(); - c = polygon.charCodeAt(0); - index++; - } - } - - function readCoords(n: any, fn: any) { - readNumbers(n * 2, function (numbers: any, index: any) { - var coords = []; - for (var i = 0; i < n; i++) { - coords.push(numbers.splice(0, 2)); - } - fn(coords, index); - }); - } - - function pushType(itemType: any, offset: any) { - return function (c: any) { - if (offset) { - c = c.map(function (c: any) { - return [x(c) + x(position), y(c) + y(position)]; - }); - } - c.unshift(position); - result.push({ - type: itemType, - coords: c, - }); - position = c[c.length - 1]; - }; - } - - function calculateCubicControlPoints(coords: any) { - return [ - coords[0], - [ - x(coords[0]) + (2.0 / 3.0) * (x(coords[1]) - x(coords[0])), - y(coords[0]) + (2.0 / 3.0) * (y(coords[1]) - y(coords[0])), - ], - [ - x(coords[2]) + (2.0 / 3.0) * (x(coords[1]) - x(coords[2])), - y(coords[2]) + (2.0 / 3.0) * (y(coords[1]) - y(coords[2])), - ], - coords[2], - ]; - } - - function calculateBezierControlPoint() { - var lastBezier = result[result.length - 1]; - var controlPoint = null; - if (!lastBezier || lastBezier.type !== bezier3Type) { - controlPoint = position; - } else { - // Calculate the mirror point of the last control point - var lastPoint = lastBezier.coords[2]; - var xOffset = x(position) - x(lastPoint); - var yOffset = y(position) - y(lastPoint); - - controlPoint = [x(position) + xOffset, y(position) + yOffset]; - } - - return controlPoint; - } - - function handleArcSegment(relative: any) { - readNumbers(7, function (numbers: any) { - var c2 = coordAdd(numbers.slice(5, 7), relative); - var args = [position].concat(numbers.slice(0, 5)).concat([c2]); - // @ts-ignore - var curve = arcToCurve.apply(args); - for (var i = 0; i < curve.length; i++) { - pushType(bezier3Type, curve[i]); - } - }); - } - - function readSegment() { - stripWhitespace(); - if (polygon === '') { - return; - } - - var operator = polygon[0]; - polygon = polygon.substring(1); - - var pushLine = pushType(lineType, null); - var origin = [0, 0]; - - switch (operator) { - case 'M': - readCoords(1, function (c: any, i: any) { - if (i === 0) { - position = c[0]; - if (!start) { - start = position; - } - } else { - pushType(lineType, c); - } - }); - break; - case 'm': - readCoords(1, function (c: any, i: any) { - if (i === 0) { - if (!position) { - position = c[0]; - } else { - position = coordAdd(c[0], position); - } - - if (!start) { - start = position; - } - } else { - var c0 = c[0]; - pushType(lineType, [coordAdd(c0, position)]); - } - }); - break; - case 'C': - readCoords(3, pushType(bezier3Type, null)); - break; - case 'c': - readCoords(3, pushType(bezier3Type, true)); - break; - case 'Q': - readCoords(2, function (coords: any) { - coords.unshift(position); - coords = calculateCubicControlPoints(coords); - coords.shift(); - pushType(bezier3Type, coords); - }); - break; - case 'q': - readCoords(2, function (coords: any) { - coords = coords.map(function (c: any) { - return coordAdd(c, position); - }); - coords.unshift(position); - coords = calculateCubicControlPoints(coords); - coords.shift(); - pushType(bezier3Type, coords); - }); - break; - case 'S': - readCoords(2, function (coords: any) { - var controlPoint = calculateBezierControlPoint(); - coords.unshift(controlPoint); - pushType(bezier3Type, coords); - }); - break; - case 's': - readCoords(2, function (coords: any) { - var controlPoint = calculateBezierControlPoint(); - coords = coords.map(function (c: any) { - return coordAdd(c, position); - }); - coords.unshift(controlPoint); - pushType(bezier3Type, coords); - }); - break; - case 'A': - handleArcSegment(origin); - break; - case 'a': - handleArcSegment(position); - break; - case 'L': - readCoords(1, pushType(lineType, null)); - break; - case 'l': - readCoords(1, function (c: any) { - pushLine([[x(c[0]) + x(position), y(c[0]) + y(position)]]); - }); - break; - case 'H': - pushType(lineType, [[readNumber(), y(position)]]); - break; - case 'h': - pushType(lineType, true)([[readNumber(), 0]]); - break; - case 'V': - pushType(lineType, [[x(position), readNumber()]]); - break; - case 'v': - pushType(lineType, true)([[0, readNumber()]]); - break; - case 'Z': - case 'z': - if (!coordEqual(position, start)) { - pushType(lineType, [start]); - } - break; - default: - throw new Error('Unknown operator: ' + operator + " for polygon '" + input + "'"); - } - } - - while (polygon.length > 0) { - readSegment(); - } - - // Remove zero-length lines - for (var i = 0; i < result.length; i++) { - var segment = result[i]; - if (segment.type === lineType && coordEqual(segment.coords[0], segment.coords[1])) { - result.splice(i, 1); - i--; - } - } - - return result; -} - -export function getIntersections(zero: any, point: any, shape: any) { - var coords = shape.coords; - switch (shape.type) { - case bezier3Type: - return intersectBezier3Line(coords[0], coords[1], coords[2], coords[3], zero, point); - case lineType: - return intersectLineLine(coords[0], coords[1], zero, point); - default: - throw new Error('Unsupported shape type: ' + shape.type); - } // jscs:ignore validateIndentation - // ^ (jscs bug) -} -export function coordMax(c1: any, c2: any) { - return [mathMax(x(c1), x(c2)), mathMax(y(c1), y(c2))]; -} - -export function coordMin(c1: any, c2: any) { - return [mathMin(x(c1), x(c2)), mathMin(y(c1), y(c2))]; -} - -export function coordMultiply(c: any, f: any) { - return [x(c) * f, y(c) * f]; -} - -function coordDot(c1: any, c2: any) { - return x(c1) * x(c2) + y(c1) * y(c2); -} - -function coordLerp(c1: any, c2: any, t: any) { - return [x(c1) + (x(c2) - x(c1)) * t, y(c1) + (y(c2) - y(c1)) * t]; -} - -function linearRoot(p2: any, p1: any) { - var results = []; - - var a = p2; - if (a !== 0) { - results.push(-p1 / p2); - } - - return results; -} -function quadRoots(p3: any, p2: any, p1: any) { - var results = []; - - if (mathAbs(p3) <= tolerance) { - return linearRoot(p2, p1); - } - - var a = p3; - var b = p2 / a; - var c = p1 / a; - var d = b * b - 4 * c; - if (d > 0) { - var e = mathSqrt(d); - results.push(0.5 * (-b + e)); - results.push(0.5 * (-b - e)); - } else if (d === 0) { - results.push(0.5 * -b); - } - - return results; -} - -export function cubeRoots(p4: any, p3: any, p2: any, p1: any) { - if (mathAbs(p4) <= tolerance) { - return quadRoots(p3, p2, p1); - } - - var results = []; - - var c3 = p4; - var c2 = p3 / c3; - var c1 = p2 / c3; - var c0 = p1 / c3; - - var a = (3 * c1 - c2 * c2) / 3; - var b = (2 * c2 * c2 * c2 - 9 * c1 * c2 + 27 * c0) / 27; - var offset = c2 / 3; - var discrim = (b * b) / 4 + (a * a * a) / 27; - var halfB = b / 2; - - /* This should be here, but there's a typo in the original code (disrim = - * 0) which causes it not to be present there. Ironically, adding the - * following code breaks the algorithm, whereas leaving it out makes it - * work correctly. - if (mathAbs(discrim) <= tolerance) { - discrim = 0; - } - */ - - var tmp; - if (discrim > 0) { - var e = mathSqrt(discrim); - tmp = -halfB + e; - var root = tmp >= 0 ? mathPow(tmp, 1 / 3) : -mathPow(-tmp, 1 / 3); - tmp = -halfB - e; - if (tmp >= 0) { - root += mathPow(tmp, 1 / 3); - } else { - root -= mathPow(-tmp, 1 / 3); - } - results.push(root - offset); - } else if (discrim < 0) { - var distance = mathSqrt(-a / 3); - var angle = Math.atan2(mathSqrt(-discrim), -halfB) / 3; - var cos = mathCos(angle); - var sin = mathSin(angle); - var sqrt3 = mathSqrt(3); - results.push(2 * distance * cos - offset); - results.push(-distance * (cos + sqrt3 * sin) - offset); - results.push(-distance * (cos - sqrt3 * sin) - offset); - } else { - if (halfB >= 0) { - tmp = -mathPow(halfB, 1 / 3); - } else { - tmp = mathPow(-halfB, 1 / 3); - } - results.push(2 * tmp - offset); - results.push(-tmp - offset); - } - - return results; -} -export function intersectBezier3Line(p1: any, p2: any, p3: any, p4: any, a1: any, a2: any) { - var result = []; - - var min = coordMin(a1, a2); // used to determine if point is on line segment - var max = coordMax(a1, a2); // used to determine if point is on line segment - - // Start with Bezier using Bernstein polynomials for weighting functions: - // (1-t^3)P1 + 3t(1-t)^2P2 + 3t^2(1-t)P3 + t^3P4 - // - // Expand and collect terms to form linear combinations of original Bezier - // controls. This ends up with a vector cubic in t: - // (-P1+3P2-3P3+P4)t^3 + (3P1-6P2+3P3)t^2 + (-3P1+3P2)t + P1 - // /\ /\ /\ /\ - // || || || || - // c3 c2 c1 c0 - - // Calculate the coefficients - var a = coordMultiply(p1, -1); - var b = coordMultiply(p2, 3); - var c = coordMultiply(p3, -3); - var c3 = coordAdd(a, coordAdd(b, coordAdd(c, p4))); - - a = coordMultiply(p1, 3); - b = coordMultiply(p2, -6); - c = coordMultiply(p3, 3); - var c2 = coordAdd(a, coordAdd(b, c)); - - a = coordMultiply(p1, -3); - b = coordMultiply(p2, 3); - var c1 = coordAdd(a, b); - - var c0 = p1; - - // Convert line to normal form: ax + by + c = 0 - // Find normal to line: negative inverse of original line's slope - var n = [y(a1) - y(a2), x(a2) - x(a1)]; - - // Determine new c coefficient - var cl = x(a1) * y(a2) - x(a2) * y(a1); - - // ?Rotate each cubic coefficient using line for new coordinate system? - // Find roots of rotated cubic - var roots = cubeRoots(coordDot(n, c3), coordDot(n, c2), coordDot(n, c1), coordDot(n, c0) + cl); - - // Any roots in closed interval [0,1] are intersections on Bezier, but - // might not be on the line segment. - // Find intersections and calculate point coordinates - for (var i = 0; i < roots.length; i++) { - var t = roots[i]; - - if (t >= 0 && t <= 1) { - // We're within the Bezier curve - // Find point on Bezier - var p5 = coordLerp(p1, p2, t); - var p6 = coordLerp(p2, p3, t); - var p7 = coordLerp(p3, p4, t); - - var p8 = coordLerp(p5, p6, t); - var p9 = coordLerp(p6, p7, t); - - var p10 = coordLerp(p8, p9, t); - - // See if point is on line segment - // Had to make special cases for vertical and horizontal lines due - // to slight errors in calculation of p10 - if (x(a1) === x(a2)) { - if (y(min) <= y(p10) && y(p10) <= y(max)) { - result.push(p10); - } - } else if (y(a1) === y(a2)) { - if (x(min) <= x(p10) && x(p10) <= x(max)) { - result.push(p10); - } - } else if (x(min) <= x(p10) && x(p10) <= x(max) && y(min) <= y(p10) && y(p10) <= y(max)) { - result.push(p10); - } - } - } - - return result; -} - -export function intersectLineLine(a1: any, a2: any, b1: any, b2: any) { - var ua_t = (x(b2) - x(b1)) * (y(a1) - y(b1)) - (y(b2) - y(b1)) * (x(a1) - x(b1)); - var ub_t = (x(a2) - x(a1)) * (y(a1) - y(b1)) - (y(a2) - y(a1)) * (x(a1) - x(b1)); - var u_b = (y(b2) - y(b1)) * (x(a2) - x(a1)) - (x(b2) - x(b1)) * (y(a2) - y(a1)); - - if (u_b !== 0) { - var ua = ua_t / u_b; - var ub = ub_t / u_b; - - if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) { - return [[x(a1) + ua * (x(a2) - x(a1)), y(a1) + ua * (y(a2) - y(a1))]]; - } - } - - return []; -} - -export function coordEqual(c1: any, c2: any) { - return Math.abs(x(c1) - x(c2)) < 1e-10 && Math.abs(y(c1) - y(c2)) < 1e-10; -} - -export function coordAdd(c1: any, c2: any) { - return [x(c1) + x(c2), y(c1) + y(c2)]; -} - -export function toFloat(v: any) { - return parseFloat(v); -} - -// @ts-ignore -export function arcToCurve( - cp1: any, - rx: any, - ry: any, - angle: any, - large_arc: any, - sweep: any, - cp2: any, - recurse: any -) { - function rotate(cx: any, cy: any, r: any) { - var cos = mathCos(r); - var sin = mathSin(r); - return [cx * cos - cy * sin, cx * sin + cy * cos]; - } - - var x1 = x(cp1); - var y1 = y(cp1); - var x2 = x(cp2); - var y2 = y(cp2); - - var rad = (mathPi / 180) * (+angle || 0); - var f1 = 0; - var f2 = 0; - var cx; - var cy; - var res = []; - - if (!recurse) { - var xy = rotate(x1, y1, -rad); - x1 = x(xy); - y1 = y(xy); - xy = rotate(x2, y2, -rad); - x2 = x(xy); - y2 = y(xy); - - var px = (x1 - x2) / 2; - var py = (y1 - y2) / 2; - var h = (px * px) / (rx * rx) + (py * py) / (ry * ry); - if (h > 1) { - h = mathSqrt(h); - rx = h * rx; - ry = h * ry; - } - - var rx2 = rx * rx; - var ry2 = ry * ry; - - var k = - (large_arc === sweep ? -1 : 1) * - mathSqrt( - mathAbs((rx2 * ry2 - rx2 * py * py - ry2 * px * px) / (rx2 * py * py + ry2 * px * px)) - ); - - cx = (k * rx * py) / ry + (x1 + x2) / 2; - cy = (k * -ry * px) / rx + (y1 + y2) / 2; - f1 = mathAsin(Number(((y1 - cy) / ry).toFixed(9))); - f2 = mathAsin(Number(((y2 - cy) / ry).toFixed(9))); - - f1 = x1 < cx ? mathPi - f1 : f1; - f2 = x2 < cx ? mathPi - f2 : f2; - - if (f1 < 0) { - f1 = mathPi * 2 + f1; - } - if (f2 < 0) { - f2 = mathPi * 2 + f2; - } - if (sweep && f1 > f2) { - f1 = f1 - mathPi * 2; - } - if (!sweep && f2 > f1) { - f2 = f2 - mathPi * 2; - } - } else { - f1 = recurse[0]; - f2 = recurse[1]; - cx = recurse[2]; - cy = recurse[3]; - } - - var df = f2 - f1; - if (mathAbs(df) > (mathPi * 120) / 180) { - var f2old = f2; - var x2old = x2; - var y2old = y2; - - f2 = f1 + ((mathPi * 120) / 180) * (sweep && f2 > f1 ? 1 : -1); - x2 = cx + rx * mathCos(f2); - y2 = cy + ry * mathSin(f2); - res = arcToCurve([x2, y2], rx, ry, angle, 0, sweep, [x2old, y2old], [f2, f2old, cx, cy]); - } - - df = f2 - f1; - - var c1 = mathCos(f1); - var s1 = mathSin(f1); - var c2 = mathCos(f2); - var s2 = mathSin(f2); - var t = mathTan(df / 4); - var hx = (4 / 3) * rx * t; - var hy = (4 / 3) * ry * t; - var m1 = [x1, y1]; - var m2 = [x1 + hx * s1, y1 - hy * c1]; - var m3 = [x2 + hx * s2, y2 - hy * c2]; - var m4 = [x2, y2]; - m2[0] = 2 * m1[0] - m2[0]; - m2[1] = 2 * m1[1] - m2[1]; - - function splitArray(n: any) { - return function (array: any) { - return array.reduce(function (m: any, v: any, i: any, l: any) { - if (i % n) { - return m; - } - - return m.concat([l.slice(i, i + n)]); - }, []); - }; - } - - var splitArray3 = splitArray(3); - var splitArray2 = splitArray(2); - function splitCurves(curves: any) { - return splitArray3(splitArray2(curves)); - } - - if (recurse) { - return splitCurves([m2, m3, m4].concat(res)); - } else { - res = [m2, m3, m4].concat(res).join().split(','); - var newres = []; - for (var i = 0, ii = res.length; i < ii; i++) { - newres[i] = i % 2 ? rotate(res[i - 1], res[i], rad)[1] : rotate(res[i], res[i + 1], rad)[0]; - } - return splitCurves(newres); - } -} diff --git a/src/app/scroll-shadow.directive.ts b/src/app/scroll-shadow.directive.ts new file mode 100644 index 00000000..9086aa45 --- /dev/null +++ b/src/app/scroll-shadow.directive.ts @@ -0,0 +1,40 @@ +import { AfterViewInit, Directive, ElementRef, NgZone, OnDestroy, inject } from '@angular/core'; + +/** + * Say when a scrolling card has something above it, out of sight. + * + * These cards keep their title in place and scroll everything else under it, + * so a card scrolled off its own top read as a card that simply started + * there: the first setting ran up beneath the title and was gone, leaving + * white space that looked like the top of the panel rather than the middle of + * it. The host carries a `scrolled` class while there is anything above, and + * the card's own stylesheet says what to do about it -- which is a shadow + * under the title, cast onto whatever is passing beneath. + * + * The listener is bound outside Angular. Through the template it would run + * change detection for every frame of every scroll, on panels that hold a + * hundred controls; nothing here needs the framework, since it is one class on + * the element being scrolled. + */ +@Directive({ + selector: '[appScrollShadow]', +}) +export class ScrollShadowDirective implements AfterViewInit, OnDestroy { + private zone = inject(NgZone); + private host = inject<ElementRef<HTMLElement>>(ElementRef); + + private readonly onScroll = () => { + const box = this.host.nativeElement; + box.classList.toggle('scrolled', box.scrollTop > 1); + }; + + ngAfterViewInit(): void { + this.zone.runOutsideAngular(() => + this.host.nativeElement.addEventListener('scroll', this.onScroll, { passive: true }) + ); + } + + ngOnDestroy(): void { + this.host.nativeElement.removeEventListener('scroll', this.onScroll); + } +} diff --git a/src/app/selected-tab.service.ts b/src/app/selected-tab.service.ts index 2bf993e0..409dedee 100644 --- a/src/app/selected-tab.service.ts +++ b/src/app/selected-tab.service.ts @@ -1,5 +1,5 @@ -import { T } from '@angular/cdk/keycodes'; -import { Injectable } from '@angular/core'; +import { RightPanelComponent } from './component/right-panel/right-panel.component'; +import { Injectable, inject } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { MechanismService } from './services/mechanism.service'; import { SynthesisBuilderService } from './services/synthesis/synthesis-builder.service'; @@ -9,22 +9,25 @@ import { SettingsService } from './services/settings.service'; export enum TabID { SYNTHESIZE, EDIT, + /** Kinematic analysis: position, velocity, acceleration. */ ANALYZE, + /** Force analysis, which asks for more of a mechanism than kinematics does. */ + FORCE, } @Injectable({ providedIn: 'root', }) export class SelectedTabService { + private synthesis = inject(SynthesisBuilderService); + private mechanism = inject(MechanismService); + private activeObjService = inject(ActiveObjService); + private settings = inject(SettingsService); + private _tabNum: BehaviorSubject<TabID>; private _tabVisible: BehaviorSubject<boolean>; - constructor( - private synthesis: SynthesisBuilderService, - private mechanism: MechanismService, - private activeObjService: ActiveObjService, - private settings: SettingsService - ) { + constructor() { this._tabNum = new BehaviorSubject<TabID>(TabID.EDIT); this._tabVisible = new BehaviorSubject<boolean>(true); } @@ -55,6 +58,15 @@ export class SelectedTabService { this._tabVisible.next(false); } + /** + * The mode, for anything that has to follow a change rather than ask about + * the current one. The canvas listens: the panel beside it is a different + * width in every mode, and the transport comes and goes with the analyses. + */ + public get tabChanged() { + return this._tabNum.asObservable(); + } + public getCurrentTab() { return this._tabNum.getValue(); } @@ -63,22 +75,55 @@ export class SelectedTabService { return this._tabVisible.getValue(); } + /** + * Either of the two analysis modes. + * + * The geometry is locked in both, and both are read-only, so almost + * everything that used to ask "is this the Analyze tab" means this instead. + */ + public isAnalysisMode(tab: TabID = this.getCurrentTab()) { + 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) { - // Replaces the old stop button: leaving Analyze is what rewinds the - // mechanism, so the other modes always act on the pose at time 0. - if (previousTab === TabID.ANALYZE) { - this.mechanism.animate(0, false); + // 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 + // looking like the app is stuck between two places. + RightPanelComponent.closeSetupUnlessFor(this.getCurrentTab()); + + // Leaving Analyze rewinds the mechanism, so the other modes always act on + // the pose at time 0. The transport's stop button does the same thing + // without leaving, for a reader who wants the drawn pose back and wants to + // stay where they are. + if (this.isAnalysisMode(previousTab) && !this.isAnalysisMode()) { + this.mechanism.easeToStart(); this.settings.animating.next(false); } - if (this.getCurrentTab() === TabID.SYNTHESIZE) { - // reset flag - this.synthesis.modifiedMechanism = false; - } 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/active-obj.service.ts b/src/app/services/active-obj.service.ts index d23d872c..a45e1433 100644 --- a/src/app/services/active-obj.service.ts +++ b/src/app/services/active-obj.service.ts @@ -1,11 +1,20 @@ import { Injectable, EventEmitter } from '@angular/core'; import { Force } from '../model/force'; -import { RealJoint } from '../model/joint'; -import { RealLink } from '../model/link'; +import { Joint, RealJoint } from '../model/joint'; +import { Link, RealLink } from '../model/link'; import { Coord } from '../model/coord'; import { SynthesisPose } from './synthesis/synthesis-util'; -export type ActiveObjType = 'Nothing' | 'Joint' | 'Force' | 'Link' | 'Grid' | 'SynthesisPose'; +export type ActiveObjType = + | 'Nothing' + | 'Joint' + | 'Force' + | 'Link' + | 'Grid' + | 'SynthesisPose' + | 'Mechanism' + /** The tracing underlay, which is scenery rather than part of the linkage. */ + | 'BackgroundImage'; @Injectable({ providedIn: 'root', @@ -18,7 +27,14 @@ export class ActiveObjService { selectedLink!: RealLink; selectedPose!: SynthesisPose; selectedForceEndPoint: string = ''; - private skipThisSeleciton: boolean = false; + /** + * Which machine is selected, by its place in MechanismService.partitions. + * + * An index rather than the object, because a mechanism is rebuilt from + * scratch on every edit — holding the old one would pin a selection to + * something that no longer exists. + */ + selectedMechanismIndex: number = -1; constructor() {} @@ -46,8 +62,35 @@ export class ActiveObjService { this.onActiveObjChange.emit(this.objType); } - updateSelectedObj(newActiveObj: any, forceParent: Force | null = null) { + /** + * Put the background image in the edit panel. + * + * It is not a mechanism object and has no entry in getSelectedObj(): the only + * thing this selection does is decide which panel is showing, and the panel + * reads the picture from its own service. + */ + selectBackgroundImage() { + this.selectedMechanismIndex = -1; + this.objType = 'BackgroundImage'; + this.onActiveObjChange.emit(this.objType); + } + + /** Select a whole machine: everything in it, rather than one part of it. */ + selectMechanism(index: number) { + this.selectedMechanismIndex = index; + this.objType = 'Mechanism'; + this.onActiveObjChange.emit(this.objType); + } + + updateSelectedObj( + // The grid canvas hands its empty-string sentinel through here for a + // background click, and a force's endpoint Coord for an endpoint drag, so + // both are part of the contract. + newActiveObj: Joint | Link | Force | Coord | SynthesisPose | string | undefined | null, + forceParent: Force | null = null + ) { this.prevSelectedJoint = this.selectedJoint; + this.selectedMechanismIndex = -1; if (newActiveObj === undefined || newActiveObj === null) { this.objType = 'Grid'; } else if (newActiveObj instanceof RealJoint) { diff --git a/src/app/services/analysis-sample.service.spec.ts b/src/app/services/analysis-sample.service.spec.ts new file mode 100644 index 00000000..79f781e8 --- /dev/null +++ b/src/app/services/analysis-sample.service.spec.ts @@ -0,0 +1,244 @@ +import { RealJoint } from '../model/joint'; +import { MODEL_SCALE } from '../model/render-scale'; +import { TEMPLATE_LINKAGES } from '../component/MODALS/templates/template-linkages'; +import { buildMechanismFixture, MechanismFixture } from '../../tests/fixtures/mechanism-fixtures'; +import { AnalysisSampleService } from './analysis-sample.service'; +import { SettingsService } from './settings.service'; +import { withTestInjector } from '../../test-utils/mechanism-harness'; + +/** + * The numbers here are the ones the graphs plotted before this arithmetic moved + * out of the graph component, so they pin the extraction. Where a value can be + * derived from the mechanism instead of copied from it — a crank pin's speed is + * ωr, its acceleration ω²r toward the pivot, a link's angle an arctangent of its + * own two ends — it is checked both ways, because a literal alone only says the + * code still does what it did. + * + * The literals are written to three decimals and the service now answers at the + * precision it solved at, so the two helpers below round to compare. That is a + * question about the reading rather than about the arithmetic: the digits below + * the third are what stopped a small force from climbing the graph in stairs, + * and there is a test at the foot of this file that they are still there. + */ +const readAt = (values: number[], places = 3) => + values.map((value) => (Number.isFinite(value) ? Number(value.toFixed(places)) + 0 : value)); +describe('AnalysisSampleService', () => { + let fixture: MechanismFixture; + let service: AnalysisSampleService; + + const exactly = (index: number, prop: string, part: string, reactionLinkId?: string) => + service.sampleAt( + fixture.mechanism, + index, + 'kinematic', + 'loop', + prop, + part, + reactionLinkId ?? '' + ); + + const sample = (index: number, prop: string, part: string, reactionLinkId?: string) => + readAt(exactly(index, prop, part, reactionLinkId)); + + const exactForce = (index: number, mode: string, prop: string, part: string, link = '') => + service.sampleAt(fixture.mechanism, index, 'force', mode, prop, part, link); + + const force = (index: number, mode: string, prop: string, part: string, link = '') => + readAt(exactForce(index, mode, prop, part, link)); + + describe('a four-bar driven at its crank', () => { + beforeEach(() => { + fixture = buildMechanismFixture(TEMPLATE_LINKAGES['4-Bar']); + service = withTestInjector( + [{ provide: SettingsService, useValue: fixture.settings }], + () => new AnalysisSampleService() + ); + }); + + /** The crank A→B at the pose given, in internal model units. */ + const crank = (index: number): [number, number] => { + const at = (id: string) => fixture.mechanism.joints[index].find((joint) => joint.id === id)!; + return [at('B').x - at('A').x, at('B').y - at('A').y]; + }; + + it('plots a joint position as its solved coordinates in the user unit', () => { + expect(sample(0, 'Linear Joint Pos', 'B')).toEqual([-2.622, 0.902]); + expect(sample(45, 'Linear Joint Pos', 'B')).toEqual([-0.709, -0.311]); + + // The same numbers the drawing is built from, divided back down. + const joint = fixture.mechanism.joints[45].find((candidate) => candidate.id === 'B')!; + expect(sample(45, 'Linear Joint Pos', 'B')).toEqual([ + Number((joint.x / MODEL_SCALE).toFixed(3)), + Number((joint.y / MODEL_SCALE).toFixed(3)), + ]); + }); + + it('leaves a grounded joint standing still', () => { + const grounded = fixture.mechanism.joints[0].find( + (joint): joint is RealJoint => joint instanceof RealJoint && joint.ground + )!; + expect(sample(0, 'Linear Joint Pos', grounded.id)).toEqual([-3.129, -2.014]); + expect(sample(0, 'Linear Joint Vel', grounded.id)).toEqual([0, 0, 0]); + expect(sample(0, 'Linear Joint Acc', grounded.id)).toEqual([0, 0, 0]); + }); + + it('plots the crank pin speed as ωr, across the crank rather than along it', () => { + const velocity = sample(0, 'Linear Joint Vel', 'B'); + expect(velocity).toEqual([6.107, -1.062, 6.199]); + + const omega = Math.abs(fixture.mechanism.inputAngularVelocities[0]); + const [dx, dy] = crank(0); + const radius = Math.hypot(dx, dy) / MODEL_SCALE; + expect(velocity[2]).toBeCloseTo(omega * radius, 3); + // A pin on a rigid crank moves perpendicular to it. + const along = (velocity[0] * dx + velocity[1] * dy) / (velocity[2] * Math.hypot(dx, dy)); + expect(Math.abs(along)).toBeLessThan(1e-3); + }); + + it('plots the crank pin acceleration as ω²r pointing at the pivot', () => { + const acceleration = sample(0, 'Linear Joint Acc', 'B'); + expect(acceleration).toEqual([-2.224, -12.791, 12.983]); + + const omega = Math.abs(fixture.mechanism.inputAngularVelocities[0]); + const [dx, dy] = crank(0); + const length = Math.hypot(dx, dy); + const radius = length / MODEL_SCALE; + expect(acceleration[2]).toBeCloseTo(omega * omega * radius, 3); + // Centripetal: back down the crank, towards the ground pivot. + expect(acceleration[0]).toBeCloseTo((-dx / length) * acceleration[2], 2); + expect(acceleration[1]).toBeCloseTo((-dy / length) * acceleration[2], 2); + }); + + it('plots a link angle in degrees, and turns it one degree per sample', () => { + expect(sample(0, 'Angular Link Pos', 'AB')).toEqual([80.137]); + expect(sample(1, 'Angular Link Pos', 'AB')).toEqual([79.137]); + expect(sample(45, 'Angular Link Pos', 'AB')).toEqual([35.137]); + + const [dx, dy] = crank(45); + expect(sample(45, 'Angular Link Pos', 'AB')[0]).toBeCloseTo( + (Math.atan2(dy, dx) * 180) / Math.PI, + 3 + ); + // 360 samples of one revolution, driven clockwise. + expect(sample(0, 'Angular Link Vel', 'AB')).toEqual([-2.094]); + }); + + it('plots a coupler by its centre of mass and its own angle', () => { + expect(sample(0, "Linear Link's CoM Pos", 'BC')).toEqual([0.194, 1.491]); + expect(sample(0, 'Angular Link Pos', 'BC')).toEqual([11.816]); + expect(sample(45, 'Angular Link Pos', 'BC')).toEqual([22.288]); + }); + + it('reports a joint reaction as x, y and the magnitude of the two', () => { + const reaction = force(0, 'static', 'Joint Forces', 'A'); + expect(reaction).toEqual([0.001, 0.015, 0.015]); + expect(reaction[2]).toBeCloseTo(Math.hypot(reaction[0], reaction[1]), 3); + expect(force(0, 'dynamic', 'Joint Forces', 'A')).toEqual([-0.01, -0.01, 0.014]); + // One value, not three: an input effort is a single quantity. + expect(force(0, 'static', 'Input Effort', 'A')).toHaveLength(1); + }); + + it('reads a reaction from the link asked for, and gaps where that link is absent', () => { + expect(force(0, 'static', 'Joint Forces', 'A', 'AB')).toEqual([0.001, 0.015, 0.015]); + // C is nowhere near link AB, so that row of the panel has no number to show. + expect(force(0, 'static', 'Joint Forces', 'C', 'AB').every(Number.isNaN)).toBe(true); + expect(force(0, 'static', 'Joint Forces', 'C', 'AB')).toHaveLength(3); + }); + + it('has nothing to say about a graph it does not draw', () => { + expect(sample(0, 'ic', 'B')).toEqual([]); + expect(sample(0, 'Linear Joint Bogus', 'B')).toEqual([]); + expect(sample(9999, 'Linear Joint Pos', 'B')).toEqual([]); + }); + }); + + describe('a cylinder-driven boom', () => { + beforeEach(() => { + fixture = buildMechanismFixture(TEMPLATE_LINKAGES['Cylinder_Boom']); + service = withTestInjector( + [{ provide: SettingsService, useValue: fixture.settings }], + () => new AnalysisSampleService() + ); + }); + + it('plots the boom rising, and the piston angle as a gap rather than a number', () => { + expect(sample(0, 'Linear Joint Pos', 'C')).toEqual([0, 4]); + expect(sample(45, 'Linear Joint Pos', 'C')).toEqual([1.061, 3.857]); + expect(sample(0, 'Angular Link Pos', 'OC')).toEqual([90]); + expect(sample(45, 'Angular Link Pos', 'OC')).toEqual([74.623]); + + // A sealed cylinder's own body has no solved angle; the graph draws a gap + // at that timestep rather than dropping the sample and shifting the rest. + const piston = sample(0, 'Angular Link Pos', 'PS'); + expect(piston).toHaveLength(1); + expect(Number.isNaN(piston[0])).toBe(true); + }); + + /** + * A drawing holds as many machines as are drawn, and every one of them is + * solved over the top of the last: the solvers keep their working state in + * statics. Positions survive that, because they were computed while the + * state was still this machine's — but rates are worked out later, when a + * graph is opened, and a cylinder drive needs the constraint set to + * differentiate. Asked for after a second machine had been built, this one + * was differentiated against the *other* machine's constraints and every + * velocity and acceleration it plotted came back NaN. + */ + it('still has rates once another machine has been solved after it', () => { + const boom = fixture; + // A second machine, built over the top of the first as the service does. + buildMechanismFixture(TEMPLATE_LINKAGES['4-Bar']); + + const velocity = service.sampleAt( + boom.mechanism, + 45, + 'kinematic', + 'loop', + 'Linear Joint Vel', + 'C', + '' + ); + expect(velocity).toHaveLength(3); + expect(velocity.every(Number.isFinite)).toBe(true); + // And the same numbers it gives when it is the only machine there. + expect(readAt(velocity)).toEqual(sample(45, 'Linear Joint Vel', 'C')); + }); + }); + + /** + * The digits below the third are what a graph draws with. + * + * A four-bar's joint reactions run to a few hundredths of a newton, so + * rounding them to a thousandth left about fifty distinct heights across the + * cycle and the curve climbed them in steps two per cent of its own range. + */ + describe('the precision a curve is drawn at', () => { + beforeEach(() => { + fixture = buildMechanismFixture(TEMPLATE_LINKAGES['4-Bar']); + service = withTestInjector( + [{ provide: SettingsService, useValue: fixture.settings }], + () => new AnalysisSampleService() + ); + }); + + it('keeps a small force smooth rather than stepping it', () => { + const along = fixture.mechanism.joints.map( + (_, index) => exactForce(index, 'static', 'Joint Forces', 'A')[0] + ); + const solved = along.filter(Number.isFinite); + expect(solved.length).toBeGreaterThan(100); + + const span = Math.max(...solved) - Math.min(...solved); + expect(span).toBeGreaterThan(0); + // Small enough that a thousandth of a newton is a visible fraction of it, + // which is the case this is about. + expect(span).toBeLessThan(1); + + // Nearly every sample is its own value. Rounded to a thousandth these + // collapsed onto about fifty, and the curve inherited the staircase. + const distinct = new Set(solved.map((value) => value.toFixed(9))).size; + expect(distinct).toBeGreaterThan(solved.length * 0.9); + expect(new Set(solved.map((value) => value.toFixed(3))).size).toBeLessThan(distinct / 2); + }); + }); +}); diff --git a/src/app/services/analysis-sample.service.ts b/src/app/services/analysis-sample.service.ts new file mode 100644 index 00000000..8b621285 --- /dev/null +++ b/src/app/services/analysis-sample.service.ts @@ -0,0 +1,209 @@ +import { Injectable, inject } from '@angular/core'; +import { ForceAnalysisMode } from '../model/mechanism/force-solver'; +import { KinematicsSolver } from '../model/mechanism/kinematic-solver'; +import { Mechanism } from '../model/mechanism/mechanism'; +import { LBF_IN_PER_NEWTON_METER, LBF_PER_NEWTON } from '../model/unit-conversions'; +import { MODEL_SCALE } from '../model/render-scale'; +import { ForceUnit } from '../model/unit-enums'; +import { SettingsService } from './settings.service'; + +/** + * What a graph plots, one solved sample at a time. + * + * This used to be a switch inside the graph component's plotting loop, which + * meant the only way to ask "what does joint B read right now" was to build the + * whole cycle and take one point out of it. A panel header wants exactly one + * sample, so the arithmetic lives here and both callers share it — the graph by + * walking every index, the header by asking for the pose on screen. + * + * Values leave at the precision they were solved at. They used to be rounded to + * three decimals, which is a tenth of a millinewton: fine against a load of tens + * of newtons and ruinous against a small one. On the four-bar template the + * solver produces 360 distinct reactions across the cycle and that rounding left + * 49 of them, each step two per cent of the whole curve — so the graph climbed a + * visible staircase. Nothing needed it: every place that shows one of these + * numbers formats it, the header to two decimals and the CSV at its own edge. + */ +/** + * A solved value below anything a mechanism can mean is float noise, not data. + * + * A joint that stands still solves to velocities like 2e-17: the residue of + * subtracting a position from itself through a chain of rotations. The header + * printed it in scientific notation and the graph drew its jitter as a + * mountain range, because a chart scales its axis to whatever range it is + * given. One part in 1e9 of the units on screen is far below anything the + * drawing can express -- solved positions are held to four decimals -- and + * some sixteen orders above nothing, so everything real passes untouched. + */ +const NOISE_FLOOR = 1e-9; + +function snapNoiseToZero(value: number): number { + return Math.abs(value) < NOISE_FLOOR ? 0 : value; +} + +@Injectable({ providedIn: 'root' }) +export class AnalysisSampleService { + private settingsService = inject(SettingsService); + + /** + * The mechanism the solver's static state was last prepared for. + * + * KinematicsSolver keeps its answers in statics, including a cached input + * joint index, so a sample of a second mechanism taken without a reset would + * be solved against the first one's indices. Resetting per sample instead + * would throw away the maps this fills 360 times per graph, so it is done + * once per mechanism — the same granularity the plotting loop always used. + */ + private preparedFor: Mechanism | undefined; + + /** + * The values a graph plots, for one solved sample. + * + * Returned in the order the graph draws them: X, then Y, then the third + * series where there is one. An empty array means this service has nothing + * to say about that property — an unknown name, an instant-centre graph, or + * a sample index the mechanism does not have. + */ + sampleAt( + mechanism: Mechanism, + index: number, + analysis: string, + analysisType: string, + mechProp: string, + mechPart: string, + reactionLinkId: string = '' + ): number[] { + const values = + analysis === 'force' + ? this.forceSample(mechanism, index, analysisType, mechProp, mechPart, reactionLinkId) + : this.kinematicSample(mechanism, index, mechProp, mechPart); + return values.map(snapNoiseToZero); + } + + private forceSample( + mechanism: Mechanism, + index: number, + analysisType: string, + mechProp: string, + mechPart: string, + reactionLinkId: string + ): number[] { + const mode: ForceAnalysisMode = analysisType === 'dynamic' ? 'dynamic' : 'static'; + const frame = mechanism.getForceAnalysis(mode).frames[index]; + if (!frame) return []; + + const forceConversion = + this.settingsService.forceUnit.value === ForceUnit.LBF ? LBF_PER_NEWTON : 1; + const torqueConversion = + this.settingsService.forceUnit.value === ForceUnit.LBF ? LBF_IN_PER_NEWTON_METER : 1; + + if (frame.status !== 'ok') { + return mechProp === 'Joint Forces' ? [Number.NaN, Number.NaN, Number.NaN] : [Number.NaN]; + } + + if (mechProp === 'Input Torque' || mechProp === 'Input Effort') { + // A torque's moment arms are internal model lengths (MODEL_SCALE times + // the user's unit), so the solved value divides back down for display. An + // input *force* has no length in it and is invariant. + if (!frame.inputEffort) return [Number.NaN]; + const isForce = frame.inputEffort.kind === 'force'; + return [ + (frame.inputEffort.valueSI * (isForce ? forceConversion : torqueConversion)) / + (isForce ? 1 : MODEL_SCALE), + ]; + } + + const byLink = frame.jointReactionsByLink.get(mechPart); + const reaction = reactionLinkId + ? byLink?.get(reactionLinkId) + : frame.jointReactions.get(mechPart); + if (!reaction) return [Number.NaN, Number.NaN, Number.NaN]; + + const x = reaction[0] * forceConversion; + const y = reaction[1] * forceConversion; + return [x, y, Math.hypot(x, y)]; + } + + private kinematicSample( + mechanism: Mechanism, + index: number, + mechProp: string, + mechPart: string + ): number[] { + const joints = mechanism.joints[index]; + const links = mechanism.links[index]; + if (!joints || !links) return []; + + // Positions, velocities and accelerations are linear in length, so each + // internal model value divides by MODEL_SCALE for display in the user's + // unit. Angular series carry no length and pass through. + if (mechProp === 'Linear Joint Pos') { + const joint = joints.find((candidate) => candidate.id === mechPart); + return [(joint?.x ?? Number.NaN) / MODEL_SCALE, (joint?.y ?? Number.NaN) / MODEL_SCALE]; + } + + switch (mechProp) { + case 'Linear Joint Vel': + this.solve(mechanism, index); + return this.scaledVector(KinematicsSolver.jointVelMap.get(mechPart), true); + case 'Linear Joint Acc': + this.solve(mechanism, index); + return this.scaledVector(KinematicsSolver.jointAccMap.get(mechPart), true); + case "Linear Link's CoM Pos": + this.solve(mechanism, index); + return this.scaledVector(KinematicsSolver.linkCoMMap.get(mechPart), false); + case "Linear Link's CoM Vel": + this.solve(mechanism, index); + return this.scaledVector(KinematicsSolver.linkVelMap.get(mechPart), true); + case "Linear Link's CoM Acc": + this.solve(mechanism, index); + return this.scaledVector(KinematicsSolver.linkAccMap.get(mechPart), true); + case 'Angular Link Pos': + this.solve(mechanism, index); + return [KinematicsSolver.linkAngPosMap.get(mechPart) ?? Number.NaN]; + case 'Angular Link Vel': + this.solve(mechanism, index); + return [KinematicsSolver.linkAngVelMap.get(mechPart) ?? Number.NaN]; + case 'Angular Link Acc': + this.solve(mechanism, index); + return [KinematicsSolver.linkAngAccMap.get(mechPart) ?? Number.NaN]; + default: + // 'ic' and anything this service does not know plot nothing. + return []; + } + } + + /** Rates and poses for one sample, left in the solver's static maps. */ + private solve(mechanism: Mechanism, index: number): void { + if (this.preparedFor !== mechanism) { + KinematicsSolver.resetVariables(); + this.preparedFor = mechanism; + } + // Every time, not only on a change of mechanism: the solvers hold their + // state in statics, and a drawing with three machines in it has solved the + // other two over the top of this one since the graphs were last drawn. + mechanism.prepareSolvers(); + KinematicsSolver.determineKinematics( + mechanism.joints[index], + mechanism.links[index], + mechanism.inputAngularVelocities[index] + ); + } + + /** + * A solved vector in display units, with its magnitude where one is plotted. + * + * A missing entry is two NaNs rather than nothing: the graph draws a gap at + * that timestep, and dropping the point would shift every later one. + */ + private scaledVector(value: [number, number] | undefined, withMagnitude: boolean): number[] { + const [rawX, rawY] = value ?? [Number.NaN, Number.NaN]; + const x = rawX / MODEL_SCALE; + const y = rawY / MODEL_SCALE; + const series = [x, y]; + if (withMagnitude) { + series.push(Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2))); + } + return series; + } +} diff --git a/src/app/services/background-image.service.spec.ts b/src/app/services/background-image.service.spec.ts new file mode 100644 index 00000000..3d4fd6d0 --- /dev/null +++ b/src/app/services/background-image.service.spec.ts @@ -0,0 +1,197 @@ +import { TestBed } from '@angular/core/testing'; +import { BackgroundImageService, BackgroundImage, MIN_WIDTH } from './background-image.service'; +import { SettingsService } from './settings.service'; +import { LengthUnit } from '../model/unit-enums'; +import { MODEL_SCALE } from '../model/render-scale'; + +/** A placed picture with a known 2:1 shape, so the derived height is checkable. */ +function placed(over: Partial<BackgroundImage> = {}): BackgroundImage { + return { + src: 'data:image/png;base64,AAAA', + naturalWidth: 400, + naturalHeight: 200, + centerX: 0, + centerY: 0, + width: 4 * MODEL_SCALE, + rotationRad: 0, + opacity: 0.5, + fileName: 'rig.png', + ...over, + }; +} + +describe('BackgroundImageService', () => { + let service: BackgroundImageService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(BackgroundImageService); + }); + + it('starts with nothing behind the grid', () => { + expect(service.image()).toBeNull(); + }); + + describe('geometry', () => { + it('derives the height from the file’s own proportions', () => { + const image = placed(); + expect(service.heightOf(image)).toBe(2 * MODEL_SCALE); + }); + + it('keeps those proportions when the width changes', () => { + service.image.set(placed()); + service.place({ width: 10 * MODEL_SCALE }); + expect(service.heightOf(service.image()!)).toBe(5 * MODEL_SCALE); + }); + + it('places the left edge half a width left of centre', () => { + const image = placed({ centerX: 3 * MODEL_SCALE }); + expect(service.leftOf(image)).toBe(MODEL_SCALE); + }); + + /** + * The image layer is drawn unflipped while the model has y up, so a picture + * centred above the axis has to be at a negative SVG y. Getting this + * backwards mirrors the underlay about the x axis, which is invisible on a + * symmetric picture and wrong on every other one. + */ + it('flips y, because the image layer is drawn in SVG coordinates', () => { + const image = placed({ centerY: 5 * MODEL_SCALE }); + expect(service.topOf(image)).toBe(-6 * MODEL_SCALE); + }); + }); + + describe('place', () => { + it('changes only the field it is given', () => { + service.image.set(placed()); + service.place({ centerX: 2 * MODEL_SCALE }); + const image = service.image()!; + expect(image.centerX).toBe(2 * MODEL_SCALE); + expect(image.centerY).toBe(0); + expect(image.width).toBe(4 * MODEL_SCALE); + expect(image.opacity).toBe(0.5); + expect(image.src).toBe('data:image/png;base64,AAAA'); + }); + + // The floor exists for the corner drag, which can be pulled through zero. + // A typed width below it is refused by the panel instead, so the number in + // the field is always the number that took effect. + it('holds the picture at its minimum width rather than letting it vanish', () => { + service.image.set(placed()); + service.place({ width: 0 }); + expect(service.image()!.width).toBe(MIN_WIDTH); + }); + + it('holds opacity inside 0..1', () => { + service.image.set(placed()); + service.place({ opacity: 4 }); + expect(service.image()!.opacity).toBe(1); + service.place({ opacity: -1 }); + expect(service.image()!.opacity).toBe(0); + }); + + it('does nothing when there is no picture', () => { + service.place({ centerX: 5 }); + expect(service.image()).toBeNull(); + }); + }); + + describe('load', () => { + it('refuses a file that is not an image', async () => { + const file = new File(['not a picture'], 'notes.txt', { type: 'text/plain' }); + await expect(service.load(file, MODEL_SCALE)).rejects.toThrow(/not an image/); + expect(service.image()).toBeNull(); + }); + + it('refuses a file too large to hold in memory', async () => { + const file = new File(['x'], 'huge.png', { type: 'image/png' }); + // A real 13 MB buffer would slow every run of this suite; the guard reads + // the reported size, so a reported size is what it is given. + Object.defineProperty(file, 'size', { value: 13 * 1024 * 1024 }); + await expect(service.load(file, MODEL_SCALE)).rejects.toThrow(/limit is 12 MB/); + expect(service.image()).toBeNull(); + }); + }); + + /** + * A unit change rescales every stored coordinate in the drawing, so a picture + * that stayed put would be left the conversion factor's worth of wrong size + * against the very linkage it was put there to be traced by. + */ + describe('a change of length unit', () => { + let settings: SettingsService; + + beforeEach(() => { + settings = TestBed.inject(SettingsService); + settings.lengthUnit.next(LengthUnit.CM); + service.image.set(placed({ centerX: 3 * MODEL_SCALE, centerY: -1 * MODEL_SCALE })); + }); + + it('restates the placement in the new unit', () => { + settings.lengthUnit.next(LengthUnit.METER); + const image = service.image()!; + // A centimetre is a hundredth of a metre, so every number is too. + expect(image.centerX).toBeCloseTo(0.03 * MODEL_SCALE, 6); + expect(image.centerY).toBeCloseTo(-0.01 * MODEL_SCALE, 6); + expect(image.width).toBeCloseTo(0.04 * MODEL_SCALE, 6); + }); + + it('leaves the picture and its proportions alone', () => { + settings.lengthUnit.next(LengthUnit.INCH); + const image = service.image()!; + expect(image.src).toBe('data:image/png;base64,AAAA'); + expect(service.heightOf(image) / image.width).toBeCloseTo(0.5, 9); + }); + + it('does nothing when the unit is set to the one already in force', () => { + const before = service.image()!; + settings.lengthUnit.next(LengthUnit.CM); + expect(service.image()).toBe(before); + }); + + it('is harmless with no picture placed', () => { + service.remove(); + expect(() => settings.lengthUnit.next(LengthUnit.METER)).not.toThrow(); + expect(service.image()).toBeNull(); + }); + }); + + /** + * The stored angle is counter-clockwise-positive like every other angle in + * the app; SVG's rotate is clockwise-positive because its y points down. A + * transform that forgot the sign turns the picture the wrong way, which on a + * photograph of a linkage looks like a mirror rather than a mistake. + */ + describe('the turn', () => { + it('is the identity when the picture is square to the grid', () => { + const image = placed({ centerX: 2 * MODEL_SCALE, centerY: MODEL_SCALE }); + expect(service.transformOf(image)).toBe(`rotate(0, ${2 * MODEL_SCALE}, ${-MODEL_SCALE})`); + }); + + it('goes to SVG the other way round, about the picture’s own centre', () => { + const image = placed({ centerX: 0, centerY: 2 * MODEL_SCALE, rotationRad: Math.PI / 2 }); + expect(service.transformOf(image)).toBe(`rotate(-90, 0, ${-2 * MODEL_SCALE})`); + }); + + it('is placed like any other part of the placement', () => { + service.image.set(placed()); + service.place({ rotationRad: Math.PI / 4 }); + expect(service.image()!.rotationRad).toBeCloseTo(Math.PI / 4, 9); + expect(service.image()!.width).toBe(4 * MODEL_SCALE); + }); + + it('survives a change of length unit, which rescales rather than turns', () => { + const settings = TestBed.inject(SettingsService); + settings.lengthUnit.next(LengthUnit.CM); + service.image.set(placed({ rotationRad: Math.PI / 6 })); + settings.lengthUnit.next(LengthUnit.METER); + expect(service.image()!.rotationRad).toBeCloseTo(Math.PI / 6, 9); + }); + }); + + it('remove clears the picture', () => { + service.image.set(placed()); + service.remove(); + expect(service.image()).toBeNull(); + }); +}); diff --git a/src/app/services/background-image.service.ts b/src/app/services/background-image.service.ts new file mode 100644 index 00000000..3e601f23 --- /dev/null +++ b/src/app/services/background-image.service.ts @@ -0,0 +1,207 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { MODEL_SCALE } from '../model/render-scale'; +import { SettingsService } from './settings.service'; +import { NumberUnitParserService } from './number-unit-parser.service'; + +/** + * A picture pinned behind the grid, for building a linkage on top of one. + * + * Everything here is in internal model units (see MODEL_SCALE), the same units + * a Joint's x and y are in, so the placement numbers can be handed straight to + * the SVG alongside the mechanism's own geometry. + */ +export interface BackgroundImage { + /** The picture itself, as a data URL: the file is never uploaded anywhere. */ + src: string; + /** The file's own pixel size, which fixes the aspect ratio. */ + naturalWidth: number; + naturalHeight: number; + /** Where the middle of the picture sits, in model units. */ + centerX: number; + centerY: number; + /** How wide the picture is drawn, in model units. Height follows the ratio. */ + width: number; + /** + * How far the picture is turned about its own centre, in radians, + * counter-clockwise-positive — the same sense as every other angle in the + * app. A photograph is rarely taken square to the mechanism in it. + */ + rotationRad: number; + /** 0..1. A tracing underlay wants to be visible *through*, not just under. */ + opacity: number; + /** The name of the file it came from, for the panel to show. */ + fileName: string; +} + +/** The largest file we will read, in bytes. */ +export const MAX_IMAGE_BYTES = 12 * 1024 * 1024; + +/** + * Nothing narrower than this is a picture you could trace anything against. + * + * A floor for the corner drag, which can be pulled through zero; a typed width + * below it is refused by the panel rather than quietly rounded up here, so the + * number in the field is always the number that took effect. + */ +export const MIN_WIDTH = 0.05 * MODEL_SCALE; + +@Injectable({ providedIn: 'root' }) +export class BackgroundImageService { + /** + * The one picture, or none. + * + * Deliberately outside the URL codec and outside the undo history: an image + * is megabytes and a shared URL is a few hundred characters, so there is no + * version of this that survives a link. The panel says so in as many words. + */ + readonly image = signal<BackgroundImage | null>(null); + + private settings = inject(SettingsService); + private nup = inject(NumberUnitParserService); + + constructor() { + // A change of length unit rescales every stored coordinate in the drawing, + // so a picture whose placement stayed put would be left at the conversion + // factor's worth of wrong size — a linkage traced in centimetres came back + // a hundred times too small against its own photograph. + // + // Watched here rather than patched into the settings panel because a unit + // change also arrives by replaying a URL: undo and redo cross one whenever + // the step they are undoing did, and both routes come through this subject. + let previous = this.settings.lengthUnit.value; + this.settings.lengthUnit.subscribe((unit) => { + const from = previous; + previous = unit; + if (from === unit) return; + this.rescale(this.nup.convertLength(1, from, unit)); + }); + } + + /** Restate the placement in units that are now worth `factor` times as much. */ + private rescale(factor: number): void { + const current = this.image(); + if (!current || !Number.isFinite(factor) || factor <= 0) return; + this.image.set({ + ...current, + centerX: current.centerX * factor, + centerY: current.centerY * factor, + width: current.width * factor, + }); + } + + /** How tall the picture is drawn, in model units — the ratio decides. */ + heightOf(image: BackgroundImage): number { + return (image.width * image.naturalHeight) / image.naturalWidth; + } + + /** The picture's left edge in SVG coordinates. */ + leftOf(image: BackgroundImage): number { + return image.centerX - image.width / 2; + } + + /** + * The picture's top edge in SVG coordinates. + * + * The image layer is drawn unflipped, so a model y of +2 is an SVG y of -2 — + * which is why this negates the centre rather than adding to it. + */ + topOf(image: BackgroundImage): number { + return -image.centerY - this.heightOf(image) / 2; + } + + /** + * The turn, as an SVG transform about the picture's own centre. + * + * Negated: the stored angle is counter-clockwise-positive like every other + * angle in the app, and SVG's rotate is clockwise-positive because its y + * points down. Everything drawn inside this transform can then be laid out + * as though the picture were square to the grid. + */ + transformOf(image: BackgroundImage): string { + const degrees = (-image.rotationRad * 180) / Math.PI; + return `rotate(${degrees}, ${image.centerX}, ${-image.centerY})`; + } + + /** + * Read a chosen file and place it, centred on the origin, sized to `fitWidth`. + * + * Rejects rather than guesses on anything it cannot decode: a broken data URL + * drawn into the canvas is an invisible failure, and the caller turns this + * into a message the user can act on. + */ + async load(file: File, fitWidth: number): Promise<void> { + if (!file.type.startsWith('image/')) { + throw new Error(`${file.name} is not an image.`); + } + if (file.size > MAX_IMAGE_BYTES) { + throw new Error( + `${file.name} is ${(file.size / 1024 / 1024).toFixed(1)} MB. The limit is ${ + MAX_IMAGE_BYTES / 1024 / 1024 + } MB.` + ); + } + + const src = await readAsDataURL(file); + const { width, height } = await measure(src); + + this.image.set({ + src, + naturalWidth: width, + naturalHeight: height, + centerX: 0, + centerY: 0, + width: Math.max(MIN_WIDTH, fitWidth), + rotationRad: 0, + opacity: 0.5, + fileName: file.name, + }); + } + + /** Change part of the placement, leaving the picture itself alone. */ + place( + change: Partial< + Pick<BackgroundImage, 'centerX' | 'centerY' | 'width' | 'rotationRad' | 'opacity'> + > + ): void { + const current = this.image(); + if (!current) return; + this.image.set({ + ...current, + ...change, + width: Math.max(MIN_WIDTH, change.width ?? current.width), + opacity: clamp(change.opacity ?? current.opacity, 0, 1), + }); + } + + remove(): void { + this.image.set(null); + } +} + +function clamp(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} + +function readAsDataURL(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error(`${file.name} could not be read.`)); + reader.readAsDataURL(file); + }); +} + +function measure(src: string): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const probe = new Image(); + probe.onload = () => { + if (!probe.naturalWidth || !probe.naturalHeight) { + reject(new Error('That image has no size the browser could read.')); + return; + } + resolve({ width: probe.naturalWidth, height: probe.naturalHeight }); + }; + probe.onerror = () => reject(new Error('That image could not be decoded.')); + probe.src = src; + }); +} diff --git a/src/app/services/color.service.ts b/src/app/services/color.service.ts index 666667f6..bd6dcc45 100644 --- a/src/app/services/color.service.ts +++ b/src/app/services/color.service.ts @@ -1,4 +1,18 @@ import { Injectable } from '@angular/core'; +import { + DEFAULT_FORCE_COLOR, + JointFamily, + JOINT_FAMILIES, + PART_COLORS, +} from '../model/joint-colors'; + +export { + JOINT_FAMILIES, + SELECTION_RING, + PART_COLORS, + DEFAULT_FORCE_COLOR, +} from '../model/joint-colors'; +export type { JointFamily } from '../model/joint-colors'; @Injectable({ providedIn: 'root', @@ -12,24 +26,46 @@ export class ColorService { ColorService.instance = this; } - private linkColorOptions = [ - '#c5cae9', - '#303e9f', - '#0d125a', - // '#283493', - // '#3948ab', - // '#3f50b5', - // '#5c6ac0', - // '#7986cb', - // '#c5cae9', - '#B2DFDB', - '#26A69A', - '#00695C', - ]; - - private jointColorOptions = ['#ffecb2']; - - private forceColorOptions = ['#3f50b5']; + private linkColorOptions: string[] = [...PART_COLORS]; + + /** + * The families a joint can be drawn in, each a set of three. + * + * A joint is drawn resting, pointed at and picked, and those only read as one + * object in three moods if they come from one family -- so choosing a colour + * for a joint chooses all three at once, not a fill. + * + * Amber through brown: warm, complementary to the indigo and teal the links + * are drawn in, and none of it competing with the link palette. Amber is + * first and is what every joint is drawn in until somebody says otherwise, + * so the same row that puts a colour on a joint takes it off again. + */ + private jointFamilies: readonly JointFamily[] = JOINT_FAMILIES; + + public getJointFamilies(): readonly JointFamily[] { + return this.jointFamilies; + } + + /** The family a joint belongs to; the first for anything unrecognised. */ + public jointFamily(id: string): JointFamily { + return this.jointFamilies.find((family) => family.id === id) ?? this.jointFamilies[0]; + } + + public getJointColorOptions(): string[] { + return this.jointFamilies.map((family) => family.normal); + } + + public getIndexFromJointFamily(id: string): number { + const at = this.jointFamilies.findIndex((family) => family.id === id); + return at === -1 ? 0 : at; + } + + public getJointFamilyFromIndex(index: number): string { + return (this.jointFamilies[index] ?? this.jointFamilies[0]).id; + } + + /** The same six the links use. A force is read against the link it acts on. */ + private forceColorOptions: string[] = [...PART_COLORS]; private linkLastColorIndex = 0; @@ -56,10 +92,6 @@ export class ColorService { return this.linkColorOptions; } - public getJointColorOptions(): string[] { - return this.jointColorOptions; - } - public getForceColorOptions(): string[] { return this.forceColorOptions; } @@ -68,23 +100,19 @@ export class ColorService { return this.linkColorOptions.indexOf(fill); } - getIndexFromJointColor(fill: string) { - return this.jointColorOptions.indexOf(fill); - } - getIndexFromForceColor(fill: string) { - return this.forceColorOptions.indexOf(fill); + // An empty colour is the default, which is one of the six -- so the picker + // always has exactly one swatch ticked, whether or not anybody has chosen. + const wanted = (fill || DEFAULT_FORCE_COLOR).toLowerCase(); + const at = this.forceColorOptions.findIndex((option) => option.toLowerCase() === wanted); + return at === -1 ? this.forceColorOptions.indexOf(DEFAULT_FORCE_COLOR) : at; } getLinkColorFromIndex(index: number) { return this.linkColorOptions[index]; } - getJointColorFromIndex(index: number) { - return this.jointColorOptions[index]; - } - getForceColorFromIndex(index: number) { - return this.forceColorOptions[index]; + return this.forceColorOptions[index] ?? DEFAULT_FORCE_COLOR; } } diff --git a/src/app/services/context-menu-builder.service.spec.ts b/src/app/services/context-menu-builder.service.spec.ts new file mode 100644 index 00000000..8d0cf23d --- /dev/null +++ b/src/app/services/context-menu-builder.service.spec.ts @@ -0,0 +1,427 @@ +import { Injector } from '@angular/core'; +import { ContextMenuBuilderService, MenuHandlers } from './context-menu-builder.service'; +import { ContextMenuModel, MenuRow } from '../component/context-menu/menu-model'; +import { ActiveObjService } from './active-obj.service'; +import { ColorService } from './color.service'; +import { DragStateService } from './drag-state.service'; +import { GridUtilsService } from './grid-utils.service'; +import { KeyboardShortcutsService } from './keyboard-shortcuts.service'; +import { MechanismService } from './mechanism.service'; +import { NotificationService } from './notification.service'; +import { NumberUnitParserService } from './number-unit-parser.service'; +import { SaveHistoryService } from './save-history.service'; +import { SettingsService } from './settings.service'; +import { SvgGridService } from './svg-grid.service'; +import { SynthesisBuilderService } from './synthesis/synthesis-builder.service'; +import { SelectedTabService, TabID } from '../selected-tab.service'; +import { silentNotifications } from '../../test-utils/notification-stub'; +import { wireGraph } from '../../test-utils/mechanism-harness'; +import { RevJoint } from '../model/joint'; +import { RealLink } from '../model/link'; +import { MODEL_SCALE } from '../model/render-scale'; + +/** + * What the right-click menu offers, and what it says when it cannot. + * + * The claim being tested is not "these rows exist" but "the menu says what the + * model says". Every refusal below is written somewhere else — in + * `describeActuator`, in `canToggleWeld`, in `deleteLink`'s orphan rule — and + * the menu is only allowed to quote them. So the assertions check the reason + * as well as the greying: a row greyed for the wrong reason is a row that will + * send a student to fix the wrong thing. + */ + +const S = MODEL_SCALE; + +/** The shortcut registry, minus the window listener a spec has no use for. */ +const keysStub = { + keysFor: (id: string) => (id === 'edit.lock' ? 'K' : id === 'edit.delete' ? 'Delete' : ''), + tip: (name: string) => name, +}; + +const noHandlers: MenuHandlers = { + attachLink: () => {}, + attachCylinder: () => {}, + attachTracerPoint: () => {}, + attachForce: () => {}, + backgroundImage: () => {}, + deletePosition: () => {}, + deleteAllPositions: () => {}, +}; + +function createBuilderHarness() { + if (!ColorService.instance) new ColorService(); + const injector = Injector.create({ + providers: [ + { provide: SettingsService, deps: [] }, + { provide: NumberUnitParserService, deps: [] }, + { provide: ActiveObjService, deps: [] }, + { provide: DragStateService, deps: [] }, + { provide: SelectedTabService, deps: [] }, + { provide: NotificationService, useFactory: silentNotifications, deps: [] }, + { provide: SaveHistoryService, useValue: { save: () => {} } }, + { provide: SynthesisBuilderService, deps: [] }, + { provide: SvgGridService, deps: [] }, + { provide: GridUtilsService, deps: [] }, + { provide: MechanismService, deps: [] }, + { provide: KeyboardShortcutsService, useValue: keysStub }, + { provide: ContextMenuBuilderService, deps: [] }, + ], + }); + return { + builder: injector.get(ContextMenuBuilderService), + mechanism: injector.get(MechanismService), + tabs: injector.get(SelectedTabService), + synthesis: injector.get(SynthesisBuilderService), + }; +} + +/** A crank-rocker: ground O, crank OA, coupler ACT with a tracer, rocker CD. */ +function fourBar(mechanism: MechanismService) { + const o = new RevJoint('O', 0, 0, false, true); + const a = new RevJoint('A', 0, 2 * S); + const c = new RevJoint('C', 3 * S, 2 * S); + const d = new RevJoint('D', 3 * S, 0, false, true); + const t = new RevJoint('T', 2 * S, 3 * S); + const crank = new RealLink('OA', [o, a], 1, 1); + const coupler = new RealLink('ACT', [a, c, t], 1, 1); + const rocker = new RealLink('CD', [c, d], 1, 1); + mechanism.joints = [o, a, c, d, t]; + mechanism.links = [crank, coupler, rocker]; + mechanism.forces = []; + wireGraph(mechanism); + return { o, a, c, d, t, crank, coupler, rocker }; +} + +function rows(model: ContextMenuModel): MenuRow[] { + return model.groups.flatMap((group) => group.rows); +} + +function row(model: ContextMenuModel, label: string): MenuRow | undefined { + return rows(model).find((one) => one.label === label); +} + +function labels(model: ContextMenuModel): string[] { + return rows(model).map((one) => one.label); +} + +describe('the right-click menu', () => { + let harness: ReturnType<typeof createBuilderHarness>; + + beforeEach(() => { + harness = createBuilderHarness(); + harness.tabs.setTab(TabID.EDIT); + }); + + describe('the ladder', () => { + it('runs Attach, then State, then the destructive footer', () => { + const parts = fourBar(harness.mechanism); + const model = harness.builder.build(parts.a, noHandlers); + expect(model.groups.map((group) => group.label)).toEqual(['Attach', 'State', undefined]); + }); + + it('puts Delete last, whatever else the menu holds', () => { + const parts = fourBar(harness.mechanism); + for (const target of [parts.a, parts.t, parts.crank, parts.coupler]) { + const all = rows(harness.builder.build(target, noHandlers)); + expect(all[all.length - 1].destructive).toBe(true); + expect(all.filter((one) => one.destructive).length).toBe(1); + } + }); + + it('names the target rather than leaving the reader to guess', () => { + const parts = fourBar(harness.mechanism); + const model = harness.builder.build(parts.a, noHandlers); + expect(model.header?.title).toBe('Joint A'); + expect(model.header?.subtitle).toBe('Pin · Links OA, ACT'); + }); + + it('writes states as states, not as verbs that rewrite themselves', () => { + const parts = fourBar(harness.mechanism); + const model = harness.builder.build(parts.o, noHandlers); + expect(labels(model)).toContain('Grounded'); + expect(labels(model)).not.toContain('Remove Ground'); + expect(row(model, 'Grounded')!.checked).toBe(true); + expect(row(model, 'Grounded')!.kind).toBe('toggle'); + }); + }); + + describe('refusals come from the model', () => { + it('greys the input on a joint where three bodies meet, and says so', () => { + const parts = fourBar(harness.mechanism); + // A fourth body at A: three links now meet there. + const extra = new RevJoint('X', -2 * S, 2 * S); + const spur = new RealLink('AX', [parts.a, extra], 1, 1); + harness.mechanism.joints.push(extra); + harness.mechanism.links.push(spur); + wireGraph(harness.mechanism); + + const driven = row(harness.builder.build(parts.a, noHandlers), 'Driven Input')!; + expect(driven.disabled).toBe(true); + expect(driven.refusal!.short).toBe('3 bodies meet'); + expect(driven.refusal!.long).toContain('would not say which pair moves'); + }); + + it('greys the weld on a joint with nothing to fuse', () => { + const parts = fourBar(harness.mechanism); + const weld = row(harness.builder.build(parts.t, noHandlers), 'Welded')!; + expect(weld.disabled).toBe(true); + expect(weld.refusal!.short).toBe('needs 2 links'); + }); + + it('leaves both directions of a mutually exclusive pair usable', () => { + const parts = fourBar(harness.mechanism); + parts.a.input = true; + const model = harness.builder.build(parts.a, noHandlers); + // Weld greys on a driven joint... + expect(row(model, 'Welded')!.refusal!.short).toBe('it is driven'); + // ...and the switch that resolves it stays live. + expect(row(model, 'Driven Input')!.disabled).toBe(false); + expect(row(model, 'Driven Input')!.checked).toBe(true); + }); + + it('will not anchor a load where two links share the pin', () => { + const parts = fourBar(harness.mechanism); + expect(row(harness.builder.build(parts.a, noHandlers), 'Force')!.refusal!.short).toBe( + '2 links share it' + ); + // And offers it where the answer is unambiguous. + expect(row(harness.builder.build(parts.t, noHandlers), 'Force')!.disabled).toBe(false); + }); + + it('greys a disc on a link with no fixed pin to sweep about', () => { + const parts = fourBar(harness.mechanism); + const disc = row(harness.builder.build(parts.coupler, noHandlers), 'Drawn as a Disc')!; + expect(disc.refusal!.short).toBe('needs a fixed pin'); + // The crank turns about a grounded pin, so it may be drawn as one. + expect(row(harness.builder.build(parts.crank, noHandlers), 'Drawn as a Disc')!.disabled).toBe( + false + ); + }); + }); + + describe('locks', () => { + it('stops a locked part being deleted, and says which way out', () => { + const parts = fourBar(harness.mechanism); + parts.a.locked = true; + const model = harness.builder.build(parts.a, noHandlers); + expect(row(model, 'Locked')!.checked).toBe(true); + const remove = rows(model).find((one) => one.destructive)!; + expect(remove.refusal!.short).toBe('unlock first'); + // Its own switch stays live: one click here frees it. + expect(row(model, 'Locked')!.disabled).toBe(false); + }); + + it('will not let a link deletion sweep up a locked joint', () => { + const parts = fourBar(harness.mechanism); + // O is on the crank alone, so deleting the crank would orphan it. The + // crank is not itself "locked" -- that needs every joint -- so the lock + // was being ignored by the longer route. + parts.o.locked = true; + expect(harness.mechanism.deleteRefusal(parts.crank)).toContain('locked'); + harness.mechanism.activeObjService.updateSelectedObj(parts.crank); + harness.mechanism.deleteLink(); + expect(harness.mechanism.links.some((one) => one.id === 'OA')).toBe(true); + expect(harness.mechanism.joints.some((one) => one.id === 'O')).toBe(true); + const remove = rows(harness.builder.build(parts.crank, noHandlers)).find( + (one) => one.destructive + )!; + expect(remove.refusal!.short).toBe('unlock first'); + }); + + it('refuses to attach to a locked joint for the same reason', () => { + const parts = fourBar(harness.mechanism); + parts.a.locked = true; + const model = harness.builder.build(parts.a, noHandlers); + expect(row(model, 'Link')!.refusal!.short).toBe('unlock first'); + }); + + it('counts what Lock All and Unlock All would touch', () => { + const parts = fourBar(harness.mechanism); + parts.a.locked = true; + const model = harness.builder.build('grid', noHandlers); + expect(row(model, 'Lock All')!.hint).toBe('4 open'); + expect(row(model, 'Unlock All')!.hint).toBe('1 locked'); + // The pair never both grey at once, so there is always a way out. + expect(row(model, 'Lock All')!.disabled).toBe(false); + expect(row(model, 'Unlock All')!.disabled).toBe(false); + }); + }); + + describe('duplicating a link', () => { + it('copies a link of any shape, set down beside the original', () => { + const parts = fourBar(harness.mechanism); + const before = harness.mechanism.links.length; + // Three joints, not two: the first cut of this quietly did nothing here, + // which is exactly what "Duplicate does nothing" looked like. + harness.mechanism.duplicateLink(parts.coupler); + expect(harness.mechanism.links.length).toBe(before + 1); + const copy = harness.mechanism.links[harness.mechanism.links.length - 1]; + expect(copy.joints.length).toBe(3); + // Beside it, not on it: a copy that lands under the original reads as + // nothing having happened. + const moved = copy.joints[0]; + const from = parts.coupler.joints[0]; + expect(Math.hypot(moved.x - from.x, moved.y - from.y)).toBeGreaterThan(0.5 * S); + // And free-standing: it shares no joint with what it was copied from. + const shared = copy.joints.filter((one) => + parts.coupler.joints.some((other) => other.id === one.id) + ); + expect(shared.length).toBe(0); + }); + + it('copies the body, including what makes those numbers custom', () => { + const parts = fourBar(harness.mechanism); + parts.crank.mass = 7; + parts.crank.massMoI = 123.456; + parts.crank.moiIsCustom = true; + parts.crank.placeCustomCoM({ x: parts.crank.CoM.x + 37, y: parts.crank.CoM.y - 21 }); + + harness.mechanism.duplicateLink(parts.crank); + const copy = harness.mechanism.links[harness.mechanism.links.length - 1] as RealLink; + expect(copy.mass).toBe(7); + expect(copy.massMoI).toBe(123.456); + // The flags, not just the values: without them the next rebuild treats + // the copy as an ordinary body and computes both back over. + expect(copy.moiIsCustom).toBe(true); + expect(copy.comIsCustom).toBe(true); + // And the offsets are held against joint letters, which have to be the + // copy's own or the point rides a bar it is not on. + expect(copy.comOffset?.frame).toEqual( + copy.joints.map((joint) => joint.id) as [string, string] + ); + }); + + it('greys the row on a welded compound rather than doing nothing', () => { + const parts = fourBar(harness.mechanism); + // A compound: several links and the welds between them. + (parts.coupler as RealLink).subset = [parts.crank, parts.rocker]; + const duplicate = row(harness.builder.build(parts.coupler, noHandlers), 'Duplicate Link')!; + expect(duplicate.disabled).toBe(true); + expect(duplicate.refusal!.short).toBe('welded compound'); + }); + }); + + describe('cascades are named, not confirmed', () => { + it('counts the joints deleting a link would sweep up', () => { + const parts = fourBar(harness.mechanism); + const remove = rows(harness.builder.build(parts.crank, noHandlers)).find( + (one) => one.destructive + )!; + // O is on the crank alone and goes with it; A is on two links and stays. + expect(remove.label).toBe('Delete Link and Joint O'); + }); + + it('names the links deleting a joint would take with it', () => { + const parts = fourBar(harness.mechanism); + const remove = rows(harness.builder.build(parts.a, noHandlers)).find( + (one) => one.destructive + )!; + // The crank is left with one end; the three-joint coupler survives. + expect(remove.label).toBe('Delete Joint and Link OA'); + }); + + it('says plain Delete Joint when nothing else goes with it', () => { + const parts = fourBar(harness.mechanism); + const remove = rows(harness.builder.build(parts.c, noHandlers)).find( + (one) => one.destructive + )!; + expect(remove.label).toBe('Delete Joint and Link CD'); + expect( + rows(harness.builder.build(parts.t, noHandlers)).find((one) => one.destructive)!.label + ).toBe('Delete Joint'); + }); + }); + + describe('words the reader has to live with', () => { + it('never writes a state as a verb that flips', () => { + const parts = fourBar(harness.mechanism); + for (const target of [parts.a, parts.o, parts.crank]) { + for (const one of rows(harness.builder.build(target, noHandlers))) { + expect(one.label).not.toMatch(/^(Add|Remove|Make|Un)[A-Z ]/); + } + } + }); + }); + + describe('what the row promises, the model does', () => { + it('will not delete a locked part, whichever surface asks', () => { + const parts = fourBar(harness.mechanism); + parts.a.locked = true; + harness.mechanism.activeObjService.updateSelectedObj(parts.a); + harness.mechanism.deleteJoint(); + // The menu greys the row; the Delete key and the panel button reach the + // same joint, so the rule has to live where all three can ask it. + expect(harness.mechanism.joints.some((one) => one.id === 'A')).toBe(true); + expect(harness.mechanism.deleteRefusal(parts.a)).toContain('locked'); + }); + + it('names the sub-link a welded compound would lose', () => { + const parts = fourBar(harness.mechanism); + // A compound of the crank and the coupler, as welding A makes. + const compound = parts.coupler; + compound.subset = [parts.crank, parts.coupler]; + parts.o.links = [compound]; + // The compound has four joints, so asking only its own count says + // nothing is doomed -- while the two-joint leaf inside it is. + const doomed = harness.mechanism.linksRemovedByDeleting(parts.o).map((one) => one.id); + expect(doomed).toContain('OA'); + }); + }); + + describe('modes', () => { + it('offers only the view of a joint in an analysis mode', () => { + const parts = fourBar(harness.mechanism); + harness.tabs.setTab(TabID.ANALYZE); + const model = harness.builder.build(parts.a, noHandlers); + expect(labels(model)).toEqual(['Trace Path']); + // Geometry is frozen there, so Attach and the footer are absent rather + // than greyed — and the way back into Edit rides the header. + expect(model.header?.crossing?.icon).toBe('edit_outline'); + }); + + it('crosses into analysis from Edit, and the canvas has nowhere to cross to', () => { + fourBar(harness.mechanism); + expect(harness.builder.build('grid', noHandlers).header?.crossing).toBeUndefined(); + }); + + it('will not offer analysis of a part that is in no mechanism', () => { + const parts = fourBar(harness.mechanism); + // Nothing has been partitioned, so no part belongs to a machine yet — + // which is exactly the state a loose bar dropped on the grid is in. + const crossing = harness.builder.build(parts.a, noHandlers).header?.crossing; + expect(crossing?.refusal?.short).toBe('not in a mechanism'); + }); + + it('asks the part which machine it is in, not the drawing', () => { + const parts = fourBar(harness.mechanism); + harness.mechanism.updateMechanism(); + const inside = harness.builder.build(parts.a, noHandlers).header?.crossing; + const readiness = harness.mechanism.readinessOfPart(parts.a); + // Whatever this machine's state is, the crossing agrees with it rather + // than with whether *something* on the grid can be analysed. + expect(!!inside?.refusal).toBe(!harness.mechanism.isPartSimulatable(parts.a)); + if (inside?.refusal) { + expect(readiness).toBeDefined(); + expect(inside.refusal.short).toBe('not ready'); + } + }); + + it('offers nothing but the positions in Synthesis', () => { + fourBar(harness.mechanism); + harness.tabs.setTab(TabID.SYNTHESIZE); + expect(rows(harness.builder.build('grid', noHandlers)).length).toBe(0); + }); + }); + + describe('away from the start pose', () => { + it('greys what edits the mechanism and leaves the view alone', () => { + const parts = fourBar(harness.mechanism); + harness.mechanism.mechanismTimeStep = 12; + const model = harness.builder.build(parts.t, noHandlers); + expect(row(model, 'Grounded')!.refusal!.short).toBe('not at the start'); + // A trace is a view of the mechanism, not a change to it. + expect(row(model, 'Trace Path')!.disabled).toBe(false); + }); + }); +}); diff --git a/src/app/services/context-menu-builder.service.ts b/src/app/services/context-menu-builder.service.ts new file mode 100644 index 00000000..3f5312b1 --- /dev/null +++ b/src/app/services/context-menu-builder.service.ts @@ -0,0 +1,892 @@ +import { Injectable, inject } from '@angular/core'; +import { + ContextMenuModel, + MenuGroup, + MenuRefusal, + MenuRow, +} from '../component/context-menu/menu-model'; +import { Joint, PrisJoint, RealJoint } from '../model/joint'; +import { Link, RealLink, SliderBlock } from '../model/link'; +import { Force } from '../model/force'; +import { SynthesisPose } from './synthesis/synthesis-util'; +import { Cylinder } from '../model/cylinder'; +import { labelForBody } from '../model/body-label'; +import { describeActuatorRefusal } from '../model/actuator'; +import { MechanismService } from './mechanism.service'; +import { GridUtilsService } from './grid-utils.service'; +import { SettingsService } from './settings.service'; +import { ActiveObjService } from './active-obj.service'; +import { KeyboardShortcutsService } from './keyboard-shortcuts.service'; +import { SynthesisBuilderService } from './synthesis/synthesis-builder.service'; +import { SelectedTabService, TabID } from '../selected-tab.service'; + +/** What the canvas does when a row asks for a gesture rather than an edit. */ +export interface MenuHandlers { + attachLink(): void; + attachCylinder(): void; + attachTracerPoint(): void; + attachForce(onLink: RealLink): void; + backgroundImage(): void; + deletePosition(id: number): void; + deleteAllPositions(): void; +} + +/** Anything the right-click can land on. */ +export type MenuTarget = Joint | Link | Force | SynthesisPose | string; + +/** + * The right-click menu, assembled. + * + * One place, because the menu's whole claim is that it says the same thing the + * panels and the drag ring say. Every refusal below is fetched from the model + * that enforces it — `describeActuator` for a driven joint, `weldRefusal` for a + * weld, `locksHolding` for a lock — rather than written out again here, so the + * three surfaces cannot end up disagreeing about what is possible. + * + * The shape is a fixed ladder in every case: Attach, State, Machine, and a + * destructive footer. Groups drop out when they are empty and never reorder, + * so the flick to Delete lands on the last row whether the menu holds two rows + * or twelve. + */ +@Injectable({ providedIn: 'root' }) +export class ContextMenuBuilderService { + private mechanism = inject(MechanismService); + private gridUtils = inject(GridUtilsService); + private settings = inject(SettingsService); + private activeObj = inject(ActiveObjService); + private keys = inject(KeyboardShortcutsService); + private synthesis = inject(SynthesisBuilderService); + private tabs = inject(SelectedTabService); + + build(target: MenuTarget, handlers: MenuHandlers): ContextMenuModel { + const model = this.buildFor(target, handlers); + return this.freezeWhileRunning(model); + } + + private buildFor(target: MenuTarget, handlers: MenuHandlers): ContextMenuModel { + if (target instanceof SynthesisPose) return this.forPose(target, handlers); + // Synthesis mode is a question about a mechanism that does not exist yet: + // the only things on the canvas that belong to anyone are the positions, + // and the panel owns everything else about them. + if (this.tabs.getCurrentTab() === TabID.SYNTHESIZE) { + const rows = this.positionRows(handlers, undefined); + return rows.length === 0 + ? { groups: [] } + : { header: { title: 'Canvas', subtitle: this.canvasSubtitle() }, groups: [{ rows }] }; + } + if (target instanceof Force) return this.forForce(target); + if (target instanceof Link) return this.forLink(target, handlers); + if (target instanceof Joint) return this.forJoint(target, handlers); + return this.forCanvas(handlers); + } + + // ---------------------------------------------------------------- canvas + + private forCanvas(handlers: MenuHandlers): ContextMenuModel { + const groups: MenuGroup[] = []; + if (this.tabs.getCurrentTab() === TabID.EDIT) { + groups.push({ + label: 'Add', + rows: [ + new MenuRow({ label: 'Link', icon: 'new_link', action: () => handlers.attachLink() }), + new MenuRow({ + label: 'Cylinder', + icon: 'add_cylinder', + action: () => handlers.attachCylinder(), + }), + new MenuRow({ + label: 'Background Image', + icon: 'background_image', + action: () => handlers.backgroundImage(), + }), + ], + }); + groups.push({ label: 'Machine', rows: this.machineRows() }); + } + groups.push({ rows: this.positionRows(handlers, undefined) }); + // Bare canvas in an analysis mode: nothing to name and nothing to do, so + // no card. A *part* still earns one — its header is the way back into + // Edit on the thing being looked at — but the grid is not a part. + if (groups.every((group) => group.rows.length === 0)) return { groups: [] }; + return { header: { title: 'Canvas', subtitle: this.canvasSubtitle() }, groups }; + } + + private canvasSubtitle(): string { + const counts = this.mechanism.lockCounts(); + if (counts.total === 0) return 'Grid · nothing drawn'; + if (counts.locked === 0) return 'Grid · nothing selected'; + if (counts.locked === counts.total) return `Grid · all ${counts.total} parts locked`; + return `Grid · ${counts.locked} of ${counts.total} parts locked`; + } + + /** + * Lock All and Unlock All, each with its own count. + * + * The pair never both grey at once — one of them always has something to do + * on a drawing that holds anything — so there is always a way out of a + * fully locked mechanism. + */ + private machineRows(): MenuRow[] { + const counts = this.mechanism.lockCounts(); + const lockKey = this.keys.keysFor('edit.lock'); + return [ + new MenuRow({ + label: 'Lock All', + icon: 'lock', + action: () => this.mechanism.setAllLocks(true), + hint: counts.open > 0 ? `${counts.open} open` : undefined, + shortcut: lockKey, + refusal: + counts.total === 0 + ? { short: 'nothing to lock', long: 'There is nothing on the grid yet.' } + : counts.open === 0 + ? { short: 'all locked', long: 'Every part is already locked.' } + : undefined, + }), + new MenuRow({ + label: 'Unlock All', + icon: 'unlock', + action: () => this.mechanism.setAllLocks(false), + hint: counts.locked > 0 ? `${counts.locked} locked` : undefined, + refusal: + counts.locked === 0 ? { short: 'nothing locked', long: 'No part is locked.' } : undefined, + }), + ]; + } + + // --------------------------------------------------------------- position + + /** + * The synthesis positions, on every menu that can reach them. + * + * They are drawn in every mode, because they are a note about what the + * mechanism was designed to do rather than a part of it — so clearing them + * is not an edit and does not have to wait for the start pose. + */ + private positionRows(handlers: MenuHandlers, pose: SynthesisPose | undefined): MenuRow[] { + const placed = this.synthesis.getAllPoses().length; + if (placed === 0) return []; + const rows: MenuRow[] = []; + if (pose) { + rows.push( + new MenuRow({ + label: `Delete Position ${pose.id}`, + icon: 'remove', + action: () => handlers.deletePosition(pose.id), + destructive: true, + alwaysAllowed: true, + shortcut: this.keys.keysFor('edit.delete'), + }) + ); + } + rows.push( + new MenuRow({ + label: pose + ? `Delete All ${placed} Positions` + : `Delete ${placed} Synthesis ${placed === 1 ? 'Position' : 'Positions'}`, + icon: 'remove', + action: () => handlers.deleteAllPositions(), + destructive: true, + alwaysAllowed: true, + }) + ); + return rows; + } + + private forPose(pose: SynthesisPose, handlers: MenuHandlers): ContextMenuModel { + const placed = this.synthesis.getAllPoses().length; + return { + header: { title: `Position ${pose.id}`, subtitle: `Synthesis · ${placed} placed` }, + groups: [{ rows: this.positionRows(handlers, pose) }], + }; + } + // ----------------------------------------------------------------- joint + + private forJoint(joint: Joint, handlers: MenuHandlers): ContextMenuModel { + const header = { + title: `Joint ${this.nameOf(joint)}`, + subtitle: this.jointSubtitle(joint), + crossing: this.crossing(joint), + }; + if (!(joint instanceof RealJoint)) return { header, groups: [] }; + if (this.tabs.isAnalysisMode()) { + return { + header, + groups: [ + { label: 'State', rows: [this.traceRow(joint), ...this.forceGraphRow(joint)] }, + { rows: this.positionRows(handlers, undefined) }, + ], + }; + } + const sealed = this.mechanism.cylinderAt(joint); + return { + header, + groups: [ + { label: 'Attach', rows: this.jointAttachRows(joint, handlers) }, + { label: 'State', rows: this.jointStateRows(joint, sealed) }, + { rows: [this.deleteJointRow(joint, sealed)] }, + ], + }; + } + + private jointAttachRows(joint: RealJoint, handlers: MenuHandlers): MenuRow[] { + const driven = this.gridUtils.isVisuallyInput(joint); + // A third body at a driven joint is what "driven" stops being able to + // describe: an input prescribes the freedom between *two* bodies. + const crowds: MenuRefusal | undefined = driven + ? { + short: 'it is driven', + long: 'An input prescribes the freedom between two bodies, so a third arriving here would leave "driven" naming no pair. Remove the input first.', + } + : undefined; + const held = this.frozenRefusal(joint); + const rows: MenuRow[] = [ + new MenuRow({ + label: 'Link', + icon: 'new_link', + action: () => handlers.attachLink(), + refusal: held ?? crowds, + }), + ]; + // A cylinder's own joints take no third member, so the row is absent there + // rather than greyed: that is a fact about the part, not this arrangement. + if (!this.mechanism.cylinderAt(joint)) { + rows.push( + new MenuRow({ + label: 'Cylinder', + icon: 'add_cylinder', + action: () => handlers.attachCylinder(), + // A weld says everything meeting here is one rigid body; a cylinder's + // joint arriving would be a third body joining that statement without + // being part of it, and the reconcilers then disagree about what the + // compound is. + refusal: + held ?? + crowds ?? + (joint.isWelded + ? { + short: 'it is welded', + long: 'A weld says the bodies meeting here are one rigid piece. Unweld the joint before attaching a cylinder to it.', + } + : undefined), + }) + ); + } + // A load has to say which body carries it. At a joint two links share there + // is no answer, so the row is greyed here and offered on the bar, where the + // anchor can be placed unambiguously. + const bars = joint.links.filter((link): link is RealLink => link instanceof RealLink); + if (bars.length > 0) { + rows.push( + new MenuRow({ + label: 'Force', + icon: 'add_force', + action: () => handlers.attachForce(bars[0]), + refusal: + held ?? + (bars.length > 1 + ? { + short: `${bars.length} links share it`, + long: 'A load applied where several links meet does not say which one carries it. Attach it to the link instead.', + } + : undefined), + }) + ); + } + return rows; + } + + private jointStateRows(joint: RealJoint, sealed: Cylinder | undefined): MenuRow[] { + const rows: MenuRow[] = [ + new MenuRow({ + label: 'Grounded', + icon: 'add_ground', + kind: 'toggle', + checked: this.groundedNow(joint), + action: () => this.mechanism.toggleGround(), + }), + new MenuRow({ + label: 'Driven Input', + icon: 'add_input', + kind: 'toggle', + checked: this.gridUtils.isVisuallyInput(joint), + action: () => this.mechanism.adjustInput(), + refusal: this.inputRefusal(joint), + }), + ]; + // A slider on a cylinder's joint is structurally off the table — the + // assembly already is one — so it is absent rather than greyed. + if (!sealed) { + const isSlider = this.gridUtils.isAttachedToSlider(joint); + rows.push( + new MenuRow({ + label: 'Slider', + icon: 'add_slider', + kind: 'toggle', + checked: isSlider, + action: () => this.mechanism.toggleSlider(), + // A block is a body too, so adding one to a driven pin puts a third + // at the joint. Taking one away is always allowed. + refusal: + !isSlider && this.gridUtils.isVisuallyInput(joint) + ? { + short: 'it is driven', + long: 'A block is a body of its own, so adding one to a driven joint would put three there. Remove the input first.', + } + : undefined, + }) + ); + } + // A slider pin keeps its Weld row, enabled. The design said a slider cannot + // be welded and so the row should be absent; the model does not agree -- + // `canToggleWeld` has no slider clause, and §4.1's rule is that a slider + // is *offered* the weld and refused with the reason if the fuse cannot + // stand. Hiding it put a reachable state out of reach from this surface + // and not from the panel, which is the disagreement this menu exists to + // stop. + if (!(joint instanceof PrisJoint)) { + rows.push( + new MenuRow({ + label: 'Welded', + icon: 'weld_joint', + kind: 'toggle', + checked: joint.isWelded, + action: () => this.mechanism.toggleWeldedJoint(), + refusal: this.gridUtils.weldRefusal(joint), + }) + ); + } + rows.push(this.traceRow(joint)); + rows.push(this.lockRow(joint, joint)); + return rows; + } + + /** Whether this joint reads as grounded — a slider's ground lives on its guide. */ + private groundedNow(joint: RealJoint): boolean { + if (this.gridUtils.isAttachedToSlider(joint)) { + return (this.gridUtils.getSliderJoint(joint) as RealJoint).ground; + } + return joint.ground; + } + + /** Why this joint will not take an input, in the model's own words. */ + private inputRefusal(joint: RealJoint): MenuRefusal | undefined { + if (this.gridUtils.canToggleInput(joint)) return undefined; + const driven = this.gridUtils.isAttachedToSlider(joint) + ? (this.gridUtils.getSliderJoint(joint) as RealJoint) + : joint; + return describeActuatorRefusal(driven); + } + + /** + * The trace switch: a view of the mechanism rather than a part of it, so it + * stays live in the analysis modes and while the animation runs. + * + * Turning one on turns the global switch on with it. A per-joint trace that + * draws nothing because the view control is off is a switch that lies. + */ + private traceRow(joint: RealJoint): MenuRow { + return new MenuRow({ + label: 'Trace Path', + icon: 'show_path', + kind: 'toggle', + checked: this.gridUtils.getJointShowCurve(joint), + alwaysAllowed: true, + action: () => { + this.gridUtils.toggleCurve(joint); + if (!this.settings.isShowTraces.value) this.settings.isShowTraces.next(true); + }, + }); + } + + /** In Force mode only: the joint's own force graphs, and why there are none. */ + private forceGraphRow(joint: RealJoint): MenuRow[] { + if (this.tabs.getCurrentTab() !== TabID.FORCE) return []; + return [ + new MenuRow({ + label: 'Graph Joint Force', + icon: 'balance', + material: true, + alwaysAllowed: true, + action: () => this.activeObj.updateSelectedObj(joint), + refusal: this.mechanism.jointHasForceToGraph(joint) + ? undefined + : // "One part meets it" is only true when that is what is wrong. + // On a machine that cannot be analysed at all, the joint may have + // three bodies at it and still no graph, and telling that reader + // to attach something would send them to fix the wrong thing. + (this.analysisRefusal(joint) ?? { + short: 'one part meets it', + long: 'Only one part meets this joint, so there is no second body for it to react against and no force to graph.', + }), + }), + ]; + } + + private deleteJointRow(joint: RealJoint, sealed: Cylinder | undefined): MenuRow { + const held = this.lockedRefusal(joint); + // The cascade is named, not confirmed: a cylinder's joint takes the whole + // assembly, and an ordinary one takes any bar left with a single end. The + // row says which, before the click rather than after it. + const label = this.deleteJointLabel(joint, sealed); + return new MenuRow({ + label, + icon: 'remove', + destructive: true, + shortcut: this.keys.keysFor('edit.delete'), + action: () => this.mechanism.deleteJoint(), + refusal: held, + }); + } + + private deleteJointLabel(joint: RealJoint, sealed: Cylinder | undefined): string { + // A slider's block is not named: it is drawn *on* this joint rather than + // beside it, so "and the block at C" describes no second thing the reader + // can see going. Nor are a cylinder's own members, which the word + // "cylinder" already covers -- what is left is the neighbouring bar the + // mount was also holding, and that one the reader has to be told about. + const inside = new Set<string>( + sealed ? [sealed.barrel.id, sealed.rod.id, sealed.block.id] : [] + ); + const doomed = this.mechanism + .linksRemovedByDeleting(joint) + .filter((link) => !(link instanceof SliderBlock) && !inside.has(link.id)); + if (!sealed) { + return doomed.length === 0 ? 'Delete Joint' : `Delete Joint and ${this.bodyList(doomed)}`; + } + return doomed.length === 0 + ? 'Delete Joint and Cylinder' + : `Delete Joint, Cylinder and ${this.bodyList(doomed)}`; + } + + private jointSubtitle(joint: Joint): string { + const sealed = this.mechanism.cylinderAt(joint); + if (sealed) { + const end = joint.id === sealed.rodFar.id ? 'Rod joint' : 'Barrel joint'; + return `${end} · ${this.cylinderName(sealed)}`; + } + const bodies = joint instanceof RealJoint ? joint.links : []; + return `${this.jointKind(joint, bodies)} · ${this.bodyList(bodies)}`; + } + + /** + * What kind of joint this is, in one word. + * + * Ordered by how much the word tells a reader: a driven joint is a driven + * joint whether or not it is also grounded, and "tracer" is only true of a + * free point on a single body — a ground pivot with one link is not one. + */ + private jointKind(joint: Joint, bodies: Link[]): string { + if (joint instanceof PrisJoint) return 'Slider'; + if (this.gridUtils.isAttachedToSlider(joint)) return 'Slider pin'; + if (joint instanceof RealJoint && joint.isWelded) return 'Welded'; + if (joint instanceof RealJoint && joint.input) return 'Driven pin'; + if (joint instanceof RealJoint && joint.ground) return 'Ground pin'; + if (bodies.length === 1) return 'Tracer'; + return 'Pin'; + } + + /** + * The bodies a joint is on: "Links OA, ACT", or their own names where a + * cylinder part or a block is among them and "Link" would be a lie. + */ + private bodyList(bodies: Link[]): string { + if (bodies.length === 0) return 'not on a link'; + const labels = bodies.map((link) => labelForBody(link, this.mechanism.cylinderAt(link))); + const plain = labels.every((label) => label.startsWith('Link ')); + if (!plain) return labels.join(', '); + const names = labels.map((label) => label.slice('Link '.length)); + return `${names.length === 1 ? 'Link' : 'Links'} ${names.join(', ')}`; + } + + // ------------------------------------------------------------------ link + + private forLink(link: Link, handlers: MenuHandlers): ContextMenuModel { + const sealed = this.mechanism.cylinderAt(link); + const header = { + title: sealed ? this.cylinderName(sealed) : labelForBody(link, undefined), + subtitle: this.linkSubtitle(link, sealed), + crossing: this.crossing(link), + }; + if (this.tabs.isAnalysisMode()) { + return { header, groups: [{ rows: this.positionRows(handlers, undefined) }] }; + } + if (sealed) { + // No Attach group at all: a sealed assembly takes no third body, and a + // copy of one would land a second cylinder on the same joints. + return { + header, + groups: [ + { + label: 'State', + rows: [ + new MenuRow({ + label: 'Driven Input', + icon: 'add_input', + kind: 'toggle', + checked: sealed.slider.input, + action: () => this.mechanism.toggleCylinderInput(sealed), + }), + this.lockRow(link as RealLink, undefined), + ], + }, + { + rows: [ + new MenuRow({ + label: 'Delete Cylinder', + icon: 'remove', + destructive: true, + shortcut: this.keys.keysFor('edit.delete'), + action: () => this.mechanism.deleteCylinder(sealed), + refusal: this.lockedRefusal(link as RealLink), + }), + ], + }, + ], + }; + } + // A slider's block is a body in the model and not one on the drawing: it + // has no bar to attach to and no disc to be drawn as, and the pin sitting + // on top of it is what a reader can see and click. Not reachable by + // pointer -- the block's hitbox hands back its pin -- but the builder + // takes any Link, and one that throws on a shape it was handed is worse + // than one that says little. + if (!(link instanceof RealLink)) { + return { header, groups: [{ rows: this.positionRows(handlers, undefined) }] }; + } + const bar = link; + return { + header, + groups: [ + { label: 'Attach', rows: this.linkAttachRows(bar, handlers) }, + { label: 'State', rows: this.linkStateRows(bar) }, + { rows: [this.deleteLinkRow(bar)] }, + ], + }; + } + + private linkAttachRows(link: RealLink, handlers: MenuHandlers): MenuRow[] { + // A welded compound is several links; clicking its fillet rather than one + // of them says which body the new member should join. + const fillet: MenuRefusal | undefined = + link.isWelded && link.lastSelectedSublink == null + ? { + short: 'pick a sub-link', + long: 'This is a welded compound. Click one of the links it is made of, so the new member knows which body it joins.', + } + : undefined; + const locked = this.lockedRefusal(link); + const rows = [ + new MenuRow({ + label: 'Link', + icon: 'new_link', + action: () => handlers.attachLink(), + refusal: fillet ?? locked, + }), + new MenuRow({ + label: 'Cylinder', + icon: 'add_cylinder', + action: () => handlers.attachCylinder(), + refusal: fillet ?? locked, + }), + new MenuRow({ + label: 'Tracer Point', + icon: 'add_tracer', + action: () => handlers.attachTracerPoint(), + refusal: fillet ?? locked, + }), + // A force does not move the geometry, so a locked link still takes one. + new MenuRow({ + label: 'Force', + icon: 'add_force', + action: () => handlers.attachForce(link), + refusal: fillet, + }), + ]; + rows.push( + new MenuRow({ + label: 'Duplicate Link', + icon: 'content_copy', + material: true, + action: () => this.mechanism.duplicateLink(link), + tip: 'Set a free-standing copy of this link down beside it.', + // A welded compound is several links and the welds between them, so + // there is no single link for this to copy. Greyed rather than + // hidden: copying one is a thing a reader can reasonably expect, and + // the row says which link to ask instead. + refusal: this.mechanism.canDuplicate(link) + ? undefined + : { + short: 'welded compound', + long: 'This is several links welded together. Copy one of the links it is made of instead.', + }, + }) + ); + return rows; + } + + private linkStateRows(link: RealLink): MenuRow[] { + return [ + new MenuRow({ + label: 'Drawn as a Disc', + icon: 'make_circular', + kind: 'toggle', + checked: link.isCircle, + action: () => this.mechanism.toggleLinkCircular(), + // Only where there is a fixed pin to draw the disc about — a crank. + refusal: link.canBeCircular() + ? undefined + : { + short: 'needs a fixed pin', + long: 'A disc is the shape a link sweeps about a fixed pin, and this link turns about none.', + }, + }), + this.lockRow(link, undefined), + ]; + } + + private deleteLinkRow(link: RealLink): MenuRow { + // Deleting a link sweeps up the joints no other link holds. The row counts + // them and names them rather than opening a dialog after the click. + const orphans = this.mechanism.jointsOrphanedByDeleting(link); + const named = orphans.map((joint) => this.nameOf(joint)).join(', '); + const label = + orphans.length === 0 + ? 'Delete Link' + : `Delete Link and ${orphans.length === 1 ? 'Joint' : 'Joints'} ${named}`; + return new MenuRow({ + label, + icon: 'remove', + destructive: true, + shortcut: this.keys.keysFor('edit.delete'), + action: () => this.mechanism.deleteLink(), + refusal: this.lockedRefusal(link), + }); + } + + private linkSubtitle(link: Link, sealed: Cylinder | undefined): string { + // A slider's block has no subsets and no bar to describe. + if (!sealed && !(link instanceof RealLink)) { + const joints = link.joints.map((joint) => this.nameOf(joint)).join(', '); + return `Block · Joints ${joints}`; + } + // Not "sealed assembly": to a reader a cylinder is one part, and how it + // is built out of a slider and a weld underneath is not their business. + if (sealed) { + const ends = [sealed.barrelFar, sealed.rodFar].map((joint) => this.nameOf(joint)); + return `Barrel and rod · Joints ${ends.join(', ')}`; + } + const bar = link as RealLink; + const kind = bar.subset.length > 0 ? 'Compound' : 'Bar'; + const joints = bar.joints.map((joint) => this.nameOf(joint)).join(', '); + const locked = this.mechanism.isLockedTarget(bar) ? ' · locked' : ''; + return `${kind} · Joints ${joints}${locked}`; + } + + // ----------------------------------------------------------------- force + + private forForce(force: Force): ContextMenuModel { + const header = { + title: `Force ${force.name || force.id}`, + subtitle: `On ${labelForBody(force.link, undefined)} · ${force.local ? 'local' : 'global'} frame`, + crossing: this.crossing(force), + }; + if (this.tabs.isAnalysisMode()) return { header, groups: [] }; + return { + header, + groups: [ + { + label: 'Set', + rows: [ + new MenuRow({ + label: 'Reverse Direction', + icon: 'switch_force_dir', + action: () => this.mechanism.changeForceDirection(), + }), + ], + }, + { + label: 'State', + rows: [ + // "Make Force Global" was a verb whose label flipped as it was + // used. The frame is a state, so it reads as one. + new MenuRow({ + label: 'Global Frame', + // The app's own force_global glyph carries its own colours, so + // it stays blue on an unticked row and reads as already on -- + // the one icon in the menu that does not take the row's colour. + icon: 'public', + material: true, + kind: 'toggle', + checked: !force.local, + action: () => this.mechanism.changeForceLocal(), + }), + this.lockRow(force, undefined), + ], + }, + { + rows: [ + new MenuRow({ + label: 'Delete Force', + icon: 'remove', + destructive: true, + shortcut: this.keys.keysFor('edit.delete'), + action: () => this.mechanism.deleteForce(force), + refusal: this.lockedRefusal(force), + }), + ], + }, + ], + }; + } + + // ----------------------------------------------------------------- locks + + private lockRow(target: RealJoint | RealLink | Force, joint: RealJoint | undefined): MenuRow { + const locked = this.mechanism.isLockedTarget(target); + // A joint can be held still without carrying a mark of its own: the marks + // on its neighbours close over it. The switch says so and names them, + // because the fix is on them rather than here. + const held = joint && !locked ? this.heldRefusal(joint) : undefined; + return new MenuRow({ + label: 'Locked', + icon: 'lock', + kind: 'toggle', + checked: locked || !!held, + shortcut: this.keys.keysFor('edit.lock'), + action: () => this.mechanism.toggleLock(target), + refusal: held, + }); + } + + /** + * Why nothing can be attached to this joint: it is locked, or something + * locked elsewhere holds it. + * + * Locking a link marks its own joints, so most of the time a held joint is + * a locked joint and its own switch is the fix. The marks land elsewhere + * only where the closure reaches — a slider's block, a sealed cylinder — + * and there the reason has to name them, because the fix is on them. + */ + private frozenRefusal(joint: RealJoint): MenuRefusal | undefined { + if (this.mechanism.isLockedTarget(joint)) { + return { + short: 'unlock first', + long: 'This joint is locked. Unlock it before attaching anything to it.', + }; + } + return this.heldRefusal(joint); + } + + /** The marks that hold this joint still, when none of them is its own. */ + private heldRefusal(joint: RealJoint): MenuRefusal | undefined { + if (this.mechanism.isLockedTarget(joint)) return undefined; + const holders = this.gridUtils.locksHolding(joint); + if (holders.length === 0) return undefined; + const named = holders.map((held) => this.nameOf(held as Joint | Force)).join(', '); + return { + short: `held by ${named}`, + long: `${holders.length === 1 ? 'A lock' : 'Locks'} on ${named} ${holders.length === 1 ? 'holds' : 'hold'} this joint still. Unlock ${holders.length === 1 ? 'it' : 'them'} to move it.`, + }; + } + + /** + * Why a locked part will not delete — the service's own rule, so the row and + * the Delete key refuse the same thing for the same reason. + */ + private lockedRefusal(target: RealJoint | RealLink | Force): MenuRefusal | undefined { + const why = this.mechanism.deleteRefusal(target); + return why ? { short: 'unlock first', long: why } : undefined; + } + + // ---------------------------------------------------------------- shared + + /** + * The way into the other mode, as one icon beside the target's name. + * + * A row of its own would cost height on every menu; this costs none. In the + * analysis modes it goes back to Edit, which is where the geometry can be + * changed; in Edit it goes to Kinematic Analysis, and greys with the + * readiness reason when there is nothing that can be analysed yet. + */ + private crossing(part: Joint | Link | Force) { + if (this.tabs.isAnalysisMode()) { + return { + icon: 'edit_outline', + tip: this.keys.tip('Edit', 'mode.edit'), + action: () => this.tabs.setTab(TabID.EDIT), + }; + } + return { + icon: 'query_stats', + material: true, + tip: this.keys.tip('Kinematic Analysis', 'mode.kinematic'), + action: () => this.tabs.setTab(TabID.ANALYZE), + refusal: this.analysisRefusal(part), + }; + } + + /** + * Why *this part* cannot be analysed — its own machine's answer, not the + * drawing's. + * + * A grid can hold a four-bar that runs beside a half-drawn chain that does + * not, and asking "is anything here analysable" would offer the crossing + * from the half-drawn one on the strength of the four-bar next to it. The + * modes themselves would then grey that part out on arrival, which is an + * offer taken back after it was accepted. + */ + private analysisRefusal(part: Joint | Link | Force): MenuRefusal | undefined { + if (this.mechanism.isPartSimulatable(part)) return undefined; + const readiness = this.mechanism.readinessOfPart(part); + if (!readiness) { + return { + short: 'not in a mechanism', + long: 'This part is not joined into a mechanism that can be analysed. Connect it to a grounded chain.', + }; + } + const several = this.mechanism.partitions.length > 1; + const blocker = readiness.checks.find((check) => check.state === 'blocker'); + return { + // Named when there is more than one machine on the grid: "not ready" on + // a drawing holding two of them does not say which one is meant. + short: several ? `${readiness.id} is not ready` : 'not ready', + long: blocker + ? `${blocker.title}. ${blocker.body}` + : `${several ? readiness.id : 'This mechanism'} cannot be analysed yet.`, + }; + } + + /** + * Nothing that edits the mechanism while it is parked away from the start. + * + * The pose on screen mid-cycle is a solved copy of the drawing, so an edit + * made there would write that pose back into it. The rows that do not touch + * the mechanism — the trace, the positions, the way into another mode — are + * untouched, and the rest say what to do about it rather than vanishing. + */ + private freezeWhileRunning(model: ContextMenuModel): ContextMenuModel { + const running = this.mechanism.isPlaying; + if (!running && this.mechanism.mechanismTimeStep === 0) return model; + const refusal: MenuRefusal = running + ? { short: 'animation running', long: 'Pause the animation to change the mechanism.' } + : { + short: 'not at the start', + long: 'The mechanism is parked mid-cycle. Return it to the start to change it.', + }; + model.groups.forEach((group) => + group.rows.forEach((row) => { + if (!row.alwaysAllowed && !row.refusal) row.refusal = refusal; + }) + ); + return model; + } + + private nameOf(part: Joint | Force): string { + return (part as { name?: string }).name || part.id; + } + + private cylinderName(sealed: Cylinder): string { + return `Cylinder ${this.nameOf(sealed.barrelFar)}${this.nameOf(sealed.rodFar)}`; + } +} diff --git a/src/app/services/cylinder-lifecycle.spec.ts b/src/app/services/cylinder-lifecycle.spec.ts index 3d362839..f8ea5795 100644 --- a/src/app/services/cylinder-lifecycle.spec.ts +++ b/src/app/services/cylinder-lifecycle.spec.ts @@ -349,6 +349,25 @@ describe('a mount welded into a neighbouring link', () => { expect(still.rodFar.id).toBe(h.sealed.rodFar.id); }); + it('will not be deleted while it is locked, by either door', () => { + const h = weldedMount(); + const sealed = resolve(h)!; + h.service.toggleLock(sealed.barrel as never); + expect(h.service.isLockedTarget(sealed.barrel as never)).toBe(true); + + // Straight at the part: the menu calls this with the cylinder it found. + h.service.deleteCylinder(resolve(h)); + expect(sealedCylinders(h.service.joints)).toHaveLength(1); + + // And through a mount, which carries its own mark rather than the part's: + // an unlocked mount on a locked cylinder used to take the whole part. + const mount = h.service.joints.find((joint) => joint.id === h.sealed.barrelFar.id)!; + expect(h.service.isLockedTarget(mount as never)).toBe(false); + h.active.updateSelectedObj(mount); + h.service.deleteJoint(); + expect(sealedCylinders(h.service.joints)).toHaveLength(1); + }); + it('still cascades a delete, unwelding the mount so the neighbour survives', () => { const h = weldedMount(); diff --git a/src/app/services/drag-state.service.ts b/src/app/services/drag-state.service.ts index 9f803096..fdec9271 100644 --- a/src/app/services/drag-state.service.ts +++ b/src/app/services/drag-state.service.ts @@ -60,6 +60,15 @@ export class DragStateService { ); } + /** + * The tracing underlay is being moved or resized by its handles. + * + * Kept apart from the four enums because it is not mechanism state: it makes + * `isDragging` true, so the canvas does not pan underneath the gesture, but + * it never reaches `noteMechanismModified` and so earns no undo entry. + */ + private backgroundImageHeld = false; + /** True while an existing object is being moved, as opposed to created. */ get isDragging(): boolean { return ( @@ -67,7 +76,8 @@ export class DragStateService { this._link === linkStates.dragging || this._force === forceStates.draggingStart || this._force === forceStates.draggingEnd || - this._force === forceStates.draggingBody + this._force === forceStates.draggingBody || + this.backgroundImageHeld ); } @@ -131,6 +141,10 @@ export class DragStateService { this._force = forceStates.draggingBody; } + beginDraggingBackgroundImage(): void { + this.backgroundImageHeld = true; + } + // --- Gesture ---------------------------------------------------------- /** A pointer went down. Starts a fresh gesture; nothing is owed yet. */ @@ -177,5 +191,6 @@ export class DragStateService { this._joint = jointStates.waiting; this._link = linkStates.waiting; this._force = forceStates.waiting; + this.backgroundImageHeld = false; } } diff --git a/src/app/services/ease-to-start.spec.ts b/src/app/services/ease-to-start.spec.ts new file mode 100644 index 00000000..9fc7ab35 --- /dev/null +++ b/src/app/services/ease-to-start.spec.ts @@ -0,0 +1,126 @@ +import { MechanismService } from './mechanism.service'; + +/** + * The frame loop behind `easeToStart`, driven by hand. + * + * It is a drawing concern -- it seeks already-solved cycles and never runs the + * solver -- so it is exercised against a stand-in that records where each + * machine is put, rather than against a built drawing. + */ +interface Stub { + isPlaying: boolean; + ownPlaying: boolean[]; + ownSeconds: number[]; + mechanisms: { cyclePeriod: number }[]; + atStartPose(): boolean; + secondsOf(index: number): number; + rewindToStart(): void; + drawOwnClocks(): void; +} + +/** + * @param at where each machine starts, in seconds + * @param periods each machine's cycle length + */ +function drive(at: number[], periods: number[], interrupt?: (frame: number, stub: Stub) => void) { + const drawn: number[][] = []; + const stub: Stub = { + isPlaying: true, + ownPlaying: at.map(() => true), + ownSeconds: at.slice(), + mechanisms: periods.map((cyclePeriod) => ({ cyclePeriod })), + atStartPose: () => stub.ownSeconds.every((seconds) => seconds === 0), + secondsOf: (index) => stub.ownSeconds[index] ?? 0, + rewindToStart: () => { + stub.ownSeconds = stub.ownSeconds.map(() => 0); + drawn.push(stub.ownSeconds.slice()); + }, + drawOwnClocks: () => drawn.push(stub.ownSeconds.slice()), + }; + + const queue: FrameRequestCallback[] = []; + const original = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + queue.push(callback); + return queue.length; + }) as typeof globalThis.requestAnimationFrame; + + try { + (MechanismService.prototype.easeToStart as (this: Stub, ms?: number) => void).call(stub, 200); + // 16 ms a frame, run well past the 200 ms the ease is given. + for (let frame = 0; frame < 40 && queue.length; frame++) { + interrupt?.(frame, stub); + queue.shift()!(frame * 16); + } + } finally { + globalThis.requestAnimationFrame = original; + } + return { drawn, stub, of: (machine: number) => drawn.map((row) => row[machine]) }; +} + +describe('MechanismService easing every machine back to its own start', () => { + it('draws the way back instead of cutting to it, and lands exactly on zero', () => { + const { of, stub } = drive([1.2], [4]); + const track = of(0); + + expect(track.length, 'more than one pose was drawn').toBeGreaterThan(4); + expect(track[0]).toBeLessThan(1.2); + expect(track.at(-1)).toBe(0); + expect(stub.ownSeconds[0]).toBe(0); + // Monotonic: a rewind that overshoots and comes back is its own glitch. + expect(track.every((seconds, i) => i === 0 || seconds <= track[i - 1])).toBe(true); + }); + + it('goes forward to the start when forward is the shorter way round', () => { + // The cycle is closed, so from near its end the start is just ahead. Going + // backwards through the whole of it reads as the machine bolting. + const { of, stub } = drive([3.6], [4]); + + expect(of(0)[0]).toBeGreaterThan(3.6); + expect(stub.ownSeconds[0]).toBe(0); + }); + + it('eases each machine on its own clock rather than herding them onto one', () => { + // Three machines at three places in three different cycles. Driving this + // off the shared sample index put all of them on the master's time on the + // first frame -- a jump, and one that undid two of the three clocks. + const { of } = drive([3.9, 1.2, 5.07], [4.4, 3, 5.43]); + + // Nobody teleports: the first frame is a step from where they were. + expect(Math.abs(of(0)[0] - 3.9)).toBeLessThan(0.6); + expect(Math.abs(of(1)[0] - 1.2)).toBeLessThan(0.6); + expect(Math.abs(of(2)[0] - 5.07)).toBeLessThan(0.6); + // And each takes whichever way round is shorter for its own cycle: the + // first and third are past half way and go forward, the second is not and + // goes back. + expect(of(0)[0]).toBeGreaterThan(3.9); + expect(of(2)[0]).toBeGreaterThan(5.07); + expect(of(1)[0]).toBeLessThan(1.2); + // All three arrive. + expect([of(0).at(-1), of(1).at(-1), of(2).at(-1)]).toEqual([0, 0, 0]); + }); + + it('stops playback before the first frame, not after the last', () => { + // The tab has already changed by the time this runs, so anything reading + // the service during the rewind has to be told playback is over. + const { stub } = drive([1.2, 0.5], [4, 4]); + expect(stub.isPlaying).toBe(false); + expect(stub.ownPlaying.some(Boolean)).toBe(false); + }); + + it('gives way to anything else that moves a machine', () => { + // A rebuild, a scrub, playback starting again: whatever it was, it is newer + // than this rewind, and finishing the rewind would undo it. + const { of, stub } = drive([2, 2], [4, 4], (frame, running) => { + if (frame === 3) running.ownSeconds[1] = 1.75; + }); + + expect(stub.ownSeconds[1], 'the other caller kept its place').toBe(1.75); + expect(of(0).at(-1), 'the rewind drew nothing after being overtaken').not.toBe(0); + }); + + it('does nothing at all when every machine is already at its start', () => { + const { drawn } = drive([0, 0], [4, 4]); + expect(drawn).toEqual([]); + }); +}); diff --git a/src/app/services/export/canvas-svg.ts b/src/app/services/export/canvas-svg.ts new file mode 100644 index 00000000..8cc58ceb --- /dev/null +++ b/src/app/services/export/canvas-svg.ts @@ -0,0 +1,204 @@ +/** + * The mechanism exactly as the canvas draws it, as a self-contained SVG. + * + * The report used to carry a skeleton drawn from the joint coordinates: bars, + * pins and ground triangles. That is fine for a four-bar and wrong for + * everything the app has learned to draw since — a slot, a piston, a sealed + * cylinder and a compound outline all come out as straight lines between the + * wrong points, and a reader comparing the page with their screen sees a + * different machine. + * + * So the page takes the canvas itself. The grid ruling and the editing handles + * come off, every computed style is written onto the element that had it — + * the app's stylesheet is not going with it — and the frame is set to what is + * actually drawn rather than to wherever the reader had panned to. + */ + +/** Layers that are the paper and the tools, not the machine. */ +const DROPPED = ['backgroundAndGrid', 'backgroundImageHandles']; + +/** + * The properties that decide what a shape looks like. + * + * Copied rather than the whole computed style: a full dump is some three + * hundred declarations per element, which on a Jansen leg is a megabyte of + * inline style for a picture three inches wide. + */ +const COPIED = [ + 'fill', + 'fill-opacity', + 'fill-rule', + 'stroke', + 'stroke-width', + 'stroke-opacity', + 'stroke-dasharray', + 'stroke-linecap', + 'stroke-linejoin', + 'opacity', + 'filter', + 'font-family', + 'font-size', + 'font-weight', + 'font-style', + 'text-anchor', + 'letter-spacing', + 'display', + 'visibility', +]; + +/** + * @param jointIds The joints of the one machine this picture is about. The + * frame is set on those, so a drawing holding three machines yields three + * pictures of one machine each rather than three of the same crowd — and the + * one being reported on fills the box instead of sitting in a corner of it. + */ +export function canvasSnapshot( + width: number, + height: number, + jointIds: string[] = [] +): string | undefined { + const canvas = document.querySelector('svg#canvas') as SVGSVGElement | null; + if (!canvas) return undefined; + + const frame = machineFrame(canvas, jointIds) ?? contentFrame(canvas); + if (!frame) return undefined; + + const clone = canvas.cloneNode(true) as SVGSVGElement; + // Styles first, while the two trees still walk in step; the layers come off + // afterwards. + inlineStyles(canvas, clone); + DROPPED.forEach((id) => clone.querySelector(`#${id}`)?.remove()); + + absolutiseHrefs(clone); + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); + clone.setAttribute('width', String(width)); + clone.setAttribute('height', String(height)); + clone.setAttribute('viewBox', `${frame.x} ${frame.y} ${frame.width} ${frame.height}`); + clone.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + clone.removeAttribute('id'); + clone.removeAttribute('class'); + clone.removeAttribute('style'); + return new XMLSerializer().serializeToString(clone); +} + +/** + * The box one machine's joints stand in, in the canvas's own coordinates. + * + * Its joints rather than its links: a joint is one marker with an id on it, + * where a link is several paths and a label with none. The margin is generous + * enough to take in the bodies hanging off those joints — a bar reaches no + * further than the two pins holding it, and a ground mark or a ram's barrel + * only a little past its own. + */ +function machineFrame(canvas: SVGSVGElement, jointIds: string[]): DOMRect | undefined { + if (jointIds.length === 0) return undefined; + const origin = canvas.getBoundingClientRect(); + let left = Infinity; + let top = Infinity; + let right = -Infinity; + let bottom = -Infinity; + jointIds.forEach((id) => { + const marker = canvas.querySelector(`#joint_${CSS.escape(id)}`); + if (!marker) return; + const box = marker.getBoundingClientRect(); + if (box.width === 0 && box.height === 0) return; + left = Math.min(left, box.left); + top = Math.min(top, box.top); + right = Math.max(right, box.right); + bottom = Math.max(bottom, box.bottom); + }); + if (!Number.isFinite(left)) return undefined; + + const span = Math.max(right - left, bottom - top, 1); + const margin = span * 0.16 + 16; + return new DOMRect( + left - origin.left - margin, + top - origin.top - margin, + right - left + 2 * margin, + bottom - top + 2 * margin + ); +} + +/** + * What is drawn, in the canvas's own coordinates. + * + * Measured off the screen rather than through `getBBox`: the layers are turned + * upside down by a transform of their own and framed by a pan-and-zoom + * viewport, and the union of those two is exactly what a client rectangle + * already accounts for. + */ +function contentFrame(canvas: SVGSVGElement): DOMRect | undefined { + const origin = canvas.getBoundingClientRect(); + const layers = [...canvas.children].filter( + (child) => + child instanceof SVGElement && !DROPPED.includes(child.id) && child.tagName !== 'defs' + ) as SVGElement[]; + + let left = Infinity; + let top = Infinity; + let right = -Infinity; + let bottom = -Infinity; + layers.forEach((layer) => { + const box = layer.getBoundingClientRect(); + if (box.width === 0 && box.height === 0) return; + left = Math.min(left, box.left); + top = Math.min(top, box.top); + right = Math.max(right, box.right); + bottom = Math.max(bottom, box.bottom); + }); + if (!Number.isFinite(left) || right <= left || bottom <= top) return undefined; + + // A margin, so a ground mark or a label is not cut off at the frame's edge. + const margin = Math.max(12, (right - left) * 0.04); + return new DOMRect( + left - origin.left - margin, + top - origin.top - margin, + right - left + 2 * margin, + bottom - top + 2 * margin + ); +} + +/** + * Point every referenced file at where it actually is. + * + * The canvas reaches for its ground marks and input arrows with paths relative + * to the app — `../../../assets/Ground.svg`. The report is its own document + * with its own base, so every one of those came out as a broken-image square + * under the joints they belong to. + */ +function absolutiseHrefs(root: Element): void { + const XLINK = 'http://www.w3.org/1999/xlink'; + [root, ...root.querySelectorAll('*')].forEach((element) => { + (['href', 'xlink:href'] as const).forEach((name) => { + const value = + name === 'href' ? element.getAttribute('href') : element.getAttributeNS(XLINK, 'href'); + // A fragment points inside this file — at a filter or a gradient — and + // must stay a fragment. + if (!value || value.startsWith('#') || value.startsWith('data:')) return; + const absolute = new URL(value, document.baseURI).href; + if (name === 'href') element.setAttribute('href', absolute); + else element.setAttributeNS(XLINK, 'xlink:href', absolute); + }); + }); +} + +/** Write what the stylesheet was saying onto the elements it was saying it to. */ +function inlineStyles(source: Element, clone: Element): void { + const from = [source, ...source.querySelectorAll('*')]; + const to = [clone, ...clone.querySelectorAll('*')]; + for (let at = 0; at < from.length && at < to.length; at++) { + const computed = getComputedStyle(from[at]); + const target = to[at] as SVGElement; + if (!target.style) continue; + COPIED.forEach((property) => { + const value = computed.getPropertyValue(property); + if (value && value !== 'none' && value !== 'normal') { + target.style.setProperty(property, value); + } + }); + // Nothing in a printed picture answers a pointer, and the attributes that + // said so are noise in the file. + target.removeAttribute('pointer-events'); + } +} diff --git a/src/app/services/export/csv-writer.ts b/src/app/services/export/csv-writer.ts new file mode 100644 index 00000000..26409323 --- /dev/null +++ b/src/app/services/export/csv-writer.ts @@ -0,0 +1,33 @@ +import { roundNumber } from '../../model/utils'; +import { Decimals } from './export-model'; +import { ExportTable } from './export-table.service'; + +/** + * A number as a file should carry it. + * + * Rounded here rather than at the source: a graph wants every digit the solver + * produced, and a spreadsheet wants a number a person can read instead of + * 0.30000000000000004. `full` is the escape hatch for anyone who would rather + * do their own rounding downstream. + */ +export function formatCell(value: number | undefined, decimals: Decimals): string { + if (value === undefined || !Number.isFinite(value)) return ''; + return String(decimals === 'full' ? value : roundNumber(value, decimals)); +} + +/** A column head carries commas and apostrophes; a CSV field has to take them. */ +export function quoteCsv(text: string): string { + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +/** One table, as a comma-separated file with a single time column. */ +export function toCsv(table: ExportTable, decimals: Decimals): string { + const head = table.heads.map(quoteCsv).join(','); + const body = table.times.map((time, row) => + [ + formatCell(time, decimals), + ...table.columns.map((column) => formatCell(column[row], decimals)), + ].join(',') + ); + return [head, ...body].join('\n') + '\n'; +} diff --git a/src/app/services/export/export-catalog.service.ts b/src/app/services/export/export-catalog.service.ts new file mode 100644 index 00000000..f285f59f --- /dev/null +++ b/src/app/services/export/export-catalog.service.ts @@ -0,0 +1,279 @@ +import { Injectable, inject } from '@angular/core'; +import { Joint, PrisJoint, RealJoint } from '../../model/joint'; +import { Cylinder, cylinderJoints } from '../../model/cylinder'; +import { Link, RealLink, SliderBlock } from '../../model/link'; +import { AngleUnit, ForceUnit, LengthUnit } from '../../model/unit-enums'; +import { MechanismService } from '../mechanism.service'; +import { SettingsService } from '../settings.service'; +import { ExportPart, ExportPartGroup } from './export-model'; + +/** + * Every part this drawing has to offer an export, and what to call things. + * + * Derivation only: nothing here remembers what was ticked. The drawer's own + * state lives in `ExportFlowService`, and what a chosen part can be asked for + * lives in `ExportColumnsService`. + */ +@Injectable({ providedIn: 'root' }) +export class ExportCatalogService { + private mechanism = inject(MechanismService); + private settings = inject(SettingsService); + + /** + * Every part of every mechanism, grouped by machine. + * + * Grounded joints are listed in both modes and tickable in only one: a fixed + * joint has no motion to report, but it does carry a reaction. + */ + partGroups(withForces: boolean): ExportPartGroup[] { + const cylinders = this.mechanism.sealedStructures(); + return this.mechanism.partitions.map((partition, index) => { + const solved = this.mechanism.mechanisms[index]; + const valid = solved?.isMechanismValid() ?? false; + const joints = partition.ownJoints + .filter((joint): joint is RealJoint => joint instanceof RealJoint) + // Every joint the canvas draws a marker for, and no others. A slot is + // a joint to the solver and nothing at all to a reader: it has a + // zero-sized marker, no hitbox and no panel, so a row for it offered + // numbers no graph in the app can show -- and the force it carries is + // reachable from the block that rides in it. + .filter((joint) => !(joint instanceof PrisJoint)) + .filter((joint) => !this.isInsideCylinder(cylinders, joint)) + .map((joint) => this.jointPart(joint, partition.id, index, withForces)); + // Bars, and the rods that stand for rams. A slider's block is a + // zero-length link binding a pin to a slot: a reader sees one slider + // where the solver has three bodies, and the block's own reactions are + // its pin's force negated and the force in the slot -- both of which the + // pin now carries. See `slotReactionOf`. + const links = partition.links + .filter((link): link is RealLink => link instanceof RealLink) + // A sealed cylinder stands in the list as one part. Its barrel, its + // piston and the joints buried inside it are pieces of a ram nobody + // drew and nobody can point at on the canvas. + .filter((link) => this.standsForCylinder(cylinders, link) !== 'hidden') + .map((link) => this.linkPart(link, partition.id, index, valid, cylinders)); + return { + index, + id: partition.id, + note: this.noteFor(index), + forcesReady: this.forcesSolve(index), + parts: valid ? [...joints, ...links] : [...joints, ...links].map(this.unsolved), + }; + }); + } + + /** Nothing solves for a mechanism that does not run, so nothing is on offer. */ + private unsolved = (part: ExportPart): ExportPart => ({ ...part, available: false }); + + /** The three joints a sealed cylinder keeps to itself: no hitbox, no row. */ + private isInsideCylinder(cylinders: Cylinder[], joint: Joint): boolean { + return cylinders.some( + (cylinder) => + cylinder.barrelNear.id === joint.id || + cylinder.pin.id === joint.id || + cylinder.slider.id === joint.id + ); + } + + /** + * Whether a body is a piece of a cylinder, and if so which piece. + * + * The rod stands for the whole ram — it is the member that moves, and it + * shares the barrel's angle — so it keeps its row under the cylinder's name; + * the barrel and the piston are hidden behind it. + */ + private standsForCylinder(cylinders: Cylinder[], link: Link): 'rod' | 'hidden' | 'none' { + const cylinder = cylinders.find( + (candidate) => + candidate.rod.id === link.id || + candidate.barrel.id === link.id || + candidate.block.id === link.id + ); + if (!cylinder) return 'none'; + return cylinder.rod.id === link.id ? 'rod' : 'hidden'; + } + + private jointPart( + joint: RealJoint, + machine: string, + index: number, + withForces: boolean + ): ExportPart { + const notes: string[] = []; + if (joint.ground) notes.push('grounded'); + if (joint.input) notes.push('input'); + if (this.mechanism.sliderFor(joint)) notes.push('slider'); + if (joint.showCurve) notes.push('tracer point'); + if (this.mechanism.isSelectedJoint(joint)) notes.push('currently selected'); + return { + // Qualified by machine, because a joint can belong to two of them: a + // chain bolted to another's ground shares that pin, and keyed by its + // letter alone, ticking it under one machine ticked it under both -- + // which wrote a file for a machine the reader had not asked about. + key: `${machine}|joint:${joint.id}`, + kind: 'joint', + id: joint.id, + label: `Joint ${joint.name || joint.id}`, + note: notes.join(', '), + // Every joint of a mechanism that solves. A pinned one has a position + // worth writing down and a reaction worth reading, and a list that + // decided for the reader which of those they meant was a list that + // hid parts they had come looking for. + available: true, + part: joint, + mechanismIndex: index, + }; + } + + private linkPart( + link: Link, + machine: string, + index: number, + valid: boolean, + cylinders: Cylinder[] + ): ExportPart { + const notes: string[] = []; + const ram = this.standsForCylinder(cylinders, link) === 'rod'; + if (ram) notes.push('cylinder'); + else if (link instanceof SliderBlock) notes.push('slider block'); + if (link.joints.some((joint) => (joint as RealJoint).input)) notes.push('input crank'); + if (link instanceof RealLink && link.subset.length > 0) notes.push('compound'); + if (this.mechanism.isSelectedBody(link)) notes.push('currently selected'); + return { + key: `${machine}|link:${link.id}`, + kind: 'link', + id: link.id, + label: ram ? this.cylinderLabel(cylinders, link) : this.mechanism.bodyLabel(link), + note: notes.join(', '), + available: valid, + part: link, + mechanismIndex: index, + }; + } + + /** `Cylinder AC` — the ram by the two mounts a reader can see. */ + private cylinderLabel(cylinders: Cylinder[], link: Link): string { + const cylinder = cylinders.find((candidate) => candidate.rod.id === link.id)!; + const mounts = + (cylinder.barrelFar.name || cylinder.barrelFar.id) + + (cylinder.rodFar.name || cylinder.rodFar.id); + return `Cylinder ${mounts}`; + } + + /** + * What to call a body anywhere a reader will read it. + * + * A cylinder is one part in this drawer, so its pieces answer to the ram's + * name: a reaction headed `Rod GC` names a body the parts list never offered. + */ + labelFor(link: Link): string { + const cylinders = this.mechanism.sealedStructures(); + const cylinder = cylinders.find( + (candidate) => + candidate.rod.id === link.id || + candidate.barrel.id === link.id || + candidate.block.id === link.id + ); + return cylinder ? this.cylinderLabel(cylinders, cylinder.rod) : this.mechanism.bodyLabel(link); + } + + /** + * The links a body is made of, for asking the force solver about it. + * + * One for an ordinary bar. A cylinder is one part to the reader and three + * links to the solver, and its two mounts sit on different ones — the barrel + * carries the far mount and the rod the other — so asking about the rod + * alone listed the force at one end of a ram and nothing at the end it is + * pushing. The same rule the analysis panel uses, for the same reason. + */ + memberIdsOf(linkId: string): string[] { + const body = this.mechanism.links.find((link) => link.id === linkId); + const sealed = body && this.mechanism.cylinderAt(body); + return sealed ? [sealed.barrel.id, sealed.rod.id, sealed.block.id] : [linkId]; + } + + /** + * The joint driving a cylinder, which is buried inside it. + * + * A ram is driven from a joint with no marker, no hitbox and no row in any + * panel, so the effort that drive supplies has to be offered against the + * part a reader can actually see. + */ + drivenJointOf(linkId: string): RealJoint | undefined { + const body = this.mechanism.links.find((link) => link.id === linkId); + const sealed = body && this.mechanism.cylinderAt(body); + if (!sealed) return undefined; + return cylinderJoints(sealed).find( + (joint): joint is RealJoint => joint instanceof RealJoint && joint.input + ); + } + + /** The slot this pin rides in, where that slot is what drives the mechanism. */ + drivingSlotOf(joint: RealJoint): RealJoint | undefined { + const slider = this.mechanism.sliderFor(joint); + return slider?.input ? slider : undefined; + } + + /** The joints a sealed cylinder keeps to itself, by id. */ + hiddenJointIds(): Set<string> { + return new Set( + this.mechanism + .sealedStructures() + .flatMap((cylinder) => [cylinder.barrelNear.id, cylinder.pin.id, cylinder.slider.id]) + ); + } + + /** What a machine is, in the one line the section heading has room for. */ + private noteFor(index: number): string { + const partition = this.mechanism.partitions[index]; + const facts = this.mechanism.readinessOfEachMechanism()[index]?.facts ?? []; + const speed = facts.find((fact) => fact.label === 'Input speed')?.value; + const bodies = partition.links.filter((link) => link instanceof RealLink).length; + const count = `${bodies} ${bodies === 1 ? 'link' : 'links'}`; + return speed ? `${count} · ${speed}` : `${count} · not running`; + } + + /** + * Whether force analysis is set up *and* solves for this machine. + * + * Both halves matter. A linkage with nothing loading it solves to a page of + * zeroes, which is not an answer anyone asked for — so the drawing-wide + * requirements decide whether forces are on offer at all, and this machine's + * own frames decide whether it is one of the machines that has them. + */ + forcesSolve(index: number): boolean { + const solved = this.mechanism.mechanisms[index]; + if (!solved?.isMechanismValid() || !this.mechanism.forceAnalysisReady()) return false; + const mode = this.settings.forceAnalysisMode.value; + return solved.getForceAnalysis(mode).successfulFrames > 0; + } + + forceUnit(): string { + return this.settings.forceUnit.value === ForceUnit.LBF ? 'lbf' : 'N'; + } + + torqueUnit(): string { + return this.settings.forceUnit.value === ForceUnit.LBF ? 'lbf·in' : 'N·m'; + } + + /** The same words the panels put on an axis, so the file agrees with them. */ + unitStr(unit: LengthUnit | AngleUnit): string { + switch (unit) { + case AngleUnit.RADIAN: + return 'rad'; + case AngleUnit.DEGREE: + return 'deg'; + case LengthUnit.INCH: + return 'in'; + case LengthUnit.METER: + return 'm'; + default: + return 'cm'; + } + } + + /** Parts of one mechanism, in the order the drawer lists them. */ + partsOf(groups: ExportPartGroup[], keys: Set<string>): ExportPart[] { + return groups.flatMap((group) => group.parts.filter((part) => keys.has(part.key))); + } +} diff --git a/src/app/services/export/export-columns.service.ts b/src/app/services/export/export-columns.service.ts new file mode 100644 index 00000000..01c7acad --- /dev/null +++ b/src/app/services/export/export-columns.service.ts @@ -0,0 +1,315 @@ +import { Injectable, inject } from '@angular/core'; +import { PrisJoint, RealJoint } from '../../model/joint'; +import { RealLink } from '../../model/link'; +import { MechanismService } from '../mechanism.service'; +import { SettingsService } from '../settings.service'; +import { ExportCatalogService } from './export-catalog.service'; +import { + ColumnTab, + ExportColumn, + ExportColumnGroup, + ExportPart, + ExportSeries, +} from './export-model'; + +/** + * What a chosen set of parts turns out to have numbers for. + * + * Split from the catalogue of parts because the two questions have different + * shapes: what a drawing contains is fixed the moment it is drawn, and what + * that selection can be asked for changes with every tick. + */ +@Injectable({ providedIn: 'root' }) +export class ExportColumnsService { + private mechanism = inject(MechanismService); + private settings = inject(SettingsService); + private catalog = inject(ExportCatalogService); + + /** + * The quantities a chosen set of parts turns out to have. + * + * Kinematics groups by kind rather than by part: every chosen joint has the + * same three quantities, and a list that repeated them per joint would be the + * same three answers asked eleven times. + */ + columnGroups(parts: ExportPart[], tab: ColumnTab): ExportColumnGroup[] { + return tab === 'forces' ? this.forceGroups(parts) : this.kinematicGroups(parts); + } + + private kinematicGroups(parts: ExportPart[]): ExportColumnGroup[] { + const groups: ExportColumnGroup[] = []; + const joints = parts.filter((part) => part.kind === 'joint'); + // Bars, and the rods that stand for rams — not slider blocks. The solver + // leaves a block out of its angular maps and gives it no centre of mass, + // so a block offered these four choices produced eleven columns of blanks. + // What a block does have is the reaction it carries, which is the other tab. + const links = parts.filter((part) => part.kind === 'link' && part.part instanceof RealLink); + const length = this.catalog.unitStr(this.settings.lengthUnit.value); + const angle = this.catalog.unitStr(this.settings.angleUnit.value); + + if (joints.length > 0) { + groups.push({ + key: 'joints', + title: this.titleOf('Joint', joints), + tab: 'kinematics', + columns: [ + this.kinematic(joints, 'j:pos', 'Position', length, [ + ['Position', 'Linear Joint Pos', length, 2], + ]), + this.kinematic(joints, 'j:vel', 'Velocity', `${length}/s`, [ + ['Velocity', 'Linear Joint Vel', `${length}/s`, 3], + ]), + this.kinematic(joints, 'j:acc', 'Acceleration', `${length}/s²`, [ + ['Acceleration', 'Linear Joint Acc', `${length}/s²`, 3], + ]), + ], + }); + } + + if (links.length > 0) { + groups.push({ + key: 'links', + title: this.titleOf('Link', links), + tab: 'kinematics', + columns: [ + this.kinematic(links, 'l:ang', 'Angle', angle, [['Angle', 'Angular Link Pos', angle, 1]]), + this.kinematic(links, 'l:angvel', 'Angular velocity', `${angle}/s`, [ + ['Angular velocity', 'Angular Link Vel', `${angle}/s`, 1], + ]), + this.kinematic(links, 'l:angacc', 'Angular acceleration', `${angle}/s²`, [ + ['Angular acceleration', 'Angular Link Acc', `${angle}/s²`, 1], + ]), + this.kinematic(links, 'l:com', 'Center of mass', 'pos, vel, acc', [ + ['Center of mass position', "Linear Link's CoM Pos", length, 2], + ['Center of mass velocity', "Linear Link's CoM Vel", `${length}/s`, 3], + ['Center of mass acceleration', "Linear Link's CoM Acc", `${length}/s²`, 3], + ]), + ], + }); + } + return groups; + } + + /** `Joints B, C` for several, `Joint B` for one — the heading names them. */ + private titleOf(kind: string, parts: ExportPart[]): string { + const names = parts.map((part) => part.label.replace(/^(Joint|Link) /, '')); + return `${kind}${names.length === 1 ? '' : 's'} ${names.join(', ')}`; + } + + private kinematic( + parts: ExportPart[], + key: string, + label: string, + unit: string, + series: [string, string, string, 1 | 2 | 3][] + ): ExportColumn { + return { + key, + label, + unit, + appliesTo: parts.map((part) => part.key), + tab: 'kinematics', + series: series.map(([seriesLabel, mechProp, seriesUnit, components]) => ({ + label: seriesLabel, + head: '', + unit: seriesUnit, + components, + analysis: 'kinematic' as const, + mechProp, + mechPart: '', + reactionLinkId: '', + })), + }; + } + + /** + * Every reaction the chosen parts carry, one group per part. + * + * Per part rather than per kind, unlike kinematics: a joint's reactions are + * named after the links it holds, so no two joints have the same list. + */ + private forceGroups(parts: ExportPart[]): ExportColumnGroup[] { + const mode = this.settings.forceAnalysisMode.value; + // A reaction at a joint buried inside a sealed cylinder is a force between + // two halves of one part, named after a pin the drawing never shows. + const hidden = this.catalog.hiddenJointIds(); + // Which reactions a row has already claimed. Joints come before bodies in + // the list, so a pin owns its own force and a body offers only what is left. + const taken = new Set<string>(); + const groups: ExportColumnGroup[] = []; + parts.forEach((part) => { + const solved = this.mechanism.mechanisms[part.mechanismIndex]; + if (!solved?.isMechanismValid()) return; + const index = solved.getForceAnalysis(mode).reactionIndex; + const columns: ExportColumn[] = + part.kind === 'joint' + ? (index.linksByJoint.get(part.id) ?? []).flatMap((linkId) => + this.jointReaction(part, linkId, index) + ) + : // Every link the body is made of: a ram's two mounts sit on + // different ones, so asking about the rod alone gave the force at + // one end of it and nothing at the end it is pushing. + this.catalog.memberIdsOf(part.id).flatMap((memberId) => + (index.jointsByLink.get(memberId) ?? []) + .filter((jointId) => !hidden.has(jointId)) + // A reaction a chosen joint already carries. At a pin joining + // two bodies the solver holds one force and its negative, so a + // joint's view and a body's view of the same pin are the same + // column written twice under two names. + .filter((jointId) => !taken.has(`${jointId}@${memberId}`)) + .map((jointId) => { + // A slot is named after the slider it belongs to: it has no + // marker of its own and no name a reader has ever seen. + const where = this.mechanism.slotName(jointId) ?? this.jointName(jointId); + return this.force( + `Force at ${where}`, + `${this.modeWord()} force on ${part.label} at ${where}`, + part, + jointId, + memberId, + 'Joint Forces', + false + ); + }) + ); + + // The effort whatever drives this part has to supply. A joint that is an + // input carries its own; a cylinder is driven from a joint buried inside + // it, which has no row of its own anywhere in the app -- so the ram's row + // is the only place that number can be asked for. + const driven = + part.kind === 'joint' + ? // The joint itself, or the slot it rides in: a slot is never on the + // list, so the effort driving one has to be asked for against the + // pin a reader can actually point at. + (part.part as RealJoint).input + ? (part.part as RealJoint) + : this.catalog.drivingSlotOf(part.part as RealJoint) + : this.catalog.drivenJointOf(part.id); + if (driven) { + const torque = !(driven instanceof PrisJoint); + const effort = torque ? 'Input torque' : 'Input force'; + columns.push( + this.force( + effort, + `${this.modeWord()} ${effort.toLowerCase()} at ${part.label}`, + part, + driven.id, + '', + 'Input Effort', + torque + ) + ); + } + + columns.forEach((column) => + taken.add(`${column.series[0].mechPart}@${column.series[0].reactionLinkId}`) + ); + if (columns.length > 0) { + groups.push({ key: `force:${part.key}`, title: part.label, tab: 'forces', columns }); + } + }); + return groups; + } + + /** + * What a joint reacts against, one column per body. + * + * A slider's block is not one of them. It is a zero-length link between the + * pin and its slot, so the force between the pin and the block is exactly the + * force between the pin and the bar, negated -- already on this row. What the + * block does have of its own is the force in the slot, which is what sizes a + * slide and is reachable from nowhere else. + */ + private jointReaction( + part: ExportPart, + linkId: string, + index: { jointsByLink: Map<string, string[]> } + ): ExportColumn[] { + const slot = this.mechanism.slotReactionOf(part.part as RealJoint); + if (slot && slot.block.id === linkId) { + return [ + this.force( + `Force on ${slot.on}`, + `${this.modeWord()} force at ${part.label} on ${slot.on}`, + part, + slot.slot.id, + linkId, + 'Joint Forces', + false + ), + ]; + } + return [ + this.force( + `Force on ${this.bodyName(linkId)}`, + `${this.modeWord()} force at ${part.label} on ${this.bodyName(linkId)}`, + part, + part.id, + linkId, + 'Joint Forces', + !(part.part instanceof PrisJoint) + ), + ]; + } + + private force( + label: string, + head: string, + part: ExportPart, + mechPart: string, + reactionLinkId: string, + mechProp: string, + /** Whether the effort is a moment. Decided by the driving joint, not the part. */ + isTorque: boolean + ): ExportColumn { + const torque = mechProp === 'Input Effort' && isTorque; + const unit = torque ? this.catalog.torqueUnit() : this.catalog.forceUnit(); + const series: ExportSeries = { + label, + head, + unit, + // A reaction is a vector; an input effort is one number. + components: mechProp === 'Input Effort' ? 1 : 3, + analysis: 'force', + mechProp, + mechPart, + reactionLinkId, + }; + return { + // The joint is in the key as well as the link: a link's reactions are + // named after the joints holding it, and two of them share every other + // part of this — so without it, unticking one untick both. + key: `f:${part.key}:${mechProp}:${mechPart}:${reactionLinkId}`, + label, + unit, + appliesTo: [part.key], + tab: 'forces', + series: [series], + }; + } + + /** + * Which force analysis a column came from, written into its own head. + * + * A static reaction and an in-motion one are different numbers for the same + * joint, and a file that does not say which it holds is a file nobody can + * check. Carried by the head rather than by a note at the top, so it survives + * being pasted into a sheet, plotted, or read by a script. + */ + private modeWord(): string { + return this.settings.forceAnalysisMode.value === 'dynamic' ? 'In-motion' : 'Static'; + } + + /** The same words the panels put on a graph, so the file agrees with them. */ + private bodyName(linkId: string): string { + const body = this.mechanism.links.find((link) => link.id === linkId); + return body ? this.catalog.labelFor(body) : linkId; + } + + private jointName(jointId: string): string { + const joint = this.mechanism.joints.find((candidate) => candidate.id === jointId) as + RealJoint | undefined; + return `Joint ${joint?.name || jointId}`; + } +} diff --git a/src/app/services/export/export-flow.service.ts b/src/app/services/export/export-flow.service.ts new file mode 100644 index 00000000..3552407c --- /dev/null +++ b/src/app/services/export/export-flow.service.ts @@ -0,0 +1,359 @@ +import { Injectable, inject } from '@angular/core'; +import { ForceAnalysisMode } from '../../model/mechanism/force-solver'; +import { SelectedTabService, TabID } from '../../selected-tab.service'; +import { MechanismService } from '../mechanism.service'; +import { ActiveObjService } from '../active-obj.service'; +import { SettingsService } from '../settings.service'; +import { ExportCatalogService } from './export-catalog.service'; +import { ExportColumnsService } from './export-columns.service'; +import { + ColumnTab, + Decimals, + ExportColumn, + ExportColumnGroup, + ExportFormat, + ExportPart, + ExportPartGroup, +} from './export-model'; + +/** Which of the three questions the drawer is asking. */ +/** + * Which of the questions the drawer is asking. + * + * Named rather than numbered, because how many there are depends on the + * drawing: a mechanism with no force analysis set up is asked three questions + * and one with is asked four. A number would have to mean a different question + * in each case. + */ +export type ExportStep = 'parts' | 'kinematics' | 'forces' | 'file'; + +/** + * What the export drawer has been told so far. + * + * Three steps, each asking one question: which parts, which numbers, and how + * the file should be written. Nothing here writes anything — `ExportWriter` + * turns this state into files — and nothing here touches the mechanism, so + * opening the drawer and changing every answer in it leaves the drawing alone. + */ +@Injectable({ providedIn: 'root' }) +export class ExportFlowService { + private catalog = inject(ExportCatalogService); + private activeObj = inject(ActiveObjService); + private columns = inject(ExportColumnsService); + private mechanism = inject(MechanismService); + private settings = inject(SettingsService); + private tabs = inject(SelectedTabService); + + step: ExportStep = 'parts'; + format: ExportFormat = 'csv'; + decimals: Decimals = 6; + /** One file for everything that can share a time column, or one per part. */ + splitPerPart = false; + /** Whether a two-component series also writes its magnitude. */ + withMagnitude = true; + /** Which kind of picture the Graph images format writes. */ + imageFormat: 'png' | 'svg' = 'png'; + /** Empty until the reader types: the name shown is derived while it is. */ + typedName = ''; + + private pickedParts = new Set<string>(); + private pickedColumns = new Set<string>(); + private touchedColumns = false; + + /** + * What this drawing has to offer, held between changes to the drawing. + * + * The lists come from the readiness report and the force solver, and the + * template asks for them a dozen times a frame — without this, moving the + * mouse over the drawer re-solves the whole drawing. Held against a + * fingerprint rather than a notification, so a rebuild that never published + * one cannot leave a stale list on screen. + */ + private cached?: { key: string; groups: ExportPartGroup[]; forces: boolean }; + + /** + * What the lists are of, cheaply enough to check on every question. + * + * A subscription to the mechanism's update subject is not enough on its own: + * not every path that changes a drawing publishes on it, and a drawer showing + * parts of a mechanism that has since stopped solving is worse than a drawer + * that rebuilds a list too often. Nothing here samples or solves — it is + * lengths, ids and flags the mechanism already holds. + */ + private fingerprint(): string { + return [ + this.mechanism.joints.length, + this.mechanism.links.length, + this.mechanism.partitions.map((partition) => partition.id).join(','), + this.mechanism.mechanisms.map((solved) => (solved.isMechanismValid() ? 1 : 0)).join(''), + this.settings.forceAnalysisMode.value, + // What the canvas is holding, because a row says so. Rebuilt on a change + // of selection rather than left as it was when the drawer opened, which + // is how a row went on claiming to be the selected one after the reader + // had picked something else. + this.activeObj.objType, + this.activeObj.selectedJoint?.id, + this.activeObj.selectedLink?.id, + // Units ride the column heads, so a change of them is a change of list. + this.settings.lengthUnit.value, + this.settings.angleUnit.value, + this.settings.forceUnit.value, + // Bumped by every rebuild of the solved cycle, which is how a change + // that leaves the counts alone still invalidates this. Deliberately not + // the pose, which playback moves every frame. + this.mechanism.solveRevision, + this.stamp, + ].join('|'); + } + + /** Bumped by `refresh()`, so an explicit ask always rebuilds. */ + private stamp = 0; + + private build(): { key: string; groups: ExportPartGroup[]; forces: boolean } { + const withForces = this.catalog.partGroups(true); + const forces = withForces.some((group) => group.forcesReady && group.parts.length > 0); + return { + key: this.fingerprint(), + groups: forces ? withForces : this.catalog.partGroups(false), + forces, + }; + } + + private lists(): { key: string; groups: ExportPartGroup[]; forces: boolean } { + const key = this.fingerprint(); + if (this.cached?.key !== key) this.cached = this.build(); + return this.cached; + } + + /** Forget the lists, so the next question rebuilds them. */ + refresh(): void { + this.stamp++; + this.cached = undefined; + } + + /** Start again at the parts, ticking whatever is selected on the grid. */ + reset(): void { + this.refresh(); + this.step = 'parts'; + this.typedName = ''; + this.touchedColumns = false; + this.pickedColumns.clear(); + this.pickedParts = new Set( + this.partGroups() + .flatMap((group) => group.parts) + .filter((part) => part.available && part.note.includes('currently selected')) + .map((part) => part.key) + ); + } + + // --- step 1: parts -------------------------------------------------------- + + partGroups(): ExportPartGroup[] { + return this.lists().groups; + } + + isPicked(part: ExportPart): boolean { + return this.pickedParts.has(part.key); + } + + togglePart(part: ExportPart): void { + if (!part.available) return; + if (!this.pickedParts.delete(part.key)) this.pickedParts.add(part.key); + this.touchedColumns = false; + } + + setParts(parts: ExportPart[], picked: boolean): void { + parts + .filter((part) => part.available) + .forEach((part) => { + if (picked) this.pickedParts.add(part.key); + else this.pickedParts.delete(part.key); + }); + this.touchedColumns = false; + } + + /** + * What is ticked *and* still has something to give. + * + * A mechanism can stop solving while the drawer is open — an undo, a reload, + * a machine that loses its input — and a tick left over from before is a + * promise of numbers that no longer exist. Kept in `pickedParts` rather than + * struck out, so a mechanism that comes back brings its selection with it. + */ + selectedParts(): ExportPart[] { + return this.catalog + .partsOf(this.partGroups(), this.pickedParts) + .filter((part) => part.available); + } + + offeredParts(): ExportPart[] { + return this.partGroups() + .flatMap((group) => group.parts) + .filter((part) => part.available); + } + + // --- the questions, and moving between them ------------------------------- + + /** + * The questions this drawing is asked, in order. + * + * Forces are a question of their own rather than a tab inside the columns: + * they are a different analysis of the same cycle, not a second way of + * looking at one — and a drawing with no force analysis set up is never asked + * about them at all. + */ + steps(): ExportStep[] { + return this.forcesAvailable() + ? ['parts', 'kinematics', 'forces', 'file'] + : ['parts', 'kinematics', 'file']; + } + + /** Which of them is showing, counted from one for the rule across the top. */ + stepNumber(step: ExportStep = this.step): number { + return this.steps().indexOf(step) + 1; + } + + /** Whether a step has already been answered, and so can be gone back to. */ + isBehind(step: ExportStep): boolean { + return this.stepNumber(step) < this.stepNumber(); + } + + goTo(step: ExportStep): void { + if (this.steps().includes(step)) this.step = step; + } + + /** Which way each of Back and Next leads from here. */ + private neighbour(by: 1 | -1): ExportStep | undefined { + const order = this.steps(); + return order[order.indexOf(this.step) + by]; + } + + nextStep(): ExportStep | undefined { + return this.neighbour(1); + } + + previousStep(): ExportStep | undefined { + return this.neighbour(-1); + } + + // --- the column steps ----------------------------------------------------- + + /** Force columns are only worth a step where a force analysis actually solves. */ + forcesAvailable(): boolean { + return this.lists().forces; + } + + /** Which columns the step being shown is about. */ + get tab(): ColumnTab { + return this.step === 'forces' ? 'forces' : 'kinematics'; + } + + columnGroups(tab: ColumnTab = this.tab): ExportColumnGroup[] { + return this.columns.columnGroups(this.selectedParts(), tab); + } + + allColumns(): ExportColumn[] { + const kinematic = this.columnGroups('kinematics').flatMap((group) => group.columns); + const force = this.forcesAvailable() + ? this.columnGroups('forces').flatMap((group) => group.columns) + : []; + return [...kinematic, ...force]; + } + + /** + * What is ticked, defaulting to everything a reader usually wants. + * + * Centre of mass is the one thing left off: it is three more series per link + * about a point most drawings never move, and a file is easier to widen than + * to explain. + */ + private defaults(): Set<string> { + return new Set( + this.allColumns() + .filter((column) => column.key !== 'l:com') + .map((column) => column.key) + ); + } + + private current(): Set<string> { + if (!this.touchedColumns) { + this.pickedColumns = this.defaults(); + this.touchedColumns = true; + } + return this.pickedColumns; + } + + isColumnPicked(column: ExportColumn): boolean { + return this.current().has(column.key); + } + + toggleColumn(column: ExportColumn): void { + const picked = this.current(); + if (!picked.delete(column.key)) picked.add(column.key); + } + + setColumns(columns: ExportColumn[], picked: boolean): void { + const current = this.current(); + columns.forEach((column) => (picked ? current.add(column.key) : current.delete(column.key))); + } + + selectedColumns(tab?: ColumnTab): ExportColumn[] { + const picked = this.current(); + return this.allColumns().filter( + (column) => picked.has(column.key) && (!tab || column.tab === tab) + ); + } + + columnCount(tab: ColumnTab): number { + return this.selectedColumns(tab).length; + } + + forceMode(): ForceAnalysisMode { + return this.settings.forceAnalysisMode.value; + } + + setForceMode(mode: ForceAnalysisMode): void { + this.settings.forceAnalysisMode.next(mode); + this.refresh(); + this.touchedColumns = false; + } + + // --- step 3: the file ----------------------------------------------------- + + /** Which machines the chosen parts come from, in drawing order. */ + mechanismIndexes(): number[] { + return [...new Set(this.selectedParts().map((part) => part.mechanismIndex))].sort( + (a, b) => a - b + ); + } + + /** The stem the files are named from, until the reader types their own. */ + defaultName(): string { + const groups = this.partGroups(); + const first = this.mechanismIndexes()[0]; + const id = first === undefined ? 'mechanism' : (groups[first]?.id ?? 'mechanism'); + const forces = this.selectedColumns('forces').length > 0; + return `${id}_${forces ? 'analysis' : 'kinematics'}`; + } + + name(): string { + return this.typedName.trim() || this.defaultName(); + } + + extension(): string { + switch (this.format) { + case 'xlsx': + return '.xlsx'; + case 'images': + return `.${this.imageFormat}`; + case 'report': + return '.pdf'; + default: + return '.csv'; + } + } + + canExport(): boolean { + return this.selectedParts().length > 0 && this.selectedColumns().length > 0; + } +} diff --git a/src/app/services/export/export-flow.spec.ts b/src/app/services/export/export-flow.spec.ts new file mode 100644 index 00000000..51dab25d --- /dev/null +++ b/src/app/services/export/export-flow.spec.ts @@ -0,0 +1,744 @@ +import { inject } from '@angular/core'; +import { RealJoint } from '../../model/joint'; +import { AngleUnit, ForceUnit } from '../../model/unit-enums'; +import { SelectedTabService, TabID } from '../../selected-tab.service'; +import { ActiveObjService } from '../active-obj.service'; +import { AnalysisSampleService } from '../analysis-sample.service'; +import { MechanismService } from '../mechanism.service'; +import { SettingsService } from '../settings.service'; +import { withTestInjector } from '../../../test-utils/mechanism-harness'; +import { + buildMechanismFixture, + LEGACY_FORCE_MECHANISM, + MechanismFixture, +} from '../../../tests/fixtures/mechanism-fixtures'; +import { TEMPLATE_LINKAGES } from '../../component/MODALS/templates/template-linkages'; +import { ExportCatalogService } from './export-catalog.service'; +import { ExportColumnsService } from './export-columns.service'; +import { ExportFlowService } from './export-flow.service'; +import { ExportTableService, ExportTable } from './export-table.service'; +import { toCsv } from './csv-writer'; +import { toXlsx } from './xlsx-writer'; +import { plotSvg } from './graph-svg'; +import { reportHtml, reportPages } from './report-html'; +import { crc32 } from './zip'; + +/** + * Every reaction the solver has for a machine, as `joint@body` pairs. + * + * What the drawer offers is checked against this rather than against a list + * written out by hand: the point of leaving a part off is that nothing is lost + * by it, and only the solver knows what there was to lose. + */ +function fixtureMechanismOf(flow: ExportFlowService, at: number): string[] | undefined { + const solved = (flow as unknown as { mechanism: MechanismService }).mechanism.mechanisms[at]; + if (!solved?.isMechanismValid()) return undefined; + const index = solved.getForceAnalysis(flow.forceMode()).reactionIndex; + return [...index.linksByJoint].flatMap(([joint, bodies]) => + bodies.map((body) => `${joint}@${body}`) + ); +} + +/** The drawer's three services, over one fixture's mechanism. */ +interface Flow { + flow: ExportFlowService; + tables: ExportTableService; + fixture: MechanismFixture; +} + +function flowFor(payload: string, options: { forces?: boolean } = {}): Flow { + const fixture = buildMechanismFixture(payload); + // The stub service the fixtures build is the panels' one; these three + // questions are the export drawer's own, so they are answered here rather + // than added to every panel's fixture. + Object.assign(fixture.service, { + readinessOfEachMechanism: () => [ + { + id: 'M1', + ready: true, + checks: [], + facts: [{ label: 'Input speed', value: '20.00 RPM CW' }], + }, + ], + forceAnalysisReady: () => options.forces === true, + }); + + const built = withTestInjector( + [ + { provide: MechanismService, useValue: fixture.service }, + { provide: ActiveObjService, useValue: fixture.active }, + { provide: SettingsService, useValue: fixture.settings }, + { + provide: SelectedTabService, + useValue: { + getCurrentTab: () => (options.forces ? TabID.FORCE : TabID.ANALYZE), + isAnalysisMode: () => true, + } as unknown as SelectedTabService, + }, + { provide: AnalysisSampleService, deps: [] }, + { provide: ExportCatalogService, deps: [] }, + { provide: ExportColumnsService, deps: [] }, + { provide: ExportFlowService, deps: [] }, + { provide: ExportTableService, deps: [] }, + ], + () => ({ + flow: inject(ExportFlowService), + tables: inject(ExportTableService), + }) + ); + built.flow.reset(); + return { ...built, fixture }; +} + +function pick(flow: ExportFlowService, ...labels: string[]): void { + flow + .partGroups() + .flatMap((group) => group.parts) + .filter((part) => labels.includes(part.label)) + .forEach((part) => flow.togglePart(part)); +} + +describe('the export drawer', () => { + beforeAll(() => vi.spyOn(console, 'log').mockImplementation(() => undefined)); + afterAll(() => vi.restoreAllMocks()); + + it('lists every part of the mechanism, pinned ones included', () => { + const { flow } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + const parts = flow.partGroups().flatMap((group) => group.parts); + expect(parts.map((part) => part.label)).toEqual([ + 'Joint A', + 'Joint B', + 'Joint C', + 'Joint D', + 'Link AB', + 'Link BC', + 'Link CD', + ]); + // A pinned joint says what it is and is offered anyway: it has a position + // worth writing down and a reaction worth reading, and a list that decided + // which of those the reader meant was a list hiding parts they came for. + const grounded = parts.find((part) => part.label === 'Joint D')!; + expect(grounded.note).toContain('grounded'); + expect(parts.every((part) => part.available)).toBe(true); + }); + + it('offers a grounded joint once force analysis is set up, for its reaction', () => { + const { flow } = flowFor(LEGACY_FORCE_MECHANISM, { forces: true }); + const grounded = flow + .partGroups() + .flatMap((group) => group.parts) + .find((part) => part.note.includes('grounded'))!; + expect(grounded.available).toBe(true); + expect(flow.forcesAvailable()).toBe(true); + }); + + it('asks about the quantities the chosen parts turn out to have', () => { + const { flow } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Joint B'); + expect(flow.columnGroups('kinematics').map((group) => group.title)).toEqual(['Joint B']); + + pick(flow, 'Joint C', 'Link AB'); + const groups = flow.columnGroups('kinematics'); + expect(groups.map((group) => group.title)).toEqual(['Joints B, C', 'Link AB']); + expect(groups[0].columns.map((column) => column.label)).toEqual([ + 'Position', + 'Velocity', + 'Acceleration', + ]); + expect(groups[1].columns.map((column) => column.label)).toContain('Center of mass'); + }); + + it('writes one row per solved sample, on the mechanism’s own clock', () => { + const { flow, tables, fixture } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Joint B'); + const [table] = tables.tables(); + expect(table.times).toHaveLength(fixture.mechanism.timeNum.length); + table.times.forEach((time, at) => expect(time).toBeCloseTo(fixture.mechanism.timeNum[at], 12)); + expect(table.columns.every((column) => column.length === table.times.length)).toBe(true); + }); + + it('heads a column with the quantity, the part, the component and the unit', () => { + const { flow, tables } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Joint B', 'Link AB'); + const [table] = tables.tables(); + expect(table.heads[0]).toBe('Time (s)'); + expect(table.heads).toContain('Position B X (cm)'); + expect(table.heads).toContain('Velocity B Mag (cm/s)'); + expect(table.heads).toContain('Angle AB (deg)'); + }); + + it('declares how many numbers a column writes, and the sampler agrees', () => { + const { flow, tables, fixture } = flowFor(LEGACY_FORCE_MECHANISM, { forces: true }); + flow.setParts(flow.offeredParts(), true); + flow.withMagnitude = true; + const solved = fixture.mechanism; + const parts = flow.selectedParts(); + + // What the drawer tells a reader a row will write, against what the solver + // hands back for it. A declaration that drifts is a row promising `X, Y` + // and a file carrying three columns. + flow.allColumns().forEach((column) => { + const declared = column.series.map((series) => series.components); + const sampled = tables + .plots(solved, 0, parts, [column]) + .slice(0, declared.length) + .map((plot) => plot.series.length); + expect([column.label, sampled]).toEqual([column.label, declared]); + }); + }); + + it('drops the magnitude series when the reader asks for X and Y alone', () => { + const { flow, tables } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Joint B'); + const withMag = tables.tables()[0].heads.length; + flow.withMagnitude = false; + const without = tables.tables()[0].heads; + expect(without.some((head) => head.includes('Mag'))).toBe(false); + // Velocity and acceleration each lose one; position never had one. + expect(without.length).toBe(withMag - 2); + }); + + it('rounds every number to the digits the reader chose', () => { + const { flow, tables } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Joint B'); + flow.decimals = 2; + const csv = toCsv(tables.tables()[0], flow.decimals); + const cells = csv.split('\n')[1].split(','); + cells.filter(Boolean).forEach((cell) => { + expect((cell.split('.')[1] ?? '').length).toBeLessThanOrEqual(2); + }); + }); + + it('splits a file per part when asked, and one per machine at the very least', () => { + const { flow, tables } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Joint B', 'Joint C'); + expect(tables.tables()).toHaveLength(1); + flow.splitPerPart = true; + const split = tables.tables(); + expect(split).toHaveLength(2); + expect(split.map((table) => table.name)).toEqual(['M1_JointB', 'M1_JointC']); + // The machine's id is not repeated in the file name: the name the reader + // typed already carries it, and one machine has nothing to be told from. + expect(split.map((table) => table.suffix)).toEqual(['JointB', 'JointC']); + }); + + it('puts an angular rate into the unit its head claims', () => { + const { flow, tables, fixture } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Link AB'); + // The solver records an angle in degrees and a rate in radians, so exactly + // one of these two is converted whichever unit the reader has chosen. + const inDegrees = tables.tables()[0]; + const degreeRate = inDegrees.plots.find((plot) => plot.head.startsWith('Angular velocity'))!; + const degreeAngle = inDegrees.plots.find((plot) => plot.head.startsWith('Angle'))!; + + fixture.settings.angleUnit.next(AngleUnit.RADIAN); + const inRadians = tables.tables()[0]; + const radianRate = inRadians.plots.find((plot) => plot.head.startsWith('Angular velocity'))!; + const radianAngle = inRadians.plots.find((plot) => plot.head.startsWith('Angle'))!; + + expect(degreeRate.unit).toBe('deg/s'); + expect(radianRate.unit).toBe('rad/s'); + expect(degreeRate.series[0].values[0]).toBeCloseTo( + radianRate.series[0].values[0] * (180 / Math.PI), + 9 + ); + expect(radianAngle.series[0].values[0]).toBeCloseTo( + degreeAngle.series[0].values[0] * (Math.PI / 180), + 9 + ); + }); + + it('keys a part by its machine, so a shared joint is two rows and two ticks', () => { + const { flow } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + const parts = flow.partGroups().flatMap((group) => group.parts); + // A chain bolted to another machine's ground shares that pin and is listed + // under both. Keyed by its letter alone, ticking it under one machine + // ticked it under the other, and the export wrote a file for a machine the + // reader had not asked about. + expect(parts.every((part) => part.key.startsWith('M1|'))).toBe(true); + expect(new Set(parts.map((part) => part.key)).size).toBe(parts.length); + }); + + it('reaches both ends of a ram, and the drive buried inside it', () => { + const { flow } = flowFor(TEMPLATE_LINKAGES['Cylinder_Boom'], { forces: true }); + const ram = flow.offeredParts().find((part) => part.label.startsWith('Cylinder '))!; + flow.togglePart(ram); + const labels = flow + .columnGroups('forces') + .flatMap((group) => group.columns) + .map((column) => column.label); + + // A cylinder is one part to the reader and three links to the solver, and + // its two mounts sit on different ones — so asking about the rod alone gave + // the force at one end of a ram and nothing at the end it is pushing. The + // drive is a joint with no marker, no hitbox and no row of its own, which + // makes this the only place its effort can be asked for. + expect(labels.filter((label) => label.startsWith('Force at Joint ')).length).toBe(2); + expect(labels).toContain('Input force'); + }); + + it('lists a slider as the one part a reader can point at', () => { + const { flow } = flowFor(TEMPLATE_LINKAGES['Scotch_Yoke'], { forces: true }); + const parts = flow.offeredParts(); + + // A slot is a joint to the solver and nothing at all to a reader: a + // zero-sized marker, no hitbox, no panel. Nor is the block between them, + // which is a zero-length link binding one to the other. + expect(parts.map((part) => part.label)).toEqual([ + 'Joint A', + 'Joint B', + 'Joint C', + 'Joint D', + 'Link AB', + 'Link CD', + ]); + }); + + it('gives a pin the force in its bar and the force in its slot, and no more', () => { + const { flow } = flowFor(TEMPLATE_LINKAGES['Slider_Crank'], { forces: true }); + const pin = flow.offeredParts().find((part) => part.note.includes('slider'))!; + flow.togglePart(pin); + const columns = flow.columnGroups('forces').flatMap((group) => group.columns); + + // Two numbers, not four. The block's force at the pin is the bar's force + // negated, and its force in the slot is the one thing it has of its own — + // so a reader is offered the bar and the slot, and nothing named after a + // joint or a body they have never seen. + expect(columns.map((column) => column.label)).toEqual([ + 'Force on Link BC', + 'Force on the ground', + ]); + expect(columns.some((column) => /Block|Joint D/.test(column.label))).toBe(false); + }); + + it('writes a reaction once, however many of its two sides are chosen', () => { + const { flow, tables } = flowFor(TEMPLATE_LINKAGES['4-Bar'], { forces: true }); + // At a pin joining two bodies the solver holds one force and its negative, + // so a joint's view and a body's view of it are the same column twice. + flow.setParts(flow.offeredParts(), true); + const written = flow + .columnGroups('forces') + .flatMap((group) => group.columns) + .map((column) => `${column.series[0].mechPart}@${column.series[0].reactionLinkId}`); + expect(new Set(written).size).toBe(written.length); + expect(new Set(tables.tables()[0].heads).size).toBe(tables.tables()[0].heads.length); + }); + + it('stands a sealed cylinder in the list as one part, not as its pieces', () => { + const { flow } = flowFor(TEMPLATE_LINKAGES['Cylinder_Boom']); + const labels = flow.partGroups().flatMap((group) => group.parts.map((part) => part.label)); + + // The ram under the two mounts a reader can point at, and none of the + // barrel, piston or buried pins it is assembled from. + expect(labels.some((label) => label.startsWith('Cylinder '))).toBe(true); + expect(labels.some((label) => label.startsWith('Rod '))).toBe(false); + expect(labels.some((label) => label.startsWith('Barrel '))).toBe(false); + expect(labels.some((label) => label.startsWith('Piston '))).toBe(false); + expect(labels.some((label) => label.startsWith('Block '))).toBe(false); + }); + + it('gives each of a link’s reactions its own tick', () => { + const { flow } = flowFor(LEGACY_FORCE_MECHANISM, { forces: true }); + const link = flow + .partGroups() + .flatMap((group) => group.parts) + .find((part) => part.kind === 'link')!; + flow.togglePart(link); + flow.step = 'forces'; + const columns = flow.columnGroups('forces').flatMap((group) => group.columns); + expect(columns.length).toBeGreaterThan(1); + expect(new Set(columns.map((column) => column.key)).size).toBe(columns.length); + + flow.toggleColumn(columns[0]); + expect(flow.isColumnPicked(columns[0])).toBe(false); + expect(flow.isColumnPicked(columns[1])).toBe(true); + }); + + it('keeps a report whole when the file step was last left splitting per part', () => { + const { flow, tables } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Joint B', 'Joint C'); + flow.splitPerPart = true; + expect(tables.tables()).toHaveLength(2); + // A report is per machine whatever that control last said, because the + // report writer takes one table per machine and would drop the rest. + flow.format = 'report'; + expect(tables.tables()).toHaveLength(1); + }); + + it('forecasts the same files, names and heads that it goes on to write', () => { + const { flow, tables } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Joint B', 'Joint C', 'Link AB'); + + // The counts under a reader's hand are arithmetic over the selection, and + // the file is the solved cycle. A forecast that disagrees with what gets + // written is worse than no forecast at all. + for (const split of [false, true]) { + flow.splitPerPart = split; + for (const format of ['csv', 'xlsx'] as const) { + flow.format = format; + const planned = tables.plan(); + const written = tables.tables(); + expect(planned.map((piece) => piece.name)).toEqual(written.map((table) => table.name)); + expect(planned.map((piece) => piece.suffix)).toEqual(written.map((table) => table.suffix)); + planned.forEach((piece, at) => { + expect(piece.heads).toEqual(written[at].heads); + expect(piece.rows).toBe(written[at].times.length); + expect(piece.plots).toBe(written[at].plots.length); + }); + } + } + }); + + it('drops the lists when the drawing stops solving, without being told to', () => { + const { flow, fixture } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + expect(flow.offeredParts().length).toBeGreaterThan(0); + + // No refresh(): a rebuild that publishes nothing must not leave a drawer + // offering parts of a mechanism that no longer has any numbers. + const solving = vi.spyOn(fixture.mechanism, 'isMechanismValid').mockReturnValue(false); + expect(flow.offeredParts()).toHaveLength(0); + expect(flow.canExport()).toBe(false); + solving.mockRestore(); + expect(flow.offeredParts().length).toBeGreaterThan(0); + }); + + it('un-chooses a part that has stopped having anything to give', () => { + const { flow, fixture } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Joint B'); + expect(flow.canExport()).toBe(true); + + const solving = vi.spyOn(fixture.mechanism, 'isMechanismValid').mockReturnValue(false); + expect(flow.selectedParts()).toHaveLength(0); + expect(flow.canExport()).toBe(false); + + // The tick itself is kept, so a mechanism that comes back brings it along. + solving.mockRestore(); + expect(flow.selectedParts()).toHaveLength(1); + }); + + it('heads a force column with the analysis it came from, in the reader’s unit', () => { + const { flow, tables, fixture } = flowFor(LEGACY_FORCE_MECHANISM, { forces: true }); + fixture.settings.forceUnit.next(ForceUnit.LBF); + flow.refresh(); + const input = flow + .partGroups() + .flatMap((group) => group.parts) + .find((part) => (part.part as RealJoint).input)!; + flow.togglePart(input); + flow.step = 'forces'; + + const heads = tables.tables()[0].heads; + expect(heads.some((head) => head.startsWith('Static force at'))).toBe(true); + expect(heads.some((head) => head.includes('input torque') && head.includes('(lbf·in)'))).toBe( + true + ); + }); +}); + +describe('what the export is written as', () => { + beforeAll(() => vi.spyOn(console, 'log').mockImplementation(() => undefined)); + afterAll(() => vi.restoreAllMocks()); + + function sample(): { flow: ExportFlowService; table: ExportTable } { + const { flow, tables } = flowFor(TEMPLATE_LINKAGES['4-Bar']); + pick(flow, 'Joint B'); + return { flow, table: tables.tables()[0] }; + } + + it('quotes a CSV head that carries a comma', () => { + const { flow, table } = sample(); + const doctored = { ...table, heads: ['Time (s)', 'Force at B, on AB X (N)'] } as ExportTable; + expect(toCsv(doctored, flow.decimals).split('\n')[0]).toContain('"Force at B, on AB X (N)"'); + }); + + it('writes a workbook a reader can open: one sheet per table, every part intact', () => { + const { flow, table } = sample(); + const bytes = toXlsx([table, { ...table, name: 'M1_forces' }], flow.decimals); + const entries = readZip(bytes); + expect(entries.map((entry) => entry.name)).toEqual([ + '[Content_Types].xml', + '_rels/.rels', + 'xl/workbook.xml', + 'xl/_rels/workbook.xml.rels', + 'xl/worksheets/sheet1.xml', + 'xl/worksheets/sheet2.xml', + ]); + entries.forEach((entry) => expect(crc32(entry.data)).toBe(entry.crc)); + const workbook = new TextDecoder().decode(entries[2].data); + expect(workbook).toContain(`name="${table.name}"`); + expect(workbook).toContain('name="M1_forces"'); + const sheet = new TextDecoder().decode(entries[4].data); + expect(sheet).toContain('<c r="A1" t="inlineStr"><is><t xml:space="preserve">Time (s)</t>'); + // One head row and one row per solved sample. + expect(sheet.match(/<row /g)).toHaveLength(table.times.length + 1); + }); + + it('breaks a graph where the solver had nothing to say, rather than drawing through it', () => { + const { table } = sample(); + const plot = table.plots.find((candidate) => candidate.head.startsWith('Velocity'))!; + const gapped = { + ...plot, + series: [ + { + name: 'X', + values: plot.series[0].values.map((value, at) => + at > 100 && at < 140 ? Number.NaN : value + ), + }, + ], + }; + const svg = plotSvg(gapped, table.times, { width: 640, height: 360, standalone: true }); + // Two runs, two lines. Joined into one, the picture drew a straight line + // through exactly the positions it has no answer for -- where the panel's + // own chart leaves a hole. + expect(svg.match(/<polyline/g)).toHaveLength(2); + const before = gapped.series[0].values.slice(0, 101).filter(Number.isFinite).length; + expect(before).toBeGreaterThan(0); + }); + + it('draws a graph with a line per series', () => { + const { table } = sample(); + const plot = table.plots.find((candidate) => candidate.head.startsWith('Velocity'))!; + const svg = plotSvg(plot, table.times, { width: 640, height: 360, standalone: true }); + expect(svg.match(/<polyline/g)).toHaveLength(plot.series.length); + expect(svg).toContain(plot.title); + expect(svg).toContain(`${table.times[table.times.length - 1].toFixed(2)} s`); + }); + + it('fills the first page with graphs before it starts a second', () => { + const bare = { heads: ['Time (s)'], rows: [] as string[][] }; + // Three rows of three, which is what the page body holds beside the + // drawing. Nine used to spill onto a page of its own at seven. + expect(reportPages({ ...bare, plots: 9 })).toBe(1); + expect(reportPages({ ...bare, plots: 10 })).toBe(2); + }); + + it('spreads a wide table evenly over the pages it needs', () => { + // Enough columns to need several passes over the rows. Packed greedily, + // the last page of an eighty-six column export held the time column and + // one other while the pages before it held fifteen each. + const heads = ['Time (s)', ...Array.from({ length: 46 }, (_, at) => `Position P${at} X (cm)`)]; + const rows = Array.from({ length: 3 }, () => heads.map(() => '-1234.567890')); + const html = reportHtml({ + logoUrl: 'assets/PMKS_logo.png', + sections: [ + { + title: 'Analysis — M1', + subtitle: '', + dataTitle: 'Data — M1', + graphsTitle: 'Graphs — M1', + drawing: '', + facts: [], + shareUrl: '', + plots: [], + heads, + rows, + footer: 'M1', + }, + ], + }); + const widths = [...html.matchAll(/<thead><tr>(.*?)<\/tr><\/thead>/g)].map( + (page) => (page[1].match(/<th>/g) ?? []).length + ); + expect(widths.length).toBeGreaterThan(2); + expect(Math.max(...widths) - Math.min(...widths)).toBeLessThanOrEqual(1); + }); + + it('paginates the report, and prints the way back to the mechanism on every page', () => { + const { flow, table } = sample(); + const rows = table.times.map((time) => [String(time), '1', '2']); + const html = reportHtml({ + logoUrl: 'assets/PMKS_logo.png', + sections: [ + { + title: 'Kinematic analysis — M1', + subtitle: '3 links · 20.00 RPM CW', + dataTitle: 'Data — M1', + graphsTitle: 'Graphs — M1', + drawing: '<svg></svg>', + facts: [{ label: 'Cycle', value: '3.00 s' }], + shareUrl: 'https://app.pmksplus.com/?abc', + plots: [], + heads: ['Time (s)', 'Position B X (cm)', 'Position B Y (cm)'], + rows, + footer: 'M1 · 3 links', + }, + ], + }); + const pages = html.match(/<section class="page">/g) ?? []; + // One first page, then as few pages of table as the rows fit into: a page + // holds sixty-odd rows of this size, and two columns fit across it easily. + expect(pages.length).toBeGreaterThan(1); + expect(pages.length).toBeLessThanOrEqual(1 + Math.ceil(rows.length / 60)); + expect(html.match(/Page 1 of /g)).toHaveLength(1); + expect(html).toContain(`Page ${pages.length} of ${pages.length}`); + expect(html).toContain('https://app.pmksplus.com/?abc'); + expect(flow.extension()).toBe('.csv'); + }); +}); + +describe('how much paper the report asks for', () => { + /** A table of `columns` columns and `rows` rows, at a given number width. */ + function report(rows: number, columns: number, digits: number): string { + const cell = (-1.0618581).toFixed(digits); + return reportHtml({ + logoUrl: 'assets/PMKS_logo.png', + sections: [ + { + title: 'Kinematic analysis — M1', + subtitle: '3 links', + dataTitle: 'Data — M1', + graphsTitle: 'Graphs — M1', + drawing: '<svg></svg>', + facts: [], + shareUrl: 'https://app.pmksplus.com/?abc', + plots: [], + heads: ['Time (s)', ...Array.from({ length: columns }, (_, at) => `Column ${at} (cm)`)], + rows: Array.from({ length: rows }, () => Array(columns + 1).fill(cell)), + footer: 'M1', + }, + ], + }); + } + + const pagesOf = (html: string): number => (html.match(/<section class="page">/g) ?? []).length; + + it('fills a page before it starts another', () => { + // A cycle's worth of rows and a wide-ish selection: this was twenty-two + // pages when a page held forty rows and seven columns. + const html = report(361, 14, 6); + expect(pagesOf(html)).toBeLessThanOrEqual(8); + + // The pages of table are filled to about the same depth, so the last one + // is not a single line of numbers under an inch of heading. + const perPage = [...html.matchAll(/<tbody>(.*?)<\/tbody>/gs)].map( + (body) => (body[1].match(/<tr>/g) ?? []).length + ); + expect(Math.min(...perPage)).toBeGreaterThan(Math.max(...perPage) * 0.8); + }); + + it('leaves room for the heads, so no row falls off the bottom of a page', () => { + const table = (head: (at: number) => string): number => { + const html = reportHtml({ + logoUrl: '', + sections: [ + { + title: 'M1', + subtitle: '', + dataTitle: 'Data', + graphsTitle: 'Graphs', + drawing: '', + facts: [], + shareUrl: '', + plots: [], + heads: ['Time (s)', ...Array.from({ length: 20 }, (_, at) => head(at))], + // Enough rows that a few fewer per page is a page more. + rows: Array.from({ length: 3000 }, () => Array(21).fill('-1.061858')), + footer: 'M1', + }, + ], + }); + return (html.match(/<section class="page">/g) ?? []).length; + }; + + // A head wraps down the page in a column half an inch wide, and a page laid + // out by hand does not carry what does not fit -- it loses it. So a long + // head has to cost rows, and enough rows cost pages. + const short = table((at) => `F${at}`); + const long = table((at) => `Static force at Joint ${at} on Link ABCD Mag (N)`); + expect(long).toBeGreaterThan(short); + }); + + it('carries every column exactly once across the pages it splits them over', () => { + // The packer takes as many columns as the widest of them allows, which is a + // different number on every page -- so what it covers is worth stating. + const heads = ['Time (s)', ...Array.from({ length: 40 }, (_, at) => `Column ${at} (cm)`)]; + const html = reportHtml({ + logoUrl: '', + sections: [ + { + title: 'M1', + subtitle: '', + dataTitle: 'Data', + graphsTitle: 'Graphs', + drawing: '', + facts: [], + shareUrl: '', + plots: [], + heads, + // A mix of narrow and wide, so the pages are not all the same width. + rows: Array.from({ length: 120 }, () => + heads.map((_, at) => (at % 3 === 0 ? '-1234.567890' : '0.01')) + ), + footer: 'M1', + }, + ], + }); + const seen = [...html.matchAll(/<thead><tr>(.*?)<\/tr><\/thead>/gs)].flatMap((head) => + [...head[1].matchAll(/<th>(.*?)<\/th>/g)].map((cell) => cell[1]) + ); + const withoutTime = seen.filter((head) => head !== 'Time (s)'); + const distinct = new Set(withoutTime); + expect(distinct.size).toBe(40); + // Each page repeats the time column and nothing else. + expect(withoutTime.length / (seen.length - withoutTime.length)).toBeGreaterThan(1); + expect(withoutTime.length % distinct.size).toBe(0); + }); + + it('asks for less paper when the numbers are narrower', () => { + // Two decimals is a narrower column than six, so more of them fit across a + // page and the table is split into fewer passes over the same rows. + expect(pagesOf(report(361, 24, 2))).toBeLessThan(pagesOf(report(361, 24, 6))); + }); + + it('has no page of graphs when they fit beside the mechanism', () => { + const withFive = reportHtml({ + logoUrl: '', + sections: [ + { + title: 'M1', + subtitle: '', + dataTitle: 'Data', + graphsTitle: 'Graphs', + drawing: '', + facts: [], + shareUrl: '', + plots: Array.from({ length: 5 }, (_, at) => ({ + title: `Plot ${at}`, + unit: 'cm', + svg: '<svg></svg>', + legend: [], + })), + heads: ['Time (s)'], + rows: [], + footer: 'M1', + }, + ], + }); + expect(pagesOf(withFive)).toBe(1); + expect(withFive).toContain('across3'); + }); +}); + +/** Read back a stored zip: enough of the format to prove this one is well formed. */ +function readZip(bytes: Uint8Array): { name: string; data: Uint8Array; crc: number }[] { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let end = bytes.length - 22; + while (end >= 0 && view.getUint32(end, true) !== 0x06054b50) end--; + const count = view.getUint16(end + 10, true); + let at = view.getUint32(end + 16, true); + const entries: { name: string; data: Uint8Array; crc: number }[] = []; + for (let n = 0; n < count; n++) { + const nameLength = view.getUint16(at + 28, true); + const size = view.getUint32(at + 24, true); + const crc = view.getUint32(at + 16, true); + const offset = view.getUint32(at + 42, true); + const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLength)); + const localNameLength = view.getUint16(offset + 26, true); + const extra = view.getUint16(offset + 28, true); + const start = offset + 30 + localNameLength + extra; + entries.push({ name, crc, data: bytes.subarray(start, start + size) }); + at += 46 + nameLength + view.getUint16(at + 30, true) + view.getUint16(at + 32, true); + } + return entries; +} diff --git a/src/app/services/export/export-model.ts b/src/app/services/export/export-model.ts new file mode 100644 index 00000000..163967c9 --- /dev/null +++ b/src/app/services/export/export-model.ts @@ -0,0 +1,124 @@ +import { Joint } from '../../model/joint'; +import { Link } from '../../model/link'; + +/** Which of the two things in a drawing a row is about. */ +export type PartKind = 'joint' | 'link'; + +/** What the file is written as. */ +export type ExportFormat = 'csv' | 'xlsx' | 'images' | 'report'; + +/** How many digits a written number keeps. `full` writes what the solver held. */ +export type Decimals = 2 | 4 | 6 | 'full'; + +/** Which tab of step 2 a column belongs to. */ +export type ColumnTab = 'kinematics' | 'forces'; + +/** One tickable row of step 1: a joint or a link of one mechanism. */ +export interface ExportPart { + /** + * Unique across the drawing: `M1|joint:A`. + * + * Qualified by machine, because a joint can belong to two of them — a chain + * bolted to another's ground shares that pin, and it is listed under both. + */ + key: string; + kind: PartKind; + id: string; + label: string; + /** Why it is worth noticing — `grounded`, `input crank`, `slider`. */ + note: string; + /** + * Whether this part has anything to give in the mode being exported. + * + * A grounded joint never moves, so kinematics has nothing for it; force + * analysis does, because a pinned joint still carries a reaction. + */ + available: boolean; + part: Joint | Link; + mechanismIndex: number; +} + +/** One mechanism's worth of step 1: a heading and its rows. */ +export interface ExportPartGroup { + index: number; + id: string; + /** `3 links · 10.00 RPM CCW` — what this machine is, in one line. */ + note: string; + /** Whether force analysis actually solves for this machine. */ + forcesReady: boolean; + parts: ExportPart[]; +} + +/** One solved series, and everything `AnalysisSampleService` needs to take it. */ +export interface ExportSeries { + /** `Position`, `Force on Link AB` — the quantity, without the part. */ + label: string; + /** + * The whole phrase a column head opens with, part included. + * + * Set where the series names one part of its own — a reaction belongs to a + * joint *and* a link, and neither is enough on its own. Left empty where the + * column spans parts, and composed per part as the file is written. + */ + head: string; + unit: string; + analysis: 'kinematic' | 'force'; + /** + * How many numbers this series writes per row before any trimming. + * + * 1 for a scalar like an angle, 2 for a position, 3 for a rate or a reaction + * — X, Y and the magnitude that can be dropped. Declared rather than + * discovered so the drawer can say what a row will write before anything has + * been sampled; `export-flow.spec.ts` holds every declaration to what the + * sampler actually hands back. + */ + components: 1 | 2 | 3; + mechProp: string; + /** Filled in per selected part where the column spans parts. */ + mechPart: string; + reactionLinkId: string; +} + +/** + * One tickable row of step 2. + * + * A kinematic row stands for a quantity of *every* selected part of its kind — + * ticking Position once means position for both chosen joints — so its series + * carry no part and the writer fills one in per part. A force row names one + * reaction of one part, and carries its own. + */ +export interface ExportColumn { + key: string; + label: string; + /** Shown at the end of the row; a hint, not the head the file carries. */ + unit: string; + /** `Centre of mass` writes three series from one tick. */ + series: ExportSeries[]; + /** + * The keys of the parts this one tick asks the question of. + * + * A kinematic row spans every chosen part that has the quantity — every + * moving joint, every link — and a force row names the one part whose + * reaction it is. Held as a list rather than as a rule, so the writer cannot + * apply a different rule from the one the drawer drew. + */ + appliesTo: string[]; + tab: ColumnTab; +} + +/** A titled run of columns: `Joints B, C`, `Link AB`, `Joint A`. */ +export interface ExportColumnGroup { + key: string; + title: string; + tab: ColumnTab; + columns: ExportColumn[]; +} + +/** One file, ready to be handed to the browser. */ +export interface ExportFile { + name: string; + mime: string; + /** Text content, or bytes for a format that is not text. */ + text?: string; + bytes?: Uint8Array; +} diff --git a/src/app/services/export/export-table.service.ts b/src/app/services/export/export-table.service.ts new file mode 100644 index 00000000..3fce4b64 --- /dev/null +++ b/src/app/services/export/export-table.service.ts @@ -0,0 +1,452 @@ +import { Injectable, inject } from '@angular/core'; +import { angularScale } from '../../model/analysis-series'; +import { RealJoint } from '../../model/joint'; +import { Mechanism } from '../../model/mechanism/mechanism'; +import { AnalysisSampleService } from '../analysis-sample.service'; +import { MechanismService } from '../mechanism.service'; +import { SettingsService } from '../settings.service'; +import { ExportFlowService } from './export-flow.service'; +import { ExportColumn, ExportPart, ExportSeries } from './export-model'; + +/** One quantity of one part over a whole cycle: a graph, and a run of columns. */ +export interface ExportPlot { + /** `Position of Joint B` — what a graph would be titled. */ + title: string; + /** `Position B` — what a column head opens with. */ + head: string; + unit: string; + columnKey: string; + partKey: string; + mechanismIndex: number; + /** X, Y and Mag where there are three; one unnamed series where there is one. */ + series: { name: string; values: number[] }[]; +} + +/** A whole file or sheet: a time column, and every series beside it. */ +export interface ExportTable { + /** What this one is called as a sheet: always qualified by its machine. */ + name: string; + /** + * What is added to the file name to tell this one from the others. + * + * Empty when there is only one file, and free of the machine's id unless + * there are several machines — the name the reader typed usually carries it + * already, and `M1_kinematics_M1_JointA.csv` says it twice. + */ + suffix: string; + mechanismIndex: number; + times: number[]; + heads: string[]; + /** Column-major, parallel to `heads` after the time column. */ + columns: number[][]; + plots: ExportPlot[]; +} + +/** + * One file the export will produce, described without solving it. + * + * Everything the drawer needs to say what it is about to write — how many + * columns, how many rows, how many printed pages — is decided by the selection + * and by numbers the mechanism already holds. Sampling a cycle to answer that + * meant every tick of a checkbox re-solved the whole drawing before the count + * beside it could change. + */ +export interface ExportPlan { + name: string; + suffix: string; + mechanismIndex: number; + rows: number; + heads: string[]; + /** How many graphs this file's columns amount to. */ + plots: number; + /** What each of those graphs is called, for a picture that carries its title. */ + titles: string[]; +} + +/** + * The numbers behind a chosen export, sampled once and shared by every format. + * + * A CSV, a workbook, a set of graph images and a report all say the same thing + * about the same cycle; the only difference is how it is written down. Solving + * it once here is what keeps them from disagreeing. + */ +@Injectable({ providedIn: 'root' }) +export class ExportTableService { + private flow = inject(ExportFlowService); + private mechanism = inject(MechanismService); + private samples = inject(AnalysisSampleService); + private settings = inject(SettingsService); + + /** + * The last answer, and what was asked to get it. + * + * Building these samples every solved position of every chosen series, and + * the drawer's own footer asks how wide the file is on every change-detection + * pass. Without this, moving the mouse over the drawer re-solves the cycle. + */ + private cache?: { key: string; tables: ExportTable[] }; + + /** + * Every file the current selection asks for. + * + * One per machine at the very least: two mechanisms run on their own clocks, + * so their rows cannot share a time column however much a reader would like + * one file. Beyond that the reader chooses — a sheet per part, or the whole + * machine in one. + */ + tables(): ExportTable[] { + const key = this.signature(); + if (this.cache?.key !== key) { + this.cache = { key, tables: this.flow.mechanismIndexes().flatMap((i) => this.tablesFor(i)) }; + } + return this.cache.tables; + } + + /** + * The same tables, sampled without holding the page still. + * + * `tables()` walks every selected series across every timestep in one + * uninterrupted loop. On the Jansen leg with everything ticked that is 361 + * rows by 188 columns, and about half a second in which nothing on the page + * moves -- including the spinner put there to say the export had started, + * which cannot paint while the loop it is waiting on owns the thread. + * + * So the work comes apart at the series, and the thread goes back to the + * browser whenever a slice has run long enough to be felt. The drawer is + * inert while this runs (`.busy` takes its pointer events away), so the + * selection being read cannot change underneath it. + */ + async tablesAsync(): Promise<ExportTable[]> { + const key = this.signature(); + if (this.cache?.key === key) return this.cache.tables; + const tables: ExportTable[] = []; + for (const index of this.flow.mechanismIndexes()) { + for (const piece of this.spread(index)) { + const table = await this.tableAsync(index, piece); + if (table.heads.length > 1) tables.push(table); + } + } + this.cache = { key, tables }; + return tables; + } + + /** + * Hand the thread back if this slice has run long enough to drop a frame. + * + * A yield per series would be a task apiece for eighty-odd of them, most of + * which take under a millisecond; a yield per slice keeps the overhead where + * the work is. + */ + private async breathe(since: number): Promise<number> { + if (performance.now() - since < 8) return since; + await yieldToBrowser(); + return performance.now(); + } + + /** + * Everything the numbers depend on, as one string. + * + * Decimals are deliberately not in it: rounding happens as a file is written, + * so changing it cannot change what was sampled. + */ + private signature(): string { + return [ + this.flow + .selectedParts() + .map((part) => part.key) + .join(','), + this.flow + .selectedColumns() + .map((column) => column.key) + .join(','), + this.flow.format, + this.flow.splitPerPart, + this.flow.withMagnitude, + this.flow.forceMode(), + this.settings.lengthUnit.value, + this.settings.angleUnit.value, + this.settings.forceUnit.value, + this.mechanism.mechanisms.map((solved) => solved.timeNum.length).join(','), + // Bumped by every rebuild of the solved cycle, so a change that leaves + // the shape of the selection alone still re-samples -- and *not* by + // playback, which moves the pose without touching a single sample. + this.mechanism.solveRevision, + ].join('|'); + } + + private tablesFor(index: number): ExportTable[] { + return this.spread(index) + .map((piece) => this.table(index, piece.name, piece.suffix, piece.parts, piece.columns)) + .filter((table) => table.heads.length > 1); + } + + /** + * How one machine's selection is divided into files, and what goes in each. + * + * The one statement of the rule, so a forecast of the export cannot be split + * differently from the export itself. + */ + private spread( + index: number + ): { name: string; suffix: string; parts: ExportPart[]; columns: ExportColumn[] }[] { + const id = this.flow.partGroups()[index]?.id ?? `M${index + 1}`; + const parts = this.flow.selectedParts().filter((part) => part.mechanismIndex === index); + const columns = this.flow.selectedColumns(); + // Only where there is more than one machine to tell apart. + const qualify = this.flow.mechanismIndexes().length > 1 ? `${id}_` : ''; + + // Per part only where the format offers the choice. A report and a set of + // graph images are per machine whatever the file step last had ticked, and + // a report built from a split selection silently kept only the first part. + if (this.flow.splitPerPart && (this.flow.format === 'csv' || this.flow.format === 'xlsx')) { + return parts.map((part) => { + const short = part.label.replace(/\s+/g, ''); + return { name: `${id}_${short}`, suffix: `${qualify}${short}`, parts: [part], columns }; + }); + } + if (this.flow.format === 'xlsx') { + // "By analysis": kinematics and forces are different questions about the + // same cycle, and a sheet each is what lets one be charted without the + // other's axis running through it. + return (['kinematics', 'forces'] as const).map((tab) => ({ + name: `${id}_${tab}`, + suffix: `${qualify}${tab}`, + parts, + columns: columns.filter((column) => column.tab === tab), + })); + } + return [{ name: id, suffix: qualify.replace(/_$/, ''), parts, columns }]; + } + + /** + * What the export will come to, arithmetic only. + * + * A forecast rather than a measurement: a series the solver turns out to have + * nothing for is dropped when the file is written, so the real table can be a + * column narrower than this says. Everything a reader is told before pressing + * Export comes from here, and nothing here touches the solver. + */ + plan(): ExportPlan[] { + return this.flow.mechanismIndexes().flatMap((index) => + this.spread(index) + .map((piece) => { + const solved = this.mechanism.mechanisms[index]; + const heads = ['Time (s)']; + const titles: string[] = []; + piece.columns.forEach((column) => { + piece.parts + .filter((part) => column.appliesTo.includes(part.key)) + .forEach((part) => { + column.series.forEach((series) => { + const kept = + series.components === 3 && !this.flow.withMagnitude ? 2 : series.components; + const head = series.head || `${series.label} ${this.shortName(part)}`; + this.seriesNames(series.components) + .slice(0, kept) + .forEach((name) => + heads.push(`${head}${name ? ' ' + name : ''} (${series.unit})`) + ); + titles.push(series.head || `${series.label} of ${part.label}`); + }); + }); + }); + return { + name: piece.name, + suffix: piece.suffix, + mechanismIndex: index, + rows: solved?.isMechanismValid() ? solved.timeNum.length : 0, + heads, + plots: titles.length, + titles, + }; + }) + .filter((piece) => piece.heads.length > 1) + ); + } + + /** One file's table, sampled a slice at a time. See `tablesAsync`. */ + private async tableAsync( + index: number, + piece: { name: string; suffix: string; parts: ExportPart[]; columns: ExportColumn[] } + ): Promise<ExportTable> { + const solved = this.mechanism.mechanisms[index]; + const times = solved?.isMechanismValid() ? [...solved.timeNum] : []; + const plots = solved ? await this.plotsAsync(solved, index, piece.parts, piece.columns) : []; + return { ...this.shapeOf(piece.name, piece.suffix, index, times, plots) }; + } + + private table( + index: number, + name: string, + suffix: string, + parts: ExportPart[], + columns: ExportColumn[] + ): ExportTable { + const solved = this.mechanism.mechanisms[index]; + const times = solved?.isMechanismValid() ? [...solved.timeNum] : []; + const plots = solved ? this.plots(solved, index, parts, columns) : []; + return this.shapeOf(name, suffix, index, times, plots); + } + + /** The heads and columns a set of sampled graphs comes to, as one table. */ + private shapeOf( + name: string, + suffix: string, + index: number, + times: number[], + plots: ExportPlot[] + ): ExportTable { + const heads = ['Time (s)']; + const values: number[][] = []; + plots.forEach((plot) => { + plot.series.forEach((series) => { + heads.push(`${plot.head}${series.name ? ' ' + series.name : ''} (${plot.unit})`); + values.push(series.values); + }); + }); + return { name, suffix, mechanismIndex: index, times, heads, columns: values, plots }; + } + + /** Every graph the selection asks for, in the order the columns are listed. */ + plots( + solved: Mechanism, + index: number, + parts: ExportPart[], + columns: ExportColumn[] + ): ExportPlot[] { + return this.plotJobs(solved, parts, columns) + .map((job) => this.samplePlot(solved, index, job)) + .filter((plot): plot is ExportPlot => plot !== undefined); + } + + /** + * The same graphs, with the thread handed back between them. + * + * One series at a time is the grain the sampling comes apart at: within a + * series every timestep is read from the same solved cycle, so stopping + * partway would be no cheaper to resume. + */ + private async plotsAsync( + solved: Mechanism, + index: number, + parts: ExportPart[], + columns: ExportColumn[] + ): Promise<ExportPlot[]> { + const plots: ExportPlot[] = []; + let since = performance.now(); + for (const job of this.plotJobs(solved, parts, columns)) { + const plot = this.samplePlot(solved, index, job); + if (plot) plots.push(plot); + since = await this.breathe(since); + } + return plots; + } + + /** + * Which series this selection asks for, before any of them are read. + * + * Listing the work and doing it are separate so that the two ways of running + * it -- straight through, or in slices with the browser let back in between + * -- cannot disagree about what the work was. + */ + private plotJobs( + solved: Mechanism, + parts: ExportPart[], + columns: ExportColumn[] + ): { part: ExportPart; column: ExportColumn; series: ExportSeries }[] { + if (!solved.isMechanismValid()) return []; + const jobs: { part: ExportPart; column: ExportColumn; series: ExportSeries }[] = []; + columns.forEach((column) => { + parts + .filter((part) => column.appliesTo.includes(part.key)) + .forEach((part) => { + column.series.forEach((series) => jobs.push({ part, column, series })); + }); + }); + return jobs; + } + + /** One series, read across the whole cycle. The expensive step. */ + private samplePlot( + solved: Mechanism, + index: number, + job: { part: ExportPart; column: ExportColumn; series: ExportSeries } + ): ExportPlot | undefined { + const { part, column, series } = job; + const mode = this.flow.forceMode(); + const mechPart = series.mechPart || part.id; + // The solver hands angles out in degrees and rates in radians, so one or + // the other is converted on the way to whatever is labelled with the + // reader's unit -- the same scale the graphs apply. + const scale = angularScale(series.mechProp, this.settings.angleUnit.value); + const rows = solved.timeNum.map((_, step) => + this.samples.sampleAt( + solved, + step, + series.analysis, + series.analysis === 'force' ? mode : 'loop', + series.mechProp, + mechPart, + series.reactionLinkId + ) + ); + const width = rows.reduce((most, row) => Math.max(most, row.length), 0); + if (width === 0) return undefined; + const kept = width === 3 && !this.flow.withMagnitude ? 2 : width; + const names = this.seriesNames(width).slice(0, kept); + return { + title: series.head || `${series.label} of ${part.label}`, + head: series.head || `${series.label} ${this.shortName(part)}`, + unit: series.unit, + columnKey: column.key, + partKey: part.key, + mechanismIndex: index, + series: names.map((name, at) => ({ + name, + values: rows.map((row) => row[at] * scale), + })), + }; + } + + /** The names the graphs plot these under, so the file agrees with the screen. */ + private seriesNames(count: number): string[] { + if (count <= 1) return ['']; + if (count === 2) return ['X', 'Y']; + return ['X', 'Y', 'Mag']; + } + + /** + * What a column head calls a part. + * + * The label without the word `Link`, rather than the id: a cylinder answers + * to the two mounts a reader can see, and a block to the pin it rides on — + * both of which have ids naming joints that were never drawn. + */ + private shortName(part: ExportPart): string { + if (part.kind === 'joint') return (part.part as RealJoint).name || part.id; + return part.label.replace(/^Link /, ''); + } +} + +/** + * Hand the thread back for one turn of the event loop. + * + * `setTimeout(0)` is the obvious way and the wrong one: browsers hold a nested + * timer to about 4 ms, which over the sixty-odd yields a full export takes + * added more waiting than sampling -- 800 ms of work to avoid 465 ms of + * freeze. A message to oneself is delivered on the next turn with no such + * floor, so the page gets to paint between slices at almost no cost. + */ +function yieldToBrowser(): Promise<void> { + if (typeof MessageChannel === 'undefined') { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + return new Promise((resolve) => { + const channel = new MessageChannel(); + channel.port1.onmessage = () => { + channel.port1.close(); + resolve(); + }; + channel.port2.postMessage(undefined); + }); +} diff --git a/src/app/services/export/export-writer.service.ts b/src/app/services/export/export-writer.service.ts new file mode 100644 index 00000000..a3f789fc --- /dev/null +++ b/src/app/services/export/export-writer.service.ts @@ -0,0 +1,488 @@ +import { Injectable, inject } from '@angular/core'; +import { ANALYSIS_SERIES_COLORS } from '../../model/analysis-series'; +import { RealJoint } from '../../model/joint'; +import { MechanismService } from '../mechanism.service'; +import { NumberUnitParserService } from '../number-unit-parser.service'; +import { SettingsService } from '../settings.service'; +import { UrlGenerationService } from '../url-generation.service'; +import { ExportCatalogService } from './export-catalog.service'; +import { ExportFlowService } from './export-flow.service'; +import { ExportFile } from './export-model'; +import { ExportPlot, ExportTable, ExportTableService } from './export-table.service'; +import { formatCell, toCsv } from './csv-writer'; +import { toXlsx, sheetNames } from './xlsx-writer'; +import { utf8, zipStore } from './zip'; +import { plotSvg } from './graph-svg'; +import { mechanismSvg } from './mechanism-svg'; +import { canvasSnapshot } from './canvas-svg'; +import { Measure, ReportSection, reportHtml, reportPages } from './report-html'; + +const SERIES_COLORS: Record<string, string> = { + X: ANALYSIS_SERIES_COLORS.X, + Y: ANALYSIS_SERIES_COLORS.Y, + Mag: ANALYSIS_SERIES_COLORS.Z, +}; + +/** + * The chosen export, written out. + * + * One entry point for four formats, because they differ only in how the same + * solved cycle is spelled: a CSV per machine, a workbook of sheets, a picture + * per graph, or a report a reader can hand in. + */ +@Injectable({ providedIn: 'root' }) +export class ExportWriterService { + private flow = inject(ExportFlowService); + private tables = inject(ExportTableService); + private catalog = inject(ExportCatalogService); + private mechanism = inject(MechanismService); + private settings = inject(SettingsService); + private nup = inject(NumberUnitParserService); + private urls = inject(UrlGenerationService); + + /** What the export would produce, for the summary the drawer shows. */ + /** + * What the export would produce, forecast rather than measured. + * + * Read off the plan, which is arithmetic over the selection — so the counts + * under a reader's hand keep up with a checkbox instead of re-solving every + * chosen series before the number beside it can change. + */ + summary(): { files: number; columns: number; rows: number; pages: number } { + const plan = this.tables.plan(); + return { + files: this.flow.format === 'xlsx' || this.flow.format === 'report' ? 1 : plan.length, + columns: plan.reduce((most, piece) => Math.max(most, piece.heads.length), 0), + rows: plan.reduce((most, piece) => Math.max(most, piece.rows), 0), + // The one number that cannot be forecast: a page holds as many columns as + // its numbers are wide, and how wide they are is a property of the values + // themselves. Paid for only where it is shown, which is the Report format. + pages: this.flow.format === 'report' ? this.printedPages() : 0, + }; + } + + /** How many printed pages the chosen selection comes to. Samples the cycle. */ + private printedPages(): number { + const tables = this.tables.tables(); + return tables + .filter( + (table, at) => tables.findIndex((one) => one.mechanismIndex === table.mechanismIndex) === at + ) + .reduce( + (total, table) => + total + + reportPages( + { plots: table.plots.length, rows: this.rowsOf(table), heads: table.heads }, + textMeasure() + ), + 0 + ); + } + + /** + * Write the chosen export, and say whether anything came of it. + * + * A selection can empty itself out from under the reader — a mechanism that + * stops solving takes its parts with it — and an export that writes nothing + * must not be reported as one that worked. + */ + /** Every row of a table, formatted the way a file or a page would carry it. */ + private rowsOf(table: ExportTable): string[][] { + const decimals = this.flow.decimals; + return table.times.map((time, row) => [ + formatCell(time, decimals), + ...table.columns.map((column) => formatCell(column[row], decimals)), + ]); + } + + async run(): Promise<boolean> { + // Sampled in slices rather than in one go, so the page keeps drawing -- + // the spinner on the button that started this among other things. + const tables = await this.tables.tablesAsync(); + if (tables.length === 0) return false; + switch (this.flow.format) { + case 'xlsx': + this.writeWorkbook(tables); + return true; + case 'images': + return this.writeImages(tables); + case 'report': + return this.writeReport(tables); + default: + this.writeCsv(tables); + return true; + } + } + + private writeCsv(tables: ExportTable[]): void { + const stem = this.flow.name(); + this.deliver( + tables.map((table) => ({ + name: `${this.fileStem(stem, table)}.csv`, + mime: 'text/csv;charset=utf-8', + text: toCsv(table, this.flow.decimals), + })), + stem + ); + } + + /** + * What the export will actually put in the downloads folder. + * + * The drawer says this before the reader presses Export, so it has to be the + * name the file lands under rather than the name it was built from: a set of + * pictures is an archive of its own, and one picture carries the graph's + * title. + */ + arrivingName(): string { + const stem = this.flow.name(); + if (this.flow.format === 'report') return `${stem}.pdf`; + if (this.flow.format === 'images') { + const plan = this.tables.plan(); + const pictures = plan.reduce((total, piece) => total + piece.plots, 0); + if (pictures > 2) return `${stem}_graphs.zip`; + const machine = plan.length > 1 ? `${plan[0].name}_` : ''; + const title = plan[0]?.titles[0]; + return title + ? `${stem}_${machine}${safe(title)}.${this.flow.imageFormat}` + : `${stem}.${this.flow.imageFormat}`; + } + const files = this.summary().files; + if (files > 2 && this.flow.format !== 'xlsx') return `${stem}.zip`; + // Two files arrive as two downloads, each carrying the suffix that tells + // them apart -- so name the first of them rather than the bare stem, which + // promised a `results.csv` where `results_M1.csv` and `results_M2.csv` + // landed. How many are coming is the line underneath. + const suffix = files > 1 ? this.tables.plan()[0]?.suffix : undefined; + const named = suffix ? `${stem}_${safe(suffix)}` : stem; + return `${named}${this.flow.extension()}`; + } + + /** + * Hand the files over, as one download where there are more than two. + * + * A browser asks before it will take a second file and stops after a handful, + * so an export of a part each arrived as one CSV and a permission prompt the + * reader had to answer for the rest. One archive is one download. + */ + private deliver(files: ExportFile[], stem: string): void { + const named = distinctNames(files); + if (named.length <= 2) { + named.forEach((file) => this.hand(file)); + return; + } + this.hand({ + name: `${stem}.zip`, + mime: 'application/zip', + bytes: zipStore( + named.map((file) => ({ + name: file.name, + data: file.bytes ?? utf8(file.text ?? ''), + })) + ), + }); + } + + private writeWorkbook(tables: ExportTable[]): void { + this.hand({ + name: `${this.flow.name()}.xlsx`, + mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + bytes: toXlsx(tables, this.flow.decimals), + }); + } + + private async writeImages(tables: ExportTable[]): Promise<boolean> { + const width = 720; + const height = 420; + // Read once, before the first await. Drawing forty graphs takes long enough + // for a reader to change their mind about PNG or SVG, or to rename the + // export -- and the settings were being read afresh per picture, so half an + // archive came out under the old answers and half under the new. + const stem = this.flow.name(); + const kind = this.flow.imageFormat; + const logo = await logoDataUrl(); + const files: ExportFile[] = []; + for (const table of tables) { + for (const plot of table.plots) { + const svg = plotSvg(plot, table.times, { width, height, standalone: true, logo }); + // The machine, where there is more than one: a joint shared by two of + // them is `Position of Joint D` in both, and two entries of one name in + // one archive is one picture that quietly replaces the other. + const machine = tables.length > 1 ? `${table.name}_` : ''; + const name = `${stem}_${machine}${safe(plot.title)}`; + files.push( + kind === 'svg' + ? { name: `${name}.svg`, mime: 'image/svg+xml', text: svg } + : { + name: `${name}.png`, + mime: 'image/png', + bytes: await rasterize(svg, width, height), + } + ); + } + } + this.deliver(files, `${stem}_graphs`); + return files.length > 0; + } + + /** Every selected machine, one section each, in one printable document. */ + private writeReport(tables: ExportTable[]): boolean { + const sections = this.flow + .mechanismIndexes() + .map((index) => + this.section( + index, + tables.filter((table) => table.mechanismIndex === index) + ) + ) + .filter((section): section is ReportSection => !!section); + if (sections.length === 0) return false; + const logo = new URL('assets/PMKS_logo.png', document.baseURI).href; + // The name the reader typed reaches the file through the document's title: + // the app never writes this one, the print dialog does, and the title is + // what it offers as the name. + print( + reportHtml({ + logoUrl: logo, + documentTitle: this.flow.name(), + sections, + measure: textMeasure(), + }) + ); + return true; + } + + private section(index: number, tables: ExportTable[]): ReportSection | undefined { + const table = tables[0]; + const solved = this.mechanism.mechanisms[index]; + const group = this.flow.partGroups()[index]; + if (!table || !solved || !group) return undefined; + // The canvas itself where there is one, so a slot, a piston and a sealed + // cylinder reach the page as the reader drew them; the skeleton is the + // fallback for a report built without a canvas to copy. + const jointIds = (this.mechanism.partitions[index]?.ownJoints ?? []).map((joint) => joint.id); + const drawing = + canvasSnapshot(330, 250, jointIds) ?? + mechanismSvg(solved.joints[0] ?? [], solved.links[0] ?? [], 330, 230); + const decimals = this.flow.decimals; + return { + title: `${this.flow.selectedColumns('forces').length > 0 ? 'Analysis' : 'Kinematic analysis'} — ${group.id}`, + subtitle: `${group.note} · ${today()}`, + dataTitle: `Data — ${group.id}`, + graphsTitle: `Graphs — ${group.id}`, + drawing, + facts: this.facts(index, table), + shareUrl: this.urls.generateFullUrl(), + plots: table.plots.map((plot) => ({ + title: plot.title, + unit: plot.unit, + svg: plotSvg(plot, table.times, { width: 320, height: 150, standalone: false }), + legend: plot.series.map((series) => ({ + name: series.name || plot.head, + color: SERIES_COLORS[series.name] ?? ANALYSIS_SERIES_COLORS.X, + })), + })), + heads: table.heads, + rows: this.rowsOf(table), + footer: `${group.id} · ${group.note}`, + }; + } + + /** What a reader needs in order to trust the numbers on the pages after it. */ + private facts(index: number, table: ExportTable): { label: string; value: string }[] { + const solved = this.mechanism.mechanisms[index]; + const group = this.flow.partGroups()[index]; + const readiness = this.mechanism.readinessOfEachMechanism()[index]; + const driven = this.mechanism.partitions[index]?.joints.find( + (joint) => joint instanceof RealJoint && joint.input + ) as RealJoint | undefined; + const speed = readiness?.facts.find((fact) => fact.label === 'Input speed')?.value ?? '—'; + const parts = this.flow + .selectedParts() + .filter((part) => part.mechanismIndex === index) + .map((part) => part.label) + .join(', '); + const length = this.catalog.unitStr(this.settings.lengthUnit.value); + const mass = this.nup.unitLabel(this.nup.massUnitFor(this.settings.lengthUnit.value)); + const angle = this.catalog.unitStr(this.settings.angleUnit.value); + return [ + { label: 'Mechanism', value: `${group.id}, ${group.note}, ${solved.dof} DoF` }, + { + label: 'Input', + value: driven ? `Joint ${driven.name || driven.id}, ${speed}` : 'Not set', + }, + { + label: 'Cycle', + value: `${solved.cyclePeriod.toFixed(2)} s, ${table.times.length} solved positions`, + }, + { label: 'Units', value: `${length}, ${mass}, ${this.catalog.forceUnit()}, ${angle}` }, + { label: 'Parts', value: parts || '—' }, + { label: 'Decimals', value: String(this.flow.decimals) }, + ]; + } + + /** One file gets the plain name; several get what tells them apart. */ + private fileStem(stem: string, table: ExportTable): string { + return table.suffix ? `${stem}_${safe(table.suffix)}` : stem; + } + + private hand(file: ExportFile): void { + const blob = file.bytes + ? new Blob([file.bytes as BlobPart], { type: file.mime }) + : new Blob([file.text ?? ''], { type: file.mime }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = file.name; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 30_000); + } + + /** Exposed so a spec can name the sheets without opening a workbook. */ + sheetNamesFor(tables: ExportTable[]): string[] { + return sheetNames(tables); + } + + plotsOf(table: ExportTable): ExportPlot[] { + return table.plots; + } +} + +/** The type the report's table is set in, for measuring a string against. */ +const TABLE_FONT = "8.5px Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif"; + +/** + * How wide a string will actually be, asked of the browser that will draw it. + * + * The report is laid out here rather than reflowed, so how many columns fit + * across a page is arithmetic — and arithmetic done against a table of glyph + * widths is right for the font it was measured in and a few per cent out for + * whichever one the reader's machine falls back to. A canvas knows exactly. + * Memoised per string, because a table of 361 rows asks the same questions + * thousands of times. + */ +function textMeasure(): Measure | undefined { + const context = document.createElement('canvas').getContext('2d'); + if (!context) return undefined; + const seen = new Map<string, number>(); + return (text: string, bold = false): number => { + const key = bold ? `b:${text}` : text; + const known = seen.get(key); + if (known !== undefined) return known; + context.font = `${bold ? '500 ' : ''}${TABLE_FONT}`; + const width = context.measureText(text).width; + seen.set(key, width); + return width; + }; +} + +/** + * Make sure no two files going out together share a name. + * + * A zip will hold two entries of one name quite happily, and every unzipper + * then writes one over the other -- so a picture goes missing with nothing + * anywhere saying it did. The last resort rather than the plan: the names are + * built to differ, and this is what catches the case nobody thought of. + */ +function distinctNames(files: ExportFile[]): ExportFile[] { + const used = new Set<string>(); + return files.map((file) => { + if (!used.has(file.name)) { + used.add(file.name); + return file; + } + const dot = file.name.lastIndexOf('.'); + const stem = dot > 0 ? file.name.slice(0, dot) : file.name; + const suffix = dot > 0 ? file.name.slice(dot) : ''; + let at = 2; + while (used.has(`${stem}_${at}${suffix}`)) at++; + const name = `${stem}_${at}${suffix}`; + used.add(name); + return { ...file, name }; + }); +} + +/** + * The PMKS+ mark as a data URI, for a picture that has to stand on its own. + * + * Read once per export and given to every graph in it. A failure here is not + * worth refusing an export over: the graph goes out without the mark. + */ +async function logoDataUrl(): Promise<string | undefined> { + try { + const response = await fetch(new URL('assets/PMKS_logo.png', document.baseURI).href); + const blob = await response.blob(); + return await new Promise<string | undefined>((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => resolve(undefined); + reader.readAsDataURL(blob); + }); + } catch { + return undefined; + } +} + +/** A picture the browser will draw, as PNG bytes. */ +async function rasterize(svg: string, width: number, height: number): Promise<Uint8Array> { + const source = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + const image = await new Promise<HTMLImageElement>((resolve, reject) => { + const element = new Image(); + element.onload = () => resolve(element); + element.onerror = () => reject(new Error('The graph could not be drawn.')); + element.src = source; + }); + const canvas = document.createElement('canvas'); + // Twice the drawn size: a chart printed or pasted at full width should not be + // the one blurred thing on the page. + canvas.width = width * 2; + canvas.height = height * 2; + const context = canvas.getContext('2d')!; + context.scale(2, 2); + context.drawImage(image, 0, 0, width, height); + const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png')); + return new Uint8Array(await (blob ?? new Blob()).arrayBuffer()); +} + +/** + * Hand a document to the browser's own printer. + * + * An off-screen frame rather than a new window: a popup blocker stops the + * window, and the frame is torn down as soon as the dialog closes. + */ +function print(html: string): void { + const frame = document.createElement('iframe'); + frame.setAttribute('aria-hidden', 'true'); + frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;'; + document.body.appendChild(frame); + const doc = frame.contentDocument; + if (!doc) { + frame.remove(); + return; + } + doc.open(); + doc.write(html); + doc.close(); + const go = () => { + frame.contentWindow?.focus(); + frame.contentWindow?.print(); + setTimeout(() => frame.remove(), 1000); + }; + if (doc.readyState === 'complete') setTimeout(go, 100); + else frame.onload = () => setTimeout(go, 100); +} + +/** A name a file system will take, from a title a person wrote. */ +function safe(text: string): string { + return text.replace(/[^\w-]+/g, '_').replace(/^_+|_+$/g, ''); +} + +function today(): string { + return new Date().toLocaleDateString(undefined, { + day: 'numeric', + month: 'long', + year: 'numeric', + }); +} diff --git a/src/app/services/export/graph-svg.ts b/src/app/services/export/graph-svg.ts new file mode 100644 index 00000000..2bcc9cec --- /dev/null +++ b/src/app/services/export/graph-svg.ts @@ -0,0 +1,199 @@ +import { ANALYSIS_SERIES_COLORS } from '../../model/analysis-series'; +import { ExportPlot } from './export-table.service'; +import { escapeXml } from './xml'; + +export interface PlotSvgOptions { + width: number; + height: number; + /** Whether the picture carries its own title and legend, or a page does. */ + standalone: boolean; + /** + * The PMKS+ mark, as a data URI. + * + * Embedded rather than linked: a graph leaves here as a file of its own and + * lands in a report or a slide deck with nothing around it to say what drew + * it, and a picture that reaches for a URL is a picture with a hole in it. + */ + logo?: string; +} + +const COLORS = [ANALYSIS_SERIES_COLORS.X, ANALYSIS_SERIES_COLORS.Y, ANALYSIS_SERIES_COLORS.Z]; + +/** + * One solved quantity, drawn. + * + * Written out rather than taken from the panel's chart library: the panel draws + * whatever the reader has opened, and an export is asked for graphs that were + * never on screen. Vector all the way through, so the same picture serves a + * printed page, an .svg download, and — via a canvas — a .png. + */ +export function plotSvg(plot: ExportPlot, times: number[], options: PlotSvgOptions): string { + const { width, height, standalone } = options; + const top = standalone ? 54 : 12; + const left = 62; + const right = 14; + const bottom = 34; + const innerWidth = width - left - right; + const innerHeight = height - top - bottom; + + const finite = plot.series.flatMap((series) => series.values.filter(Number.isFinite)); + const lowest = finite.length > 0 ? Math.min(...finite) : 0; + const highest = finite.length > 0 ? Math.max(...finite) : 0; + const [low, high] = pad(lowest, highest); + const span = high - low || 1; + const lastTime = times.length > 0 ? times[times.length - 1] : 0; + + const x = (index: number) => + left + (times.length < 2 ? innerWidth / 2 : (index / (times.length - 1)) * innerWidth); + const y = (value: number) => top + innerHeight - ((value - low) / span) * innerHeight; + + const ticks = [0, 0.25, 0.5, 0.75, 1].map((fraction) => high - fraction * span); + const digits = tickDigits(span); + const grid = ticks + .map( + (value) => + `<line x1="${left}" y1="${round(y(value))}" x2="${left + innerWidth}" y2="${round( + y(value) + )}" stroke="#eceef5" stroke-width="1"/>` + ) + .join(''); + const yLabels = ticks + .map( + (value) => + `<text x="${left - 8}" y="${round(y(value) + 4)}" text-anchor="end" font-size="11" fill="rgba(0,0,0,0.55)">${axisNumber( + value, + digits + )}</text>` + ) + .join(''); + + // A line per unbroken run, not one line through every point there is. Where + // the solver has nothing to say -- a position a force analysis could not + // balance -- the panel's own chart breaks, and a picture that joined the two + // ends drew a straight line through the very positions it has no answer for. + const curves = plot.series + .map((series, at) => { + const colour = COLORS[at] ?? COLORS[0]; + return runsOf(series.values) + .map((run) => { + const points = run + .map((index) => `${round(x(index))},${round(y(series.values[index]))}`) + .join(' '); + // A run of one has no line in it; a dot is what the panel leaves. + return run.length === 1 + ? `<circle cx="${round(x(run[0]))}" cy="${round( + y(series.values[run[0]]) + )}" r="1.4" fill="${colour}"/>` + : `<polyline points="${points}" fill="none" stroke="${colour}" stroke-width="1.8" stroke-linejoin="round"/>`; + }) + .join(''); + }) + .join(''); + + const axis = + `<line x1="${left}" y1="${top + innerHeight}" x2="${left + innerWidth}" y2="${ + top + innerHeight + }" stroke="#d5d7e0" stroke-width="1"/>` + + `<text x="${left}" y="${top + innerHeight + 20}" font-size="11" fill="rgba(0,0,0,0.55)">0 s</text>` + + `<text x="${left + innerWidth}" y="${ + top + innerHeight + 20 + }" text-anchor="end" font-size="11" fill="rgba(0,0,0,0.55)">${lastTime.toFixed(2)} s</text>`; + + // On the top line, ahead of the title. The bottom right is where the last + // axis label is, and a mark parked there sat on top of it. + const markWidth = 74; + const mark = + standalone && options.logo + ? `<image href="${options.logo}" x="12" y="8" width="${markWidth}" height="18" preserveAspectRatio="xMinYMid meet"/>` + : ''; + const titleAt = standalone && options.logo ? 12 + markWidth + 12 : left; + const heading = standalone + ? `<text x="${titleAt}" y="22" font-size="15" font-weight="500" fill="#2c2c2c">${escapeXml( + plot.title + )}</text>` + + `<text x="${width - right}" y="22" text-anchor="end" font-size="12" fill="rgba(0,0,0,0.45)">${escapeXml( + plot.unit + )}</text>` + + legend(plot, left) + + mark + : ''; + + return ( + `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="Roboto, Helvetica, Arial, sans-serif">` + + `<rect width="${width}" height="${height}" fill="#ffffff"/>` + + grid + + yLabels + + curves + + axis + + heading + + `</svg>` + ); +} + +/** The series names, in their own colours, under the title. */ +function legend(plot: ExportPlot, left: number): string { + let at = left; + return plot.series + .map((series, index) => { + const name = series.name || plot.head; + const swatch = `<rect x="${at}" y="32" width="10" height="10" rx="2" fill="${ + COLORS[index] ?? COLORS[0] + }"/><text x="${at + 15}" y="41" font-size="12" fill="rgba(0,0,0,0.65)">${escapeXml( + name + )}</text>`; + at += 15 + Math.max(14, name.length * 7) + 14; + return swatch; + }) + .join(''); +} + +/** The stretches of a series the solver actually answered for, as index runs. */ +function runsOf(values: number[]): number[][] { + const runs: number[][] = []; + let run: number[] = []; + values.forEach((value, index) => { + if (Number.isFinite(value)) { + run.push(index); + return; + } + if (run.length > 0) runs.push(run); + run = []; + }); + if (run.length > 0) runs.push(run); + return runs; +} + +/** A little air above and below, so a flat line is not drawn on the frame. */ +function pad(low: number, high: number): [number, number] { + if (low === high) { + const margin = Math.abs(low) > 1e-9 ? Math.abs(low) * 0.1 : 1; + return [low - margin, high + margin]; + } + const margin = (high - low) * 0.08; + return [low - margin, high + margin]; +} + +/** + * How many decimals an axis label needs, read off the range rather than the tick. + * + * Per tick, a value that is float noise beside its neighbours -- 9.2e-6 on an + * axis running to 7 -- came out in scientific notation, which read as a real + * quantity of its own. What matters is how far apart the labels are. + */ +function tickDigits(span: number): number { + if (span >= 100) return 0; + if (span >= 10) return 1; + if (span >= 1) return 2; + if (span >= 0.01) return 4; + return -1; +} + +function axisNumber(value: number, digits: number): string { + if (digits < 0) return value === 0 ? '0' : value.toExponential(1); + if (Math.abs(value) >= 1e5) return value.toExponential(1); + return String(Number(value.toFixed(digits))); +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/src/app/services/export/mechanism-svg.ts b/src/app/services/export/mechanism-svg.ts new file mode 100644 index 00000000..7d23facd --- /dev/null +++ b/src/app/services/export/mechanism-svg.ts @@ -0,0 +1,98 @@ +import { Joint, PrisJoint, RealJoint } from '../../model/joint'; +import { Link, RealLink } from '../../model/link'; +import { escapeXml } from './xml'; + +/** + * The mechanism as a skeleton, fitted to a box. + * + * A report that shows numbers without showing what they are about asks a reader + * to take the geometry on trust, so page one carries the drawing. Deliberately + * a skeleton rather than a copy of the canvas: the canvas is a live SVG full of + * handles, marks and hit targets, and what a printed page needs is bars, + * joints, and which of them are pinned down. + */ +export function mechanismSvg( + joints: Joint[], + links: Link[], + width: number, + height: number +): string { + const drawn = joints.filter((joint) => !(joint instanceof PrisJoint)); + if (drawn.length === 0) { + return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`; + } + + // Model y points up and SVG y points down, which is why the canvas itself + // draws through a scaleY(-1); here the flip is folded into the projection. + const xs = drawn.map((joint) => joint.x); + const ys = drawn.map((joint) => -joint.y); + const pad = 26; + const spanX = Math.max(...xs) - Math.min(...xs) || 1; + const spanY = Math.max(...ys) - Math.min(...ys) || 1; + const scale = Math.min((width - 2 * pad) / spanX, (height - 2 * pad) / spanY); + const midX = (Math.max(...xs) + Math.min(...xs)) / 2; + const midY = (Math.max(...ys) + Math.min(...ys)) / 2; + const at = (joint: Joint): [number, number] => [ + round(width / 2 + (joint.x - midX) * scale), + round(height / 2 + (-joint.y - midY) * scale), + ]; + + const bars = links + .filter((link): link is RealLink => link instanceof RealLink) + .map((link) => { + const points = link.joints + .filter((joint) => !(joint instanceof PrisJoint)) + .map((joint) => at(joint).join(',')) + .join(' '); + if (!points) return ''; + const shape = link.joints.length > 2 ? 'polygon' : 'polyline'; + return `<${shape} points="${points}" fill="none" stroke="${ + link.fill || '#5c6bc0' + }" stroke-width="9" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>`; + }) + .join(''); + + const grounds = drawn + .filter((joint) => (joint as RealJoint).ground) + .map((joint) => { + const [x, y] = at(joint); + return ( + `<path d="M ${x} ${y} L ${x - 9} ${y + 14} L ${x + 9} ${y + 14} Z" fill="none" stroke="#2c2c2c" stroke-width="1.5"/>` + + `<line x1="${x - 13}" y1="${y + 14}" x2="${x + 13}" y2="${y + 14}" stroke="#2c2c2c" stroke-width="1.5"/>` + ); + }) + .join(''); + + const pins = drawn + .map((joint) => { + const [x, y] = at(joint); + const input = (joint as RealJoint).input; + return `<circle cx="${x}" cy="${y}" r="6" fill="${ + input ? '#ffca28' : '#fff8e1' + }" stroke="rgba(0,0,0,0.35)" stroke-width="1"/>`; + }) + .join(''); + + const labels = drawn + .map((joint) => { + const [x, y] = at(joint); + return `<text x="${x + 9}" y="${y - 8}" font-size="13" font-weight="500" fill="#2c2c2c">${escapeXml( + (joint as RealJoint).name || joint.id + )}</text>`; + }) + .join(''); + + return ( + `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="Roboto, Helvetica, Arial, sans-serif">` + + `<rect width="${width}" height="${height}" fill="#ffffff"/>` + + bars + + grounds + + pins + + labels + + `</svg>` + ); +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/src/app/services/export/report-html.ts b/src/app/services/export/report-html.ts new file mode 100644 index 00000000..560333ef --- /dev/null +++ b/src/app/services/export/report-html.ts @@ -0,0 +1,524 @@ +import { escapeXml } from './xml'; + +export interface ReportPlot { + title: string; + unit: string; + /** The chart itself, without a title of its own — the page supplies one. */ + svg: string; + legend: { name: string; color: string }[]; +} + +/** One mechanism's worth of report: its drawing, its graphs and its table. */ +export interface ReportSection { + title: string; + subtitle: string; + /** The drawing, as it stands at the start of the cycle. */ + drawing: string; + facts: { label: string; value: string }[]; + shareUrl: string; + plots: ReportPlot[]; + heads: string[]; + rows: string[][]; + /** `Four-bar test · M1` — what every page of this section is footed with. */ + footer: string; + /** What the data pages are headed with: `Data — M1`. */ + dataTitle: string; + /** What the graph pages after the first are headed with. */ + graphsTitle: string; +} + +/** + * How wide a string is, in the type the table is set in. + * + * Passed in so the page can be laid out against the browser's own measurement + * rather than against a table of glyph widths that is right for one font and a + * few per cent out for the next one down the stack. The estimate below is the + * fallback for anywhere without a canvas to ask. + */ +export type Measure = (text: string, bold?: boolean) => number; + +export interface ReportContext { + logoUrl: string; + /** + * What the document is called. + * + * The print dialog offers this as the PDF's file name, which is the only way + * the name typed in the drawer can reach a file the app never writes itself. + */ + documentTitle?: string; + sections: ReportSection[]; + /** Omitted where there is nothing to measure with; estimated instead. */ + measure?: Measure; +} + +/** + * What one printed page holds, measured rather than guessed. + * + * A Letter page at half-inch margins leaves 960px of content at 96dpi; the + * heading and the footer take about 88 of them. A table row is set in 8.5px + * type with a pixel of padding either side, which measures 12px. + */ +const BODY_HEIGHT = 860; +const ROW_HEIGHT = 13; +const BODY_WIDTH = 720; +/** + * How wide the characters of a number are in this type, measured at 8.5px. + * + * A digit, a point and a minus sign are three different widths, and counting + * characters instead of measuring them over-estimates a signed decimal by about + * a tenth — which is the difference between fifteen columns on a page and + * fourteen, and so between six pages of table and twelve. + * + * Taken from the browser, not from a rule of thumb: `-1234.567890` measures + * 52.3px, which is ten digits at 4.78, a point at 2.25 and a sign at 2.36. + */ +const DIGIT_WIDTH = 4.78; +const POINT_WIDTH = 2.25; +const SIGN_WIDTH = 2.36; +/** Padding either side of a cell, and a little over on the measurement. */ +const COLUMN_PADDING = 4; +const WIDTH_MARGIN = 1.02; +/** However narrow the numbers are, a head needs room to be read. */ +const MOST_COLUMNS = 24; +/** + * A letter *averages* narrower than a digit — the digits of this type are all + * one width and most letters are less — and a head is set on 11px lines. + * `Position B X (cm)` measures 65.7px across seventeen characters. + */ +const LETTER_WIDTH = 3.95; +const HEAD_LINE = 11; +const HEAD_PADDING = 14; +const MOST_HEAD_LINES = 14; +/** + * How many graphs a page carries. + * + * Four on the first page, in two columns, beside the drawing and the settings — + * the size the design draws them at. Nine is what fits when they are set three + * across, which is what a first page holds when that is all of them: three + * rows of three end 105px inside the page body, and a fourth row does not fit + * however few graphs are on it. Eight is what a page of nothing but graphs + * holds. + */ +const GRAPHS_ON_FIRST_PAGE = 4; +const GRAPHS_ON_FIRST_PAGE_TIGHT = 9; +const GRAPHS_PER_PAGE = 8; + +/** + * How many rows fit under the heads a page of this many columns carries. + * + * Not a constant, because the heads are not. `Static force at Joint B on Link + * AB Mag (N)` in a column half an inch wide is nine lines, and a head band nine + * lines deep is nine rows of numbers that no longer fit — which, on a page laid + * out by hand rather than reflowed, means rows clipped off the bottom. + */ +function rowsPerPage(heads: string[], columns: number, measure: Measure): number { + const columnWidth = Math.max(12, BODY_WIDTH / (columns + 1) - COLUMN_PADDING); + const lines = heads.reduce( + (most, head) => Math.max(most, headLines(head, columnWidth, measure)), + 1 + ); + const headBand = Math.min(lines, MOST_HEAD_LINES) * HEAD_LINE + HEAD_PADDING; + return Math.max(10, Math.floor((BODY_HEIGHT - headBand) / ROW_HEIGHT)); +} + +/** + * How many lines a head takes in a column of a given width. + * + * Word by word, because that is how the browser does it: a head only breaks + * inside a word when the word alone will not fit. Dividing the head's whole + * width by the column's assumes a perfect packing nothing achieves, and every + * line it under-counts is a row clipped off the bottom of the page. + */ +function headLines(head: string, columnWidth: number, measure: Measure): number { + let lines = 1; + let used = 0; + const space = measure(' ', true); + for (const word of head.split(' ')) { + const width = measure(word, true); + if (width > columnWidth) { + if (used > 0) lines++; + const pieces = Math.ceil(width / columnWidth); + lines += pieces - 1; + used = width - (pieces - 1) * columnWidth; + continue; + } + const gap = used === 0 ? 0 : space; + if (used + gap + width > columnWidth) { + lines++; + used = width; + } else { + used += gap + width; + } + } + return lines; +} + +/** + * How many columns of a given width fit across the page. + * + * Every column is drawn the same width, so what fits is decided by the widest + * number among them — and one fewer than that, because the time column rides + * every page beside them. Capped, because a column narrower than its own head + * sets that head wrapping down the page a letter at a time. + */ +function capacityFor(widest: number): number { + const width = (widest + COLUMN_PADDING) * WIDTH_MARGIN; + return Math.min(MOST_COLUMNS, Math.max(3, Math.floor(BODY_WIDTH / width) - 1)); +} + +/** + * Which columns go on which page, packed by what they are actually as wide as. + * + * Sizing every page from the widest number anywhere in the table charged a page + * of positions in the tens for the one reaction that ran to five figures. Taken + * a column at a time, a run of narrow columns packs tighter — on an eighty-six + * column export that is one pass over the rows fewer, which is six pages. + */ +function columnChunks(heads: string[], rows: string[][], measure: Measure): number[][] { + const columns = heads.slice(1).map((_, at) => at + 1); + if (columns.length === 0) return []; + const widthOf = (at: number): number => + rows.reduce((most, row) => Math.max(most, measure(row[at] ?? '', false)), 4 * DIGIT_WIDTH); + // Time rides every chunk: a column of numbers with nothing to line it up + // against is not data anyone can use, so its width is charged to every page. + const time = widthOf(0); + const widths = columns.map(widthOf); + + /** + * Walk the columns, taking as many as fit — but never more than `wanted` + * says, which is how the same number of pages can be packed evenly instead + * of greedily. + */ + const pack = (wanted: (left: number, pagesLeft: number) => number, pages: number): number[][] => { + const chunks: number[][] = []; + let at = 0; + while (at < columns.length) { + const cap = Math.max(1, wanted(columns.length - at, Math.max(1, pages - chunks.length))); + let widest = time; + let take = 1; + for (let end = at; end < columns.length && end - at + 1 <= cap; end++) { + const next = Math.max(widest, widths[end]); + if (end - at + 1 > capacityFor(next)) break; + widest = next; + take = end - at + 1; + } + chunks.push(columns.slice(at, at + take)); + at += take; + } + return chunks; + }; + + const greedy = pack(() => Number.POSITIVE_INFINITY, 1); + if (greedy.length <= 1) return greedy; + // The same pages, filled evenly. Greedily packed, an eighty-six column export + // ended on a page holding the time column and one other, because the three + // before it had taken fifteen each. + const balanced = pack((left, pagesLeft) => Math.ceil(left / pagesLeft), greedy.length); + return balanced.length <= greedy.length ? balanced : greedy; +} + +/** + * What a string measures, when there is no browser to ask. + * + * Only the fallback: every glyph here was measured once in one browser, and the + * whole point of `Measure` is not to have to trust that anywhere it matters. + */ +export function estimateWidth(text: string, bold = false): number { + let width = 0; + for (const character of text) { + width += + character === '.' + ? POINT_WIDTH + : character === '-' + ? SIGN_WIDTH + : /[0-9]/.test(character) + ? DIGIT_WIDTH + : LETTER_WIDTH; + } + // A head is set in medium, which is a shade wider than the numbers under it. + return bold ? width * 1.03 : width; +} + +/** + * Split into pages that are as full as each other rather than as full as + * possible. + * + * Nine pages of forty and a tenth holding one is the same paper as six pages of + * sixty; spread evenly it is six pages of sixty and no orphan at all. Taking + * the most each time is not enough on its own — fifty-eight graphs eight to a + * page is seven full pages and one holding two — so the remainder is shared out + * a page at a time. + */ +function spread<T>(items: T[], most: number): T[][] { + if (items.length === 0) return []; + const pages = Math.max(1, Math.ceil(items.length / most)); + const base = Math.floor(items.length / pages); + const over = items.length % pages; + const out: T[][] = []; + let at = 0; + for (let page = 0; page < pages; page++) { + const size = base + (page < over ? 1 : 0); + out.push(items.slice(at, at + size)); + at += size; + } + return out; +} + +/** + * The Report format: the mechanism, what it was solved under, its graphs, and + * every row the CSV would have held. + * + * Written as a printable HTML document rather than assembled with a PDF + * library. The browser already has a typesetter and a PDF writer in it, the + * pages are laid out here rather than left to reflow, and a reader who wanted + * paper instead of a file gets that from the same command. + */ +export function reportHtml(context: ReportContext): string { + const bodies: { body: string; footer: string }[] = []; + context.sections.forEach((section) => { + const plan = planSection( + { ...section, plots: section.plots.length }, + context.measure ?? estimateWidth + ); + plan.graphPages.forEach((graphs, at) => { + const plots = section.plots.slice(graphs.from, graphs.from + graphs.count); + bodies.push({ + body: + at === 0 + ? firstPageBody(context, section, { plots, across: graphs.across }) + : `${sectionHead(context, section.graphsTitle, section.subtitle)} + <div class="graphs across${graphs.across}">${plots.map(graphBlock).join('')}</div>`, + footer: section.footer, + }); + }); + plan.tablePages.forEach((page) => { + bodies.push({ body: dataPageBody(context, section, page), footer: section.footer }); + }); + }); + + const pages = bodies.map((entry, at) => page(entry.body, entry.footer, at + 1, bodies.length)); + const title = context.documentTitle || context.sections[0]?.title || 'PMKS+ report'; + return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml( + title + )}${pages.join('')}`; +} + +/** + * How long the report will be, before anyone waits for it to be built. + * + * The table is every row the CSV would hold, which is the point of the format + * and also how a wide selection becomes a hundred pages. Counted from the same + * plan that lays the report out, because a promise made by different arithmetic + * from the one that keeps it is a promise that drifts. + */ +export function reportPages( + section: { plots: number; rows: string[][]; heads: string[] }, + measure: Measure = estimateWidth +): number { + const plan = planSection(section, measure); + return plan.graphPages.length + plan.tablePages.length; +} + +/** Which graphs and which numbers land on which page. */ +function planSection( + section: { plots: number; rows: string[][]; heads: string[] }, + measure: Measure +): { + graphPages: { from: number; count: number; across: 2 | 3 }[]; + tablePages: { columns: number[]; rows: string[][] }[]; +} { + const tablePages = columnChunks(section.heads, section.rows, measure).flatMap((chunkOfColumns) => + // Paginated per chunk, because a chunk whose heads are short leaves room + // for more rows than one whose heads wrap down half the page. + spread( + section.rows, + rowsPerPage( + [section.heads[0], ...chunkOfColumns.map((at) => section.heads[at])], + chunkOfColumns.length, + measure + ) + ).map((rows) => ({ columns: chunkOfColumns, rows })) + ); + return { graphPages: planGraphs(section.plots), tablePages }; +} + +/** + * Which graphs go on which page, and how many across. + * + * A fifth graph used to go on a page of its own, nine tenths of it white. Set + * three across instead, the first page takes six — so a report with no more + * graphs than that has no graph page at all. + */ +function planGraphs(plots: number): { from: number; count: number; across: 2 | 3 }[] { + if (plots === 0) return [{ from: 0, count: 0, across: 2 }]; + if (plots <= GRAPHS_ON_FIRST_PAGE) return [{ from: 0, count: plots, across: 2 }]; + if (plots <= GRAPHS_ON_FIRST_PAGE_TIGHT) return [{ from: 0, count: plots, across: 3 }]; + // Three across on the first page too, where the two it makes room for are the + // two that would otherwise start a page of their own. + const pagesAfter = (first: number): number => Math.ceil((plots - first) / GRAPHS_PER_PAGE); + const onFirst = + pagesAfter(GRAPHS_ON_FIRST_PAGE_TIGHT) < pagesAfter(GRAPHS_ON_FIRST_PAGE) + ? GRAPHS_ON_FIRST_PAGE_TIGHT + : GRAPHS_ON_FIRST_PAGE; + const across: 2 | 3 = onFirst === GRAPHS_ON_FIRST_PAGE_TIGHT ? 3 : 2; + let from = onFirst; + const rest = spread( + Array.from({ length: plots - onFirst }, (_, at) => at), + GRAPHS_PER_PAGE + ).map((page) => { + const entry = { from, count: page.length, across: 2 as const }; + from += page.length; + return entry; + }); + return [{ from: 0, count: onFirst, across }, ...rest]; +} + +function sectionHead(context: ReportContext, title: string, subtitle: string): string { + return `
${escapeHtml( + title + )}
${escapeHtml( + subtitle + )}
`; +} + +function graphBlock(plot: ReportPlot): string { + return `
${escapeHtml( + plot.title + )}
${plot.legend + .map( + (entry) => + `${escapeHtml( + entry.name + )}` + ) + .join('')}${escapeHtml( + plot.unit + )}
${plot.svg}
`; +} + +function firstPageBody( + context: ReportContext, + section: ReportSection, + graphs: { plots: ReportPlot[]; across: 2 | 3 } +): string { + const facts = section.facts + .map( + (fact) => + `${escapeHtml(fact.label)}${escapeHtml( + fact.value + )}` + ) + .join(''); + return `${sectionHead(context, section.title, section.subtitle)} +
${section.drawing}
+
Solved under
${facts}
+
Every graph and every row below comes from this one solve. Reopen the mechanism at ${escapeHtml( + section.shareUrl + )}
+ ${ + graphs.plots.length > 0 + ? `
Graphs
${graphs.plots.map(graphBlock).join('')}
` + : '' + }`; +} + +function dataPageBody( + context: ReportContext, + section: ReportSection, + table: { columns: number[]; rows: string[][] } +): string { + const heads = [0, ...table.columns] + .map((at) => `${escapeHtml(section.heads[at])}`) + .join(''); + const body = table.rows + .map( + (row) => + `${[0, ...table.columns].map((at) => `${escapeHtml(row[at])}`).join('')}` + ) + .join(''); + const split = table.columns.length < section.heads.length - 1; + const note = split + ? `
Columns ${table.columns[0]}–${ + table.columns[table.columns.length - 1] + } of ${section.heads.length - 1}. The rest are on their own pages.
` + : ''; + return `${sectionHead( + context, + section.dataTitle, + `${section.heads.length - 1} columns, ${section.rows.length} rows, one row per solved position` + )} + ${heads}${body}
${note}`; +} + +function page(body: string, footer: string, number: number, total: number): string { + return `
${body}
PMKS+ · app.pmksplus.com${escapeHtml( + footer + )}Page ${number} of ${total}
`; +} + +function escapeHtml(text: string): string { + return escapeXml(text ?? ''); +} + +function styles(): string { + return ` +@page { size: letter; margin: 0.5in; } +* { box-sizing: border-box; } +/* A printer drops backgrounds unless it is told not to, which took the colour + out of every legend swatch on the page and left the reader three unnamed + lines. */ +body { margin: 0; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; color: #2c2c2c; -webkit-print-color-adjust: exact; print-color-adjust: exact; } +* { -webkit-print-color-adjust: exact; print-color-adjust: exact; } +.page { + width: 7.5in; + /* A shade under the 10in a Letter page leaves at half-inch margins. A block + exactly the height of the printable area rounds over it in some printers + and lays a blank page after every real one, which is where the empty pages + in the first cut of this came from. */ + height: 9.85in; + display: flex; + flex-direction: column; + page-break-after: always; + break-after: page; + overflow: hidden; +} +.page:last-child { page-break-after: auto; } +.pageBody { flex: 1 1 auto; min-height: 0; } +.head { display: flex; align-items: flex-start; gap: 16px; padding-bottom: 10px; border-bottom: 1px solid #eceef5; } +.title { font-size: 19px; font-weight: 500; } +.subtitle { font-size: 12px; color: rgba(0,0,0,0.55); } +.logo { margin-left: auto; height: 34px; } +.topRow { display: flex; gap: 18px; padding: 14px 0; } +.drawing { flex: 0 0 auto; } +.solvedUnder { flex: 1 1 auto; min-width: 0; } +.panelTitle { font-size: 12px; font-weight: 500; color: #5f6368; text-transform: uppercase; letter-spacing: 0.06em; } +.factGrid { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; padding-top: 6px; font-size: 12px; } +.factLabel { color: rgba(0,0,0,0.55); } +.factValue { color: #2c2c2c; } +.shareNote { padding-top: 10px; font-size: 11px; line-height: 15px; color: rgba(0,0,0,0.55); } +.shareUrl { font-family: 'Roboto Mono', Menlo, monospace; word-break: break-all; color: #3f51b5; } +.graphsTitle { padding-top: 6px; } +.graphs { display: grid; gap: 8px; padding-top: 8px; } +.graphs.across2 { grid-template-columns: 1fr 1fr; } +.graphs.across3 { grid-template-columns: 1fr 1fr 1fr; } +.graph { border: 1px solid #eceef5; border-radius: 4px; padding: 6px; } +.graph svg { width: 100%; height: auto; } +.graphTitle { font-size: 12px; font-weight: 500; } +.graphLegend { display: flex; align-items: center; gap: 10px; font-size: 11px; color: rgba(0,0,0,0.55); padding: 2px 0 4px; } +.graphLegend .spacer { flex: 1 1 auto; } +.swatch { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 4px; border: 0.5px solid rgba(0,0,0,0.15); } +.dataTable { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 8.5px; font-variant-numeric: tabular-nums; margin-top: 8px; } +/* A head is several words and a column is one number wide, so the head wraps + rather than setting the width of everything under it. */ +.dataTable th { text-align: right; font-weight: 500; color: #5f6368; border-bottom: 1px solid #d5d7e0; padding: 2px; line-height: 11px; word-break: break-word; } +.dataTable td { text-align: right; padding: 1px 2px; border-bottom: 1px solid #f7f8fc; line-height: 10px; } +/* Time is the column every page repeats, and the one a reader scans down. */ +.dataTable th:first-child, .dataTable td:first-child { text-align: left; } +.tableNote { padding-top: 8px; font-size: 10px; color: rgba(0,0,0,0.5); } +.pageFoot { flex: 0 0 auto; display: flex; align-items: center; gap: 10px; padding-top: 8px; border-top: 1px solid #eceef5; font-size: 10px; color: rgba(0,0,0,0.5); } +.pageFoot .spacer { flex: 1 1 auto; } +.pageNumber { font-variant-numeric: tabular-nums; } +`; +} diff --git a/src/app/services/export/xlsx-writer.ts b/src/app/services/export/xlsx-writer.ts new file mode 100644 index 00000000..20b17ba3 --- /dev/null +++ b/src/app/services/export/xlsx-writer.ts @@ -0,0 +1,116 @@ +import { Decimals } from './export-model'; +import { ExportTable } from './export-table.service'; +import { formatCell } from './csv-writer'; +import { utf8, zipStore } from './zip'; +import { escapeXml } from './xml'; + +/** + * A workbook, written by hand. + * + * The minimum an .xlsx can be: a content-type map, two relationship files, a + * workbook naming the sheets, and a sheet per table with its strings inline. + * No styles, no shared strings, no calc chain — a reader opens this to chart + * columns of numbers, and every part beyond these is one Excel supplies itself. + */ +export function toXlsx(tables: ExportTable[], decimals: Decimals): Uint8Array { + const names = sheetNames(tables); + const parts = [ + { name: '[Content_Types].xml', data: utf8(contentTypes(tables.length)) }, + { name: '_rels/.rels', data: utf8(rootRels()) }, + { name: 'xl/workbook.xml', data: utf8(workbook(names)) }, + { name: 'xl/_rels/workbook.xml.rels', data: utf8(workbookRels(tables.length)) }, + ...tables.map((table, at) => ({ + name: `xl/worksheets/sheet${at + 1}.xml`, + data: utf8(sheet(table, decimals)), + })), + ]; + return zipStore(parts); +} + +/** + * Sheet names Excel will actually accept: 31 characters, no `[]:*?/\`, unique. + * + * A name it refuses is not a warning but a file that will not open at all, so + * this is cleaned rather than trusted. + */ +export function sheetNames(tables: ExportTable[]): string[] { + const used = new Set(); + return tables.map((table, at) => { + const cleaned = table.name.replace(/[[\]:*?/\\]/g, '-').slice(0, 31) || `Sheet${at + 1}`; + let name = cleaned; + let n = 2; + while (used.has(name.toLowerCase())) { + const suffix = `_${n++}`; + name = cleaned.slice(0, 31 - suffix.length) + suffix; + } + used.add(name.toLowerCase()); + return name; + }); +} + +function contentTypes(sheets: number): string { + const overrides = Array.from( + { length: sheets }, + (_, at) => + `` + ).join(''); + return `${overrides}`; +} + +function rootRels(): string { + return ``; +} + +function workbook(names: string[]): string { + const sheets = names + .map((name, at) => ``) + .join(''); + return `${sheets}`; +} + +function workbookRels(sheets: number): string { + const rels = Array.from( + { length: sheets }, + (_, at) => + `` + ).join(''); + return `${rels}`; +} + +function sheet(table: ExportTable, decimals: Decimals): string { + const rows: string[] = [ + `${table.heads + .map( + (head, at) => + `${escapeXml( + head + )}` + ) + .join('')}`, + ]; + table.times.forEach((time, row) => { + const cells = [ + formatCell(time, decimals), + ...table.columns.map((c) => formatCell(c[row], decimals)), + ] + .map((value, at) => + value === '' ? '' : `${value}` + ) + .join(''); + rows.push(`${cells}`); + }); + return `${rows.join( + '' + )}`; +} + +/** `A1`, `Z1`, `AA1` — the address a cell has to carry to be read back. */ +export function cellRef(column: number, row: number): string { + let name = ''; + let at = column; + do { + name = String.fromCharCode(65 + (at % 26)) + name; + at = Math.floor(at / 26) - 1; + } while (at >= 0); + return `${name}${row}`; +} diff --git a/src/app/services/export/xml.ts b/src/app/services/export/xml.ts new file mode 100644 index 00000000..eb0ac349 --- /dev/null +++ b/src/app/services/export/xml.ts @@ -0,0 +1,9 @@ +/** The five characters that cannot be written raw into XML or SVG. */ +export function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/app/services/export/zip.ts b/src/app/services/export/zip.ts new file mode 100644 index 00000000..db839ea4 --- /dev/null +++ b/src/app/services/export/zip.ts @@ -0,0 +1,107 @@ +/** + * A ZIP container, stored rather than compressed. + * + * An .xlsx is a zip of XML parts, and writing one is the whole reason this + * exists — a spreadsheet is the second thing every reader of this app asks + * for, and a dependency that pulls in a compressor to save a few kilobytes of + * text is a poor trade against a hundred lines that can be read in full. + * + * Deflate is deliberately not implemented: method 0 (stored) is part of the + * format and every unzipper accepts it, and Excel does not care. + */ +export interface ZipEntry { + name: string; + data: Uint8Array; +} + +/** A fixed timestamp, so the same selection writes byte-identical files. */ +const DOS_TIME = 0; +const DOS_DATE = 0x5021; // 1 January 2020, in the packed form the header expects. + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +export function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +export function utf8(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +/** The archive, ready to be handed to the browser as one download. */ +export function zipStore(entries: ZipEntry[]): Uint8Array { + const locals: Uint8Array[] = []; + const centrals: Uint8Array[] = []; + let offset = 0; + + entries.forEach((entry) => { + const name = utf8(entry.name); + const crc = crc32(entry.data); + const local = block(30 + name.length); + local.view.setUint32(0, 0x04034b50, true); + local.view.setUint16(4, 20, true); + local.view.setUint16(6, 0x0800, true); // Names are UTF-8. + local.view.setUint16(8, 0, true); // Stored. + local.view.setUint16(10, DOS_TIME, true); + local.view.setUint16(12, DOS_DATE, true); + local.view.setUint32(14, crc, true); + local.view.setUint32(18, entry.data.length, true); + local.view.setUint32(22, entry.data.length, true); + local.view.setUint16(26, name.length, true); + local.bytes.set(name, 30); + locals.push(local.bytes, entry.data); + + const central = block(46 + name.length); + central.view.setUint32(0, 0x02014b50, true); + central.view.setUint16(4, 20, true); + central.view.setUint16(6, 20, true); + central.view.setUint16(8, 0x0800, true); + central.view.setUint16(10, 0, true); + central.view.setUint16(12, DOS_TIME, true); + central.view.setUint16(14, DOS_DATE, true); + central.view.setUint32(16, crc, true); + central.view.setUint32(20, entry.data.length, true); + central.view.setUint32(24, entry.data.length, true); + central.view.setUint16(28, name.length, true); + central.view.setUint32(42, offset, true); + central.bytes.set(name, 46); + centrals.push(central.bytes); + + offset += local.bytes.length + entry.data.length; + }); + + const directory = concat(centrals); + const end = block(22); + end.view.setUint32(0, 0x06054b50, true); + end.view.setUint16(8, entries.length, true); + end.view.setUint16(10, entries.length, true); + end.view.setUint32(12, directory.length, true); + end.view.setUint32(16, offset, true); + return concat([...locals, directory, end.bytes]); +} + +function block(size: number): { bytes: Uint8Array; view: DataView } { + const bytes = new Uint8Array(size); + return { bytes, view: new DataView(bytes.buffer) }; +} + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let at = 0; + parts.forEach((part) => { + out.set(part, at); + at += part.length; + }); + return out; +} diff --git a/src/app/services/grid-utils.service.spec.ts b/src/app/services/grid-utils.service.spec.ts index f6b2ffb1..ef620461 100644 --- a/src/app/services/grid-utils.service.spec.ts +++ b/src/app/services/grid-utils.service.spec.ts @@ -1,4 +1,5 @@ import '../model/joint'; +import { createMechanismHarness } from '../../test-utils/mechanism-harness'; import { Injector } from '@angular/core'; import { Coord } from '../model/coord'; import { Force } from '../model/force'; @@ -13,24 +14,11 @@ import { SettingsService } from './settings.service'; import { SvgGridService } from './svg-grid.service'; import { DragStateService } from './drag-state.service'; import { SynthesisBuilderService } from './synthesis/synthesis-builder.service'; +import { silentNotifications } from '../../test-utils/notification-stub'; function createHarness() { - if (!ColorService.instance) new ColorService(); - const settings = new SettingsService(); - const parser = new NumberUnitParserService(); - // GridUtilsService resolves MechanismService at call time, so it has to be - // handed an injector that reads the binding below rather than a finished one. - let service!: MechanismService; - const grid = new GridUtilsService( - new SynthesisBuilderService(parser, settings), - new SvgGridService(settings, new DragStateService(), {} as unknown as Injector), - { get: () => service } as unknown as Injector - ); - const active = new ActiveObjService(); - let saves = 0; - const injector = { get: () => ({ save: () => saves++ }) } as unknown as Injector; - service = new MechanismService(grid, active, injector, settings, parser); - return { service, grid, active, settings, saveCount: () => saves }; + const harness = createMechanismHarness(); + return { ...harness, grid: harness.injector.get(GridUtilsService) }; } function wire(id: string, joints: RevJoint[]): RealLink { @@ -82,6 +70,8 @@ describe('GridUtilsService.dragLink', () => { it('carries a hand-placed centre of mass along instead of recomputing it', () => { const scene = createFourBar(); scene.bc.CoM = new Coord(1, 2.9); + // Placing it by hand is exactly what the custom flag records. + scene.bc.comIsCustom = true; scene.grid.dragLink(scene.bc, 1.5, -0.5); @@ -192,3 +182,69 @@ describe('GridUtilsService.dragLink', () => { expect([prismatic.x, prismatic.y]).toEqual([scene.c.x, scene.c.y]); }); }); + +/** + * What a link drag becomes when exactly one of the joints it would carry is + * locked: the body swings about that joint. The rigid-body promises are the + * same ones dragLink makes — same shape, same passengers — plus the one the + * pivot adds: the held joint does not move at all. + */ +describe('GridUtilsService.rotateLink', () => { + const QUARTER = Math.PI / 2; + + it('holds the pivot exactly still and swings the other joint about it', () => { + const scene = createFourBar(); + + scene.grid.rotateLink(scene.bc, new Coord(scene.b.x, scene.b.y), QUARTER); + + expect([scene.b.x, scene.b.y]).toEqual([0, 2]); + // C was at (4,3), which is (4,1) from B; a quarter turn puts it at (-1,4) from B. + expect(scene.c.x).toBeCloseTo(-1, 6); + expect(scene.c.y).toBeCloseTo(6, 6); + }); + + it('preserves the body: lengths survive the turn', () => { + const scene = createFourBar(); + const before = Math.hypot(scene.c.x - scene.b.x, scene.c.y - scene.b.y); + + scene.grid.rotateLink(scene.bc, new Coord(scene.b.x, scene.b.y), 0.37); + + expect(Math.hypot(scene.c.x - scene.b.x, scene.c.y - scene.b.y)).toBeCloseTo(before, 6); + }); + + it('turns a force on the body with the body, direction included', () => { + const scene = createFourBar(); + const force = new Force('F1', scene.bc, new Coord(2, 2.5), new Coord(3, 2.5), false, true, 10); + scene.bc.forces.push(force); + scene.service.forces.push(force); + + scene.grid.rotateLink(scene.bc, new Coord(scene.b.x, scene.b.y), QUARTER); + + // (2,2.5) is (2,0.5) from B(0,2); a quarter turn lands it at (-0.5,2) from B. + expect(force.startCoord.x).toBeCloseTo(-0.5, 6); + expect(force.startCoord.y).toBeCloseTo(4, 6); + // The arrow pointed +x along the body; after a quarter turn it points +y. + expect(force.endCoord.x - force.startCoord.x).toBeCloseTo(0, 6); + expect(force.endCoord.y - force.startCoord.y).toBeCloseTo(1, 6); + }); + + it('deforms the neighbours rather than carrying them', () => { + const scene = createFourBar(); + + scene.grid.rotateLink(scene.bc, new Coord(scene.b.x, scene.b.y), QUARTER); + + expect([scene.a.x, scene.a.y]).toEqual([0, 0]); + expect([scene.d.x, scene.d.y]).toEqual([5, 0]); + expect(scene.cd.length).toBeCloseTo(Math.hypot(scene.c.x - 5, scene.c.y), 6); + }); + + it('does nothing at all for a zero turn', () => { + const scene = createFourBar(); + const rebuild = vi.spyOn(scene.service, 'updateMechanism'); + + scene.grid.rotateLink(scene.bc, new Coord(0, 2), 0); + + expect([scene.c.x, scene.c.y]).toEqual([4, 3]); + expect(rebuild).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/services/grid-utils.service.ts b/src/app/services/grid-utils.service.ts index e6151e61..eb1f0d4e 100644 --- a/src/app/services/grid-utils.service.ts +++ b/src/app/services/grid-utils.service.ts @@ -1,17 +1,6 @@ -import { Injectable, Injector } from '@angular/core'; +import { Injectable, Injector, inject } from '@angular/core'; import { Joint, PrisJoint, RealJoint, RevJoint } from '../model/joint'; -import { - gridStates, - jointStates, - linkStates, - forceStates, - shapeEditModes, - createModes, - moveModes, - roundNumber, - getDistance, - point_on_line_segment_closest_to_point, -} from '../model/utils'; +import { roundNumber, point_on_line_segment_closest_to_point } from '../model/utils'; import { Link, SliderBlock, RealLink } from '../model/link'; import { Cylinder, @@ -19,19 +8,18 @@ import { layoutCylinder, poseFromStrokeAndStart, sealedCylinderStructures, + stretchedCylinderPose, } from '../model/cylinder'; import { SettingsService } from './settings.service'; import { MechanismService } from './mechanism.service'; import { canDrive } from '../model/actuator'; -import { ToolbarComponent } from '../component/toolbar/toolbar.component'; -import { Mechanism } from '../model/mechanism/mechanism'; +import { Lockable, frozenJointIds, locksHolding } from '../model/lock-set'; import { Coord } from '../model/coord'; import { PositionSolver } from '../model/mechanism/position-solver'; 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'; @@ -74,11 +62,9 @@ function pointThroughFrame( providedIn: 'root', }) export class GridUtilsService { - constructor( - private synthesisBuilder: SynthesisBuilderService, - public svgGrid: SvgGridService, - private injector: Injector - ) {} + private synthesisBuilder = inject(SynthesisBuilderService); + svgGrid = inject(SvgGridService); + private injector = inject(Injector); /** * MechanismService injects this service, so it can only be resolved at call @@ -238,8 +224,71 @@ export class GridUtilsService { return joint.isWelded || joint.links.length >= 2; } + /** + * Why Weld is greyed on this joint, short and long. + * + * The branches of `canToggleWeld`, read back out. The control and its reason + * come from one place so a menu cannot grey a row it has no explanation for, + * or explain one it left enabled. + */ + weldRefusal(joint: Joint): { short: string; long: string } | undefined { + if (this.canToggleWeld(joint)) return undefined; + if (!(joint instanceof RealJoint)) { + return { short: 'not a joint', long: 'Only a joint can be welded.' }; + } + const sealed = this.mechanismSrv.cylinderAt(joint); + if (sealed && (joint.id === sealed.barrelFar.id || joint.id === sealed.rodFar.id)) { + return { + short: 'part is sealed', + long: 'A cylinder is one sealed part, so its joints cannot be fused into a neighbouring body. Attach a link here instead.', + }; + } + if (joint.input) { + return { + short: 'it is driven', + long: 'A weld says these bodies do not move relative to each other, and an input says they do. Remove the input first.', + }; + } + // A loose joint has none at all, and telling it "only one meets here" is + // a sentence about a link that is not there. + const meeting = joint.links.length; + return { + short: 'needs 2 links', + long: + meeting === 0 + ? 'A weld fuses the links that meet at a joint, and this joint is on none.' + : 'A weld fuses the links that meet at a joint, and only one meets here.', + }; + } + + /** + * The joints the current Lock marks hold still. Derived fresh each time — + * the mechanism is small and the closure is cheap — so every asker (the + * drag gates, the canvas paint, the panel) reads the same answer with no + * cache to invalidate. + */ + frozenJointIds(): Set { + return frozenJointIds(this.mechanismSrv.joints, this.mechanismSrv.links); + } + + isJointFrozen(joint: Joint): boolean { + return this.frozenJointIds().has(joint.id); + } + + /** The locked objects an Unlock action has to clear for this joint to move. */ + locksHolding(joint: Joint): Lockable[] { + return locksHolding(joint.id, this.mechanismSrv.joints, this.mechanismSrv.links); + } + dragJoint(selectedJoint: RealJoint, trueCoord: Coord) { - // console.error('new drag Joint cycle'); + // The last line of defence, not the first: the canvas refuses at the + // grab and the panel greys its fields, but every route to "move this + // joint" — distance fields aimed at a neighbour, the linkage table, a + // caller not yet written — lands here, and a held joint holds whoever + // asks. + if (this.frozenJointIds().has(selectedJoint.id)) { + return selectedJoint; + } // TODO: have the round Number be integrated within function for determining trueCoord // A cylinder mount never free-moves, whoever asks — canvas drag, the @@ -321,16 +370,25 @@ export class GridUtilsService { const jointIndex = l.joints.findIndex((jt) => jt.id === selectedJoint.id); l.joints[jointIndex].x = roundNumber(trueCoord.x, 6); l.joints[jointIndex].y = roundNumber(trueCoord.y, 6); - // l.reComputeDPath(); - l.CoM = RealLink.determineCenterOfMass(l.joints); - l.updateCoMDs(); + // A dragged joint deforms the link, so an auto centre of mass + // follows the geometry. A custom one stays where its author put it: + // there is no rigid motion to carry it, and guessing would move a + // number somebody chose. + if (!l.comIsCustom) { + l.CoM = RealLink.determineCenterOfMass(l.joints); + l.updateCoMDs(); + } l.updateLengthAndAngle(); if (l.subset.length > 0) { l.subset.forEach((slink) => { let subLink = slink as RealLink; - subLink.CoM = RealLink.determineCenterOfMass(subLink.joints); - subLink.updateCoMDs(); + // Same rule as the root: a member's hand-placed centre survives + // for the unweld that will one day restore it. + if (!subLink.comIsCustom) { + subLink.CoM = RealLink.determineCenterOfMass(subLink.joints); + subLink.updateCoMDs(); + } subLink.updateLengthAndAngle(); }); } @@ -417,7 +475,33 @@ export class GridUtilsService { if (dx === 0 && dy === 0) { return selectedLink; } + return this.moveLinkRigidly(selectedLink, (x, y) => ({ x: x + dx, y: y + dy })); + } + + /** + * Turn a whole link about a point, carrying everything rigidly attached. + * + * This is what a link drag becomes when exactly one of the joints it would + * carry is locked: the body cannot translate without moving the held joint, + * but it can swing about it — which is also the only motion the real linkage + * would allow if that pin were bolted down. + */ + rotateLink(selectedLink: Link, pivot: Coord, theta: number) { + if (theta === 0) { + return selectedLink; + } + const cos = Math.cos(theta); + const sin = Math.sin(theta); + return this.moveLinkRigidly(selectedLink, (x, y) => ({ + x: pivot.x + (x - pivot.x) * cos - (y - pivot.y) * sin, + y: pivot.y + (x - pivot.x) * sin + (y - pivot.y) * cos, + })); + } + private moveLinkRigidly( + selectedLink: Link, + mapPoint: (x: number, y: number) => { x: number; y: number } + ) { // A neighbour's forces are placed relative to its own two reference joints, // so where they end up depends on where those joints were before the move. // Captured up front, because the move is about to overwrite them. @@ -446,8 +530,9 @@ export class GridUtilsService { const moveJoint = (joint: Joint) => { if (movedJointIDs.has(joint.id)) return; movedJointIDs.add(joint.id); - joint.x = roundNumber(joint.x + dx, 6); - joint.y = roundNumber(joint.y + dy, 6); + const at = mapPoint(joint.x, joint.y); + joint.x = roundNumber(at.x, 6); + joint.y = roundNumber(at.y, 6); }; selectedLink.joints.forEach((joint) => { @@ -460,9 +545,9 @@ export class GridUtilsService { }); }); - this.translateLinkBody(selectedLink, dx, dy); + this.transformLinkBody(selectedLink, mapPoint); if (selectedLink instanceof RealLink) { - selectedLink.subset.forEach((sub) => this.translateLinkBody(sub, dx, dy)); + selectedLink.subset.forEach((sub) => this.transformLinkBody(sub, mapPoint)); } // Any other link holding one of the moved joints has been deformed, not @@ -480,12 +565,30 @@ export class GridUtilsService { force.moveForceTo(x, y); }); } - link.CoM = RealLink.determineCenterOfMass(link.joints); + // A custom centre of mass rides the body through the same change of + // frame the forces just did — it is a point somebody fixed to this link. + // An auto one is re-derived from the joints like always, and a frame too + // degenerate to transport through leaves a custom point untouched. + if (link.comIsCustom) { + if (from.length === 2 && start && end) { + const [comX, comY] = pointThroughFrame(link.CoM, from[0], from[1], start, end); + link.CoM = new Coord(comX, comY); + } + } else { + link.CoM = RealLink.determineCenterOfMass(link.joints); + } link.updateCoMDs(); link.updateLengthAndAngle(); link.subset.forEach((sub) => { const subLink = sub as RealLink; - subLink.CoM = RealLink.determineCenterOfMass(subLink.joints); + if (subLink.comIsCustom) { + if (from.length === 2 && start && end) { + const [subX, subY] = pointThroughFrame(subLink.CoM, from[0], from[1], start, end); + subLink.CoM = new Coord(subX, subY); + } + } else { + subLink.CoM = RealLink.determineCenterOfMass(subLink.joints); + } subLink.updateCoMDs(); subLink.updateLengthAndAngle(); }); @@ -501,17 +604,15 @@ export class GridUtilsService { // untouched mount and no two of them are writing to the same joint. carriedCylinders.forEach(({ sealed, barrelLength }) => { if (movedJointIDs.has(sealed.pin.id)) return; - const movedBarrelMount = movedJointIDs.has(sealed.barrelFar.id); - const movedRodMount = movedJointIDs.has(sealed.rodFar.id); - if (!movedBarrelMount && !movedRodMount) return; - const pose = layoutCylinder( + if (!movedJointIDs.has(sealed.barrelFar.id) && !movedJointIDs.has(sealed.rodFar.id)) return; + // Both mounts held: they are where the drag put them, and the ram + // resizes between them if it has to reach. Anchoring on one of them and + // recomputing the other put a mount somewhere the drag had not asked for. + const pose = stretchedCylinderPose( { x: sealed.barrelFar.x, y: sealed.barrelFar.y }, { x: sealed.rodFar.x, y: sealed.rodFar.y }, barrelLength, - 0.15 * SettingsService.objectScale, - // Anchor on the mount that did NOT ride along; if both did, the whole - // axis translated and either anchor reproduces it. - movedBarrelMount ? 'rod' : 'barrel' + 0.15 * SettingsService.objectScale ); if (pose) this.applyCylinderPose(sealed, pose); }); @@ -594,6 +695,27 @@ export class GridUtilsService { }); } + /** + * Swing the whole assembly about a point — a rotation is rigid, so + * collinearity survives and the pose lands as-is. This is a body drag with + * one mount locked: the part cannot translate, but it can turn on the mount. + */ + rotateCylinder(sealed: Cylinder, pivot: Coord, theta: number): void { + if (theta === 0) return; + const cos = Math.cos(theta); + const sin = Math.sin(theta); + const turn = (point: { x: number; y: number }) => ({ + x: pivot.x + (point.x - pivot.x) * cos - (point.y - pivot.y) * sin, + y: pivot.y + (point.x - pivot.x) * sin + (point.y - pivot.y) * cos, + }); + this.applyCylinderPose(sealed, { + barrelFar: turn(sealed.barrelFar), + barrelNear: turn(sealed.barrelNear), + pin: turn(sealed.pin), + rodFar: turn(sealed.rodFar), + }); + } + /** * Land a pose on the assembly's five joints (the slider rides the pin), * then rebuild what depends on them — member links, genuinely deformed @@ -623,7 +745,59 @@ export class GridUtilsService { this.applyCylinderPose(sealed, pose); } + /** + * Land a pose, and carry any ram bolted to what just moved. + * + * Two rams can share a mount: the first's rod end is the second's barrel end. + * Moving the first moves that joint without the second being asked, and the + * second was then holding a barrel of the wrong length with its head as far + * outside it as the stretch -- on screen, a part in two pieces with a gap + * down the middle. It re-lays itself between its own two mounts instead, + * resizing to reach: both halves together, so both of its ends move, which is + * what a drag on a ram's own mount has always done past its stops. + * + * `dragLink` has always repaired this for a link drag. Every path that poses + * a cylinder needs it, which is all of them: dragging the body, dragging a + * mount, and the Travel and Starts-at fields in its panel. + * + * One level deep, as `dragLink` is: a third ram bolted to the second follows + * on the next rebuild rather than in this one. + */ private applyCylinderPose(sealed: Cylinder, pose: CylinderPose): void { + // Every other ram's rigid barrel length, read while its geometry is still + // straight -- rebuilding from a bent intermediate state is what bakes the + // split in. + const others = sealedCylinderStructures(this.mechanismSrv.joints) + .filter((other) => other.pin.id !== sealed.pin.id) + .map((other) => ({ + other, + barrelLength: this.getPointDistance( + other.barrelFar.x, + other.barrelFar.y, + other.barrelNear.x, + other.barrelNear.y + ), + })); + + const movedIds = this.placeCylinder(sealed, pose); + + others.forEach(({ other, barrelLength }) => { + if (!movedIds.has(other.barrelFar.id) && !movedIds.has(other.rodFar.id)) return; + const carried = stretchedCylinderPose( + { x: other.barrelFar.x, y: other.barrelFar.y }, + { x: other.rodFar.x, y: other.rodFar.y }, + barrelLength, + 0.15 * SettingsService.objectScale + ); + if (carried) this.placeCylinder(other, carried); + }); + + this.mechanismSrv.reseatFloatingSliders(); + this.mechanismSrv.updateMechanism(false); + } + + /** One assembly's five joints, and everything derived from them. */ + private placeCylinder(sealed: Cylinder, pose: CylinderPose): Set { const placements: [Joint, { x: number; y: number }][] = [ [sealed.barrelFar, pose.barrelFar], [sealed.barrelNear, pose.barrelNear], @@ -657,28 +831,55 @@ export class GridUtilsService { force.moveForceTo(x, y); }); } - link.CoM = RealLink.determineCenterOfMass(link.joints); + // A custom centre of mass rides the body through the same change of + // frame the forces just did — it is a point somebody fixed to this link. + // An auto one is re-derived from the joints like always, and a frame too + // degenerate to transport through leaves a custom point untouched. + if (link.comIsCustom) { + if (from.length === 2 && start && end) { + const [comX, comY] = pointThroughFrame(link.CoM, from[0], from[1], start, end); + link.CoM = new Coord(comX, comY); + } + } else { + link.CoM = RealLink.determineCenterOfMass(link.joints); + } link.updateCoMDs(); link.updateLengthAndAngle(); link.subset.forEach((sub) => { const subLink = sub as RealLink; - subLink.CoM = RealLink.determineCenterOfMass(subLink.joints); + if (subLink.comIsCustom) { + if (from.length === 2 && start && end) { + const [subX, subY] = pointThroughFrame(subLink.CoM, from[0], from[1], start, end); + subLink.CoM = new Coord(subX, subY); + } + } else { + subLink.CoM = RealLink.determineCenterOfMass(subLink.joints); + } subLink.updateCoMDs(); subLink.updateLengthAndAngle(); }); PositionSolver.setUpInitialJointLocations(link.joints); }); - this.mechanismSrv.reseatFloatingSliders(); - this.mechanismSrv.updateMechanism(false); + return movedIds; } - private translateLinkBody(link: Link, dx: number, dy: number) { - link.forces.forEach((force) => - force.moveForceTo(force.startCoord.x + dx, force.startCoord.y + dy) - ); + private transformLinkBody( + link: Link, + mapPoint: (x: number, y: number) => { x: number; y: number } + ) { + // Both endpoints through the map, not just the anchor: a load is fixed to + // the body it acts on, so a body that turns takes the arrow's direction + // round with it — under a pure translation this collapses to the old move. + link.forces.forEach((force) => { + const start = mapPoint(force.startCoord.x, force.startCoord.y); + const end = mapPoint(force.endCoord.x, force.endCoord.y); + force.moveAnchor(new Coord(start.x, start.y)); + force.moveDirectionHandle(new Coord(end.x, end.y)); + }); if (!(link instanceof RealLink)) return; - link.CoM = new Coord(link.CoM.x + dx, link.CoM.y + dy); + const centre = mapPoint(link.CoM.x, link.CoM.y); + link.CoM = new Coord(centre.x, centre.y); link.updateCoMDs(); link.updateLengthAndAngle(); PositionSolver.setUpInitialJointLocations(link.joints); @@ -763,14 +964,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); @@ -795,8 +988,21 @@ export class GridUtilsService { return joint.connectedJoints.find((j) => j instanceof PrisJoint); } + /** + * Turn a joint's traced path on, or off. + * + * A prismatic joint answers for itself. It used to fall through both arms of + * this and change nothing at all -- `containsSlider` is false of a prismatic + * joint, and it is not a RevJoint -- which mattered the moment the menu + * started offering a Trace Path row on one: a switch that flips nothing. + */ toggleCurve(lastRightClick: Joint | Link | Force | String) { - console.log(this.getSliderJoint(lastRightClick as RealJoint)! as PrisJoint); + if (lastRightClick instanceof PrisJoint) { + lastRightClick.showCurve = !lastRightClick.showCurve; + return; + } + // A pin that rides a block draws its path through the block's prismatic + // half, so that is the flag the drawing reads. if (this.containsSlider(lastRightClick as RealJoint)) { (this.getSliderJoint(lastRightClick as RealJoint)! as PrisJoint).showCurve = !( lastRightClick as RealJoint @@ -805,7 +1011,6 @@ export class GridUtilsService { if (lastRightClick instanceof RevJoint) { lastRightClick.showCurve = !lastRightClick.showCurve; } - console.log(this.getSliderJoint(lastRightClick as RealJoint)! as PrisJoint); } getLinkSubset(link: Link): Link[] { @@ -829,15 +1034,11 @@ export class GridUtilsService { //First convert the screen coordinates to true coordinates let trueCoords = this.svgGrid.screenToSVG(new Coord(mouseEvent.offsetX, mouseEvent.offsetY)); - // console.log(trueCoords.x, trueCoords.y); - clickedObj.lastSelectedSublink = null; clickedObj.subset.forEach((link) => { if (this.isPointInsideLink(trueCoords, link as RealLink)) { clickedObj.lastSelectedSublink = link; - // console.log('Found a link'); - // console.log(link); } }); } diff --git a/src/app/services/keyboard-shortcuts.service.ts b/src/app/services/keyboard-shortcuts.service.ts new file mode 100644 index 00000000..89b0d265 --- /dev/null +++ b/src/app/services/keyboard-shortcuts.service.ts @@ -0,0 +1,304 @@ +import { Injectable, NgZone, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable, Subject } from 'rxjs'; + +/** + * Everything a key can ask for. The string is the shortcut's identity: the + * registry below names it, the component that owns the action listens for it, + * and a tooltip asks for its keys by it. + */ +export type ShortcutId = + | 'mode.synthesis' + | 'mode.edit' + | 'mode.kinematic' + | 'mode.force' + | 'playback.toggle' + | 'playback.back' + | 'playback.forward' + | 'playback.speed' + | 'view.zoomIn' + | 'view.zoomOut' + | 'view.reset' + | 'view.centerOfMass' + | 'view.jointIds' + | 'view.paths' + | 'edit.lock' + | 'edit.deselect' + | 'edit.delete' + | 'history.undo' + | 'history.redo' + | 'app.settings' + | 'app.help'; + +export interface Shortcut { + id: ShortcutId; + /** The heading this falls under wherever the whole set is listed. */ + section: 'Modes' | 'Playback' | 'View' | 'Editing' | 'General'; + /** What pressing it does, in the same words the control it doubles uses. */ + label: string; + /** The keys as a reader should see them, already in this platform's signs. */ + keys: string; + /** `event.key` values that fire it, lowercased. */ + match: string[]; + /** Whether Ctrl (or Command) has to be down. Undefined means "must not be". */ + meta?: boolean; +} + +/** Command on a Mac, Ctrl everywhere else -- shown the way the platform writes it. */ +function onAMac(): boolean { + if (typeof navigator === 'undefined') return false; + return /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); +} + +/** + * The keys, and what they ask for. + * + * One table, because a shortcut has to be two things at once and they must not + * drift: the key that fires it, and the hint on the control it doubles. A + * hint written into a template by hand is a hint nobody updates when the key + * moves -- so nothing here is written twice, and a control asks for its own + * hint by id. + * + * The actions are deliberately *not* here. They belong to the components that + * already own them, with the guards those components already apply -- a mode + * that is not ready opens its setup drawer rather than switching, and a + * transport with nothing to play stays put. A service reaching past them to do + * the work itself would have to repeat every one of those rules. + */ +@Injectable({ providedIn: 'root' }) +export class KeyboardShortcutsService { + private zone = inject(NgZone); + private dialog = inject(MatDialog); + private readonly presses = new Subject(); + + /** Fires when a shortcut's keys are pressed anywhere outside a text field. */ + readonly pressed: Observable = this.presses.asObservable(); + + private readonly mod = onAMac() ? '⌘' : 'Ctrl'; + + readonly shortcuts: Shortcut[] = [ + { id: 'mode.synthesis', section: 'Modes', label: 'Synthesis', keys: '1', match: ['1'] }, + { id: 'mode.edit', section: 'Modes', label: 'Edit', keys: '2', match: ['2'] }, + { + id: 'mode.kinematic', + section: 'Modes', + label: 'Kinematic Analysis', + keys: '3', + match: ['3'], + }, + { id: 'mode.force', section: 'Modes', label: 'Force Analysis', keys: '4', match: ['4'] }, + + { + id: 'playback.toggle', + section: 'Playback', + label: 'Play / Pause', + keys: 'Space', + match: [' ', 'spacebar'], + }, + { + id: 'playback.back', + section: 'Playback', + label: 'Step back one frame', + keys: '←', + match: ['arrowleft'], + }, + { + id: 'playback.forward', + section: 'Playback', + label: 'Step forward one frame', + keys: '→', + match: ['arrowright'], + }, + { + id: 'playback.speed', + section: 'Playback', + label: 'Cycle playback speed', + keys: 'S', + match: ['s'], + }, + + { id: 'view.zoomIn', section: 'View', label: 'Zoom In', keys: '+', match: ['+', '='] }, + { id: 'view.zoomOut', section: 'View', label: 'Zoom Out', keys: '−', match: ['-', '_'] }, + { id: 'view.reset', section: 'View', label: 'Reset View', keys: '0', match: ['0'] }, + { + id: 'view.centerOfMass', + section: 'View', + label: 'Center of mass', + keys: 'M', + match: ['m'], + }, + { id: 'view.jointIds', section: 'View', label: 'Joint IDs', keys: 'L', match: ['l'] }, + { id: 'view.paths', section: 'View', label: 'Traced paths', keys: 'P', match: ['p'] }, + + { + id: 'edit.lock', + section: 'Editing', + label: 'Lock / Unlock what is selected', + keys: 'K', + match: ['k'], + }, + { + id: 'edit.deselect', + section: 'Editing', + label: 'Deselect', + keys: 'Esc', + match: ['escape', 'esc'], + }, + { + id: 'edit.delete', + section: 'Editing', + label: 'Delete what is selected', + // Backspace as well as Delete: a MacBook has one key here and it sends + // Backspace, so Delete alone left the shortcut unreachable on the + // keyboard it is printed on. + keys: 'Delete', + match: ['delete', 'backspace'], + }, + { + id: 'history.undo', + section: 'Editing', + label: 'Undo', + keys: `${this.mod}Z`, + match: ['z'], + meta: true, + }, + { + id: 'history.redo', + section: 'Editing', + label: 'Redo', + keys: `${this.mod}⇧Z`, + match: ['y'], + meta: true, + }, + { + id: 'app.settings', + section: 'General', + label: 'Settings', + // The key every application puts preferences on, minus the modifier: + // nothing on this canvas is typed, so the plain comma is free. + keys: ',', + match: [','], + }, + { + id: 'app.help', + section: 'General', + label: 'Help, and this list', + keys: '?', + // The character, not the keystroke that makes it: which key and which + // modifiers produce a question mark is a matter of layout, and asking + // for Shift and the US layout's slash left it unreachable on the rest. + match: ['?'], + }, + ]; + + constructor() { + if (typeof window === 'undefined') return; + // Outside Angular, then back in only when a key actually matches: this + // listener sees every keystroke in the app, and a change-detection pass + // per keystroke is a pass per character typed into every field. + this.zone.runOutsideAngular(() => + window.addEventListener('keydown', (event) => this.onKeyDown(event)) + ); + } + + /** The keys for a control's tooltip, or nothing if it has no shortcut. */ + keysFor(id: ShortcutId): string { + return this.shortcuts.find((one) => one.id === id)?.keys ?? ''; + } + + /** + * A tooltip with its shortcut on the end: "Zoom In (+)". + * + * The name first and the key in brackets after it, because the name is what + * a reader came to the tooltip for and the key is what they leave with. + */ + tip(name: string, id: ShortcutId): string { + const keys = this.keysFor(id); + return keys ? `${name} (${keys})` : name; + } + + /** The whole set, grouped, for the list that teaches them. */ + bySection(): { section: string; shortcuts: Shortcut[] }[] { + const sections: string[] = []; + for (const one of this.shortcuts) { + if (!sections.includes(one.section)) sections.push(one.section); + } + return sections.map((section) => ({ + section, + shortcuts: this.shortcuts.filter((one) => one.section === section), + })); + } + + private onKeyDown(event: KeyboardEvent): void { + // 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 + // sight of the reader who pressed it. + if (this.somethingIsOver()) return; + + const key = event.key.toLowerCase(); + const held = event.ctrlKey || event.metaKey; + // Redo is Undo's key with Shift, as well as its own -- so it is looked for + // first, or the plain Undo match would swallow it. + const found = + held && key === 'z' && event.shiftKey + ? this.shortcuts.find((one) => one.id === 'history.redo') + : this.shortcuts.find((one) => one.match.includes(key) && !!one.meta === held); + if (!found) return; + // A shortcut that reached us is a shortcut we are answering: Space would + // otherwise scroll the page and Command-Z would reach the browser's own. + event.preventDefault(); + this.zone.run(() => this.presses.next(found.id)); + } + + /** + * Whether the canvas is covered by a conversation of its own. + * + * Only dialogs now. It used to also ask after the intro.js overlay, which + * dimmed the whole page; the tutorial that replaced it is a drawer page + * beside the canvas rather than over it, and the shortcuts stay live because + * the student is meant to be using them on the drawing while it is open. + */ + private somethingIsOver(): boolean { + return this.dialog.openDialogs.length > 0; + } + + /** Whether the keystroke was aimed at somewhere text is being entered. */ + private typingInAField(event: KeyboardEvent): boolean { + const target = event.target as HTMLElement | null; + if (!target) return false; + const tag = target.tagName; + // SELECT as well as the two that take typing: a dropdown answers to the + // keyboard the whole time it has focus -- letters jump to an option, and + // 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.spec.ts b/src/app/services/mechanism.service.spec.ts index f280206f..01899449 100644 --- a/src/app/services/mechanism.service.spec.ts +++ b/src/app/services/mechanism.service.spec.ts @@ -1,6 +1,7 @@ import '../model/joint'; -import { Injector } from '@angular/core'; +import { NotificationService } from './notification.service'; import { Coord } from '../model/coord'; +import { MODEL_SCALE } from '../model/render-scale'; import { Force } from '../model/force'; import { SliderBlock, RealLink } from '../model/link'; import { PrisJoint, RealJoint, RevJoint } from '../model/joint'; @@ -11,13 +12,12 @@ import { GridUtilsService } from './grid-utils.service'; import { MechanismService } from './mechanism.service'; import { NumberUnitParserService } from './number-unit-parser.service'; import { SettingsService } from './settings.service'; -import { SvgGridService } from './svg-grid.service'; -import { DragStateService } from './drag-state.service'; import { UrlGenerationService } from './url-generation.service'; import { MechanismBuilder } from './transcoding/mechanism-builder'; import { StringTranscoder } from './transcoding/string-transcoder'; -import { SynthesisBuilderService } from './synthesis/synthesis-builder.service'; import { NewGridComponent } from '../component/new-grid/new-grid.component'; +import { createMechanismHarness } from '../../test-utils/mechanism-harness'; +import { encodeUrlOf } from '../../test-utils/url-encoding'; interface Harness { service: MechanismService; @@ -28,22 +28,8 @@ interface Harness { } function createHarness(): Harness { - if (!ColorService.instance) new ColorService(); - const settings = new SettingsService(); - const parser = new NumberUnitParserService(); - const svg = new SvgGridService(settings, new DragStateService(), {} as unknown as Injector); - const synthesis = new SynthesisBuilderService(parser, settings); - // GridUtilsService resolves MechanismService at call time, so it has to be - // handed an injector that reads the binding below rather than a finished one. - let service!: MechanismService; - const grid = new GridUtilsService(synthesis, svg, { - get: () => service, - } as unknown as Injector); - const active = new ActiveObjService(); - let saves = 0; - const injector = { get: () => ({ save: () => saves++ }) } as unknown as Injector; - service = new MechanismService(grid, active, injector, settings, parser); - return { service, active, settings, grid, saveCount: () => saves }; + const harness = createMechanismHarness(); + return { ...harness, grid: harness.injector.get(GridUtilsService) }; } function createChain(jointCount = 3) { @@ -83,6 +69,11 @@ describe('MechanismService welded links and force ownership', () => { harness.links[1].mass = 1; harness.links[1].massMoI = 4; harness.links[1].CoM = new Coord(1.5, 0); + // Chosen values, so the auto pass leaves them alone — as the panel flags. + for (const link of [harness.links[0], harness.links[1]]) { + (link as RealLink).moiIsCustom = true; + (link as RealLink).comIsCustom = true; + } const force = attachForce(harness.service, harness.links[0], 'F1', 0.25); harness.service.weldJoint(harness.joints[1]); @@ -95,7 +86,16 @@ describe('MechanismService welded links and force ownership', () => { expect(compound.mass).toBe(3); expect(compound.CoM.x).toBeCloseTo(5 / 6, 12); expect(compound.CoM.y).toBe(0); - expect(compound.massMoI).toBeCloseTo(23 / 3, 12); + // Parallel axis in *stored* units: distances here are model coordinates + // (MODEL_SCALE user units) and the metric store is kg·cm² against gram + // masses, so each m·d² term carries 0.001 / MODEL_SCALE². The old + // expectation (23/3) baked in the unconverted sum — the bug that welded + // two 1 g bars into tens of thousands of kg·cm². + const parallelAxis = 0.001 / MODEL_SCALE ** 2; + expect(compound.massMoI).toBeCloseTo( + 3 + 4 + (2 * (0.5 - 5 / 6) ** 2 + 1 * (1.5 - 5 / 6) ** 2) * parallelAxis, + 12 + ); compound.reComputeDPath(); expect(compound.CoM.x).toBeCloseTo(5 / 6, 12); expect(force.link).toBe(compound); @@ -199,6 +199,8 @@ describe('MechanismService welded links and force ownership', () => { link.mass = 2; link.massMoI = 3; link.CoM = new Coord(0.5, 0); + link.moiIsCustom = true; + link.comIsCustom = true; const force = attachForce(harness.service, link, 'F1', 0.25); harness.settings.lengthUnit.next(LengthUnit.METER); @@ -232,6 +234,8 @@ describe('MechanismService welded links and force ownership', () => { link.mass = 2; link.massMoI = 3; link.CoM = new Coord(0.5, 0); + link.moiIsCustom = true; + link.comIsCustom = true; const force = attachForce(harness.service, link, 'F1', 0.25); const original = { x: harness.joints[1].x, @@ -255,6 +259,9 @@ describe('MechanismService welded links and force ownership', () => { it('converts the RPM setting once at the mechanism boundary and preserves direction', () => { const harness = createChain(); + // Grounded, so the chain is a machine at all: a component that never + // reaches ground has no solvable position and is not built as a mechanism. + harness.joints[0].ground = true; harness.settings.inputSpeed.next(60); harness.settings.lengthUnit.next(LengthUnit.METER); harness.settings.isInputCW.next(false); @@ -382,11 +389,7 @@ describe('MechanismService joint merging', () => { const scene = createOpenFourBar(); scene.service.mergeJoints(scene.e, scene.c); - const encoded = new UrlGenerationService( - scene.service, - scene.settings, - scene.active - ).generateUrlQuery(); + const encoded = encodeUrlOf(scene.service, scene.settings); const decoder = new StringTranscoder(); decoder.decodeURL(encoded); const restored = { @@ -600,12 +603,13 @@ describe('MechanismService declining a weld that pins a pair twice', () => { it('names the pair it just pinned twice', () => { const scene = triangle(); - const notify = vi.spyOn(NewGridComponent, 'sendNotification').mockImplementation(() => {}); + const notify = vi.spyOn(NotificationService.prototype, 'warning').mockImplementation(() => {}); scene.service.weldJoint(scene.b); expect(notify).toHaveBeenCalledTimes(1); - expect(notify.mock.calls[0][0]).toMatch(/\bA and C\b/); + // The id is first now, the sentence second. + expect(notify.mock.calls[0][1]).toMatch(/\bA and C\b/); notify.mockRestore(); }); @@ -633,7 +637,7 @@ describe('MechanismService declining a weld that pins a pair twice', () => { wire('XY', [x, y]), wire('YZ', [y, z]), ]; - const notify = vi.spyOn(NewGridComponent, 'sendNotification').mockImplementation(() => {}); + const notify = vi.spyOn(NotificationService.prototype, 'warning').mockImplementation(() => {}); harness.service.weldJoint(y); diff --git a/src/app/services/mechanism.service.ts b/src/app/services/mechanism.service.ts index 467bb140..d8370877 100644 --- a/src/app/services/mechanism.service.ts +++ b/src/app/services/mechanism.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Injector } from '@angular/core'; +import { Injectable, Injector, inject } from '@angular/core'; import { Joint, PrisJoint, RealJoint, RevJoint } from '../model/joint'; import { Link, SliderBlock, RealLink } from '../model/link'; import { isSlideCandidate, slideAssemblyAt } from '../model/slide-assembly'; @@ -7,9 +7,7 @@ import { cylinderCreationLayout, cylinderJoints, cylinderStrokeAlong, - cylinderOfJoint, cylinderOfJointIn, - cylinderOfLink, cylinderOfLinkIn, isCylinderInterior, normalizedCylinderPose, @@ -17,34 +15,47 @@ import { structuralCylinderAt, } from '../model/cylinder'; import { Force } from '../model/force'; +import { + DriveProfile, + driveProfileOf as buildDriveProfile, + fractionalSampleAlong, +} from '../model/mechanism/drive-profile'; import { Mechanism } from '../model/mechanism/mechanism'; -import { ToolbarComponent } from '../component/toolbar/toolbar.component'; +import { + MechanismPartition, + partitionMechanisms, + UnassignedGeometry, +} from '../model/mechanism/mechanism-partition'; +import { + describeUnassigned, + ForceRequirement, + MechanismReadiness, + readinessOf, + UnassignedReport, +} from '../model/mechanism/readiness'; import { InstantCenter } from '../model/instant-center'; import { - gridStates, jointStates, - linkStates, - forceStates, - shapeEditModes, - createModes, - moveModes, roundNumber, LengthUnit, point_on_line_segment_closest_to_point, getDistance, distance_points, } from '../model/utils'; -import { BehaviorSubject, connect, Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import { GridUtilsService } from './grid-utils.service'; import { ActiveObjService } from './active-obj.service'; -import { AnimationBarComponent } from '../component/animation-bar/animation-bar.component'; import { NewGridComponent } from '../component/new-grid/new-grid.component'; -import { canDrive, describeActuator } from '../model/actuator'; +import { describeActuator } from '../model/actuator'; +import { NotificationService } from './notification.service'; import { SettingsService } from './settings.service'; import { slotHalfLength } from '../model/joint-marks'; +import { uniformBodyOf } from '../model/uniform-body'; +import { siUnitFactors } from '../model/unit-conversions'; import { DragStateService } from './drag-state.service'; import { Coord } from '../model/coord'; -import { Line } from '../model/line'; +import { SelectedTabService, TabID } from '../selected-tab.service'; +import { frozenJointIds } from '../model/lock-set'; import { SaveHistoryService } from './save-history.service'; import { NumberUnitParserService } from './number-unit-parser.service'; import { PositionSolver, SAMPLES_PER_STROKE } from '../model/mechanism/position-solver'; @@ -54,6 +65,15 @@ import { transformRigidCoord, transformRigidPath } from '../model/compound-link- import { MergeRefusal, refuseJointMerge } from '../model/drop-target'; import { redundantlyHeldJointSets } from '../model/rigid-bodies'; import { MODEL_SCALE } from '../model/render-scale'; +import { labelForBody } from '../model/body-label'; + +/** + * The names joints are given, in the order they are handed out. + * + * Letters only, and only ones that survive a round trip through the URL codec, + * where a joint's name is a token in a comma- and period-delimited payload. + */ +const JOINT_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; /** Blend two angles along the shorter arc, so a wrap past pi does not spin. */ function blendAngle(from: number, to: number, blend: number): number { @@ -67,7 +87,23 @@ function blendAngle(from: number, to: number, blend: number): number { providedIn: 'root', }) export class MechanismService { + gridUtils = inject(GridUtilsService); + activeObjService = inject(ActiveObjService); + private injector = inject(Injector); + private settingsService = inject(SettingsService); + private nup = inject(NumberUnitParserService); + private notify = inject(NotificationService); + public mechanismTimeStep: number = 0; + /** + * Is the animation running? + * + * On the service that owns playback. It used to be a static on the animation + * bar, which was a component: the flag outlived the bar's own rendering by a + * whole redesign, and every part of the app that needed to know whether the + * mechanism was moving had to import a piece of chrome to ask. + */ + public isPlaying: boolean = false; /** Playback rate relative to real time. 1 means one simulated second per second. */ public animationSpeedMultiplier: number = 1; public joints: Joint[] = []; @@ -75,9 +111,17 @@ export class MechanismService { public forces: Force[] = []; public ics: InstantCenter[] = []; public mechanisms: Mechanism[] = []; + /** + * Which part of the drawing each entry of `mechanisms` was built from, in the + * same order. Kept beside the solved mechanisms rather than inside them + * because these are the *editable* objects the grid draws and the panels + * mutate; a Mechanism holds deep copies at every timestep. + */ + public partitions: MechanismPartition[] = []; + /** Geometry that is in no mechanism, and why. Reported, never solved. */ + public unassigned: UnassignedGeometry = { floatingChains: [], looseJoints: [], fixedLinks: [] }; public showPathHolder: boolean = true; - // private moveModes: moveModes = moveModes; // private selectedJoint!: RealJoint; // This is the state of the mechanism @@ -97,16 +141,41 @@ export class MechanismService { private static readonly FRAME_INTERVAL_MS = 16; private playbackClockMs: number | null = null; private playbackTimeSeconds = 0; + /** + * Whether every machine runs on the one clock. + * + * Synced is the default and the useful case: two mechanisms on the same wall + * clock can be compared, which is the whole reason for drawing them together. + * Unsynced gives each its own time and its own play button, for reading one + * machine's cycle without the others moving. + */ + syncMechanisms = true; + /** + * Each machine's own place in its own cycle, in seconds from its start pose. + * + * Always -- not only while unsynced. The machines were sharing one clock and + * one running flag, which is why pausing or scrubbing one of them moved the + * others. `syncMechanisms` decides how many rows the transport offers, and + * nothing else now. + */ + private ownSeconds: number[] = []; + /** Which machines are running. */ + private ownPlaying: boolean[] = []; + /** + * Which way each machine's playback runs, +1 or -1. + * + * Only ever -1 for a machine whose input reverses on its own. Turning a + * continuous drive round is an edit -- the solved cycle itself is mirrored -- + * but a machine that already goes out and back has no other direction to be + * driven in, so the only thing "reverse" can mean there is to play the cycle + * backwards. That is a view of the same motion, not a different mechanism, + * which is why it lives here and not in the URL. + */ + private playbackDirection: number[] = []; private playbackFrameQueued = false; private advancingPlayback = false; - - constructor( - public gridUtils: GridUtilsService, - public activeObjService: ActiveObjService, - private injector: Injector, - private settingsService: SettingsService, - private nup: NumberUnitParserService - ) {} + /** Set while one row is being seeked, so the seek does not spread. */ + private seekingOneMechanism = false; /** * Recompute every link outline after the object scale changed. @@ -170,6 +239,7 @@ export class MechanismService { // within a revision the topology cannot have changed. this.cylinderRevision++; this.poseRevision++; + this.solveRevision++; Force.normalizeVisualWidths(this.forces); // Changing the input speed re-samples the same geometry onto a different time // axis. Hold the simulation time rather than the sample index, so t and the pose @@ -177,6 +247,27 @@ export class MechanismService { // rewinding, which is what the held time is measured against. The drawn time, // not the sample's: during playback it carries the sub-sample fraction. const heldTime = this.currentTimeSeconds(); + // Each machine's own place, not just the master's. Restoring only the + // shared time put every machine back on the master's clock, so any edit -- + // a speed change, a reversal, a joint moved -- silently pulled them all + // back into step with each other. + const heldEach = this.partitions.map((partition, index) => ({ + id: partition.id, + seconds: this.ownSeconds[index] ?? 0, + })); + // Which machines are only running backwards because their drive was turned + // round without re-solving them. + // + // `reverseDrive` leaves the frames alone and walks them backwards instead, + // which is what keeps a reader's place on the chart. The rebuild below + // solves fresh frames *from the drive's new sign*, so they already run the + // new way and that compensation becomes a second reversal: the machine + // went back to turning the way it originally did while the stored speed + // said the opposite. + const heldCompensation = this.partitions.map((partition, index) => ({ + id: partition.id, + compensating: this.mechanisms[index]?.framesRunBackwards === true, + })); this.restoreStartPose(); // The sealed-cylinder invariant is enforced HERE, at the one funnel every @@ -191,28 +282,6 @@ export class MechanismService { this.links.forEach((link) => { if (link instanceof RealLink) link.reComputeDPath(); }); - // console.log(this.mechanisms[0]); - //There are multiple mechanisms since there was a plan to support multiple mechanisms - //You can treat this as a single mechanism for now at index 0 - - this.mechanisms = []; - // TODO: Determine logic later once everything else is determined - // Settings exposes RPM to users and persistence; solvers use rad/s. - // - // A prismatic input is a different quantity, not another unit of the same - // one: its speed is length per second, so it comes from its own setting and - // never meets the pi/30 conversion -- which used to run on it anyway, - // leaving a driven block travelling at a tenth of the speed the panel - // reported. What it does need is the MODEL_SCALE the solvers measure length - // in; an angular speed has no length in it to want one. - const drivenJoint = this.joints.find((j) => j instanceof RealJoint && j.input); - let inputAngularVelocity = - drivenJoint instanceof PrisJoint - ? this.settingsService.linearInputSpeed.value * MODEL_SCALE - : (this.settingsService.inputSpeed.value * Math.PI) / 30; - if (this.settingsService.isInputCW.value) { - inputAngularVelocity = inputAngularVelocity * -1; - } let unitStr = 'cm'; switch (this.settingsService.lengthUnit.value) { case LengthUnit.INCH: @@ -222,27 +291,267 @@ export class MechanismService { unitStr = 'm'; break; } - this.mechanisms.push( - //This creates a new mechanism with the current state of the joints, links, forces, and ics - //If the mechnaism is simulatable, it will generate loops and all future time steps - new Mechanism( - this.joints, - this.links, - this.forces, - this.ics, - true, - unitStr, - inputAngularVelocity - ) + + this.applyUniformBodyProperties(unitStr); + + // One machine per grounded component of the drawing. Each is solved on its + // own -- its own mobility, its own input, its own cycle -- so a half-built + // chain in the corner no longer makes the finished linkage beside it + // unsolvable. + const partitioning = partitionMechanisms(this.joints, this.links, this.forces); + this.partitions = partitioning.mechanisms; + this.unassigned = partitioning.unassigned; + this.rebuildOwnerIndex(); + this.mechanisms = this.partitions.map( + //If the mechanism is simulatable, it will generate loops and all future time steps + (partition) => + new Mechanism( + partition.joints, + partition.links, + partition.forces, + this.ics, + this.settingsService.isGravity.value, + unitStr, + this.inputVelocityFor(partition) + ) ); + // The frames are new and run the drive's way, so anything that was walking + // the old ones backwards to make up for a reversal stops. A machine whose + // playback the reader turned round themselves -- a rocking one, which + // reverses by playback alone -- was never compensating and is left as it is. + this.partitions.forEach((partition, index) => { + const was = heldCompensation.find((entry) => entry.id === partition.id); + if (was?.compensating) { + this.playbackDirection[index] = this.directionOf(index) < 0 ? 1 : -1; + } + }); this.activeObjService.fakeUpdateSelectedObj(); this.reseekToTime(heldTime); + this.restoreOwnTimes(heldEach); if (save) { this.save(); } } + /** + * How fast this mechanism's own input is driven, in the units its solver wants. + * + * Settings exposes RPM to users and persistence; solvers use rad/s. A + * prismatic input is a different quantity, not another unit of the same one: + * its speed is length per second, so it comes from its own setting and never + * meets the pi/30 conversion -- which used to run on it anyway, leaving a + * driven block travelling at a tenth of the speed the panel reported. What it + * does need is the MODEL_SCALE the solvers measure length in; an angular + * speed has no length in it to want one. + * + * Asked per mechanism rather than once for the drawing, because which of the + * two quantities applies depends on what *this* machine is driven by: a + * cylinder beside a crank must not be handed the crank's rpm. + */ + private inputVelocityFor(partition: MechanismPartition): number { + const driven = partition.joints.find((j) => j instanceof RealJoint && j.input) as + RealJoint | undefined; + const signed = this.driveSpeedOf(driven); + // rpm for a pin, length per second for a slider -- two different + // quantities, and only the second has a length in it wanting MODEL_SCALE. + return driven instanceof PrisJoint ? signed * MODEL_SCALE : (signed * Math.PI) / 30; + } + + /** + * How fast a joint drives its mechanism, signed for direction, in the units + * its kind of drive is measured in. + * + * One definition, read by the solver and shown by the panel. Zero on the + * joint means "follow the document-wide default", which is what a URL written + * before mechanisms could be driven separately says, and what a joint just + * switched on says too. + */ + driveSpeedOf(joint: RealJoint | undefined): number { + if (joint && joint.driveSpeed !== 0) { + return joint.driveSpeed; + } + const magnitude = + joint instanceof PrisJoint + ? this.settingsService.linearInputSpeed.value + : this.settingsService.inputSpeed.value; + return this.settingsService.isInputCW.value ? -magnitude : magnitude; + } + + /** + * Set the speed of one mechanism's drive. + * + * The document-wide default follows along, so the next joint switched on + * starts where the reader last was rather than at whatever the app shipped + * with -- and so a drawing holding one mechanism behaves exactly as it did + * when the speed really was one number. + */ + setDriveSpeed(joint: RealJoint, signed: number): void { + if (!Number.isFinite(signed) || signed === 0) { + return; + } + joint.driveSpeed = signed; + const magnitude = Math.abs(signed); + if (joint instanceof PrisJoint) { + this.settingsService.linearInputSpeed.next(magnitude); + } else { + this.settingsService.inputSpeed.next(magnitude); + } + this.settingsService.isInputCW.next(signed < 0); + } + + /** + * Take the drive away from whatever currently has it in this joint's machine. + * + * One input per *mechanism*, not one per drawing. Clearing every input in the + * document -- which is what this used to do -- meant that driving a second + * linkage stopped the first, so two machines could never run at once however + * well the solver handled them. + */ + private clearInputsSharingMechanismWith(joint: RealJoint): void { + const index = this.indexOfMechanismContaining(joint); + const scope = index === -1 ? this.joints : this.partitions[index].ownJoints; + scope.forEach((candidate) => { + if (candidate instanceof RealJoint && candidate.input && candidate.id !== joint.id) { + candidate.input = false; + } + }); + } + + /** + * Which machine each part belongs to, worked out once per rebuild. + * + * This question is asked of every joint and every link on every change + * detection pass -- the canvas asks it to decide each part's colour, the + * panels ask it to decide whose clock they are reading -- and answering it by + * searching the partitions is quadratic in the size of the drawing. On a + * forty-five joint linkage that was tens of thousands of id comparisons per + * frame, which is what made the stress test crawl and what made *anything* + * that redraws, the settings drawer included, crawl along with it. + */ + private ownerOfPart = new Map(); + + /** Every joint some link is made of, for the same reason. */ + private jointsOnALink = new Set(); + + private rebuildOwnerIndex(): void { + const attached = new Set(); + this.links.forEach((link) => link.joints.forEach((joint) => attached.add(joint))); + this.jointsOnALink = attached; + + const owner = new Map(); + // In partition order, first writer wins: a fixed bar between two frames + // puts its far end in both, and `ownJoints` is what says whose it is. + this.partitions.forEach((partition, index) => { + const claim = (part: Joint | Link | Force) => { + if (!owner.has(part)) owner.set(part, index); + }; + partition.ownJoints.forEach(claim); + partition.links.forEach(claim); + partition.forces.forEach(claim); + }); + this.ownerOfPart = owner; + } + + /** Which mechanism holds this joint, link or force — none, if it is unassigned. */ + indexOfMechanismContaining(part: Joint | Link | Force): number { + // Optional because this method is also borrowed onto a stub in the test + // fixtures, which has partitions but never ran a rebuild. + const known = this.ownerOfPart?.get(part); + if (known !== undefined) return known; + // A part the index has not seen -- one made since the last rebuild, or a + // copy carrying the same id. Answer it the slow, always-correct way. + const id = part.id; + return this.partitions.findIndex( + (partition) => + partition.ownJoints.some((joint) => joint.id === id) || + partition.links.some((link) => link.id === id) || + partition.forces.some((force) => force.id === id) + ); + } + + /** The partition this part belongs to, if it belongs to one. */ + partitionContaining(part: Joint | Link | Force): MechanismPartition | undefined { + const index = this.indexOfMechanismContaining(part); + return index === -1 ? undefined : this.partitions[index]; + } + + /** The solved mechanism this part belongs to, if it belongs to one. */ + mechanismContaining(part: Joint | Link | Force): Mechanism | undefined { + const index = this.indexOfMechanismContaining(part); + return index === -1 ? undefined : this.mechanisms[index]; + } + + /** + * The readiness of the machine this part belongs to, or nothing when it + * belongs to none. + * + * Per part rather than per drawing. With several machines on one grid, "is + * anything analysable" is the wrong question to ask about the joint under + * the pointer: a drawing can hold a four-bar that runs and a half-drawn + * chain that does not, and offering analysis from the half-drawn one is an + * offer the analysis modes will not honour. + */ + readinessOfPart(part: Joint | Link | Force): MechanismReadiness | undefined { + const index = this.indexOfMechanismContaining(part); + return index === -1 ? undefined : this.readinessOfEachMechanism()[index]; + } + + /** + * Whether this joint has any force to graph — the reaction index's answer, + * not a count of what looks like it meets here. + * + * The count is wrong for a floating slider. Its carrier is deliberately not + * in `joint.links` (the slider rides the bar rather than being one of its + * members), so counting links says one body meets a joint where the solver + * generates a reaction against two. The panel reads the index; so does this, + * or the menu greys a row the panel would have filled. + */ + jointHasForceToGraph(joint: Joint): boolean { + if (!(joint instanceof RealJoint)) return false; + // A driven joint always has one: the effort that drives it. + if (joint.input) return true; + const solved = this.mechanismContaining(joint); + if (!solved?.isMechanismValid()) return false; + const mode = this.settingsService.forceAnalysisMode.value; + const index = solved.getForceAnalysis(mode).reactionIndex; + return (index.linksByJoint.get(joint.id) ?? []).length > 0; + } + + /** Can this part's own machine be simulated? Says nothing about the others. */ + isPartSimulatable(part: Joint | Link | Force): boolean { + return this.mechanismContaining(part)?.isMechanismValid() ?? false; + } + + /** + * The mechanism whose cycle is longest, which is the one the shared clock is + * measured against: run every machine on the same wall clock and the master + * timeline has to be long enough to hold the slowest of them. + */ + masterMechanismIndex(): number { + let best = -1; + let longest = -1; + this.mechanisms.forEach((mechanism, index) => { + if (!mechanism.isMechanismValid()) { + return; + } + if (mechanism.cyclePeriod > longest) { + longest = mechanism.cyclePeriod; + best = index; + } + }); + // Nothing runs, so nothing is really the master. Naming the first anyway + // keeps the timeline reporting the empty clock an unsolved mechanism has + // always reported, instead of a missing one. + return best === -1 && this.mechanisms.length > 0 ? 0 : best; + } + + /** The mechanism the shared scrubber is measured against, if any can run. */ + masterMechanism(): Mechanism | undefined { + const index = this.masterMechanismIndex(); + return index === -1 ? undefined : this.mechanisms[index]; + } + /** * Make every sealed cylinder collinear again, whatever wrote its joints. * @@ -290,8 +599,10 @@ export class MechanismService { for (const link of this.links) { if (!(link instanceof RealLink)) continue; if (!link.joints.some((joint) => movedIds.has(joint.id))) continue; - link.CoM = RealLink.determineCenterOfMass(link.joints); - link.updateCoMDs(); + if (!link.comIsCustom) { + link.CoM = RealLink.determineCenterOfMass(link.joints); + link.updateCoMDs(); + } link.updateLengthAndAngle(); } } @@ -302,7 +613,7 @@ export class MechanismService { * time held from a slower cycle inside the new, shorter one. */ private reseekToTime(seconds: number) { - if (!this.mechanisms[0]?.isMechanismValid()) { + if (!this.oneValidMechanismExists()) { // The rebuild can invalidate the mechanism; a step left pointing into the // old cycle would keep the editor gated on a time that no longer exists. this.mechanismTimeStep = 0; @@ -313,13 +624,67 @@ export class MechanismService { return; } const wrapped = this.wrapTime(seconds); - this.animate(this.stepAtTime(wrapped), AnimationBarComponent.animate); + this.animate(this.stepAtTime(wrapped), this.isPlaying); // animate() treats any external call as a seek and snaps its clock to the // sample, so restore the sub-sample fraction afterwards — playback resumes // from exactly the held time, not the nearest sample. this.playbackTimeSeconds = wrapped; } + /** + * Put each machine back where it was after a rebuild. + * + * By partition id, not by index: a rebuild can add, remove or reorder + * mechanisms, and pairing them positionally would hand one machine another's + * place in its cycle. + */ + private restoreOwnTimes(held: { id: string; seconds: number }[]): void { + if (!this.oneValidMechanismExists()) return; + this.partitions.forEach((partition, index) => { + const was = held.find((entry) => entry.id === partition.id); + const period = this.mechanisms[index]?.cyclePeriod ?? 0; + if (!was || !(period > 0) || !(was.seconds > 0)) return; + this.ownSeconds[index] = ((was.seconds % period) + period) % period; + }); + this.applyPose(); + } + + /** + * Run something with the drawing parked at its start pose, and put every + * machine back afterwards. + * + * Encoding a URL is a round trip through t = 0, because that is the pose the + * format stores. Getting there and back used to go through `animate`, which + * treats an outside call as a seek of the whole drawing -- so saving, which + * happens on every edit, quietly pulled every machine onto the master's + * clock. That is most of what "the mechanisms are not independent" was. + */ + encodeFromStartPose(encode: (heldStep: number) => T): T { + const held = this.ownSeconds.slice(); + const step = this.mechanismTimeStep; + const playing = this.isPlaying; + // Any machine away from its own start, not just the one the shared handle + // reports. Unsynced, each keeps its own clock: with the master parked at + // zero and another machine scrubbed a quarter of the way round, the rewind + // was skipped and the encoder wrote down the pose that machine happened to + // be *displaying* as the pose it starts in. The linkage the reader shared + // arrived at the other end a different shape. + if (step > 0 || held.some((seconds) => seconds > 0)) { + this.animate(0, false); + } + try { + return encode(step); + } finally { + this.ownSeconds = held; + this.seekingOneMechanism = true; + try { + this.animate(step, playing); + } finally { + this.seekingOneMechanism = false; + } + } + } + save() { const saveHistoryService = this.injector.get(SaveHistoryService); saveHistoryService.save(); @@ -347,6 +712,10 @@ export class MechanismService { link.massMoI *= inertiaScale; link.CoM.x *= lengthScale; link.CoM.y *= lengthScale; + // The stored along/across offset is in model lengths too; re-read it + // from the correctly scaled point, or the next rebuild would derive + // the CoM from a stale offset and throw it lengthScale times as far. + if (link.comIsCustom) link.captureComOffset(); link.subset.forEach(updateLink); link.updateLengthAndAngle(); link.updateCoMDs(); @@ -365,26 +734,6 @@ export class MechanismService { }); this.updateMechanism(true); - // this.settingsService.lengthUnit.subscribe((val) => { - //For each jo - // let unit = this.settingsService.lengthUnit.value; - // if (unit !== this.lengthUnit) { - // this.mechanismService.joints.forEach((joint) => { - // this.activeSrv.updateSelectedObj(joint); - // this.activeSrv.fakeUpdateSelectedObj(); - // this.gridUtils.dragJoint( - // this.activeSrv.selectedJoint, - // new Coord( - // this.nup.convertLength(joint.x, this.lengthUnit, unit), - // this.nup.convertLength(joint.y, this.lengthUnit, unit) - // ) - // ); - // this.jointForm.controls['input'].patchValue(wasInput); - // }); - // this.lengthUnit = this.settingsService.lengthUnit.value; - // this.activeSrv.fakeUpdateSelectedObj(); - // } - // }); } getLinkProp(l: Link, propType: string) { @@ -431,16 +780,18 @@ export class MechanismService { * Nothing to draw yet is an ordinary state; the honest answer is no path. */ getJointPath(joint: Joint) { - const solved = this.mechanisms[0]; + // Its own mechanism, found by id: a joint traces the cycle of the machine + // it belongs to, and looking its position up by index into another + // machine's frames would draw some other joint's path under it. + const solved = this.mechanismContaining(joint); if (!solved || solved.joints.length === 0 || solved.joints[0].length === 0) { return ''; } - const jointIndex = this.joints.findIndex((j) => j.id === joint.id); - if (jointIndex < 0 || !solved.joints[0][jointIndex]) { + if (!solved.joints[0].some((candidate) => candidate.id === joint.id)) { return ''; } const at = (step: number) => { - const sample = solved.joints[step][jointIndex]; + const sample = solved.joints[step].find((candidate) => candidate.id === joint.id)!; return `${sample.x} , ${sample.y}`; }; let string = 'M' + at(0); @@ -450,11 +801,20 @@ export class MechanismService { return string; } + /** + * Can anything in this drawing be simulated? + * + * "Any" rather than "all" on purpose: one half-built chain in the corner must + * not lock a finished linkage out of analysis. Where the question is really + * about a particular part, ask `isPartSimulatable` instead. + */ oneValidMechanismExists() { - if (this.mechanisms.length == 0 || this.mechanisms[0] === undefined) { - return false; - } - return this.mechanisms[0].isMechanismValid(); + return this.mechanisms.some((mechanism) => mechanism?.isMechanismValid()); + } + + /** Every mechanism in the drawing is ready. The stricter question. */ + allMechanismsValid(): boolean { + return this.mechanisms.length > 0 && this.mechanisms.every((m) => m.isMechanismValid()); } mergeToJoints(joints: Joint[]) { @@ -469,22 +829,127 @@ export class MechanismService { }); } - determineNextLetter(additionalLetters?: string[]) { - let lastLetter = ''; - if (this.joints.length === 0 && additionalLetters === undefined) { - return 'A'; - } - this.joints.forEach((j) => { - if (j.id > lastLetter) { - lastLetter = j.id; - } + /** + * A free-standing bar between two points — what `Add Link` on bare grid + * commits when the drag is released. + * + * Here rather than in the canvas because it is a creation, and every other + * one lives here; the canvas has the same recipe inline because it also has a + * ghost to draw and a drag to end. Anything that wants a bar without a + * gesture — the tutorial doing a step for the student — asks for one here. + */ + addBar(from: Coord, to: Coord): RealLink { + const first = this.createRevJoint(from.x.toString(), from.y.toString()); + const second = this.createRevJoint(to.x.toString(), to.y.toString(), first.id); + return this.joinWithBar(first, second, [first, second]); + } + + /** + * A bar hung off a joint that is already there, as `Attach Link` makes. + * + * The anchor is not created and not re-merged: it is already in the drawing, + * and pushing it a second time gives it two entries and one very confusing + * delete. + */ + addBarFrom(anchor: RealJoint, to: Coord): RealLink { + const far = this.createRevJoint(to.x.toString(), to.y.toString()); + return this.joinWithBar(anchor, far, [far]); + } + + /** The wiring both of the above share: connect, name, merge, re-solve. */ + private joinWithBar(first: RealJoint, second: RealJoint, fresh: Joint[]): RealLink { + first.connectedJoints.push(second); + second.connectedJoints.push(first); + const link = this.gridUtils.createRealLink(first.id + second.id, [first, second]); + first.links.push(link); + second.links.push(link); + this.mergeToJoints(fresh); + this.mergeToLinks([link]); + this.updateMechanism(true); + return link; + } + + /** + * Everything on the grid, gone, as one undo entry. + * + * The joints go one at a time because deleting a joint is what takes its + * links with it, but none of them saves — otherwise clearing a four-bar + * would cost four presses of Undo to take back. + */ + deleteAll(): void { + if (this.joints.length === 0 && this.links.length === 0 && this.forces.length === 0) return; + [...this.joints].forEach((joint) => { + this.activeObjService.updateSelectedObj(joint); + this.deleteJoint(false, true); }); - additionalLetters?.forEach((l) => { - if (l > lastLetter) { - lastLetter = l; - } + this.activeObjService.updateSelectedObj(null); + this.finishStructuralEdit(true); + } + + /** + * The next name for a joint: A, B, C ... Z, then a, b, c ... z. + * + * It used to be the highest letter in use plus one in character codes, which + * walks straight off the end of the alphabet: the joint after Z was called + * "[", then "\", then "]" -- names that read as damage, and that the panels + * then repeated back as "Edit Cylinder ]`". Past z the gaps left by deleted + * 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. + */ + /** + * 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; + taken.forEach((id) => { + const at = JOINT_ALPHABET.indexOf(id); + if (at > highest) highest = at; }); - return String.fromCharCode(lastLetter.charCodeAt(0) + 1); + + const next = JOINT_ALPHABET[highest + 1]; + if (next !== undefined && !taken.has(next)) return next; + + const free = [...JOINT_ALPHABET].find((letter) => !taken.has(letter)); + if (free !== undefined) return free; + + for (const first of JOINT_ALPHABET) { + for (const second of JOINT_ALPHABET) { + if (!taken.has(first + second)) return first + second; + } + } + return 'A'; + } + + /** + * Names for the joints inside a part, which nothing ever shows. + * + * Hung off the letter of the part's own mount and numbered -- A1, A2, A3 -- + * so they read as belonging to it, and so `determineNextLetter` walks past + * them: it ranks ids by their place in the alphabet, and one of these has no + * place in it, which is exactly the point. They still have to be unique, + * because two rams can share a mount and would otherwise ask for the same + * three names. + */ + private determineInteriorNames(base: string, count: number): string[] { + const taken = new Set(this.joints.map((joint) => joint.id)); + const names: string[] = []; + for (let index = 1; names.length < count; index++) { + const candidate = `${base}${index}`; + if (taken.has(candidate)) continue; + taken.add(candidate); + names.push(candidate); + } + return names; } createRevJoint(x: string, y: string, prevID?: string) { @@ -499,6 +964,294 @@ export class MechanismService { return new RevJoint(id, x_num, y_num); } + /** + * Toggle the Lock mark on a joint, link, or force. + * + * A lock is an edit: it enters the URL, so it survives undo and travels in a + * shared link — an instructor can lock everything but the one joint a class + * is meant to drag. Geometry is untouched, so the rebuild is cheap and the + * undo entry is the mark itself. + */ + toggleLock(target: RealJoint | Link | Force): void { + const wasLocked = this.isLockedTarget(target); + // One kind of lock: a mark on a joint (a force, having none, marks + // itself). Locking a link is a shortcut that marks all of its joints, so + // unlocking one of those joints afterwards frees exactly that joint — + // there is no second, link-level ledger to keep in agreement. + this.lockMarksOf(target).forEach((mark) => (mark.locked = !wasLocked)); + this.updateMechanism(true); + // The panel's position fields grey out against the frozen set, and that + // set just changed under the same selection — re-announce it so they ask. + this.activeObjService.fakeUpdateSelectedObj(); + } + + /** The joint (or force) marks a Lock on this target sets and clears. */ + private lockMarksOf(target: RealJoint | Link | Force): (RealJoint | Force)[] { + if (target instanceof Force) return [target]; + const sealed = this.sealedPartOf(target); + if (sealed) { + // One sealed part, one mark: the prismatic pin, where the assembly's + // other permanent bit (isSealed) already lives, and whose hold the + // closure spreads to all five joints. + return [sealed.slider]; + } + if (target instanceof RealJoint) return [target]; + return target.joints.filter((joint): joint is RealJoint => joint instanceof RealJoint); + } + + /** + * The sealed cylinder a Lock on this target should mark — its body or an + * interior joint. A *mount* is deliberately not the part: locking a mount + * pins that one point and leaves the ram free to re-pose and swing about + * it, which is the useful thing a locked attachment point means. + */ + private sealedPartOf(target: RealJoint | Link | Force): Cylinder | undefined { + if (target instanceof Force) return undefined; + const sealed = this.cylinderAt(target); + if (!sealed) return undefined; + const isMount = + target instanceof RealJoint && + (target.id === sealed.barrelFar.id || target.id === sealed.rodFar.id); + return isMount ? undefined : sealed; + } + + /** + * Clear a specific set of Lock marks — what the refusal's Unlock button + * carries: exactly the marks that held the refused gesture, nothing else. + */ + unlock(marks: (RealJoint | Force)[]): void { + if (marks.length === 0) return; + marks.forEach((mark) => (mark.locked = false)); + this.updateMechanism(true); + this.activeObjService.fakeUpdateSelectedObj(); + } + + /** + * Why this part cannot be deleted, or nothing. + * + * A lock is the user saying this part is settled, and deletion is the one + * edit worth stopping outright rather than warning about. The rule lives + * here rather than in the menu that shows it, because the menu is not the + * only way to delete something: the Delete key and the panel's own button + * reach the same joint, and a greyed row beside a live keystroke is a rule + * that only looks enforced. + */ + deleteRefusal(target: RealJoint | Link | Force): string | undefined { + if (this.isLockedTarget(target)) { + return 'That part is locked. Unlock it before deleting it.'; + } + // A link is "locked" only when every one of its joints is, so a bar with + // one locked end is not -- and deleting it swept that end away as an + // orphan, which is the lock being ignored by a longer route. The cascade + // is part of the deletion, so the lock has to be asked of the cascade. + if (target instanceof Link) { + const held = this.jointsOrphanedByDeleting(target).filter((joint) => joint.locked); + if (held.length > 0) { + const names = held.map((joint) => joint.name || joint.id).join(', '); + return `Deleting this would also remove locked ${held.length === 1 ? 'joint' : 'joints'} ${names}. Unlock ${held.length === 1 ? 'it' : 'them'} first.`; + } + } + return undefined; + } + + /** Say why, and answer whether the caller should stop. */ + private blockedByLock(target: RealJoint | Link | Force | undefined): boolean { + if (!target) return false; + const why = this.deleteRefusal(target); + if (!why) return false; + this.notify.refusal('delete.locked', why); + return true; + } + + /** Whether the Lock item for this object should read as "on". */ + isLockedTarget(target: RealJoint | Link | Force): boolean { + const marks = this.lockMarksOf(target); + return marks.length > 0 && marks.every((mark) => mark.locked); + } + + /** + * Lock the whole drawing, or let all of it go. + * + * Marks land on every joint and every force — the same marks the per-object + * controls set — so Unlock on any one object, or on any one joint, frees + * exactly that much and no bookkeeping disagrees about the rest. + */ + setAllLocks(locked: boolean): void { + this.joints.forEach((joint) => { + if (joint instanceof RealJoint) joint.locked = locked; + }); + this.forces.forEach((force) => (force.locked = locked)); + this.updateMechanism(true); + this.activeObjService.fakeUpdateSelectedObj(); + } + + /** + * How much of the drawing is held, for the counts beside Lock All and + * Unlock All. + * + * A "part" here is a thing that carries a Lock mark -- every joint and every + * force -- because that is what Lock All actually sets. Counting links + * instead would let the menu say "3 locked" while five marks were down. + */ + lockCounts(): { locked: number; open: number; total: number } { + const marks: { locked: boolean }[] = [ + ...this.joints.filter((joint): joint is RealJoint => joint instanceof RealJoint), + ...this.forces, + ]; + const locked = marks.filter((mark) => mark.locked).length; + return { locked, open: marks.length - locked, total: marks.length }; + } + + /** + * The joints deleting this link would sweep up with it. + * + * `deleteLink` keeps a joint only while some *other* link still holds it, so + * this is that rule asked in advance -- what the menu row needs to be able to + * name the cascade before the click rather than after it. + */ + jointsOrphanedByDeleting(link: Link): RealJoint[] { + const doomed = new Set([link, ...(link instanceof RealLink ? link.subset : [])]); + return link.joints.filter( + (joint): joint is RealJoint => + joint instanceof RealJoint && + !this.links.some((other) => !doomed.has(other) && other.joints.includes(joint)) + ); + } + + /** + * The links deleting this joint would take with it. + * + * `deleteJoint` removes every link the joint sits on that has fewer than + * three joints, because a bar with one end left is not a bar. Asked here in + * advance, so the row can say so before the click rather than after it. + * + * A welded compound is asked leaf by leaf, because that is what the deletion + * does to it: the joint comes out of each sub-link it is on, and a sub-link + * left with a single end goes the same way a bare bar would. Asking only the + * compound's own joint count let the row promise `Delete Joint` and then take + * a leaf with it -- the compound has four joints, so nothing looked doomed, + * while the two-joint leaf inside it was. + */ + linksRemovedByDeleting(joint: RealJoint): Link[] { + const doomed: Link[] = []; + for (const link of joint.links) { + const parts = link instanceof RealLink && link.subset.length > 0 ? link.subset : [link]; + for (const part of parts) { + if (!part.joints.some((member) => member.id === joint.id)) continue; + if (part.joints.length < 3) doomed.push(part); + } + } + return doomed; + } + + /** + * Copy a bar and the two joints it stands on, a little to one side. + * + * Free-standing, deliberately: the copy shares no joint with the original, + * carries none of its forces and none of its locks, and is not welded to + * anything. A duplicate that arrived already attached would be a different + * mechanism rather than a second copy of the same bar, and there is no + * reading of "duplicate" that says which of the original's neighbours the + * copy should have inherited. + */ + duplicateLink(link: RealLink): void { + // A compound is several links and the welds between them, and a cylinder + // part belongs to an assembly; neither copies as one link. The menu greys + // the row for both rather than accepting the click and doing nothing. + if (!this.canDuplicate(link)) return; + const source = link.joints.filter((joint): joint is RealJoint => joint instanceof RealJoint); + + // Set beside the link rather than diagonally away from it: a copy that + // lands along the bar's own direction overlaps it and reads as nothing + // having happened, which is exactly how the first cut of this was + // reported. Across the bar, the two are plainly two. + const step = this.sideStepFor(source); + // `determineNextLetter` reads the drawing, and none of these are in it + // yet, so the letters handed out so far are passed back in explicitly. + const taken: string[] = []; + const made = source.map((joint) => { + const id = this.determineNextLetter(taken); + taken.push(id); + return new RevJoint(id, joint.x + step.x, joint.y + step.y); + }); + const copy = this.gridUtils.createRealLink( + made + .map((joint) => joint.id) + .sort() + .join(''), + made + ); + // A copy of the body, not of its outline: a bar carrying seven grams and a + // hand-set moment of inertia is that bar because of those numbers, and a + // duplicate that quietly dropped them handed back a shape with a force + // analysis that no longer agreed with the original. The name is not + // copied -- two links answering to "Crank" is a drawing nobody can talk + // about -- and neither is the lock, which is a statement about the part + // that was settled rather than about the one just made. + // + // The *custom* flags come too, and they are the half that matters: without + // them the next rebuild treats the copy as an ordinary body and computes + // both back over -- a hand-set inertia of 123.456 was landing as 0.005 + // before the reader had done anything. And everything the offsets are held + // against is a joint letter, so those letters are the source's and have to + // be re-read as the copy's or the point rides a bar it is not on. + const renamed = new Map(source.map((joint, index) => [joint.id, made[index].id])); + const rename = (id: string): string => renamed.get(id) ?? made[0].id; + copy.mass = link.mass; + copy.massMoI = link.massMoI; + copy.moiIsCustom = link.moiIsCustom; + copy.fill = link.fill; + copy.isCircle = link.isCircle; + copy.comIsCustom = link.comIsCustom; + copy.comAnchor = + typeof link.comAnchor === 'object' ? { joint: rename(link.comAnchor.joint) } : link.comAnchor; + copy.comAnchorOffset = link.comAnchorOffset ? { ...link.comAnchorOffset } : undefined; + copy.comOffset = link.comOffset + ? { + along: link.comOffset.along, + across: link.comOffset.across, + frame: [rename(link.comOffset.frame[0]), rename(link.comOffset.frame[1])], + } + : undefined; + made.forEach((joint) => { + joint.links.push(copy); + made.forEach((other) => { + if (other.id !== joint.id) joint.connectedJoints.push(other); + }); + }); + this.joints.push(...made); + this.links.push(copy); + this.activeObjService.updateSelectedObj(copy); + this.finishStructuralEdit(true); + } + + /** Whether Duplicate has a single link to copy — the menu's enable rule. */ + canDuplicate(link: Link): boolean { + if (!(link instanceof RealLink) || link.subset.length > 0) return false; + if (this.cylinderAt(link)) return false; + return link.joints.filter((joint) => joint instanceof RealJoint).length >= 2; + } + + /** + * Where a copy is set down: one part-width to the side of the link's own + * direction, so the copy sits beside the original rather than along it. + */ + private sideStepFor(joints: RealJoint[]): { x: number; y: number } { + const gap = 0.9 * this.settingsService.objectScale; + const span = { x: joints[1].x - joints[0].x, y: joints[1].y - joints[0].y }; + const length = Math.hypot(span.x, span.y); + if (length < 1e-6) return { x: gap, y: -gap }; + return { x: (-span.y / length) * gap, y: (span.x / length) * gap }; + } + + /** Whether any Lock mark is set at all — what enables Unlock All. */ + anythingLocked(): boolean { + return ( + this.joints.some((joint) => joint instanceof RealJoint && joint.locked) || + this.forces.some((force) => force.locked) + ); + } + toggleWeldedJoint() { const joint = this.joints.find((j) => j.id === this.activeObjService.selectedJoint?.id) as RealJoint | undefined; @@ -546,15 +1299,28 @@ export class MechanismService { leaves.reduce((sum, link) => sum + link.CoM.x, 0) / Math.max(1, leaves.length), leaves.reduce((sum, link) => sum + link.CoM.y, 0) / Math.max(1, leaves.length) ); + // The parallel-axis term measures distances in model coordinates, which + // are MODEL_SCALE user units, and mass and inertia live in different + // prefixes per unit system. Unconverted, welding two 1 g bars produced + // tens of thousands of kg·cm² — off by MODEL_SCALE² times the unit ratio. + const parallelAxis = this.storedMoiFactor(this.currentUnitStr()); const massMoI = leaves.reduce( (sum, link) => sum + link.massMoI + - link.mass * (Math.pow(link.CoM.x - CoM.x, 2) + Math.pow(link.CoM.y - CoM.y, 2)), + link.mass * + (Math.pow(link.CoM.x - CoM.x, 2) + Math.pow(link.CoM.y - CoM.y, 2)) * + parallelAxis, 0 ); const newLink = new RealLink(id, newLinkJoints, totalMass, massMoI, CoM, leaves); + // The parallel-axis sum above is worth keeping exactly when a part's + // numbers were chosen by a person; parts that all followed their geometry + // leave the compound following its geometry too. + newLink.moiIsCustom = leaves.some((leaf) => leaf instanceof RealLink && leaf.moiIsCustom); + newLink.comIsCustom = leaves.some((leaf) => leaf instanceof RealLink && leaf.comIsCustom); + if (newLink.comIsCustom) newLink.captureComOffset(); newLink.fill = leaves[0]?.fill ?? ColorService.instance?.getNextLinkColor() ?? '#555555'; return newLink; } @@ -636,7 +1402,35 @@ export class MechanismService { * compound keeps existing as a member of that compound's subset, so the * pointer stays valid while no longer naming a body any solver iterates. */ - private rootLinkOwning(link: Link): Link | undefined { + /** + * The one door for writing a body's mass, wherever the field lives. + * + * A compound and its members must keep telling one story: edit a member and + * the compound's aggregate moves by the same amount; edit the compound and + * the members scale to match, so unwelding later restores what the sum + * really was. Without this, the mass table and the cylinder fields were two + * sources of truth that only agreed until the first unweld. + */ + assignBodyMass(body: Link, value: number): void { + if (!(value >= 0)) return; + if (body instanceof RealLink && body.subset.length > 0) { + const members = body.subset; + const total = members.reduce((sum, member) => sum + member.mass, 0); + for (const member of members) { + member.mass = total > 0 ? (member.mass / total) * value : value / members.length; + } + } else { + const root = this.rootLinkOwning(body); + if (root && root !== body) { + root.mass += value - body.mass; + } + } + body.mass = value; + } + + /** Public: the cylinder panel writes part masses and must keep a welded + * compound's aggregate true. */ + rootLinkOwning(link: Link): Link | undefined { const contains = (candidate: Link): boolean => candidate.id === link.id || (candidate instanceof RealLink && candidate.subset.some(contains)); @@ -869,7 +1663,9 @@ export class MechanismService { } if (link instanceof RealLink) { - link.CoM = RealLink.determineCenterOfMass(link.joints); + if (!link.comIsCustom) { + link.CoM = RealLink.determineCenterOfMass(link.joints); + } link.reComputeDPath(); } } @@ -917,7 +1713,19 @@ export class MechanismService { this.slotStashes.clear(); } - deleteJoint() { + /** + * @param save mints the undo entry. Only a caller that deletes several joints + * as one gesture passes `false`, and it owes a `finishStructuralEdit(true)` + * of its own once the last one is gone — see `deleteMechanism`. + */ + deleteJoint(save: boolean = true, ignoreLocks: boolean = false) { + if (!ignoreLocks && this.blockedByLock(this.activeObjService.selectedJoint)) return; + // A cylinder's own joint carries its own mark -- locking a mount pins that + // point and leaves the ram free to swing about it -- so an unlocked mount + // on a *locked* cylinder passed the test above and then took the whole + // locked part with it. + const sealedHere = this.cylinderAt(this.activeObjService.selectedJoint); + if (!ignoreLocks && sealedHere && this.blockedByLock(sealedHere.barrel)) return; // Deleting a mount (or, defensively, any member joint) of a sealed cylinder // takes the whole assembly with it (§ cylinder 5) — and then goes on to // delete the joint itself. @@ -936,7 +1744,7 @@ export class MechanismService { // removed as orphaned, and there is nothing left to delete. if (!this.joints.some((joint) => joint.id === doomed.id)) { this.activeObjService.updateSelectedObj(undefined); - this.finishStructuralEdit(true); + this.finishStructuralEdit(save); return; } } @@ -1034,7 +1842,10 @@ export class MechanismService { if (!(l instanceof RealLink)) { return; } - const subsetNum = l.subset.length; + // Captured as a const so the narrowing to RealLink survives into the + // closures below. + const subsets = l.subset; + const subsetNum = subsets.length; if (subsetNum === 0) { return; } @@ -1069,9 +1880,19 @@ export class MechanismService { const selectedJoint = this.activeObjService.selectedJoint; deleteJointFromLink(l, selectedJoint); deleteJointFromLink(sub, selectedJoint); - const tempIdSubs = idSubs.filter((str) => str !== sub.id); - // sub contains id that is not shared with any other subset - if (!sub.id.split('').some((char) => tempIdSubs.some((str) => str.includes(char)))) { + // Whether this subset still shares a joint with another one, asked of + // the joints rather than of the letters in their ids. A link id is + // its joints' names run together, so comparing characters says AA1 + // and A2B share something because both contain an "A" -- and a + // cylinder's interior joints, or any drawing past its fifty-second, + // have ids longer than a character. + const sharesAJoint = sub.joints.some((joint) => + subsets.some( + (other) => + other !== sub && other.joints.some((candidate) => candidate.id === joint.id) + ) + ); + if (!sharesAJoint) { // This link will be pushed to this.links if (sub.joints.length > 1) { sub.joints.forEach((childJoint) => { @@ -1097,7 +1918,6 @@ export class MechanismService { childJoint.connectedJoints.push(jt); }); }); - // childSub.link.forEach(jt => childJoint.connectedJoints.push(jt)); }); this.links.push(sub); // This is an orphaned joint @@ -1183,8 +2003,10 @@ export class MechanismService { } }); l_subset_index = l_subset_index - 1; - } else if (sub.id.length === 1) { - // special case, can slice this subset + } else if (sub.joints.length === 1) { + // special case, can slice this subset: one joint left, counted + // rather than read off the length of a name that may not be one + // character per joint. l.subset.splice(l_subset_index, 1); l_subset_index = l_subset_index - 1; } @@ -1314,13 +2136,15 @@ export class MechanismService { }); } this.joints.splice(jointIndex, 1); - // if (this.activeObjService.selectedLink !== undefined) { - // this.activeObjService.selectedLink.d = this.activeObjService.selectedLink.getPathString(); - // } // Through the shared path, so a slot whose defining joint was just deleted // gets reconciled. Deleting a joint by itself is the one way to strand a // slot that does not go through mergeJoints or deleteLink. - this.finishStructuralEdit(false); + // + // Saving here is what makes the deletion undoable. This read `false` for as + // long as the tail read `updateMechanism()`, whose save flag defaults off — + // so a joint deleted on its own left no history at all, while the very same + // deletion routed through the cylinder branch above did. + this.finishStructuralEdit(save); setTimeout(() => { this.onMechUpdateState.next(3); }); @@ -1342,6 +2166,7 @@ export class MechanismService { deleteForce(force: Force = this.activeObjService.selectedForce) { if (!force) return; + if (this.blockedByLock(force)) return; this.detachForce(force); this.updateMechanism(true); this.onMechUpdateState.next(3); @@ -1359,6 +2184,24 @@ export class MechanismService { this.onMechUpdateState.next(2); } + /** + * Draw the selected link as the disc it sweeps, or as a bar again. + * + * The editable link's own outline is rebuilt here rather than left to the + * next update: the drawing on screen in Edit mode is this object, not a + * solved copy of it, so nothing else would redraw it until the mechanism + * moved. Saved, because it is a change to the drawing a person would expect + * to be able to undo. + */ + toggleLinkCircular() { + const link = this.activeObjService.selectedLink; + if (!link.canBeCircular()) return; + link.isCircle = !link.isCircle; + link.reComputeDPath(); + this.updateMechanism(true); + this.onMechUpdateState.next(2); + } + addJointAtCOM() { let link = this.activeObjService.selectedLink; let com = link.CoM; @@ -1421,6 +2264,7 @@ export class MechanismService { deleteLink() { const link = this.activeObjService.selectedLink; + if (this.blockedByLock(link)) return; // Deleting any member of a sealed cylinder — barrel, rod, block, or a // compound that swallowed one — deletes the whole assembly (§ cylinder 5). const sealed = this.cylinderAt(link); @@ -1463,6 +2307,15 @@ export class MechanismService { * on every frame to answer a question whose answer cannot have changed. */ poseRevision = 0; + /** + * Bumped when the *solved cycle* changes, rather than the pose being drawn. + * + * `poseRevision` moves on every frame of playback, because the pose is what + * playback changes. Anything cached against the numbers themselves — the + * export's sampled tables, for one — has to key on this instead, or watching + * a mechanism run rebuilds it sixty times a second. + */ + solveRevision = 0; private structuresCache?: { revision: number; list: Cylinder[] }; /** @@ -1493,6 +2346,314 @@ export class MechanismService { * * Returns nothing when the mechanism is fine. */ + readinessOfEachMechanism(): MechanismReadiness[] { + return this.partitions.map((partition, index) => + readinessOf(partition, this.mechanisms[index], { + cylinderName: (sliderId) => { + const found = this.sealedStructures().find((c) => c.slider.id === sliderId); + return found ? this.cylinderName(found) : sliderId; + }, + drivenRefusal: (part) => { + const driven = part.ownJoints.find((joint) => joint instanceof RealJoint && joint.input); + if (!driven) { + return undefined; + } + const refusal = describeActuator(driven); + return typeof refusal === 'string' ? refusal : undefined; + }, + strokeWarning: (part) => this.strokeWarningFor(part), + describeSpeed: (part) => { + const driven = part.joints.find((joint) => joint instanceof RealJoint && joint.input) as + RealJoint | undefined; + if (!driven) { + return 'Not set'; + } + const signed = this.driveSpeedOf(driven); + const magnitude = Math.abs(signed).toFixed(2); + const way = signed < 0 ? 'CW' : 'CCW'; + return driven instanceof PrisJoint + ? `${magnitude} ${this.nup.unitLabel(this.settingsService.lengthUnit.value)}/s` + : `${magnitude} RPM ${way}`; + }, + }) + ); + } + + /** + * What force analysis still needs, asked of the force solver rather than + * guessed at. + * + * The solver already refuses precisely and says why -- an unsupported + * topology, mass properties that are not numbers, an equilibrium that is + * singular. Restating those conditions here would be a second opinion free to + * drift from the first, and the drift would show as a tab that says Ready + * above a panel that says it cannot solve. + */ + /** + * Re-derive every auto link's mass properties from its own skeleton. + * + * Runs at the one funnel every mutation passes through, for the same reason + * the cylinder invariant does: whatever moved a joint — a drag, a panel + * field, an undo — an auto link's centroid and moment of inertia follow the + * geometry, and a custom one holds whatever its author typed. + * + * MoI = mass × k², with k² from the skeleton in model units. The unit + * factor converts (stored-mass × user-length²) into the stored inertia unit + * exactly — derived from the same siUnitFactors the solver converts with, + * so the identity survives every unit system rather than being tuned to one. + */ + private applyUniformBodyProperties(unitStr: string): void { + const factor = this.storedMoiFactor(unitStr); + for (const link of this.links) { + if (!(link instanceof RealLink)) continue; + // A body with no mass has no moment of inertia, full stop: the solver + // applies I·α regardless of mass, so a leftover typed inertia on a + // weightless bar would quietly steer every dynamic answer. Zeroing the + // mass zeroes the inertia and hands the field back to the shape, which + // is also what the panel shows (the field disables and reads 0). + if (!(link.mass > 0) && (link.massMoI !== 0 || link.moiIsCustom)) { + link.massMoI = 0; + link.moiIsCustom = false; + } + // A link drawn as a disc that has since lost the pin the disc was centred + // on -- or been given one back. Nothing else would notice: an outline is + // only rebuilt when something asks it to, and grounding a joint asks + // about the joint. Left alone, a crank kept its disc after its ground was + // removed, and the panel and the canvas disagreed about what it was. + if (link.isCircle && link.drawnAsDisc !== link.canBeCircular()) { + link.reComputeDPath(); + } + if (link.comIsCustom) { + // A placed point rides the link: re-derived from its stored offset + // against the centroid, so drags, turns and deformations carry it. + const placed = link.customCoMFromOffset(); + if (placed) { + link.CoM = placed; + link.updateCoMDs(); + } else { + // Decoded from a URL that only carries the world coordinate: + // capture the offset once, against today's geometry. + link.captureComOffset(); + } + } + if (link.moiIsCustom && link.comIsCustom) continue; + const derived = this.uniformBodyFor(link, factor); + if (!link.comIsCustom) { + link.CoM = new Coord(derived.com.x, derived.com.y); + link.updateCoMDs(); + } + if (!link.moiIsCustom) { + link.massMoI = derived.moi; + } + } + } + + /** + * Converts mass × (model length)² into the stored inertia unit, exactly: + * derived from the same siUnitFactors the force solver converts with, so + * MoI = m·k² survives every unit system instead of being tuned to one. + */ + private storedMoiFactor(unitStr: string): number { + const units = siUnitFactors(unitStr); + return (units.massToKg * units.distanceToM ** 2) / units.inertiaToKgM2 / MODEL_SCALE ** 2; + } + + private currentUnitStr(): string { + switch (this.settingsService.lengthUnit.value) { + case LengthUnit.INCH: + return 'in'; + case LengthUnit.METER: + return 'm'; + default: + return 'cm'; + } + } + + /** + * The uniform body a link derives its auto properties from. + * + * A plain link is a rod or a hull plate over its own joints. A compound is + * the *sum of its parts* — each member as its own body, combined by the + * parallel-axis theorem — never a plate over the whole hull: a V-shaped + * weld is two bars, and a plate spanning the crook would weigh material + * that is not there. Members somebody typed at contribute the numbers they + * were given. + */ + private uniformBodyFor(link: RealLink, factor: number): { com: Coord; moi: number } { + if (link.subset.length === 0) { + const body = uniformBodyOf(link.joints); + return { + com: new Coord(body.centroid.x, body.centroid.y), + moi: link.mass * body.gyrationSq * factor, + }; + } + const parts = link.subset + .filter((member): member is RealLink => member instanceof RealLink) + .map((member) => { + const own = this.uniformBodyFor(member, factor); + return { + mass: member.mass, + com: member.comIsCustom ? new Coord(member.CoM.x, member.CoM.y) : own.com, + moi: member.moiIsCustom ? member.massMoI : own.moi, + }; + }); + const totalMass = parts.reduce((sum, part) => sum + part.mass, 0); + const com = + totalMass > 0 + ? new Coord( + parts.reduce((sum, part) => sum + part.mass * part.com.x, 0) / totalMass, + parts.reduce((sum, part) => sum + part.mass * part.com.y, 0) / totalMass + ) + : new Coord( + parts.reduce((sum, part) => sum + part.com.x, 0) / Math.max(1, parts.length), + parts.reduce((sum, part) => sum + part.com.y, 0) / Math.max(1, parts.length) + ); + const moi = parts.reduce( + (sum, part) => + sum + + part.moi + + part.mass * ((part.com.x - com.x) ** 2 + (part.com.y - com.y) ** 2) * factor, + 0 + ); + return { com, moi }; + } + + forceAnalysisRequirements(): ForceRequirement[] { + const requirements: ForceRequirement[] = []; + + const runnable = this.mechanisms.filter((mechanism) => mechanism.isMechanismValid()); + requirements.push({ + met: runnable.length > 0, + title: 'A mechanism that runs', + body: + runnable.length > 0 + ? 'Force analysis is solved over a solved cycle, and there is one.' + : 'Forces are solved at each position of a cycle, so the kinematics have to work first. Analysis setup lists what is missing.', + }); + if (runnable.length === 0) { + return requirements; + } + + // Ask for the analysis the panel would show. It is memoised per mechanism, + // so this costs nothing after the first time. + const refused = runnable + .map((mechanism) => ({ mechanism, series: mechanism.getForceAnalysis('static') })) + .filter(({ series }) => series.successfulFrames === 0); + requirements.push({ + met: refused.length === 0, + title: 'A topology the solver can balance', + body: + refused.length === 0 + ? 'Every body has an equilibrium the solver can write down.' + : (refused[0].series.diagnostic ?? + 'One of these mechanisms has no determinate force-equilibrium model.'), + }); + + // Only the links of machines that could actually be analysed. A massless + // bar in some unrelated -- or unassigned -- corner of the drawing used to + // block force analysis for a perfectly good mechanism. + const analysable = new Set( + this.partitions + .filter((_, index) => this.mechanisms[index]?.isMechanismValid()) + .flatMap((partition) => partition.links.map((link) => link.id)) + ); + // Something has to load the linkage, but weight counts: with gravity on, + // a link with mass hangs from it, and that is a complete static problem. + // Demanding a drawn arrow on top of that refused analyses that meant + // something. + const loads = this.forces.filter((force) => analysable.has(force.link?.id)); + // Any body's mass, not only a RealLink's: the solver hangs a slider block's + // weight from gravity too, so a drawing whose only massive body is a block + // is genuinely loaded. (The massless *warning* below stays about links -- + // every block starts massless and naming them all would be noise.) + const weighted = this.links.some( + (link) => + (link instanceof RealLink || link instanceof SliderBlock) && + analysable.has(link.id) && + link.mass > 0 + ); + const gravityLoads = this.settingsService.isGravity.value && weighted; + const loaded = loads.length > 0 || gravityLoads; + + // A massless link is a legitimate idealization -- the solver simply skips + // its weight and inertia -- so this is a warning, not a gate. It is worth + // one, because zero is the mass nobody chose: every link starts there. + // Only once something loads the mechanism, though: the unloaded blocker + // below already says every link is massless, and saying it twice made the + // list read longer than the problem is. + if (loaded) { + const massless = this.links.filter( + (link) => link instanceof RealLink && analysable.has(link.id) && !(link.mass > 0) + ) as RealLink[]; + requirements.push({ + met: massless.length === 0, + warning: true, + title: 'Massless links', + body: + massless.length === 0 + ? 'Every link has a mass and a moment of inertia.' + : `${massless + .map((link) => this.bodyLabel(link)) + .join( + ', ' + )} ${massless.length === 1 ? 'weighs' : 'weigh'} nothing, so gravity and inertia pass ${massless.length === 1 ? 'it' : 'them'} by. Fine for an idealized bar — type a mass in the table below to include ${massless.length === 1 ? 'it' : 'them'}.`, + }); + } + + // Gravity off over a drawing that does have mass is the one refusal here + // with a one-click way out, so it gets a button as well as a sentence: + // everything the analysis needs is already drawn, and the only thing + // standing in the way is a switch in another panel. + const gravityWouldLoad = !this.settingsService.isGravity.value && weighted; + requirements.push({ + met: loads.length > 0 || gravityLoads, + title: 'A load to react against', + act: gravityWouldLoad ? 'gravity' : undefined, + body: + loads.length > 0 + ? `${loads.length} ${loads.length === 1 ? 'force is' : 'forces are'} applied.` + : gravityLoads + ? 'Gravity loads the links that have mass.' + : gravityWouldLoad + ? 'Nothing loads this mechanism: gravity is off, so the mass it has weighs nothing. Turn gravity on, or attach a force.' + : this.settingsService.isGravity.value + ? 'Nothing loads this mechanism yet: no force is applied and every link is massless. Attach a force or give a link mass.' + : 'Nothing loads this mechanism: gravity is off and no force is applied. Attach a force, or turn gravity on in Settings and give a link mass.', + }); + + return requirements; + } + + /** Can the Force tab show anything worth reading? */ + forceAnalysisReady(): boolean { + return this.forceAnalysisRequirements().every( + (requirement) => requirement.met || requirement.warning === true + ); + } + + /** What to say about geometry that is in no mechanism. */ + unassignedReports(): UnassignedReport[] { + return describeUnassigned(this.unassigned); + } + + /** How many blockers stand between this drawing and a full set of animations. */ + /** + * How many things stand between the drawing and being analysed. + * + * Geometry that is in no mechanism counts. Without it a drawing of two + * joints and a bar -- which is in no mechanism, has no input and cannot be + * analysed at all -- reported nothing to fix, because there was no mechanism + * for anything to be wrong with, and the chip read "Ready". + */ + blockerCount(): number { + const inMechanisms = this.readinessOfEachMechanism().reduce( + (total, readiness) => + total + readiness.checks.filter((check) => check.state === 'blocker').length, + 0 + ); + return inMechanisms + this.unassignedReports().length; + } + invalidReason(): string | undefined { if (this.oneValidMechanismExists()) { return undefined; @@ -1506,7 +2667,7 @@ export class MechanismService { return `Slider ${names} has nothing to slide along. Drag it onto a link to cut a slot, or ground it to fix its direction.`; } if (!this.joints.some((joint) => joint instanceof RealJoint && joint.input)) { - return 'No joint is driven. Right-click a joint and choose Add Input to say what moves the mechanism.'; + return 'No joint is driven. Right-click a joint and switch on Driven Input to say what moves the mechanism.'; } // A driven joint the actuator record cannot describe -- most often because // an edit added a third body to it long after Driven was switched on. The @@ -1518,7 +2679,9 @@ export class MechanismService { return refusal; } } - const dof = this.mechanisms[0]?.dof; + // Nothing in the drawing runs, so the mobility worth reporting is the first + // one that is wrong -- not whichever mechanism happened to be built first. + const dof = (this.mechanisms.find((m) => !m.isMechanismValid()) ?? this.mechanisms[0])?.dof; if (dof !== undefined && Number.isNaN(dof)) { return 'Nothing is holding this mechanism in place. Ground a joint, or ground a slider\u2019s guide.'; } @@ -1572,12 +2735,26 @@ export class MechanismService { private reachWarningCache: string | undefined; private computeCylinderReachWarning(): string | undefined { - const solved = this.mechanisms[0]; - if (!solved || !this.oneValidMechanismExists()) return undefined; - const frames = solved.joints.length; - if (frames < 2) return undefined; + return this.strokeWarningFor(); + } + /** + * The first cylinder in `only` -- or in the whole drawing -- that the linkage + * stops before the barrel does. + */ + private strokeWarningFor(only?: MechanismPartition): string | undefined { for (const cylinder of this.sealedStructures()) { + if (only && !only.joints.some((joint) => joint.id === cylinder.pin.id)) { + continue; + } + // Each ram is measured against the frames of its own machine. Read from + // another mechanism's cycle -- a different length, a different motion -- + // the travel below is a measurement of the wrong thing entirely. + const solved = this.mechanismContaining(cylinder.pin); + if (!solved?.isMechanismValid()) continue; + const frames = solved.joints.length; + if (frames < 2) continue; + const r = 0.15 * SettingsService.objectScale; const barrelLength = getDistance(cylinder.barrelFar, cylinder.barrelNear); const travel = cylinderStrokeAlong(barrelLength, r); @@ -1621,6 +2798,41 @@ export class MechanismService { ); } + /** + * The one reaction a slider's block has that its pin does not. + * + * A block is a zero-length link binding a pin to a slot. It meets the world + * twice: at the pin, where the force is exactly the pin's own reaction + * negated -- the same number already carried under the name of the bar it + * holds -- and at the slot, where it presses on whatever the slot is cut + * into. The second is the force that sizes a slide, and it is here or + * nowhere: a slot has no marker, no hitbox and no panel. + */ + slotReactionOf(pin: Joint | undefined): { slot: PrisJoint; block: Link; on: string } | undefined { + const slot = this.sliderFor(pin); + if (!slot) return undefined; + const block = this.links.find( + (link) => link instanceof SliderBlock && link.joints.some((joint) => joint.id === slot.id) + ); + if (!block) return undefined; + const carrier = slot.isFloating && slot.isSlotWellFormed ? slot.carrier : undefined; + return { slot, block, on: carrier ? this.bodyLabel(carrier) : 'the ground' }; + } + + /** + * What to call a reaction that acts at a slot. + * + * The slider it belongs to, because that is the pin a reader can point at: a + * slot has no name anyone has ever been shown. + */ + slotName(jointId: string): string | undefined { + const slot = this.joints.find((joint) => joint.id === jointId); + if (!(slot instanceof PrisJoint)) return undefined; + const pin = slot.connectedJoints.find((joint) => !(joint instanceof PrisJoint)) as + RealJoint | undefined; + return pin ? `the slider at ${pin.name || pin.id}` : 'the slider'; + } + /** The sealed cylinder a joint or link belongs to, if any. */ cylinderAt(obj: Joint | Link | undefined): Cylinder | undefined { if (obj instanceof Joint) return cylinderOfJointIn(this.sealedStructures(), obj); @@ -1628,6 +2840,18 @@ export class MechanismService { return undefined; } + /** + * What the panels call a body: a cylinder part by its role in the machine, + * a block by the joint it rides on, an ordinary bar by its name — never the + * internal concatenated id, which names joints a reader cannot even click. + * + * Always a complete noun phrase, so a caller can drop it into a sentence + * without having to know which kind of body came back. + */ + bodyLabel(body: Link): string { + return labelForBody(body, this.cylinderAt(body)); + } + /** * Every sealed cylinder a joint belongs to, not just the first. * @@ -1670,19 +2894,25 @@ export class MechanismService { // the item out; this is the same rule where the edit actually happens, so // no other caller can get round it. if (mountAt?.isWelded) { - NewGridComponent.sendNotification( + this.notify.refusal( + 'cylinder.welded-mount', 'This joint is welded, so a cylinder mounted on it would be a third body inside one rigid one. Unweld it, or attach the cylinder to the link instead.' ); return; } const creation = cylinderCreationLayout(start, end, this.settingsService.objectScale); - const taken = mountAt ? [mountAt.id] : []; + // A ram is five joints and shows two of them. The mounts are what the + // reader points at, names and reads back out of a panel, so they take + // letters; the barrel's near end, the pin and the slider are inside the + // part and are never drawn, labelled or listed. Spending a letter on each + // of those ran a drawing through the alphabet three times faster than the + // joints anyone could see, and it was the hidden ones that pushed the + // visible ones into punctuation. const aId = mountAt ? mountAt.id : this.determineNextLetter(); - const bId = this.determineNextLetter(taken.concat(aId)); - const cId = this.determineNextLetter([bId]); - const dId = this.determineNextLetter([cId]); - const pId = this.determineNextLetter([dId]); + const dId = this.determineNextLetter([aId]); + const insideNames = this.determineInteriorNames(aId, 3); + const [bId, cId, pId] = insideNames; const place = (at: { x: number; y: number }): [number, number] => [ roundNumber(at.x, 3), @@ -1742,6 +2972,10 @@ export class MechanismService { this.cylinderAt(this.activeObjService.selectedJoint) ?? this.cylinderAt(this.activeObjService.selectedLink); if (!sealed) return; + // Asked here as well as on the way in: the menu calls this directly with + // the cylinder it found, so a guard that only sat on deleteLink was a + // guard with a door beside it. + if (this.blockedByLock(sealed.barrel)) return; this.deleteCylinderTopology(sealed); this.activeObjService.updateSelectedObj(undefined); this.finishStructuralEdit(true); @@ -1812,10 +3046,8 @@ export class MechanismService { const sealed = target ?? this.cylinderAt(this.activeObjService.selectedLink); if (!sealed) return; if (!sealed.slider.input) { - // Only one input at a time, same as adjustInput. - this.joints.forEach((joint) => { - if (joint instanceof RealJoint && joint.input) joint.input = false; - }); + // One input per mechanism, same as adjustInput. + this.clearInputsSharingMechanismWith(sealed.slider); } sealed.slider.input = !sealed.slider.input; this.updateMechanism(); @@ -1896,15 +3128,17 @@ export class MechanismService { if (!jointToToggleInput.input) { const refusal = describeActuator(jointToToggleInput); if (typeof refusal === 'string') { - NewGridComponent.sendNotification(refusal); + // Silently: this is unreachable from either surface. Both the menu item + // and the panel's button are disabled on `canToggleInput`, which is + // `input || canDrive` -- and `canDrive` is exactly "describeActuator + // did not refuse", asked of this same joint. Checked across all 25 + // templates: 147 presses of every enabled control, no refusal. Kept as + // a guard so a third caller cannot drive what the model will not. return; } - // One input at a time, so the joint taking the job displaces the old one. - this.joints.forEach((j) => { - if (j instanceof RealJoint && j.input) { - j.input = false; - } - }); + // One input per mechanism, so the joint taking the job displaces the old + // one -- in its own machine only. + this.clearInputsSharingMechanismWith(jointToToggleInput); } //Toggle the input joint @@ -2098,7 +3332,8 @@ export class MechanismService { // the cylinder (§ cylinder 4). The panel and menu grey the control on the // mounts; this is the rule they are both fronting. if (this.cylinderAt(this.activeObjService.selectedJoint)) { - NewGridComponent.sendNotification( + this.notify.refusal( + 'cylinder.sealed-slider', 'A cylinder is one sealed part — delete the cylinder instead of editing its slider.' ); return; @@ -2132,16 +3367,7 @@ export class MechanismService { this.activeObjService.selectedJoint.input = false; this.activeObjService.selectedJoint.ground = false; const prismaticJointId = this.determineNextLetter(); - const inputJointIndex = this.findInputJointIndex(); const connectedJoints: Joint[] = [this.activeObjService.selectedJoint]; - // this.joints.forEach((j) => { - // if (!(j instanceof RealJoint)) { - // return; - // } - // if (j.ground) { - // connectedJoints.push(j); - // } - // }); // Born dangling on an ungrounded pin: a floating slot needs a carrier, // which is geometry the drop gesture supplies and no toggle can invent. // A slider with a stash gets its old slot back instead, which is what @@ -2210,9 +3436,17 @@ export class MechanismService { }); } - /** Sample times (seconds) of the solved mechanism, empty when nothing is solved. */ + /** + * Sample times (seconds) of the mechanism the shared clock follows, empty + * when nothing is solved. + * + * Every machine runs on the same wall clock, so the scrubber has to span the + * longest cycle in the drawing; a shorter one wraps inside it. Measuring the + * timeline against whichever mechanism happened to be built first would cut + * playback off partway through the slowest one. + */ private sampleTimes(): number[] { - return this.mechanisms[0]?.timeNum ?? []; + return this.masterMechanism()?.timeNum ?? []; } /** Simulation time of a sample index. */ @@ -2225,9 +3459,9 @@ export class MechanismService { return times[clamped]; } - /** Seconds spanned by one full traversal of the motion. */ + /** Seconds spanned by one full traversal of the longest motion in the drawing. */ cyclePeriod(): number { - return this.mechanisms[0]?.cyclePeriod ?? 0; + return this.masterMechanism()?.cyclePeriod ?? 0; } /** Nearest sample index to a simulation time. Sample times strictly increase. */ @@ -2269,9 +3503,7 @@ export class MechanismService { * it was blended from. */ currentTimeSeconds(): number { - return AnimationBarComponent.animate - ? this.playbackTimeSeconds - : this.timeAtStep(this.mechanismTimeStep); + return this.isPlaying ? this.playbackTimeSeconds : this.timeAtStep(this.mechanismTimeStep); } /** Fold a time back into [0, period) so playback and re-seeks loop cleanly. */ @@ -2289,12 +3521,35 @@ export class MechanismService { progress = Math.round(progress); // Sample counts change whenever the mechanism is rebuilt at a new input speed; // never index past them. - const sampleCount = this.mechanisms[0]?.joints.length ?? 0; + const sampleCount = this.masterMechanism()?.joints.length ?? 0; progress = Math.min(Math.max(progress, 0), Math.max(sampleCount - 1, 0)); - // Set the step before announcing it: subscribers read the drawn time back off - // the service, so it has to be current by the time they are notified. + // Set the step *and the running flag* before announcing it: subscribers + // read the drawn time back off the service, so both have to be current by + // the time they are notified. + // + // The flag used to be applied twenty lines further down, which meant a + // caller stopping playback -- leaving an analysis mode does exactly that, + // with animate(0, false) -- notified its subscribers while the service + // still believed it was playing. currentTimeSeconds() then answered with + // the playback clock instead of the time of sample zero, and the readout + // kept showing the time the mechanism had been left at while the mechanism + // itself had rewound. this.mechanismTimeStep = progress; + // The pose applier reads each machine's own clock now, so a seek has to put + // those clocks where the caller asked. A seek through here means the whole + // drawing -- a rewind, a URL restore, the shared scrubber -- so every + // machine goes. The playback loop has already moved them and says so; so + // does a row being scrubbed on its own. + if (!this.advancingPlayback && !this.seekingOneMechanism) { + this.seekAllTo(this.timeAtStep(progress)); + } + if (animationState !== undefined) { + this.isPlaying = animationState; + if (!animationState) { + this.playbackTimeSeconds = this.timeAtStep(progress); + } + } this.onMechPositionChange.next(progress); // Paths are drawn whenever there is a solved cycle to draw them from, // including at rest. @@ -2309,27 +3564,27 @@ export class MechanismService { // hiding it until the user presses play is hiding the thing they just // switched on. this.showPathHolder = this.oneValidMechanismExists(); - if (animationState !== undefined) { - AnimationBarComponent.animate = animationState; - } - if (sampleCount === 0 || this.mechanisms[0].joints[progress].length === 0) { + if (sampleCount === 0 || this.masterMechanism()!.joints[progress].length === 0) { this.playbackClockMs = null; return; } - // Samples are one degree of crank rotation apart, so at a low input speed a - // sample can span a tenth of a second and stepping between them reads as - // stutter. Blend toward the next sample by where playback actually sits - // between the two. - this.applyPose(progress, this.blendToNextSample(progress)); + this.applyPose(); - if (!AnimationBarComponent.animate) { + if (!this.isPlaying) { this.playbackClockMs = null; return; } - // Anything other than the playback loop itself (slider, time field, URL restore) - // is a seek: re-anchor the clock to the sample the caller asked for. - if (!this.advancingPlayback) { + // Anything other than the playback loop itself (a scrubber, a URL restore) + // is a seek of the whole drawing: re-anchor the clock to the sample the + // caller asked for. + // + // Except a seek of one machine, which must leave the frame clock alone. + // Clearing it says "the next frame has no previous frame to measure from", + // so a drag -- which seeks sixty times a second -- measured zero elapsed + // every frame and every other machine stood still for as long as the drag + // lasted. + if (!this.advancingPlayback && !this.seekingOneMechanism) { this.playbackTimeSeconds = this.timeAtStep(this.mechanismTimeStep); this.playbackClockMs = null; } @@ -2341,24 +3596,83 @@ export class MechanismService { * blended toward the next sample. These objects are what the grid renders — and * also what a rebuild treats as t = 0, so see restoreStartPose. */ - private applyPose(step: number, blend: number) { - const nextStep = blend > 0 ? step + 1 : step; - const frames = this.mechanisms[0]; + private applyPose() { // This is the one place a solved sample becomes the drawn pose, so it is // where anything cached against the pose has to be let go of. this.poseRevision++; - this.joints.forEach((j, j_index) => { - const from = frames.joints[step][j_index]; - const to = frames.joints[nextStep][j_index]; + // Each machine finds its own clock's time among its own samples, so a + // mechanism with a shorter cycle wraps inside the master one rather than + // running out (applyMechanismPose interpolates between samples by time). + this.mechanisms.forEach((frames, index) => { + this.applyMechanismPose(frames, this.partitions[index], this.ownSeconds[index] ?? 0); + }); + } + + /** + * Draw one machine's solved sample onto its own editable objects. + * + * Matched by id rather than by position. The editable arrays hold the whole + * drawing while a Mechanism holds only its own component, so the two are no + * longer the same list in the same order -- and pairing them positionally + * would quietly move one mechanism's joint to another's coordinates. + */ + /** + * The sample one machine is showing right now, in its own sample array. + * + * Not `mechanismTimeStep`: that indexes the shared clock's cycle, and while + * the machines are unsynced each of them is somewhere else. Anything asking + * "what does this quantity read at the pose on screen" has to ask the + * machine that quantity belongs to. + */ + currentSampleOf(index: number): number { + const frames = this.mechanisms[index]; + const times = frames?.timeNum ?? []; + if (!frames || times.length === 0) return 0; + const period = frames.cyclePeriod; + let local = this.secondsOf(index); + if (period > 0 && Number.isFinite(local) && local !== period) { + // Exactly the period is the last sample, not a wrap back to the first. + local = ((local % period) + period) % period; + } + let step = 0; + while (step + 1 < times.length && times[step + 1] <= local) step++; + return step; + } + + private applyMechanismPose(frames: Mechanism, partition: MechanismPartition, seconds: number) { + const times = frames.timeNum ?? []; + if (!frames.isMechanismValid() || times.length === 0) { + return; + } + const period = frames.cyclePeriod; + let local = seconds; + if (period > 0 && Number.isFinite(seconds)) { + local = seconds % period; + if (local < 0) local += period; + } + let step = 0; + while (step + 1 < times.length && times[step + 1] <= local) step++; + const nextStep = Math.min(step + 1, times.length - 1); + const span = times[nextStep] - times[step]; + const blend = span > 0 ? Math.min(Math.max((local - times[step]) / span, 0), 1) : 0; + + const jointFrom = frames.joints[step]; + const jointTo = frames.joints[nextStep]; + partition.joints.forEach((j) => { + const from = jointFrom.find((candidate) => candidate.id === j.id); + const to = jointTo.find((candidate) => candidate.id === j.id); + if (!from || !to) { + return; + } j.x = from.x + (to.x - from.x) * blend; j.y = from.y + (to.y - from.y) * blend; }); - this.links.forEach((l, l_index) => { + partition.links.forEach((l) => { if (!(l instanceof RealLink)) { return; } - const link = frames.links[step][l_index]; + const link = frames.links[step].find((candidate) => candidate.id === l.id); if (!(link instanceof RealLink)) { return; } @@ -2375,9 +3689,12 @@ export class MechanismService { } this.placeLinkGeometry(l, link, blend); }); - this.forces.forEach((f, f_index) => { - const from = frames.forces[step][f_index]; - const to = frames.forces[nextStep][f_index]; + partition.forces.forEach((f) => { + const from = frames.forces[step].find((candidate) => candidate.id === f.id); + const to = frames.forces[nextStep].find((candidate) => candidate.id === f.id); + if (!from || !to) { + return; + } f.startCoord.x = from.startCoord.x + (to.startCoord.x - from.startCoord.x) * blend; f.startCoord.y = from.startCoord.y + (to.startCoord.y - from.startCoord.y) * blend; f.endCoord.x = from.endCoord.x + (to.endCoord.x - from.endCoord.x) * blend; @@ -2402,14 +3719,41 @@ export class MechanismService { private restoreStartPose() { // While playing, the drawn pose is blended past its sample, so step 0 alone // does not mean the joints hold the start pose — only paused-at-0 does. - if (this.atStartPose() || !this.mechanisms[0]?.joints[0]?.length) { + if (this.atStartPose() || !this.masterMechanism()?.joints[0]?.length) { return; } - this.applyPose(0, 0); + // Every machine at *its own* time zero, bypassing the clocks entirely. + // Going through applyPose would have honoured the private clocks while + // unsynced and left each mechanism wherever its own scrubber was -- and + // since this runs immediately before a rebuild deep-copies the editable + // joints as t = 0, that silently redefines the start pose as wherever + // playback happened to be. The pose then ratchets forward on every edit. + this.poseRevision++; + this.mechanisms.forEach((frames, index) => { + this.applyMechanismPose(frames, this.partitions[index], 0); + }); } + /** + * Are the editable objects holding the pose a rebuild may treat as t = 0? + * + * Every clock has to be at zero, not just the shared one: while unsynced a + * row can be scrubbed away from the start with the shared step still reading + * zero, and that combination used to answer yes. + */ private atStartPose(): boolean { - return this.mechanismTimeStep === 0 && !AnimationBarComponent.animate; + if (this.isPlaying) { + return false; + } + if (!this.syncMechanisms && this.ownSeconds.some((seconds) => seconds !== 0)) { + return false; + } + return this.mechanismTimeStep === 0; + } + + /** The same question, for a control that has to say when it would do nothing. */ + isAtStartPose(): boolean { + return this.atStartPose(); } /** @@ -2427,24 +3771,87 @@ export class MechanismService { } /** - * How far playback sits past sample `step`, as a 0..1 fraction of the way to the - * next one. Zero for any seek and at the last sample, where there is nothing to - * blend toward. + * Draw the way back to the start of the cycle rather than cutting to it. + * + * Leaving an analysis mode rewinds the mechanism, and doing that in one frame + * teleports a linkage the user was just watching move -- the pose they had + * paused on is replaced by a different one with nothing in between, which + * reads as the drawing breaking rather than as playback ending. + * + * This is a seek per frame, not playback: it never runs the solver and it + * lands on exactly the same pose the cut landed on. If anything else moves + * the mechanism while it is running -- another seek, a rebuild, playback + * starting again -- it stops where it is and leaves that caller alone. */ - private blendToNextSample(step: number): number { - if (!this.advancingPlayback) { - return 0; - } - const times = this.sampleTimes(); - if (step + 1 >= times.length) { - return 0; + easeToStart(durationMs = 220): void { + if (this.atStartPose()) return; + // Each machine goes back to its own start, on its own clock, by whichever + // way round is shorter for it. Driving this off the shared sample index + // pulled every machine onto the master's time on the first frame -- so + // three machines at three different places in their cycles all jumped to + // one place and then eased down together, which is the bounce. + const from = this.mechanisms.map((_, index) => this.secondsOf(index)); + const periods = this.mechanisms.map((mechanism) => mechanism.cyclePeriod); + const deltas = from.map((seconds, index) => { + const period = periods[index]; + if (!(period > 0) || !(seconds > 0)) return 0; + return seconds > period / 2 ? period - seconds : -seconds; + }); + if (deltas.every((delta) => delta === 0)) { + this.rewindToStart(); + return; } - const span = times[step + 1] - times[step]; - if (!(span > 0)) { - return 0; + + this.isPlaying = false; + this.ownPlaying = this.ownPlaying.map(() => false); + let startedAt: number | null = null; + let lastDrawn = from.slice(); + + const frame = (now: number) => { + // Someone else has taken a machine somewhere since the last frame. + // Whatever they wanted, it is newer than this. + if (this.mechanisms.some((_, index) => this.secondsOf(index) !== lastDrawn[index])) { + return; + } + startedAt ??= now; + const t = Math.min(1, (now - startedAt) / durationMs); + // Ease out: quick off the pose being left, gentle into the start. + const eased = 1 - (1 - t) ** 3; + const next = from.map((seconds, index) => { + const period = periods[index]; + const ahead = seconds + eased * deltas[index]; + return period > 0 ? ((ahead % period) + period) % period : ahead; + }); + // The first frame is the pose already on screen; drawing it again is a + // frame's worth of nothing. A microsecond, not exact equality: wrapping + // into the period puts float noise on a value that has not moved. + if (next.some((seconds, index) => Math.abs(seconds - lastDrawn[index]) > 1e-6)) { + lastDrawn = next; + lastDrawn.forEach((seconds, index) => (this.ownSeconds[index] = seconds)); + this.drawOwnClocks(); + } + if (t < 1) requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + } + + /** + * Draw every machine where its own clock says, without moving any of them. + * + * `animate` treats an outside call as a seek of the whole drawing, which is + * right for a scrubber and wrong for anything that has already decided where + * each machine goes. + */ + private drawOwnClocks(playing = false): void { + const master = this.masterMechanismIndex(); + const step = + master === -1 ? this.mechanismTimeStep : this.stepAtTime(this.ownSeconds[master] ?? 0); + this.seekingOneMechanism = true; + try { + this.animate(step, playing); + } finally { + this.seekingOneMechanism = false; } - const fraction = (this.playbackTimeSeconds - times[step]) / span; - return Math.min(Math.max(fraction, 0), 1); } /** @@ -2490,7 +3897,7 @@ export class MechanismService { * many samples the cycle was solved into. */ private advancePlayback() { - if (!AnimationBarComponent.animate) { + if (!this.isPlaying) { this.playbackClockMs = null; return; } @@ -2499,9 +3906,32 @@ export class MechanismService { const elapsedSeconds = this.playbackClockMs === null ? 0 : (now - this.playbackClockMs) / 1000; this.playbackClockMs = now; this.playbackTimeSeconds = this.wrapTime( - this.playbackTimeSeconds + elapsedSeconds * this.animationSpeedMultiplier + this.playbackTimeSeconds + + elapsedSeconds * this.animationSpeedMultiplier * this.directionOf(0) ); + // Every running machine carries its own time forward, off the same wall + // clock. Synced means they are started and stopped together, not that they + // share a variable -- two machines at different speeds have no shared + // position to share. + this.mechanisms.forEach((mechanism, index) => { + if (!this.isMechanismPlaying(index) || !mechanism.isMechanismValid()) { + return; + } + const period = mechanism.cyclePeriod; + const next = + (this.ownSeconds[index] ?? 0) + + elapsedSeconds * this.animationSpeedMultiplier * this.directionOf(index); + this.ownSeconds[index] = period > 0 ? ((next % period) + period) % period : next; + }); + + // The shared readout follows the master machine, which is the one the + // sample index and the graphs have always meant. + const master = this.masterMechanismIndex(); + if (master !== -1) { + this.playbackTimeSeconds = this.ownSeconds[master] ?? this.playbackTimeSeconds; + } + this.advancingPlayback = true; try { // Blend forward from the sample at or before now, not the nearest one. @@ -2511,8 +3941,363 @@ export class MechanismService { } } + /** + * Turn one machine's drive round without moving it, and without moving the + * cycle under the reader. + * + * Nothing about the loop of poses changes: the machine walks the same loop + * the other way. So the frames stay exactly where they are, the pose on + * screen stays exactly where it is, the clock is not touched -- and the only + * things that change are the sign of the drive, the way the playhead + * travels, and the sign of every rate derived from it. + * + * That is the whole reason this does not re-solve or mirror. Either of those + * puts every pose at a different time, so the curve a reader was following + * slides end for end and the peak they were reading jumps across the chart, + * for a machine that has not moved at all. + */ + reverseDrive(index: number): boolean { + const driven = this.partitions[index]?.joints.find( + (joint): joint is RealJoint => joint instanceof RealJoint && joint.input + ); + if (!driven) return false; + + // Write every machine's current speed onto its own drive first. Setting one + // machine's speed also moves the document-wide default, and a machine that + // has never been given a speed of its own reads that default -- so + // reversing one machine turned round every machine that had not been + // turned round before. + this.pinDriveSpeeds(); + this.setDriveSpeed(driven, -this.driveSpeedOf(driven)); + + // Not saved. Reversing is done from the transport, which is a way of + // watching the drawing rather than of changing it, and nothing else a + // reader does in an analysis mode lands in the undo history. The drive + // still carries its new sign, so the next real edit writes it out. + const reversed = this.mechanisms[index]?.withReversedDrive(); + if (reversed) { + this.mechanisms[index] = reversed; + // A solved cycle has been replaced without going through + // `updateMechanism`, so say so. The graphs notice by object identity, + // but the export's sampled tables are cached against this counter -- + // without the bump an export taken after reversing served the rates it + // had sampled before, every angular velocity still carrying the sign it + // had turned round from. + this.solveRevision++; + // Through the cycle the other way, from where it stands. + this.playbackDirection[index] = this.directionOf(index) < 0 ? 1 : -1; + } else { + this.updateMechanism(false); + } + + // Every rate on screen has just turned round, and a graph only redraws when + // it is told. Geometry is locked in the analysis modes, so this is the one + // thing a reader can do there that changes what the graphs say. + this.onMechUpdateState.next(2); + return true; + } + + /** + * Where one machine's input sits, out of everything it can do, 0..1. + * + * The transport's own coordinate. Cached against the solved Mechanism, so it + * is rebuilt exactly when the cycle is. + */ + travelOf(index: number): number | undefined { + const mechanism = this.mechanisms[index]; + const profile = this.driveProfileOf(index); + if (!mechanism || !profile) return undefined; + const period = mechanism.cyclePeriod; + const last = profile.along.length - 1; + if (!(period > 0) || last <= 0) return profile.along[0] ?? 0; + const fraction = Math.min(Math.max(this.secondsOf(index) / period, 0), 1); + return profile.along[Math.min(Math.round(fraction * last), last)]; + } + + /** + * Put every machine at one moment of the longest cycle in the drawing. + * + * The combined handle measures *time*, where a single machine's own handle + * measures how far along its input has come. That is the right thing for one + * machine -- degrees of crank, centimetres of ram -- but there is no such + * thing as the input position of three machines at once, and the leader's own + * is worse than useless when the leader rocks: a rocking input passes through + * the same position twice per cycle, so "where the handle is" answered with + * two different times and a drag across it ran the whole drawing backwards. + * Dragged from end to end it jumped back thirty times in two hundred steps. + * + * Time has one answer everywhere, always increases with the handle, and is + * what the machines are actually sharing while they are synced. + */ + seekAllAlong(leader: number, along: number): void { + const period = this.mechanisms[leader]?.cyclePeriod; + if (!(period > 0)) return; + this.seekAllTo(Math.min(Math.max(along, 0), 1) * period); + this.drawOwnClocks(this.isPlaying); + } + + /** + * Is this machine's input going the way its drive speed says, right now? + * + * Read off the profile's own slope rather than off the half-way point of the + * cycle: a ram does not turn around half way through its period, it turns + * around when it reaches the end of its stroke, and those are not the same + * moment. Playback running backwards flips the answer again. + */ + travellingForward(index: number): boolean { + const profile = this.driveProfileOf(index); + const mechanism = this.mechanisms[index]; + const forwardDrive = (mechanism?.inputAngularVelocities[0] ?? 0) < 0; + // Which way playback runs *through the frames*, which is not the same as + // which way it runs through the cycle once the drive has been turned round: + // reversing walks the frames backwards precisely so the machine goes + // forwards along its new direction, and counting both flips cancelled them. + const rewinding = this.directionOf(index) < 0 !== (mechanism?.framesRunBackwards ?? false); + if (!profile || !mechanism || profile.continuous) { + return forwardDrive !== rewinding; + } + const period = mechanism.cyclePeriod; + const last = profile.along.length - 1; + if (!(period > 0) || last <= 0) return forwardDrive !== rewinding; + const sample = Math.min( + Math.round(Math.min(Math.max(this.secondsOf(index) / period, 0), 1) * last), + last + ); + const before = profile.along[Math.max(sample - 1, 0)]; + const after = profile.along[Math.min(sample + 1, last)]; + const rising = after >= before; + return rising !== rewinding; + } + + /** + * Give every machine a drive speed of its own. + * + * Until one is set, a driven joint reads the document-wide default, which + * means two machines share a number and neither of them knows it. + */ + private pinDriveSpeeds(): void { + this.partitions.forEach((partition) => { + const driven = partition.ownJoints.find( + (joint): joint is RealJoint => joint instanceof RealJoint && joint.input + ); + if (driven && driven.driveSpeed === 0) { + driven.driveSpeed = this.driveSpeedOf(driven); + } + }); + } + + /** + * Which way the input crank is pointing, in degrees from the positive x axis. + * + * An absolute bearing, not a count of how far round it has come: a reader + * comparing the drawing with the readout is looking at where the crank is + * pointing, and "0" meaning "wherever it was drawn" is a different question + * they did not ask. + * + * Except on a cycle of several turns — a branch-swapping slide closes after + * two — where the bearing repeats every turn and cannot say which one the + * machine is on. There the readout is progress round the whole cycle, + * 0 to 720. + */ + inputAngleDegrees(index: number): number | undefined { + const partition = this.partitions[index]; + const driven = partition?.ownJoints.find( + (joint): joint is RealJoint => joint instanceof RealJoint && joint.input + ); + if (!driven) return undefined; + // The end of the crank, which is what points somewhere -- the input joint + // itself is usually pinned to the frame and points nowhere. + const end = driven.connectedJoints.find((joint) => !(joint as RealJoint).ground); + if (!end) return undefined; + const samples = this.mechanisms[index]?.joints.length ?? 0; + const turns = samples > 1 ? Math.round((samples - 1) / 360) : 1; + const profile = this.driveProfileOf(index); + if (turns > 1 && profile?.continuous && !profile.linear) { + return (this.currentSampleOf(index) * (turns * 360)) / (samples - 1); + } + const degrees = (Math.atan2(end.y - driven.y, end.x - driven.x) * 180) / Math.PI; + return (degrees + 360) % 360; + } + + /** Put one machine at a place along its input's travel. */ + seekMechanismTo(index: number, along: number): void { + const mechanism = this.mechanisms[index]; + const profile = this.driveProfileOf(index); + if (!mechanism || !profile) return; + const period = mechanism.cyclePeriod; + const last = profile.along.length - 1; + if (!(period > 0) || last <= 0) return; + const nearSample = Math.round(Math.min(Math.max(this.secondsOf(index) / period, 0), 1) * last); + const sample = fractionalSampleAlong(profile, Math.min(Math.max(along, 0), 1), nearSample); + this.seekMechanism(index, (sample / last) * period); + } + + driveProfileOf(index: number): DriveProfile | undefined { + const mechanism = this.mechanisms[index]; + if (!mechanism) return undefined; + if (!this.profiles.has(mechanism)) { + // A ram is measured by its own extension, so the profile is told which + // two joints that is between. + const driven = mechanism.joints[0]?.find((joint) => (joint as RealJoint).input); + const sealed = driven && this.cylinderAt(this.joints.find((j) => j.id === driven.id)); + const ram = sealed ? { from: sealed.barrelFar.id, to: sealed.rodFar.id } : undefined; + this.profiles.set(mechanism, buildDriveProfile(mechanism, ram) ?? null); + } + return this.profiles.get(mechanism) ?? undefined; + } + + private profiles = new WeakMap(); + + /** Which way this machine's playback is running: +1 forward, -1 backward. */ + directionOf(index: number): number { + return this.playbackDirection[index] === -1 ? -1 : 1; + } + + /** + * Turn a reversing machine's playback round without moving it. + * + * It keeps its place in the cycle and keeps running; only the way it is + * headed changes. + */ + setPlaybackDirection(index: number, direction: number): void { + this.playbackDirection[index] = direction < 0 ? -1 : 1; + // The clock measures from the last frame, and this frame is not late. + this.playbackClockMs = null; + } + + /** Where one machine is in its own cycle. */ + secondsOf(index: number): number { + return this.ownSeconds[index] ?? 0; + } + + /** Put one machine at a place in its own cycle, and leave the others alone. */ + seekMechanism(index: number, seconds: number): void { + const period = this.mechanisms[index]?.cyclePeriod ?? 0; + // Exactly the period is the end of the cycle, not the start: dragging the + // handle to the track's right edge should read 24.00 s, not 0.00 s. + const local = + period > 0 ? (seconds === period ? period : ((seconds % period) + period) % period) : seconds; + this.ownSeconds[index] = local; + // The sample index is the master machine's, and half the app reads it -- + // the graphs, the URL, and the rule that says the editor is only open at + // the start pose. Moving the master without it left the drawing mid-cycle + // while everything else believed it was parked. + const step = + index === this.masterMechanismIndex() ? this.stepAtTime(local) : this.mechanismTimeStep; + this.seekingOneMechanism = true; + try { + this.animate(step, this.isPlaying); + } finally { + this.seekingOneMechanism = false; + } + } + + /** + * Put every machine at the same moment of the shared clock. + * + * Each in its own cycle: a machine whose cycle is shorter than the one the + * sample index is measured in wraps inside it rather than running out. + */ + private seekAllTo(seconds: number): void { + this.mechanisms.forEach((mechanism, index) => { + if (!mechanism.isMechanismValid()) return; + const period = mechanism.cyclePeriod; + this.ownSeconds[index] = period > 0 ? ((seconds % period) + period) % period : seconds; + }); + } + + /** + * Is this machine running? + * + * Synced, one flag answers for all of them -- that is what synced means. Set + * them apart and each row answers for itself, and stopping one of them says + * nothing about the rest. + */ + isMechanismPlaying(index: number): boolean { + if (this.syncMechanisms) { + return this.isPlaying && (this.mechanisms[index]?.isMechanismValid() ?? false); + } + return !!this.ownPlaying[index]; + } + + toggleMechanismPlaying(index: number): void { + this.ownPlaying[index] = !this.isMechanismPlaying(index); + // The frame loop is shared, so it has to be running for any machine to move + // -- and there is no reason for it to be running when none of them are. The + // transport's own button reads this flag, so leaving it set with every row + // stopped showed a pause button over a drawing that was not moving. + this.isPlaying = this.ownPlaying.some(Boolean); + this.drawOwnClocks(this.isPlaying); + } + + /** + * Start or stop everything at once, from the transport's own button. + * + * Unsynced, the rows are what actually run, so the master has to move them + * rather than a flag they do not read. + */ + setAllPlaying(playing: boolean): void { + this.isPlaying = playing; + this.ownPlaying = this.mechanisms.map((mechanism) => playing && mechanism.isMechanismValid()); + this.drawOwnClocks(playing); + } + + /** + * Switch between one clock and several. + * + * Leaving sync hands every machine the time it is showing right now, so + * nothing jumps at the moment the toggle is pressed; returning to sync puts + * them all back on the shared clock, which is where they visibly converge. + */ + setSyncMechanisms(sync: boolean): void { + if (sync === this.syncMechanisms) { + return; + } + this.syncMechanisms = sync; + // Coming back to one row means one answer about what is running, so the + // machines are put back in step with each other -- not with a shared + // variable, which no longer exists, but with each other's play state. + if (sync) { + this.ownPlaying = this.mechanisms.map( + (mechanism) => this.isPlaying && mechanism.isMechanismValid() + ); + // And back onto one clock. Synced means one row and one handle, and a + // handle standing for machines that are at four different times can only + // lie about three of them -- which is what made it jump when dragged + // after a spell apart. + this.seekAllTo(this.ownSeconds[this.masterMechanismIndex()] ?? 0); + } + this.drawOwnClocks(this.isPlaying); + } + + /** + * Whether lock styling paints right now. A lock is an editing affordance, + * so the mark shows only where it means something: the Edit tab, standing + * still. Analysis reads clean, and a locked coupler mid-animation does not + * look pinned while visibly moving. + */ + lockVisualsOn(): boolean { + return this.tabs.getCurrentTab() === TabID.EDIT && !this.isPlaying; + } + + /** Painted as held: the joint itself, wherever its stillness comes from. */ + isJointLockedVisual(joint: Joint): boolean { + return this.lockVisualsOn() && frozenJointIds(this.joints, this.links).has(joint.id); + } + + /** Painted as held: a body whose whole pose is frozen, not one merely touched. */ + isLinkLockedVisual(link: Link): boolean { + if (!this.lockVisualsOn()) return false; + const frozen = frozenJointIds(this.joints, this.links); + return link.joints.length > 0 && link.joints.every((joint) => frozen.has(joint.id)); + } + getJointCSSClass(joint: Joint) { - // const j = joint as RealJoint; + const lockSuffix = this.isJointLockedVisual(joint) ? ' joint-locked' : ''; + return this.jointStateClass(joint) + lockSuffix; + } + + private jointStateClass(joint: Joint) { if ( NewGridComponent.debugGetJointState() == jointStates.dragging && joint.id === this.activeObjService.selectedJoint.id @@ -2526,6 +4311,21 @@ export class MechanismService { ) { return 'joint-selected'; } + // Selecting a whole machine selects everything in it, so every one of its + // joints reads as selected rather than the reader having to infer the + // extent of the thing they just picked. + if (this.isPartInert(joint)) { + return 'joint-inert'; + } + if (this.isInSelectedMechanism(joint)) { + return 'joint-selected'; + } + if (this.isHoveredPart(joint)) { + return 'joint-pointed'; + } + if (this.isInHoveredMechanism(joint)) { + return 'joint-highlight'; + } if (joint.showHighlight) { return 'joint-highlight'; } else { @@ -2533,13 +4333,177 @@ export class MechanismService { } } + /** + * Geometry the analysis modes have nothing to say about. + * + * A machine that cannot be simulated has no cycle, no graphs and no row in + * the transport, so in an analysis mode it is scenery: drawn so the reader + * can see it is still there, greyed so they can see it is not part of the + * question, and not selectable, because every panel behind a selection is + * about a machine that runs. + */ + isPartInert(part: Joint | Link): boolean { + return this.tabs.isAnalysisMode() && !this.isPartSimulatable(part); + } + + /** + * The tab service, held after the first ask. + * + * It is fetched through the injector to break a cycle, and `isPartInert` runs + * once per joint and once per link on every change detection pass -- so the + * lookup itself was on the redraw path. + */ + private tabsService?: SelectedTabService; + private get tabs(): SelectedTabService { + return (this.tabsService ??= this.injector.get(SelectedTabService)); + } + + /** Is this part of the machine the reader has selected as a whole? */ + isPartInSelectedMechanism(part: Joint | Link): boolean { + return this.isInSelectedMechanism(part); + } + + /** Is this part of the machine the reader is pointing at? */ + isPartInHoveredMechanism(part: Joint | Link): boolean { + return this.isInHoveredMechanism(part); + } + + /** Is this part of the machine the reader has selected as a whole? */ + private isInSelectedMechanism(part: Joint | Link): boolean { + if (this.activeObjService.objType !== 'Mechanism') { + return false; + } + return this.isInPartition(part, this.activeObjService.selectedMechanismIndex); + } + + /** + * Which machine the reader is pointing at without having picked it yet. + * + * The transport names machines M1, M2, M3 and there is nothing in those + * names to say which is which. Pointing at one lights it up on the canvas, + * so the answer is available before committing to a selection. + */ + hoveredMechanismIndex = -1; + + /** + * The one part a list elsewhere is pointing at. + * + * The export drawer lists every joint and link by name, and a name is not a + * place: a reader ticking `Joint F` on a Jansen leg has no idea which of + * eleven pins that is. Pointing at the row lights it on the canvas. + * + * Deferred to whatever is selected: a selection is the stronger statement, + * and a hover that repainted over it would take the reader's own mark away + * while they were reading the list beside it. + */ + hoveredPart: Joint | Link | undefined; + + private isHoveredPart(part: Joint | Link): boolean { + return !!this.hoveredPart && this.hoveredPart.id === part.id && this.nothingIsChosen(); + } + + /** + * Whether the canvas is free to answer a pointed-at row. + * + * The canvas carries one mark because it holds one selection, and a hover + * that added a second while a joint, a link, a force or a whole machine was + * already chosen made the drawing say two things at once. + */ + private nothingIsChosen(): boolean { + const chosen = this.activeObjService.objType; + return chosen !== 'Joint' && chosen !== 'Link' && chosen !== 'Force' && chosen !== 'Mechanism'; + } + + /** + * Whether the canvas's current selection is this joint. + * + * The selection *type* decides, not the remembered object: clicking the grid + * sets the type to `Grid` and leaves `selectedJoint` holding whatever was + * chosen before, so a list reading that field alone went on marking a row + * after the reader had let go of the thing it names. + */ + isSelectedJoint(joint: Joint | undefined): boolean { + return ( + !!joint && + this.activeObjService.objType === 'Joint' && + this.activeObjService.selectedJoint?.id === joint.id + ); + } + + /** + * Whether the canvas's current selection is this body. + * + * A sealed cylinder answers for all of its pieces, as it does everywhere + * else: the list offers the ram as one part, and clicking it on the canvas + * lands on whichever of the barrel, the block or the rod the pointer was + * over. + */ + isSelectedBody(body: Link | undefined): boolean { + if (!body || this.activeObjService.objType !== 'Link') return false; + const chosen = this.activeObjService.selectedLink; + if (!chosen) return false; + if (chosen.id === body.id) return true; + const cylinder = this.cylinderAt(chosen); + return !!cylinder && cylinder === this.cylinderAt(body); + } + + /** + * Whether this body is the one a list is pointing at. + * + * A sealed cylinder answers for all of its pieces: the list offers the ram + * as one part, and the canvas draws it as a barrel, a block and a rod under + * one silhouette — so pointing at the row has to light that silhouette + * whichever piece the mark happens to be built around. + */ + isPointedAtBody(body: Link | undefined): boolean { + const pointed = this.hoveredPart; + if (!body || !pointed || pointed instanceof Joint || !this.nothingIsChosen()) return false; + if (pointed.id === body.id) return true; + const cylinder = this.cylinderAt(pointed); + return !!cylinder && cylinder === this.cylinderAt(body); + } + + private isInHoveredMechanism(part: Joint | Link): boolean { + return this.hoveredMechanismIndex >= 0 + ? this.isInPartition(part, this.hoveredMechanismIndex) + : false; + } + + private isInPartition(part: Joint | Link, index: number): boolean { + const partition = this.partitions[index]; + if (!partition) { + return false; + } + return ( + partition.ownJoints.some((joint) => joint.id === part.id) || + partition.links.some((link) => link.id === part.id) + ); + } + getLinkCSSClass(link: Link) { + const lockSuffix = this.isLinkLockedVisual(link) ? ' link-locked' : ''; + return this.linkStateClass(link) + lockSuffix; + } + + private linkStateClass(link: Link) { + if (this.isPartInert(link)) { + return 'link-inert'; + } if ( this.activeObjService.objType == 'Link' && link.id === this.activeObjService.selectedLink.id ) { return 'link-selected'; } + if (this.isInSelectedMechanism(link)) { + return 'link-selected'; + } + if (this.isHoveredPart(link)) { + return 'link-pointed'; + } + if (this.isInHoveredMechanism(link)) { + return 'link-hovered'; + } return 'link-default'; } @@ -2568,6 +4532,7 @@ export class MechanismService { isJointOrphan(joint: Joint) { //Return true if the given joint is an orphan (not part of a link). + if (this.jointsOnALink) return !this.jointsOnALink.has(joint); return this.links.every((l) => !l.joints.includes(joint)); } @@ -2591,7 +4556,8 @@ export class MechanismService { if (!this.weldTopology(joint)) return; if (created) { - NewGridComponent.sendNotification( + this.notify.warning( + 'weld.pinned-twice', `${created[0]} and ${created[1]} are now pinned together twice. The linkage still ` + 'moves, but its forces have no unique solution.' ); @@ -2854,8 +4820,13 @@ export class MechanismService { ); } - createForce(startCoord: Coord, endCoord: Coord): Force | undefined { - const selectedLink = this.activeObjService.selectedLink; + /** + * `onLink` is handed in by the gesture that started this, because the force + * row is on a joint's menu as well as a link's and the selection there is + * the joint. Falls back to the selected link for the callers that have one. + */ + createForce(startCoord: Coord, endCoord: Coord, onLink?: RealLink): Force | undefined { + const selectedLink = onLink ?? this.activeObjService.selectedLink; if (!(selectedLink instanceof RealLink)) return undefined; startCoord = new Coord(startCoord.x, startCoord.y); endCoord = new Coord(endCoord.x, endCoord.y); diff --git a/src/app/services/notification.service.ts b/src/app/services/notification.service.ts new file mode 100644 index 00000000..929372a9 --- /dev/null +++ b/src/app/services/notification.service.ts @@ -0,0 +1,176 @@ +import { Injectable } from '@angular/core'; + +/** + * How much the reader is meant to care. + * + * The distinction that matters is not severity but *who acted*. A refusal is + * the app declining something the reader just tried: they are already looking + * at the place it happened, they know what they asked for, and the message is + * over as soon as it is read. A warning is the opposite — it reports a state + * the drawing is now in, which nobody asked for and which is still true after + * the message goes away. That is the one that has to wait to be dismissed. + */ +export type NotificationKind = 'success' | 'refusal' | 'warning' | 'failure'; + +/** + * Something to do about it, offered on the message itself. + * + * A message that says "set the object scale in Settings" is asking the reader + * to go and find a control in order to carry out an instruction the app could + * simply follow. Where the fix is unambiguous, it goes on the message. + * + * More than one is allowed, because "unambiguous" is not the same as "single": + * a drawing at the wrong size can be fixed by resizing the drawing or by + * moving the view, and which of those somebody wants depends on what they were + * doing. Two is the sensible limit — a third is a menu, and a menu on a message + * that may take itself away in four seconds is a trap. + */ +export interface NotificationAction { + label: string; + run: () => void; +} + +export interface NotificationOptions { + /** How long this message stays quiet after saying itself, in ms. */ + cooldownMs?: number; + actions?: NotificationAction[]; +} + +/** One message, while it is on screen. */ +export interface LiveNotification { + /** Distinct per appearance, so a message said twice animates twice. */ + key: number; + id: string; + kind: NotificationKind; + text: string; + actions: NotificationAction[]; +} + +/** How long each kind stays, in ms. `undefined` means until it is dismissed. */ +const DURATION: Record = { + // Confirmation of something the reader already watched happen. It is there to + // be caught out of the corner of an eye, not read. + success: 2500, + refusal: 4000, + warning: undefined, + failure: undefined, +}; + +/** How long the same message stays quiet after saying itself, in ms. */ +const DEFAULT_COOLDOWN = 800; + +/** + * How many may be on screen at once. + * + * More than three is a wall of text over the drawing, and the fourth is worth + * less than the view it covers. The oldest that was going to leave by itself + * makes room; one waiting to be dismissed is not pushed out from under the + * reader. + */ +const MAX_STACK = 3; + +/** + * Everything the app says out loud, said in one place. + * + * Replaces `NewGridComponent.sendNotification`, which had two faults that were + * not about wording. It kept a single `lastNotificationTime` for the whole + * app, so an unrelated message a moment earlier silenced the next one — two + * different refusals in the same second showed only the first. And it + * initialised that timestamp to the moment the canvas was constructed, so any + * message asking for a long quiet period was muted for that period *from page + * load*: the two zoom warnings could not appear in the first twenty seconds of + * a session, which is when zooming happens. + * + * The cooldown here is per message id, and starts unarmed. + * + * This keeps its own stack rather than driving `MatSnackBar`, which shows one + * message at a time and takes the previous one away to do it — so a refusal + * could silently swallow a warning the reader had not read yet. + */ +@Injectable({ providedIn: 'root' }) +export class NotificationService { + /** What is on screen, oldest first. Read by the stack component. */ + readonly live: LiveNotification[] = []; + + private lastSaid = new Map(); + private timers = new Map>(); + private nextKey = 1; + + /** Something the reader asked for, happened. */ + success(id: string, text: string, options?: NotificationOptions): void { + this.say('success', id, text, options); + } + + /** Something the reader asked for, did not happen, and why. */ + refusal(id: string, text: string, options?: NotificationOptions): void { + this.say('refusal', id, text, options); + } + + /** It happened, but the drawing is now in a state worth knowing about. */ + warning(id: string, text: string, options?: NotificationOptions): void { + this.say('warning', id, text, options); + } + + /** Something outside the reader's control went wrong. */ + failure(id: string, text: string, options?: NotificationOptions): void { + this.say('failure', id, text, options); + } + + dismiss(key: number): void { + const at = this.live.findIndex((one) => one.key === key); + if (at === -1) return; + this.live.splice(at, 1); + const timer = this.timers.get(key); + if (timer !== undefined) clearTimeout(timer); + this.timers.delete(key); + } + + dismissAll(): void { + [...this.live].forEach((one) => this.dismiss(one.key)); + } + + /** Do what the message offers, and take the message away. */ + act(one: LiveNotification, action: NotificationAction): void { + action.run(); + this.dismiss(one.key); + } + + private say( + kind: NotificationKind, + id: string, + text: string, + { cooldownMs = DEFAULT_COOLDOWN, actions = [] }: NotificationOptions = {} + ): void { + // Already on screen. Saying it again would stack the same sentence twice -- + // which is exactly what a reader holding a key down or dragging against a + // rule would produce. + if (this.live.some((one) => one.id === id)) return; + + const last = this.lastSaid.get(id); + if (last !== undefined && last + cooldownMs > Date.now()) return; + this.lastSaid.set(id, Date.now()); + + const one: LiveNotification = { key: this.nextKey++, id, kind, text, actions }; + this.live.push(one); + this.makeRoom(); + + const duration = DURATION[kind]; + if (duration !== undefined) { + this.timers.set( + one.key, + setTimeout(() => this.dismiss(one.key), duration) + ); + } + } + + /** Drop the oldest message that was leaving anyway, until the stack fits. */ + private makeRoom(): void { + while (this.live.length > MAX_STACK) { + const leaving = this.live.find((one) => DURATION[one.kind] !== undefined); + // Nothing but messages waiting to be dismissed. The newest is the one the + // reader has a chance of connecting to what they just did, so the oldest + // still goes -- but it has at least been on screen the longest. + this.dismiss((leaving ?? this.live[0]).key); + } + } +} diff --git a/src/app/services/number-unit-parser.service.ts b/src/app/services/number-unit-parser.service.ts index 7c1d19fe..83b30dd8 100644 --- a/src/app/services/number-unit-parser.service.ts +++ b/src/app/services/number-unit-parser.service.ts @@ -50,6 +50,8 @@ export class NumberUnitParserService { return 'lbm'; case InertiaUnit.KG_CM2: return 'kg·cm²'; + case InertiaUnit.G_CM2: + return 'g·cm²'; case InertiaUnit.KG_M2: return 'kg·m²'; case InertiaUnit.LBM_IN2: @@ -276,6 +278,8 @@ export class NumberUnitParserService { public getInertiaUnit(input: string): InertiaUnit { switch (this.normalizeUnitToken(input)) { + case 'gcm2': + return InertiaUnit.G_CM2; case 'kgcm2': return InertiaUnit.KG_CM2; case 'kgm2': @@ -288,6 +292,58 @@ export class NumberUnitParserService { } } + /** The mass unit that pairs with a length system: g / kg / lbm. */ + public massUnitFor(length: LengthUnit): MassUnit { + switch (length) { + case LengthUnit.INCH: + return MassUnit.LBM; + case LengthUnit.METER: + return MassUnit.KG; + default: + return MassUnit.GRAM; + } + } + + /** + * The unit an inertia field shows beside its number, per length system: + * g·cm² / kg·m² / lbm·in², each coherent with the mass unit beside it. + */ + public displayInertiaUnit(length: LengthUnit): InertiaUnit { + switch (length) { + case LengthUnit.INCH: + return InertiaUnit.LBM_IN2; + case LengthUnit.METER: + return InertiaUnit.KG_M2; + default: + return InertiaUnit.G_CM2; + } + } + + /** + * The unit link.massMoI is *stored* in, per length system — the one the + * solver's siUnitFactors and every circulating URL are written against. + * Metric is the one system where it differs from the display unit. + */ + public storedInertiaUnit(length: LengthUnit): InertiaUnit { + switch (length) { + case LengthUnit.INCH: + return InertiaUnit.LBM_IN2; + case LengthUnit.METER: + return InertiaUnit.KG_M2; + default: + return InertiaUnit.KG_CM2; + } + } + + /** A stored inertia, formatted in the display unit for its length system. */ + public formatStoredInertia(stored: number, length: LengthUnit): string { + const display = this.displayInertiaUnit(length); + return this.formatValueAndUnit( + this.convertInertia(stored, this.storedInertiaUnit(length), display), + display + ); + } + public parseInertiaString(input: string, desiredUnits: InertiaUnit): [boolean, number] { const [value, unit] = this.preProcessInput(input); diff --git a/src/app/services/save-history.service.spec.ts b/src/app/services/save-history.service.spec.ts index df18e978..79b0ab8f 100644 --- a/src/app/services/save-history.service.spec.ts +++ b/src/app/services/save-history.service.spec.ts @@ -1,5 +1,5 @@ -import { Injector } from '@angular/core'; import { SaveHistoryService } from './save-history.service'; +import { withTestInjector } from '../../test-utils/mechanism-harness'; import { UrlGenerationService } from './url-generation.service'; import { UrlProcessorService } from './url-processor.service'; @@ -13,10 +13,13 @@ describe('SaveHistoryService', () => { const processor = { updateFromURL: (...args: unknown[]) => restores.push(args), } as unknown as UrlProcessorService; - const injector = { - get: () => processor, - } as unknown as Injector; - const history = new SaveHistoryService(generator, injector); + const history = withTestInjector( + [ + { provide: UrlGenerationService, useValue: generator }, + { provide: UrlProcessorService, useValue: processor }, + ], + () => new SaveHistoryService() + ); history.save(); history.save(); diff --git a/src/app/services/save-history.service.ts b/src/app/services/save-history.service.ts index 4ef736f7..3170eb9a 100644 --- a/src/app/services/save-history.service.ts +++ b/src/app/services/save-history.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Injector } from '@angular/core'; +import { Injectable, Injector, inject } from '@angular/core'; import { UrlGenerationService } from './url-generation.service'; import { UrlProcessorService } from './url-processor.service'; @@ -12,16 +12,14 @@ import { UrlProcessorService } from './url-processor.service'; providedIn: 'root', }) export class SaveHistoryService { + private urlGenerationService = inject(UrlGenerationService); + private injector = inject(Injector); + private history: string[] = []; // index of the current state in the history private index: number = -1; - constructor( - private urlGenerationService: UrlGenerationService, - private injector: Injector - ) {} - /* * Add a new state to the history. * If the current state is not the last state in the history, diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index 95078e98..f860e695 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -9,6 +9,24 @@ import { } from '../model/unit-enums'; import type { ForceAnalysisMode } from '../model/mechanism/force-solver'; import { MODEL_SCALE } from '../model/render-scale'; +import { OBJECT_SCALE } from '../model/object-scale'; +import { local_storage_available } from '../model/utils'; + +/** + * A preference remembered on this machine. + * + * Storage can be missing or refused -- private windows, an embedded frame -- + * and a setting that cannot be remembered is not worth failing over. + */ +function readStoredFlag(key: string, fallback: boolean): boolean { + if (!local_storage_available()) return fallback; + const stored = localStorage.getItem(key); + return stored === null ? fallback : stored === 'true'; +} + +export function writeStoredFlag(key: string, value: boolean): void { + if (local_storage_available()) localStorage.setItem(key, String(value)); +} @Injectable({ providedIn: 'root', @@ -20,8 +38,14 @@ export class SettingsService { // inputTorque = new BehaviorSubject(TorqueUnit.CM_N); globalUnit = new BehaviorSubject(GlobalUnit.METRIC); isInputCW = new BehaviorSubject(true); - /** Always RPM. The input panel converts for display via inputSpeedUnit. */ - inputSpeed = new BehaviorSubject(20); + /** + * Always RPM. The input panel converts for display via inputSpeedUnit. + * + * Slow on purpose: the point of the animation is to be watched, and 20 RPM + * is three seconds a turn, which is faster than a reader can follow a coupler + * point round. + */ + inputSpeed = new BehaviorSubject(5); /** * Display unit for the input joint's speed. Chosen from the Input Settings * section rather than typed, so it is a view preference only — inputSpeed @@ -44,21 +68,85 @@ export class SettingsService { animating = new BehaviorSubject(false); isShowMajorGrid = new BehaviorSubject(true); isShowMinorGrid = new BehaviorSubject(true); + /** + * Whether dragging lands on the grid. + * + * The reader's own editing preference, kept on their machine rather than in + * the URL: a shared link says what the mechanism is, and how someone else's + * cursor behaves while they edit their copy of it is not part of that. + * + * Off until asked for. Dragging has always put a joint exactly where the + * cursor let go, and a drawing where a joint cannot sit at 3.7 unless it is + * told otherwise is a different app from the one everyone already has. + */ + isSnapToGrid = new BehaviorSubject(readStoredFlag('snapToGrid', false)); + + /** + * Whether a drag squares itself up with its neighbours. + * + * On, because it always has been: a joint dragged nearly level with another + * one has been pulled level, with a guide line to say so, for as long as + * there have been guide lines. The toggle is for turning it off. + */ + isSnapToAlignment = new BehaviorSubject(readStoredFlag('snapToAlignment', true)); isShowID = new BehaviorSubject(false); - isShowCOM = new BehaviorSubject(false); + /** + * On by default, and remembered on this machine rather than in the URL: the + * mark only appears on links that have been given mass — a massless link has + * no centre of mass worth pointing at — so a fresh drawing shows nothing + * extra, and a link given weight announces it. Nearly every circulating URL + * carries the *old* default (off) in its stored bit, which is why the bit is + * no longer read: a display preference should not travel with the drawing. + */ + isShowCOM = new BehaviorSubject(readStoredFlag('showCoM', true)); + /** + * Whether weight loads the mechanism. On by default — every link with mass + * hangs from it — and recorded in the URL when turned off, so a shared + * analysis means the same thing on arrival. + */ + isGravity = new BehaviorSubject(true); + /** + * Whether the traced paths are drawn at all. + * + * Which joints trace their path is a property of the drawing, kept per joint + * and shared in its URL. This is the view's own switch over the lot of them: + * a drawing with several machines tracing at once is mostly ink, and reading + * it means putting the ink away for a moment -- not editing every joint that + * was asked to trace and then having to ask again. + */ + isShowTraces = new BehaviorSubject(true); + /** + * A link whose centre of mass to draw while the reader is asking about it. + * + * Hovering the analysis panel's centre-of-mass heading points at the thing on + * the grid the numbers under it describe, without turning the setting on + * behind the reader's back. + */ + previewCoMLinkId: string | null = null; tempGridDisable: boolean = false; //This is to hide the grid lines to fit only to the linkage when doing a svg fit isGridDebugOn: boolean = false; // In user units; the internal world is MODEL_SCALE times larger, and every // visual size in the mark system is a multiple of this number. // - // 0.7 rather than 1: joints, blocks and cylinder heads at full size crowd a - // linkage of ordinary proportions, and the first thing most people did was - // turn it down. A mechanism arriving from a URL carries its own scale, so - // this is what a new project starts at and what an older URL without the - // setting falls back to. - static _objectScale = new BehaviorSubject(0.7 * MODEL_SCALE); + // The subject itself lives in model/object-scale.ts — a leaf module — so + // that Coord's closeness thresholds can read it without importing this + // service and closing a module cycle. This static is its public face. + static _objectScale = OBJECT_SCALE; + + /** + * Whether anybody has said what size the drawn marks should be. + * + * A fit will otherwise choose one to suit the zoom, which a huge mechanism + * needs and a scale somebody picked must not be overruled by. Comparing the + * value against the default cannot answer this -- 0.7 is a perfectly ordinary + * thing to type -- so the act of choosing is recorded instead. It is not in + * the URL: every URL carries a scale whether or not its author chose it, so + * a drawing that arrives is exactly the case this cannot tell apart, and the + * value comparison is all that is left to go on there. + */ + static objectScaleChosen = false; static get objectScale(): number { return SettingsService._objectScale.value; diff --git a/src/app/services/slider-mark.service.ts b/src/app/services/slider-mark.service.ts index 1addfa76..565264ed 100644 --- a/src/app/services/slider-mark.service.ts +++ b/src/app/services/slider-mark.service.ts @@ -174,6 +174,52 @@ export class SliderMarkService { return `translate(${mark.x} ${mark.y}) rotate(${mark.rotation})`; } + /** + * The same mark with its frame turned to a different slot angle — the preview + * a block shows while it is being dropped into a slot it has not entered yet. + * + * The block turns to lie along the slot it is about to take; the links pinned + * to it do not. Their far joints have not moved, and after the drop they will + * still be exactly where they are now — a slot decides where the block points, + * not where its riders point. But a rider is drawn *in the block's frame*, so + * turning the frame and leaving the geometry swung every rider bodily about + * the pin. Between two slots declared in opposite order that is a half turn, + * and the rider was drawn pointing away from its own far joint, which sat + * there on the grid with nothing attached to it. + * + * So the geometry is turned back by exactly what the frame turns by, which + * leaves it where the world says it is. Only the pieces drawn in this frame + * are touched: the block and its arrows are meant to turn, and a grounded + * guide's rails carry a frame of their own. + */ + reframed(mark: SliderMark, rotationDeg: number): SliderMark { + const delta = ((rotationDeg - mark.rotation) * Math.PI) / 180; + const back = (path: string) => + path === '' + ? path + : transformRigidPath( + path, + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 0, y: 0 }, + { x: Math.cos(-delta), y: Math.sin(-delta) } + ); + const turnBack = ( + piece: T + ): T => ({ + ...piece, + path: back(piece.path), + outline: back(piece.outline), + cuts: piece.cuts.map(back), + }); + return { + ...mark, + rotation: rotationDeg, + riders: mark.riders.map(turnBack), + plate: mark.plate ? turnBack(mark.plate) : undefined, + }; + } + /** * `travel` is how far each slider's block runs across the solved timesteps, * keyed by joint id. Absent entries fall back to the drawn rail length. diff --git a/src/app/services/svg-grid.service.ts b/src/app/services/svg-grid.service.ts index 9ee43c92..586f4e11 100644 --- a/src/app/services/svg-grid.service.ts +++ b/src/app/services/svg-grid.service.ts @@ -1,4 +1,4 @@ -import { afterNextRender, Injectable, Injector } from '@angular/core'; +import { afterNextRender, DestroyRef, Injectable, Injector, inject } from '@angular/core'; // TS 6 no longer allows calling/constructing `import * as` namespaces of // CommonJS (export =) modules - use default imports for these two. import svgPanZoom from 'svg-pan-zoom'; @@ -6,14 +6,139 @@ import { Coord } from '../model/coord'; import { NewGridComponent } from '../component/new-grid/new-grid.component'; import { SettingsService } from './settings.service'; import { DragStateService } from './drag-state.service'; +import { NotificationService } from './notification.service'; +import { MechanismService } from './mechanism.service'; import Hammer from 'hammerjs'; import { MODEL_SCALE } from '../model/render-scale'; +import { DEFAULT_OBJECT_SCALE } from '../model/object-scale'; +import { + Rect, + centerOf, + drawingScreenBox, + fitsInside, + freeCanvasRect, + sameRect, +} from './view-framing'; +import { SelectedTabService } from '../selected-tab.service'; +import { CHROME_MOVED } from '../model/chrome-motion'; + +/** + * Minor lines to a major cell. + * + * Five, so the lines between two labels are whole units: the labels fall on + * multiples of five, and at four divisions every line between them was a + * quarter of whatever the label said. + */ +const MINOR_DIVISIONS = 5; +import { LengthUnit } from '../model/unit-enums'; + +/** One of each unit, in centimetres. The only ratio this file needs. */ +const LENGTH_IN_CM: Record = { + [LengthUnit.CM]: 1, + [LengthUnit.METER]: 100, + [LengthUnit.INCH]: 2.54, + [LengthUnit.NULL]: 1, +}; + +/** + * How much of the free canvas a fit fills. + * + * A margin all round, rather than the flat 30% shrink that a `fit()` followed + * by a `zoomOut()` used to leave: that number was chosen to clear a toolbar + * that has not existed for two layouts, and it was measured against the window + * rather than against the part of it the drawing can be seen in. + */ +const FIT_FILL = 0.86; + +/** + * The layers a fit is about. + * + * The grid ruling and the axes are drawn to the viewport, so they are always + * exactly as big as the window: a bounding box that includes them cannot be + * fitted to anything. Ordered as they are drawn, which is only for reading. + */ +const DRAWING_LAYERS = [ + 'railHolder', + 'jointBGHolder', + 'motorHolder', + 'linkHolder', + 'sliderHolder', + 'jointHolder', + 'pathsHolder', + 'forcesHolder', + // Synthesis is drawing too: in that mode the positions being designed for are + // often the only thing on the canvas, and a fit that could not see them + // framed an empty grid and left them off the side of it. + 'synthesis', +] as const; + +/** + * How big a drawn mark is, as a fraction of the mechanism's larger dimension. + * + * Read off the drawings rather than picked: the wiper is 12.8 units across and + * the scale a new project starts at draws its joints at 0.7, and the Jansen leg + * is 126 units across and its author chose 7 -- both a twentieth of the + * mechanism, from two people who never discussed it. Anything derived from the + * zoom instead would make the size of a joint depend on the size of the window, + * which is a property of the drawing depending on a property of the reader. + */ +const MARK_FRACTION = 0.055; + +/** + * How far off that a scale has to be before it is worth overriding. + * + * Wide, because the point is to fix the drawings the default is obviously + * wrong for rather than to have an opinion about every one. An ordinary + * mechanism lands within a few per cent of the default and must keep it + * exactly -- both because it is right, and because a scale that drifted on + * every load would churn the URL. + */ +const SCALE_SLACK = 2.5; + +/** How many pixels a mark is drawn at when only the view is being fitted to. */ +const MARK_TARGET_PX = 60; + +/** How far a drawing may hang out of the free canvas before it counts as lost. */ +const OVERHANG_SLACK = 8; + +/** + * How far inside the free canvas a point has to be to count as on screen. + * + * A position exactly on the edge is a position half of which is not there, and + * the mark drawn at one is bigger than the point it stands for. + */ +const REVEAL_MARGIN = 48; + +/** + * How long to watch the chrome before framing around where it ended up. + * + * A panel takes about a third of a second to glide, and the mode says it has + * changed before Angular has started moving it -- so a rect that looks settled + * in the first few frames is only one that has not begun to move. + */ +const SETTLE_STABLE_FRAMES = 3; +const SETTLE_MIN_MS = 360; +const SETTLE_MAX_MS = 900; @Injectable({ providedIn: 'root', }) export class SvgGridService { - public panZoomObject: any; + private settingsService = inject(SettingsService); + private dragState = inject(DragStateService); + private injector = inject(Injector); + private notify = inject(NotificationService); + private destroyRef = inject(DestroyRef); + + /** + * Where the cursor last was, in model units. + * + * Kept here rather than on the grid component so the status strip can read it + * without reaching into a component to do so -- and so it survives the strip + * and the grid being rebuilt independently. + */ + public cursorAt: { x: number; y: number } | null = null; + public panZoomObject!: SvgPanZoom.Instance; public CTM!: SVGMatrix; public viewBoxMinX: number = 0; public viewBoxMaxX: number = 0; @@ -37,11 +162,6 @@ export class SvgGridService { private MAX_ZOOM: number = 16.5; private MIN_ZOOM: number = 0.0002; - constructor( - private settingsService: SettingsService, - private dragState: DragStateService, - private injector: Injector - ) {} setNewElement(root: HTMLElement) { var eventsHandler; @@ -49,7 +169,7 @@ export class SvgGridService { eventsHandler = { haltEventListeners: ['touchstart', 'touchend', 'touchmove', 'touchleave', 'touchcancel'], - init: function (options: any) { + init: function (options: SvgPanZoom.CustomEventOptions) { var instance = options.instance, initialScale = 1, pannedX = 0, @@ -65,17 +185,17 @@ export class SvgGridService { this.hammer.get('pinch').set({ enable: true }); // Handle double tap - this.hammer.on('doubletap', function (ev: any) { + this.hammer.on('doubletap', function (ev: HammerInput) { // instance.zoomIn(); }); // Handle tap (click) and no drag. - this.hammer.on('tap', function (ev: any) { + this.hammer.on('tap', function (ev: HammerInput) { NewGridComponent.instance.handleTap(); }); // Handle pan - this.hammer.on('panstart panmove', function (ev: any) { + this.hammer.on('panstart panmove', function (ev: HammerInput) { // The canvas may only pan while a pointer is genuinely down. Hammer // tracks that from events on the element it is bound to, and a // gesture whose target is destroyed mid-drag — a joint merged into @@ -101,7 +221,7 @@ export class SvgGridService { }); // Handle pinch - this.hammer.on('pinchstart pinchmove', function (ev: any) { + this.hammer.on('pinchstart pinchmove', function (ev: HammerInput) { // On pinch start remember initial zoom if (ev.type === 'pinchstart') { initialScale = instance.getZoom(); @@ -111,10 +231,6 @@ export class SvgGridService { instance.zoomAtPoint(initialScale * ev.scale, { x: ev.center.x, y: ev.center.y }); }); - // this.hammer.on('press', function (ev: any) { - // NewGridComponent.onContextMenu(ev.center.x, ev.center.y); - // }); - // Prevent moving the page on some devices when panning over SVG options.svgElement.addEventListener('touchmove', function (e: TouchEvent) { e.preventDefault(); @@ -143,9 +259,46 @@ export class SvgGridService { customEventsHandler: eventsHandler, }); this.guardAgainstStuckPan(root); + this.restoreMissingPointerDown(root); + this.releaseGesturesOnLostPointer(); + this.watchTheChrome(); 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... @@ -169,6 +322,70 @@ export class SvgGridService { return screenPos; } + /** + * Keep the drawing the same size on screen across a change of length unit. + * + * A unit change rescales the stored geometry, so without this the linkage + * would appear to grow or shrink by the conversion factor. The settings panel + * does this for a change the reader made; undo and redo replay a URL that can + * carry the same change, and had no way to do it -- the geometry came back at + * its old size through a viewport still zoomed for the new one, which is the + * jump. + */ + compensateForUnitChange(fromUnit: LengthUnit, toUnit: LengthUnit): void { + if (fromUnit === toUnit || !this.panZoomObject) return; + const origin = this.SVGtoScreen(new Coord(0, 0)); + // Through `ourOwnMove`, because this is the app holding a view still across + // a change of units rather than the reader choosing a new one: read as a + // choice it would throw away a fit that is still perfectly fitted. + this.ourOwnMove(() => + this.panZoomObject.zoomAtPointBy(LENGTH_IN_CM[toUnit] / LENGTH_IN_CM[fromUnit], { + x: origin.x, + y: origin.y, + }) + ); + } + + /** + * Carry the remembered view across a change in the size of the window. + * + * A view somebody drove to keeps the share of the canvas it had, so it scales + * with the window. Nothing to do for a view a fit put there: that one is + * framed afresh instead, and there is nothing written down to scale. + */ + private growChosenView(growth: number): void { + const held = this.chosenView; + if (growth === 1 || !held) return; + this.chosenView = { + zoom: this.clampZoom(held.zoom * growth), + offset: { x: held.offset.x * growth, y: held.offset.y * growth }, + }; + } + + /** Whatever the view was is now whatever the reader just made it. */ + private forgetChosenView(): void { + this.viewIsFitted = false; + this.chosenView = null; + } + + /** + * Run a view change the app is making on the reader's behalf. + * + * The library calls back into handlePan and handleZoom while these run, and + * those are also how a wheel or a drag arrives. Flagged so the two can be + * told apart: a view the app moved is still a view nobody chose. Public, + * because the settings panel compensates for a unit change from its own side. + */ + ourOwnMove(change: () => void): void { + const was = this.movingTheViewOurselves; + this.movingTheViewOurselves = true; + try { + change(); + } finally { + this.movingTheViewOurselves = was; + } + } + screenToSVGfromXY(screenX: number, screenY: number): Coord { return this.screenToSVG(new Coord(screenX, screenY)); } @@ -186,9 +403,6 @@ export class SvgGridService { this.viewBoxMinY = visibleY; this.viewBoxMaxY = visibleY + visibleHeight; // this.panZoomObject.updateBBox(); // Update viewport bounding box - // console.log(viewBox); - // console.log(this.viewBoxMinX, this.viewBoxMaxX); - // console.log(this.viewBoxMinY, this.viewBoxMaxY); } /** @@ -232,7 +446,74 @@ export class SvgGridService { ); } - handleBeforePan(oldPan: any, newPan: any) { + /** + * Give back the `pointerdown` Safari owes us after a native menu closes. + * + * The canvas is driven entirely by pointer events -- the root's own handler + * and every joint, link and force binding are `(pointerdown)` -- while + * svg-pan-zoom listens for `mousedown`. Normally the pair arrive together and + * in that order, so whichever one the gesture belongs to claims it. + * + * Safari breaks the pair exactly once after dismissing a native `