diff --git a/README.md b/README.md index 81aa61a..15ba133 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ scan observations. It keeps selections, analytic primitives, reference geometry, exact relationships, reuse lineage, residuals, and output coordinates explicit instead of treating scan-to-CAD as an opaque automatic conversion. -[![Scansor repeated-feature reuse demonstration](docs/assets/readme/repeated-boss-fitted-surfaces-and-reuse-volumes.jpg)](https://youtu.be/oP44yI9DAoE) +[![Scansor repeated-feature reuse demonstration](docs/assets/readme/repeated-boss-fitted-surfaces-and-reuse-volumes.jpg)](https://youtu.be/KLs3RinJzSg) -**[Watch the narrated Scansor prototype demonstration](https://youtu.be/oP44yI9DAoE)** +**[Watch the narrated Scansor prototype demonstration](https://youtu.be/KLs3RinJzSg)** The current local browser prototype demonstrates: diff --git a/docs/src/project/planning/demo-video-narration.md b/docs/src/project/planning/demo-video-narration.md index a1046ae..80762be 100644 --- a/docs/src/project/planning/demo-video-narration.md +++ b/docs/src/project/planning/demo-video-narration.md @@ -1,176 +1,168 @@ # Demo video narration -**Working production script, 2026-09-25.** This script accompanies the -[two-workflow demo plan](demo-video.md). It describes an exploratory prototype, -not a product release or an accuracy claim. The working voice is Kokoro -`af_heart` at normal speed. Pronounce Scansor as “SCAN-sor.” +**Working production script, 2026-10-08.** This script accompanies the +[two-workflow demo plan](demo-video.md). The working voice is Kokoro `af_heart` +at normal speed. Pronounce Scansor as “SCAN-sor.” Shot IDs remain stable across +the capture, narration, captions, and rendered timeline. ## 00 — Title -**Focus:** Title card, then the clean nozzle mesh. +**Focus:** Title card introducing aligned, usable geometry. **Narration:** -Scansor is an exploratory workspace for turning scan observations into explicit -geometric intent. This demonstration builds up parts of two workflows: retaining -and fitting selections on captured data, then reusing fitted features and -recovering an output coordinate system on a generated example. +Scansor helps you turn a scan into clean, aligned geometry. Choose the surfaces +that matter, fit them, and describe how they belong together. -## 01 — Captured nozzle +## 01 — Nozzle bayonet mount -**Focus:** Oblique nozzle overview with a short, restrained orbit. +**Focus:** The captured nozzle's bayonet mount in Model, with a short orbit. **Narration:** -We begin with a captured nozzle scan. Its surface is irregular, its units are -not confirmed, and nothing here is presented as physical validation. The mesh is -the evidence layer. Named features in the tree preserve the interpretation we -add to that evidence. +Start with this captured nozzle. We're modeling its bayonet mount: the round +body, rim, and repeated locking features. The model should follow the scan +while keeping a common center, square shoulders, and evenly spaced details. -## 02 — Build retained selections +## 02 — Select the surfaces that matter -**Focus:** Add several retained selections to the visible inspection set. +**Focus:** Begin with the bare scan; create meaningful outer-wall, top-rim, and +repeated-slope selections through real pointer strokes. **Narration:** -The colored patches appear as we select named regions from the feature tree. -They are retained vertex selections, not temporary click state. Several can be -inspected together without a modifier key, clicked again to remove them, or -cleared as a set. They remain editable inputs for later operations. +Start by selecting the outside wall, then the top rim. Keep each surface in its +own named selection. Paint a strip of the wall, rotate the view, and add another +strip. Looking down onto the rim helps keep that selection on the flat face. +A smaller selection captures a slope on one bayonet feature. These are the +observations we'll use to describe the mount. -## 03 — Build reference geometry and fits +## 03 — Turn selections into surfaces -**Focus:** Add the center axis and two cone fits to the inspection set. +**Focus:** Pick the newly created selections in Model and create representative +cone and plane fits that actually use their recorded observations. **Narration:** -The next layer is analytic geometry. A free axis is an explicit datum with its -own identity. Cone fits are separate graph features that retain their source -selections and can be recomputed. A fit may stand alone, refer to fixed geometry, -or participate in a combined solve when the design intent requires it. +Click a selection directly in the model to use it for a fit. The outside wall +becomes a cone; the top rim becomes a plane. These simple surfaces describe +the shape while keeping the scan available to inspect. -## 04 — Rotational symmetry +## 04 — Align the bayonet mount's features -**Focus:** Highlight the three repeated recess-slope selections around the nozzle. +**Focus:** Clearly cut to the remaining prepared features, retaining the filmed +selections and fits. Create a parallel-plane relationship through Model picks, +then inspect common-axis, perpendicular, and threefold relationships. **Narration:** -These three slope patches repeat around the center. After fitting the patches, -Scansor can declare exact threefold rotational symmetry about an axis. The three -observations then influence a shared solve while keeping their individual -selection lineage. Rotational symmetry is an explicit relationship, not an -assumption inferred merely because the patches look similar. +With the remaining surfaces prepared, choose a relationship and pick its +surfaces in the model. Parallel planes share a direction. Other relationships +share a center axis, square the top to it, and align the repeated bayonet features. -## 05 — Residuals +## 05 — Check the fit against the scan -**Focus:** Switch to residual coloring and pause on the signed scale legend. +**Focus:** Residual coloring with the signed scale visible. **Narration:** -Residual display replaces selection colors with signed distance from the fitted -surface. The legend shows both the display scale and the observed negative and -positive peaks. These are local fit residuals in scan coordinates. They are not, -by themselves, manufacturing error or an acceptance decision. +Use residual colors to see where the fitted surface sits above or below the +scan. The scale shows the size of those differences. This helps you inspect +the fit; it does not establish manufacturing accuracy. -## 06 — Transition +## 06 — Repeated-feature transition -**Focus:** Reuse and coordinate recovery title card. +**Focus:** Brief transition card: repeated features and aligned surfaces. **Narration:** -The second workflow uses a generated, noisy fixture so repeated features, -relationships, and coordinate recovery are easy to inspect. +Now let's look at repeated features on a generated, noisy example. -## 07 — Source scan frame +## 07 — Repeated bosses -**Focus:** Rotated boss plate with a small orbit before applying the output transform. +**Focus:** Boss plate in its source orientation, with a restrained orbit. **Narration:** -The plate begins in its source scan frame, deliberately rotated and scaled away -from the desired output coordinates. Four bosses vary in height and orientation, -and three spherical references sit near the plate corners. The colored points -again represent retained observations on the noisy mesh. +This plate has four bosses, including one taller than the others. The scan +starts tilted. We'll describe the surfaces and their relationships before +choosing the model's final orientation. -## 08 — Build a reusable source +## 08 — Define one boss -**Focus:** Add source selections, datums, and a cylinder fit to the inspection set. +**Focus:** Source boss cylinder, shared axis, and perpendicular shoulder plane. **Narration:** -The first boss supplies a partial reusable definition. Its named outer, bore, -shoulder, and clock regions preserve distinct observations. An axis and two -planes provide a local frame, while cylinder and plane fits describe surfaces. -These pieces stay general: the reusable unit is a graph lineage, not a special -hard-coded boss feature. +On the first boss, the outside and bore share an axis. The shoulder is square +to it. These relationships keep the feature coherent as its surfaces fit +the scan. -## 09 — Feature reuse +## 09 — Reuse the selection work -**Focus:** Expand Feature reuse and enable the visible reuse volumes. +**Focus:** Feature reuse and its visible selection volumes at the other bosses. **Narration:** -Feature reuse combines those source fits with one simple reference patch and -several target patches. Approximate matches place surface-relative selection -volumes at each target. The volumes generate new target selections, and those -selections drive fresh local fits. The initial match is only a starting pose; -it does not become the final fitted transform or force all results to be equal. +Reuse carries that selection work to the other bosses. Each gets its own +local observations and fresh fits, so differences between the features remain +available to inspect. -## 10 — Exact relationships +## 10 — Pick surfaces and relate them -**Focus:** Inspect equal-radius and parallel-plane relationships with residuals. +**Focus:** Pick fitted planes in Model for a parallel relationship; inspect +existing coincident-plane and equal-radius relationships. **Narration:** -Reused fits remain independent unless a relationship says otherwise. Here, -selected cylinder families share exact radii, while the plate top and chosen -shoulder planes share an exact direction. The taller boss remains offset but -parallel. These declarations influence fitted quantities and are distinct from -a later tolerance or closeness check. +To align planes, choose a relationship and click the fitted surfaces in the +model. Parallel planes share a direction while keeping their offsets. +Coincident planes share a surface. Equal-radius relationships keep matching +round features the same size. -## 11 — Measurable datums +## 11 — Inspect the aligned model -**Focus:** Add sphere fits, point datums, a directed axis, frame, and scale. +**Focus:** Cleanly aligned shoulder and plate planes, round features, and +evaluated constructed faces where available. **Narration:** -The three sphere fits provide measurable centers. Two centers initialize point -datums, and their ordered pair defines a directed axis. A coordinate frame maps -chosen direction references onto explicit output axes. Separately, one or more -known distances between points determine a least-squares uniform scale, so -additional readings can contribute rather than being discarded. +The result is geometry that fits together: level shoulders, aligned planes, +and consistent round features. The taller boss stays taller. You choose +which dimensions and directions belong together rather than accepting a +collection of unrelated fits. -## 12 — Applied transform +Small gaps between faces that should align, off-center bores, or end faces not +quite perpendicular to their cylinder axes make sketches and mating parts +awkward to work with. +Scansor's relationships give you the control to eliminate that mess. -**Focus:** Select Output transform, switch to Top, then make one small view adjustment. +## 12 — Choose the output orientation + +**Focus:** Apply Output transform continuously, then show an aligned view. **Narration:** -The Output transform composes the chosen frame and scale. Selecting it applies -that similarity transform to the view: the model moves into its intended output -orientation and size, while upstream fitted values remain in source coordinates. -The mesh, selections, analytic guides, and residual markers all share the same -display placement. +Reference points and directions let you choose the output orientation. +Known distances set its scale. Apply the transform to place the model in +useful coordinates while keeping the original scan available for inspection. -## 13 — Export boundary +## 13 — CAD handoff -**Focus:** Open CAD export and inspect the chosen transform and units. +**Focus:** Current Export CAD dialog with an available scope, units, and transform. **Narration:** -The same transform is available at export together with an explicit unit choice. -That makes coordinate recovery a visible handoff decision instead of silently -rewriting the fitted geometry. This export dialog demonstrates the current -boundary; it is not a claim of a finished production CAD integration. +At export, choose the geometry, units, and transform for the CAD handoff. +Those choices stay visible, so the exported geometry has the orientation +and size you intend. ## 14 — Close -**Focus:** Closing card. +**Focus:** Closing card emphasizing selection, fitting, and relationships. **Narration:** -Together, the examples show the current direction for Scansor: preserve user -observations, fit simple analytic primitives, express exact geometric intent, -reuse selection effort without freezing local fits, and make output scale and -coordinates explicit. The prototype is still evolving, but these operations -remain inspectable graph features rather than a flattened final result. +Select the evidence, fit the surfaces, and connect them with relationships. +That's how Scansor helps turn a rough scan into a well-organized model +with cleanly aligned planes and features. diff --git a/docs/src/project/planning/demo-video.md b/docs/src/project/planning/demo-video.md index 8363639..ef6748f 100644 --- a/docs/src/project/planning/demo-video.md +++ b/docs/src/project/planning/demo-video.md @@ -1,24 +1,28 @@ # Two-workflow demo video -**Planned, 2026-09-24.** This is a production plan for one concise Scansor -prototype walkthrough. It does not establish product readiness, physical -accuracy, supported input formats, or a public recipe schema. +**Production plan, updated 2026-10-08.** This is a concise walkthrough of +Scansor's selection, fitting, relationships, and CAD handoff workflow. ## Purpose and audience -The video should let a technically interested viewer understand the current -interaction model without requiring a live presenter. It will use two different -examples because they carry complementary evidence: - -- the captured nozzle scan shows retained user selection effort and local - analytic surface fitting on imperfect real-world geometry; -- the generated repeated-boss scan shows feature reuse, generated subtrees, - exact relationships, reference geometry, calibration, and an applied output - transform in a controlled example. - -The target is an approximately **5:15** silent master at `1920x1080`, accompanied by a -timestamped TTS script and WebVTT or SRT captions. Use Kokoro's `af_heart` voice -for the working voiced edition, pronouncing the project name as “SCAN-sor.” +The video should help a prospective user see how to turn scan observations into +a model with cleanly aligned planes and features. Show painting a selection and +choosing geometry directly in the Model view, then explain the result of each +relationship in terms of the part: common centers, square shoulders, parallel +planes, shared surfaces, and matching radii. Use two complementary examples: + +- the captured nozzle scan begins bare and focuses on its bayonet mount: create + representative wall, rim, and repeated locking-feature selections, fit those + observations through Model picks, and align the surfaces with relationships; +- the generated repeated-boss scan shows reusing selection work, picking fitted + planes for a relationship, and inspecting aligned surfaces before choosing + output orientation and CAD export settings. + +Keep the narration below **500 words**, with enough time for real interactions +and pauses to inspect their results. Produce a silent master at `1920x1080`, a +working voiced edition, a timestamped script, and SRT captions. Use Kokoro's +`af_heart` voice, pronouncing the project name as “SCAN-sor.” Final timing follows +the dry-run capture and synthesized narration rather than a fixed runtime. ## Named inputs @@ -30,68 +34,91 @@ names, never the old Downloads filenames: | Nozzle selection and fitting | [`nozzle-selection-and-fitting-demo.json`](../../../../examples/nozzle-bayonette-simplified/recipes/nozzle-selection-and-fitting-demo.json) | Checked-in captured nozzle example | | Repeated-boss reuse and alignment | [`repeated-boss-reuse-and-alignment-demo.json`](../../../../examples/repeated-boss-selection/recipes/repeated-boss-reuse-and-alignment-demo.json) | Generated `scan-coarse` realization | -The boss realization must be regenerated and hash-checked before capture. Both -recipes must validate and complete **Evaluate all** without errors. +Generate the boss realization in the session's temporary directory and hash-check +it before capture. Both recipes must validate; every fit and relationship shown +must evaluate successfully. The current boss recipe also includes constructed +faces and a Body feature. Verify their actual evaluated state before showing +them or making an assembly claim; a saved Body definition alone does not prove a +closed solid. ## Storyboard -| Time | Picture and interaction | Narration purpose | +| Shot | Picture and interaction | User-facing purpose | | --- | --- | --- | -| 0:00–0:12 | Title: **Scansor — selections, fits, reuse, and alignment** over a clean oblique mesh view | State that this is a working exploratory prototype, not a product or accuracy claim. | -| 0:12–0:38 | Nozzle scan alone with a restrained orbit | Introduce captured, irregular geometry and the evidence layer. | -| 0:38–0:58 | Add `outer`, `recesses`, and three slope selections one at a time | Show retained selections accumulating as reusable feature inputs. | -| 0:58–1:19 | Add `the middle`, `outer cone fit`, and `recess cone fit`, then make a small view adjustment | Build the distinction between observations, datums, and independently recomputable primitives. | -| 1:19–1:43 | Highlight the three repeated recess-slope selections | Explain exact threefold rotational symmetry as an explicit relationship around an axis. | -| 1:43–2:03 | Enable residual colors and pause on the signed scale | Show local signed deviation without describing it as physical error or acceptance. | -| 2:03–2:13 | Transition card: **Reuse and coordinate recovery** | Mark the move to a generated controlled example. | -| 2:13–2:32 | Rotated noisy boss plate with a restrained orbit | Establish the source scan frame and repeated but imperfect features. | -| 2:32–2:54 | Add source selections, its axis, a plane, and a cylinder fit | Partially build the general feature lineage that will be reused. | -| 2:54–3:20 | Expand `Feature reuse`, select it, and enable reuse volumes | Show approximate placement producing target-local selections and fresh local fits. | -| 3:20–3:42 | Inspect equal-radius and parallel-plane relationships, then show residuals | Distinguish independent local fits from optional exact relationships. | -| 3:42–4:07 | Add sphere fits, point datums, directed axis, `Output frame`, and `Output scale` | Explain measurable landmarks, orientation, and multi-reading uniform scale. | -| 4:07–4:30 | Select `Output transform`, switch to Top, and make one small view adjustment | Deliver the visible before/after alignment while preserving source-coordinate fit values. | -| 4:30–4:50 | Open CAD export and show Transform and units | Show the explicit STEP-bundle handoff boundary without implying production CAD integration. | -| 4:50–5:15 | Closing card | Recap retained observations, primitives, relationships, reuse, and explicit output coordinates. | - -The cut must not remove rotational symmetry, the nozzle residual view, boss -reuse volumes, or visible transform application. Those moments distinguish the -two workflows and the kinds of geometric intent they demonstrate. The graph -workspace is intentionally omitted from this cut so the available time stays on -the primary modeling workflow. +| 00 | Title: **Scansor — clean, aligned geometry** | Introduce the modeling task. | +| 01 | Bare captured nozzle with a restrained orbit | Introduce its bayonet mount before selections and fits exist. | +| 02 | Create outer-wall, top-rim, and one repeated-slope selection through real strokes | Show how useful modeling inputs are chosen. | +| 03 | Pick those selections in Model and create representative cone and plane fits | Turn the actual recorded observations into simple surfaces. | +| 04 | Clearly cut to the remaining prepared fits, create a representative relationship through Model picks, and inspect the common-axis, perpendicular, and threefold results | Show how relationships align the mount's body, rim, and repeated bayonet features without recording every feature's creation. | +| 05 | Enable residual colors with the signed scale visible | Briefly check fitted geometry against the scan. | +| 06 | Transition card: **Repeated features, aligned surfaces** | Identify the generated example. | +| 07 | Rotated boss plate with a restrained orbit | Introduce four bosses, including a taller one. | +| 08 | Show the first boss's cylinder fits, common axis, and shoulder plane | Show a coherent round feature with a square shoulder. | +| 09 | Expand Feature reuse and briefly show reuse volumes | Carry selection effort to other bosses while fitting their own observations. | +| 10 | Choose a parallel-plane relationship, pick its fitted participants in Model, and apply; inspect existing coincident-plane and equal-radius relationships | Explain shared directions, shared surfaces, and matching sizes using visible results. | +| 11 | Inspect aligned shoulder and plate planes, round features, and evaluated constructed faces where available | Show a well-organized model that keeps the taller boss's distinct height. | +| 12 | Apply Output transform continuously, then show an aligned view | Choose useful orientation and scale without losing the scan reference. | +| 13 | Open Export CAD and select an available scope, Transform, and units | Make the CAD handoff choices visible. | +| 14 | Closing card | Recap selecting evidence, fitting surfaces, and connecting them with relationships. | + +Retain rotational symmetry, the nozzle residual check, boss reuse volumes, and +visible transform application. Give selection painting and fitted-surface picking +enough screen time to see the pointer, input selection, and resulting geometry. +Keep the Graph panel closed so the emphasis stays on modeling the part. ## Capture staging Capture the two applications on separate local ports so one continuous browser automation can move between them without shell windows appearing onscreen. Use a fresh browser profile, fixed viewport, 100% page zoom, hidden bookmarks and -developer UI, and a deterministic feature-panel split. Do not capture the +developer UI, and deterministic Features, Model, and Edit placement. Use the +current dockable toolbar and input-selection controls. Do not capture the desktop, terminal, Downloads directory, or unrelated browser content. -The nozzle segment loads its checked-in recipe directly. The boss segment needs -two staged states: +The nozzle segment starts with only the source node from its checked-in recipe. +Create genuine outer-wall, top-rim, and repeated-slope selections through the UI; +use those recorded memberships in the fits shown immediately afterward. Gather +the wall observations from more than one viewing direction so its cone fit has +useful coverage. The rim selection should stay on the flat face. + +Save that filmed-stage recipe separately. Use a clear cut and “With the remaining +surfaces prepared…” to move to the remaining features. Keep the actual recorded +selection memberships and fits in that checkpoint, remapping the prepared +features to use them. Record both checkpoints in the manifest. Do not silently +substitute the saved selections for the recorded ones. + +The boss segment needs two staged states: 1. an establishing state in the source scan frame, before the output transform is active in the view; -2. the complete checked-in graph with every action evaluated, where selecting - `Output transform` visibly applies it. +2. the evaluated model, where selecting `Output transform` visibly applies it. The staging script may change the in-memory graph output pointer for the first -boss shot, but it must not rewrite the checked-in recipe. The visible workflow -must use actual application behavior; no geometry or result may be composited to -fake a successful fit, relationship, or transform. - -Use slow eased cursor motion, a visible click indicator, and at least `500 ms` -after tree expansion or camera motion and `900 ms` after meaningful result -changes. Record those interactions as live application clips. Extend the last -frame when narration outlasts a clip; do not loop the cursor or camera motion. +boss shot. Painting, fitting, and relationship creation may add features through +the real UI on these disposable servers. Preserve the checked-in recipes and +record the resulting session recipes with the capture artifacts. No geometry or +result may be composited to fake a successful fit, relationship, or transform. + +Find eligible model targets through the application's hover and picking behavior, +then record actual pointer clicks. Show an overlapping-item chooser when the +application needs one. Confirm that picked inputs contain the intended features +before applying the form, and wait for the resulting fit or relationship to +finish evaluating. + +Use brisk eased cursor motion and a visible click indicator. Keep painting, +geometry picks, Apply, and their results readable. Shorten navigation, repeated +tree clicks, and idle time when they create gaps after the narration; an overlong +clip should be edited or recaptured rather than automatically retaining all of +its quiet time. Record those interactions as live application clips. Extend the +last frame when narration outlasts a clip; do not loop the cursor or camera motion. Avoid scrolling while narration asks the viewer to inspect geometry. Camera presets should do most of the movement; free orbit is reserved for opening views and one restrained post-transform adjustment. ## Narration and captions -Write the final narration after a dry-run capture establishes real interaction -durations. The script should contain one block per storyboard row with: +Adjust the working narration after a dry-run capture establishes real interaction +durations. Keep one block per storyboard row with: - shot ID and time window; - plain text for TTS; @@ -99,6 +126,17 @@ durations. The script should contain one block per storyboard row with: - the matching caption text; - the intended on-screen focus. +Preserve section IDs `00` through `14` and their existing capture filenames so +the renderer can assemble the revised shots. No segment may cut off painting, +input picking, applying a form, or the visible result; allow additional quiet +screen time when an interaction outlasts its narration. +Modest acceleration of an overlong recorded clip is allowed to tighten the +pacing, with its rate recorded in the manifest. Keep strokes, input choices, +and results readable; do not imply that the cut measures evaluation speed. +Long evaluation waits may be shortened while retaining the submitted action and +the real evaluated result. Record those omissions in the capture manifest; +do not imply that the edited video demonstrates evaluation speed. + Target `125–140` spoken words per minute and leave approximately `500 ms` of silence around section transitions. Pronounce **Scansor** consistently as “SCAN-sor” unless the project owner chooses another pronunciation. Captions @@ -159,8 +197,17 @@ linked into, bundled with, or distributed as part of Scansor. ## Acceptance checklist -- Both named checked-in recipes validate and evaluate without errors. +- Both named checked-in recipes validate, and all demonstrated geometry and + relationships evaluate successfully; any assembly failure remains explicit. - No source selection IDs, fit results, or transforms are fabricated for video. +- Representative selections are visibly created from the bare scan and then + used by their demonstrated fits. +- The transition to prepared remaining features is clear. +- Navigation and pauses do not leave long quiet tails after the narration. +- A fit input and relationship participants are visibly picked in Model, and + the recorded form selections match the geometry being discussed. +- Common-axis, perpendicular, parallel, coincident, and equal-radius examples + show their intended geometric effect without implying unverified solids. - Text remains readable at `1920x1080` without depending on fullscreen playback. - Cursor, camera, and tree motion are real application capture and never compete with narration. diff --git a/scripts/demo-video/README.md b/scripts/demo-video/README.md index b58fb7d..889c9a2 100644 --- a/scripts/demo-video/README.md +++ b/scripts/demo-video/README.md @@ -15,8 +15,24 @@ node scripts/demo-video/capture.mjs /tmp/scansor-demo/capture The capture uses Chrome's DevTools protocol directly and adds no browser-driving package to Scansor. Override `SCANSOR_DEMO_CHROME`, `SCANSOR_DEMO_BOSS_URL`, or `SCANSOR_DEMO_NOZZLE_URL` when needed. The capture -changes only the boss server's in-memory output pointer while staging the -pre-transform shot; it does not modify the checked-in recipe. +stages the boss server's in-memory output pointer for the pre-transform shot. +For the nozzle, it maps physical painting targets using the saved example's +hover picker, resets to the bare source, creates wall, rim, and slope selections, +and fits their actual observations through Model picks. It then adds the prepared +remaining features at a visible cut, preserving the filmed selections and fits +as their inputs, and creates a parallel-plane relationship through the real UI. +The filmed stage and completed checkpoint are saved separately in the manifest. +Use disposable servers: these changes stay in their sessions. +The checked-in recipes are preserved. `SCANSOR_DEMO_CDP_PORT` changes +the debugging port; `SCANSOR_DEMO_WORKFLOW=boss` or `nozzle` recaptures just that +workflow into the same capture directory. +`SCANSOR_DEMO_FROM_SHOT=10` retains earlier clips while replaying their staging +interactions and recording from shot 10 onward. Reset the disposable server to +its original recipe before recapturing, to avoid accumulating demo features. +For nozzle shots 4 and 5, it restores the saved filmed stage and prepared +checkpoint, preserving the selections and fits recorded in the earlier clips. +`SCANSOR_DEMO_REUSE_STROKES=1` reuses the saved nozzle stroke plan when recapturing +with the same source mesh, fixed viewport, and docked panel arrangement. The narration source is [`demo-video-narration.md`](../../docs/src/project/planning/demo-video-narration.md). @@ -36,18 +52,34 @@ node scripts/demo-video/render.mjs \ /tmp/scansor-demo/render ``` -The capture records short application clips with a visible cursor, actual tree -interaction, and restrained camera motion. Title and transition cards remain +The capture records short application clips with a visible cursor, scan painting, +input picks on rendered geometry, tree interaction, and restrained camera motion. +Title and transition cards remain still images. The renderer synthesizes one audio file per narration section so timing and wording remain reviewable, builds sentence-level SRT captions, and -extends each clip's final frame to match its narration rather than looping the -interaction. +extends each clip's final frame to match its narration. When painting or picking +outlasts the speech, it pads the audio with silence so the entire interaction and +result remain visible. Overlong clips receive a modest playback adjustment, +capped at `1.35` by default, to shorten quiet tails after the narration. +`SCANSOR_DEMO_MAX_CLIP_RATE=1` preserves the capture speed. Actual rates are +recorded per shot in the manifest. It also writes a timestamped narration script and records +source mesh metadata and hashes alongside the capture sources. +Long evaluation waits are shortened between the submitted action and its real +completed result. The capture retains two seconds at the start and one at the +end of the wait, and records omitted intervals in each shot's edits JSON and the +render manifest. Pointer gestures and the evaluated result are preserved. +Naming dialogs and brush setup are also omitted from the selection sequence; +their reasons and intervals are recorded alongside the evaluation edits. Set `SCANSOR_DEMO_REUSE_TTS=1` to reuse already-rendered section WAV files after changing only assembly settings. The working cut uses a small `1.04` post-process playback adjustment; `SCANSOR_DEMO_PLAYBACK_RATE` can override it without resynthesizing the voice. +`SCANSOR_DEMO_TTS_ONLY=1` synthesizes the narration without assembling video. +The renderer also accepts `SCANSOR_DEMO_WORKFLOW=boss` or `nozzle` to assemble a +preview of one workflow from its captures and narration. + Kokoro's English phonemizer requires the spaCy `en_core_web_sm` model. It is declared explicitly in `mise.demo.toml`; relying on Kokoro's runtime auto-installer can put the model outside the isolated tool environment. diff --git a/scripts/demo-video/capture.mjs b/scripts/demo-video/capture.mjs index 9c53010..bdef735 100644 --- a/scripts/demo-video/capture.mjs +++ b/scripts/demo-video/capture.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { execFileSync, spawn } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; const outputDirectory = resolve(process.argv[2] || '/tmp/scansor-demo-capture'); @@ -9,6 +9,10 @@ const chrome = process.env.SCANSOR_DEMO_CHROME || 'google-chrome'; const debuggingPort = Number(process.env.SCANSOR_DEMO_CDP_PORT || 9223); const bossUrl = process.env.SCANSOR_DEMO_BOSS_URL || 'http://127.0.0.1:8765/'; const nozzleUrl = process.env.SCANSOR_DEMO_NOZZLE_URL || 'http://127.0.0.1:8766/'; +const workflow = process.env.SCANSOR_DEMO_WORKFLOW || 'all'; +if (!['all', 'boss', 'nozzle'].includes(workflow)) throw new Error(`Unknown workflow: ${workflow}`); +const firstShot = Number(process.env.SCANSOR_DEMO_FROM_SHOT || 0); +if (!Number.isInteger(firstShot) || firstShot < 0 || firstShot > 14) throw new Error('Invalid starting shot'); const profile = resolve(outputDirectory, 'chrome-profile'); function command(executable, arguments_) { @@ -25,6 +29,7 @@ function mise(tool, arguments_) { } await mkdir(outputDirectory, { recursive: true }); +await rm(profile, { recursive: true, force: true }); const browser = spawn( chrome, @@ -105,6 +110,7 @@ class Cdp { } let cdp; +let evaluationWaits = null; try { const target = await retry(async () => { const response = await fetch(`http://127.0.0.1:${debuggingPort}/json/list`); @@ -153,6 +159,8 @@ try { await waitFor("document.querySelector('#status')?.textContent.includes('All actions evaluated')"); await sleep(1000); await installDemoCursor(); + const metadata = await evaluate("fetch('/api/meta').then(response => response.json())"); + await writeFile(resolve(outputDirectory, url === bossUrl ? 'boss-source.json' : 'nozzle-source.json'), `${JSON.stringify(metadata, null, 2)}\n`); } async function screenshot(name) { @@ -179,7 +187,7 @@ try { })()`); } - async function moveCursorToPoint(x, y, duration = 650) { + async function moveCursorToPoint(x, y, duration = 280, buttons = 0) { await evaluate(`new Promise((resolvePromise) => { const cursor = document.querySelector('#scansor-demo-cursor'); const animation = cursor.animate( @@ -192,7 +200,8 @@ try { resolvePromise(); }; })`); - await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, buttons, + ...(buttons ? {button: 'left'} : {}) }); } async function elementPoint(expression) { @@ -201,6 +210,7 @@ try { if (!element) return null; element.scrollIntoView({block: 'center', inline: 'nearest'}); const rectangle = element.getBoundingClientRect(); + if (!rectangle.width || !rectangle.height) throw new Error('Element is not visible'); return {x: rectangle.left + rectangle.width / 2, y: rectangle.top + rectangle.height / 2}; })()`); if (!point) throw new Error(`Missing element: ${expression}`); @@ -214,7 +224,7 @@ try { [{transform: cursor.style.transform, background: '#58d4eb99'}, {transform: cursor.style.transform + ' scale(.55)', background: '#fff'}, {transform: cursor.style.transform, background: '#58d4eb99'}], - {duration: 260, easing: 'ease-out'}, + {duration: 130, easing: 'ease-out'}, ); animation.onfinish = resolvePromise; })`); @@ -224,7 +234,7 @@ try { await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1, }); - await sleep(90); + await sleep(55); await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1, }); @@ -232,10 +242,11 @@ try { } async function click(selector) { - const point = await elementPoint(`document.querySelector(${JSON.stringify(selector)})`); + const proxy = /^#[\w-]+$/.test(selector) ? `[data-command-id="${selector.slice(1)}"]` : selector; + const point = await elementPoint(`document.querySelector(${JSON.stringify(proxy)}) || document.querySelector(${JSON.stringify(selector)})`); await moveCursorToPoint(point.x, point.y); await clickPoint(point.x, point.y); - await sleep(500); + await sleep(180); } async function action(id) { @@ -245,13 +256,16 @@ try { .find((candidate) => candidate.dataset.actionId === ${quoted}) || [...document.querySelectorAll('summary[data-action-id]')] .find((candidate) => candidate.dataset.actionId === ${quoted}); + for (let parent = element?.parentElement; parent; parent = parent.parentElement) { + if (parent.tagName === 'DETAILS') parent.open = true; + } return element?.classList.contains('action-select') - ? element : element?.querySelector('.group-action'); + ? element : element?.querySelector('.action-name'); })()`; const point = await elementPoint(expression); await moveCursorToPoint(point.x, point.y); await clickPoint(point.x, point.y); - await sleep(500); + await sleep(250); } async function orbit(deltaX, deltaY, duration = 1600) { @@ -263,7 +277,7 @@ try { x: box.left + box.width * 0.58, y: box.top + box.height * 0.52, }; - await moveCursorToPoint(start.x, start.y, 600); + await moveCursorToPoint(start.x, start.y, 280); await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...start, button: 'right', buttons: 2, clickCount: 1, }); @@ -288,6 +302,11 @@ try { } async function recordClip(name, choreography, tailMilliseconds = 700) { + if (Number(name.slice(0, 2)) < firstShot) { + await choreography(); + return; + } + console.log(`Capturing ${name}`); const framesDirectory = resolve(outputDirectory, `${name}-frames`); await mkdir(framesDirectory, { recursive: true }); const frames = []; @@ -302,23 +321,34 @@ try { await cdp.send('Page.startScreencast', { format: 'jpeg', quality: 88, maxWidth: 1920, maxHeight: 1080, everyNthFrame: 1, }); + evaluationWaits = []; await sleep(350); await choreography(); await sleep(tailMilliseconds); await cdp.send('Page.stopScreencast'); removeListener(); await Promise.all(writes); + await screenshot(name); if (!frames.length) throw new Error(`No screencast frames captured for ${name}`); const timeline = ['ffconcat version 1.0']; + const edits = evaluationWaits; + evaluationWaits = null; for (let index = 0; index < frames.length; index++) { - const duration = index + 1 < frames.length - ? Math.max(1 / 60, frames[index + 1].timestamp - frames[index].timestamp) - : tailMilliseconds / 1000; - timeline.push(`file '${frames[index].filename}'`, `duration ${duration.toFixed(6)}`); + const start = frames[index].timestamp; + const end = index + 1 < frames.length ? frames[index + 1].timestamp : start + tailMilliseconds / 1000; + let duration = end - start; + for (const edit of edits) duration -= Math.max(0, Math.min(end, edit.end) - Math.max(start, edit.start)); + if (duration > 0) timeline.push(`file '${frames[index].filename}'`, `duration ${Math.max(1 / 60, duration).toFixed(6)}`); } timeline.push(`file '${frames.at(-1).filename}'`); const timelinePath = resolve(framesDirectory, 'frames.ffconcat'); await writeFile(timelinePath, `${timeline.join('\n')}\n`); + await writeFile(resolve(outputDirectory, `${name}-edits.json`), `${JSON.stringify({ + omitted_intervals: edits.map(edit => ({ + reason:edit.reason || 'evaluation_wait', + start: edit.start - frames[0].timestamp, end: edit.end - frames[0].timestamp, + })), + }, null, 2)}\n`); mise('ffmpeg', [ '-hide_banner', '-loglevel', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', timelinePath, '-vf', 'fps=30,format=yuv420p', '-c:v', 'libx264', @@ -331,6 +361,18 @@ try { await click('#clear-feature-selection'); } + async function closeEditPanel() { + const point = await elementPoint(`[...document.querySelectorAll('[role="tab"]')] + .find(tab => tab.textContent.startsWith('Edit'))?.querySelector('[title="Close"]')`); + await moveCursorToPoint(point.x, point.y); + await clickPoint(point.x, point.y); + await sleep(500); + } + + async function dockEdit() { + if (await evaluate("document.querySelector('#edit-popup').open")) await click('#dock-edit'); + } + async function setChecked(selector, checked) { const quoted = JSON.stringify(selector); const point = await elementPoint(`document.querySelector(${quoted})`); @@ -347,6 +389,7 @@ try { async function setSelect(selector, value) { const quotedSelector = JSON.stringify(selector); const quotedValue = JSON.stringify(value); + if (await evaluate(`document.querySelector(${quotedSelector})?.value === ${quotedValue}`)) return; const point = await elementPoint(`document.querySelector(${quotedSelector})`); await moveCursorToPoint(point.x, point.y); await evaluate(`(() => { @@ -362,14 +405,277 @@ try { async function toggleActionGroup(id) { const quoted = JSON.stringify(id); const expression = `[...document.querySelectorAll('summary[data-action-id]')] - .find((candidate) => candidate.dataset.actionId === ${quoted})`; + .find((candidate) => candidate.dataset.actionId === ${quoted})?.querySelector('.tree-toggle')`; const point = await elementPoint(expression); - point.x -= 26; await moveCursorToPoint(point.x, point.y); await clickPoint(point.x, point.y); await sleep(500); } + async function fill(selector, value) { + await click(selector); + await evaluate(`(() => { + const input = document.querySelector(${JSON.stringify(selector)}); + input.value = ${JSON.stringify(value)}; + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + })()`); + } + + async function featureContext(id) { + const point = await elementPoint(`[...document.querySelectorAll('.action-select, summary[data-action-id]')].find(element => element.dataset.actionId === ${JSON.stringify(id)})`); + await moveCursorToPoint(point.x, point.y); + await cdp.send('Input.dispatchMouseEvent', {type: 'mousePressed', ...point, button: 'right', buttons: 2, clickCount: 1}); + await cdp.send('Input.dispatchMouseEvent', {type: 'mouseReleased', ...point, button: 'right', buttons: 0, clickCount: 1}); + await sleep(300); + } + + // Locate patches through the application's public hover/raycast interaction. + // Only the eventual CDP mouse click commits a pick; no feature IDs are injected. + async function patch(label, clearance = 0) { + await evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); + const point = await evaluate(`(() => { + const canvas = document.querySelector('#viewport canvas'), box = canvas.getBoundingClientRect(); + const hit = (x, y) => { + if (document.elementFromPoint(x, y) !== canvas) return false; + canvas.dispatchEvent(new PointerEvent('pointermove', {clientX: x, clientY: y, pointerId: 1})); + const message = document.querySelector('#model-pick-message').textContent.trim(); + return message === ${JSON.stringify(label)} || message.startsWith(${JSON.stringify(`${label} — `)}); + }; + const step = ${clearance} ? 8 : 14; + for (let y = box.top + 85; y < box.bottom - 55; y += step) { + for (let x = box.left + 10; x < box.right - 10; x += step) { + if (hit(x, y) && (!${clearance} || [[${clearance},0],[-${clearance},0],[0,${clearance}],[0,-${clearance}]] + .every(([dx,dy]) => hit(x + dx, y + dy)))) return {x, y}; + } + } + return null; + })()`); + if (!point) throw new Error(`No visible model patch: ${label}`); + return point; + } + + async function pickPatch(label, checkedSelector, hint) { + const point = hint || await patch(label); + await moveCursorToPoint(point.x, point.y); + await sleep(500); + await clickPoint(point.x, point.y); + const choice = await evaluate(`(() => { + const button = [...document.querySelectorAll('#model-pick-choices button')] + .find(button => button.textContent.includes(${JSON.stringify(label)})); + if (!button || !button.getBoundingClientRect().width) return null; + const box = button.getBoundingClientRect(); + return {x: box.left + box.width/2, y: box.top + box.height/2}; + })()`); + if (choice) { + await moveCursorToPoint(choice.x, choice.y); + await clickPoint(choice.x, choice.y); + } + if (checkedSelector) await waitFor(`document.querySelector(${JSON.stringify(checkedSelector)})?.checked`); + await sleep(500); + return point; + } + + async function evaluated() { + const waitStart = Date.now() / 1000; + await waitFor(`(async () => { + const graph = await fetch('/api/graph').then(response => response.json()); + return !graph.evaluation_running && Object.values(graph.states).every(state => state === 'ready'); + })()`); + const graph = await evaluate("fetch('/api/graph').then(response => response.json())"); + const failures = Object.entries(graph.states).filter(([,state]) => state !== 'ready'); + if (failures.length) throw new Error(`Failed demo features: ${JSON.stringify(failures)}`); + const waitEnd = Date.now() / 1000; + if (evaluationWaits && waitEnd - waitStart > 6) { + evaluationWaits.push({start: waitStart + 2, end: waitEnd - 1}); + } + return graph; + } + + async function replaceRecipe(recipe) { + await evaluate(`(async () => { + const state = await fetch('/api/graph').then(response => response.json()); + const response = await fetch('/api/graph', { + method: 'POST', headers: {'Content-Type': 'application/json', 'X-Scansor-Request': '1'}, + body: JSON.stringify({token: state.token, recipe: ${JSON.stringify(recipe)}}), + }); + if (!response.ok) throw new Error(await response.text()); + })()`); + await navigate(nozzleUrl); + } + + async function selectionView(view) { + await click(view === 'top' ? '#top' : '#side'); + if (view === 'other-side') await orbit(240, 0, 450); + } + + // Find physical regions before filming. Hover only locates strokes: the + // subsequent real left-button gestures determine every recorded membership. + async function strokePaths(label, diameter, maximum, view = 'side') { + await evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); + const paths = await evaluate(`(async () => { + const canvas = document.querySelector('#viewport canvas'), box = canvas.getBoundingClientRect(); + const step = ${diameter} <= 4 ? 2 : ${diameter} <= 8 ? 4 : 8, radius = ${diameter} / 2; + let left=box.left+10, right=box.right-10, top=box.top+85, bottom=box.bottom-55; + if (${JSON.stringify(view)} !== 'other-side') { + // Public source coordinates narrow the search; the application's actual + // hover result and brush clearance still qualify every planned stroke. + const THREE = await import('three'); + const metadata = await fetch('/api/meta').then(response => response.json()); + const positions = new Float32Array(await fetch(metadata.positions.url).then(response => response.arrayBuffer())); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position',new THREE.BufferAttribute(positions,3)); geometry.computeBoundingSphere(); + const sphere=geometry.boundingSphere, camera=new THREE.PerspectiveCamera(45,box.width/box.height,Math.max(.001,sphere.radius/1000),sphere.radius*1000); + camera.up.set(0,0,1); + const distance=sphere.radius/Math.sin(Math.min(camera.fov*Math.PI/360,Math.atan(Math.tan(camera.fov*Math.PI/360)*camera.aspect)))*1.12; + const direction=${JSON.stringify(view)} === 'top' ? new THREE.Vector3(0,-.001,1) : new THREE.Vector3(0,-1,0); + camera.position.copy(sphere.center).addScaledVector(direction.normalize(),distance); camera.lookAt(sphere.center); camera.updateMatrixWorld(); + const graph=await fetch('/api/graph').then(response => response.json()); + const ids=graph.recipe.nodes.find(node => node.label===${JSON.stringify(label)}).ids; + const projected=ids.map(id => new THREE.Vector3().fromArray(positions,id*3).project(camera)); + const xs=projected.map(p => box.left+(p.x+1)*box.width/2), ys=projected.map(p => box.top+(1-p.y)*box.height/2); + left=Math.max(left,Math.min(...xs)-16); right=Math.min(right,Math.max(...xs)+16); + top=Math.max(top,Math.min(...ys)-16); bottom=Math.min(bottom,Math.max(...ys)+16); + geometry.dispose(); + } + const hit = (x, y) => { + if (document.elementFromPoint(x, y) !== canvas) return false; + canvas.dispatchEvent(new PointerEvent('pointermove', {clientX:x, clientY:y, pointerId:1})); + const message = document.querySelector('#model-pick-message').textContent.trim(); + return message === ${JSON.stringify(label)} || message.startsWith(${JSON.stringify(`${label} — `)}); + }; + const points = []; + for (let y = top; y < bottom; y += step) + for (let x = left; x < right; x += step) + if (hit(x,y) && [[radius,0],[-radius,0],[0,radius],[0,-radius]].every(([dx,dy]) => hit(x+dx,y+dy))) + points.push({x,y}); + const candidates = []; + for (const [fixed, variable, diagonal] of [['y','x',0],['x','y',0],['y','x',1],['y','x',-1]]) { + const groups = new Map(); + for (const point of points) { + const key=diagonal ? Math.round((point.y-diagonal*point.x)/step) : point[fixed]; + const group = groups.get(key) || []; + group.push(point); groups.set(key,group); + } + for (const group of groups.values()) { + group.sort((a,b) => a[variable]-b[variable]); + let run = []; + for (const point of group) { + if (run.length && point[variable]-run.at(-1)[variable] > step+0.1) { + if (run.length >= 3) candidates.push(run); + run = []; + } + run.push(point); + } + if (run.length >= 3) candidates.push(run); + } + } + candidates.sort((a,b) => b.length-a.length); + const chosen = []; + for (const path of candidates) { + const center = path[Math.floor(path.length/2)]; + if (chosen.every(other => { + const middle = other[Math.floor(other.length/2)]; + return Math.hypot(center.x-middle.x,center.y-middle.y) > ${diameter}*2; + })) chosen.push(path); + if (chosen.length === ${maximum}) break; + } + return chosen; + })()`); + if (!paths.length) throw new Error(`No useful painting stroke for ${label}`); + console.log(`Mapped ${label}: ${paths.map(path => path.length).join(',')} points`); + return paths; + } + + async function paintPaths(paths, diameter) { + await omitSetup('brush_setup', async () => { + await setSelect('#selection-shape', 'paint'); + await fill('#brush-size', String(diameter)); + }); + for (const path of paths) { + await moveCursorToPoint(path[0].x, path[0].y); + await cdp.send('Input.dispatchMouseEvent', {type:'mousePressed', ...path[0], button:'left', buttons:1, clickCount:1}); + // The normal brush sweeps the full segment between pointer events. + // Fewer evenly spaced events keep this straight stroke brisk. + const moves=path.slice(1).filter((_,index) => index%4===3); + if (moves.at(-1) !== path.at(-1)) moves.push(path.at(-1)); + for (const point of moves) await moveCursorToPoint(point.x, point.y, 45, 1); + await cdp.send('Input.dispatchMouseEvent', {type:'mouseReleased', ...path.at(-1), button:'left', buttons:0, clickCount:1}); + await waitFor("!document.querySelector('#evaluate-all').disabled"); + await evaluated(); + } + } + + async function createSelection(label) { + await click('#add-selection'); + await waitFor("!document.querySelector('#selection-tools').hidden"); + const id = await evaluate("fetch('/api/graph').then(response => response.json()).then(graph => graph.recipe.nodes.at(-1).id)"); + await omitSetup('selection_naming', async () => { + await featureContext(id); + await click('#feature-context-rename'); + await fill('#rename-feature-label', label); + await click('#rename-feature-form button'); + }); + return id; + } + + async function omitSetup(reason, operation) { + const start=Date.now()/1000; + await operation(); + if (evaluationWaits) evaluationWaits.push({start,end:Date.now()/1000,reason}); + } + + async function createFit(label, selection, kind, hint) { + await click('#new-fit'); + await setSelect('#new-fit-kind', kind); + await fill('#new-fit-label', label); + await click('[data-pick-control="new-fit-inputs"]'); + await pickPatch(selection, undefined, hint); + await waitFor(`[...document.querySelector('#new-fit-inputs').selectedOptions].some(option => option.textContent === ${JSON.stringify(selection)})`); + await click('#stop-model-picking'); + await click('#add-fit-form > button'); + const state = await evaluated(); + const fit = state.recipe.nodes.find(node => node.label === label); + if (state.states[fit?.id] !== 'ready') throw new Error(`Fit not ready: ${label}`); + console.log(`Created ${label}: RMS ${state.results[fit.id].weighted_rms}`); + return fit.id; + } + + async function projectedSelection(label, view = 'top') { + return evaluate(`(async () => { + const THREE=await import('three'), metadata=await fetch('/api/meta').then(response => response.json()); + const positions=new Float32Array(await fetch(metadata.positions.url).then(response => response.arrayBuffer())); + const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.BufferAttribute(positions,3));geometry.computeBoundingSphere(); + const sphere=geometry.boundingSphere,box=document.querySelector('#viewport canvas').getBoundingClientRect(); + const camera=new THREE.PerspectiveCamera(45,box.width/box.height,Math.max(.001,sphere.radius/1000),sphere.radius*1000);camera.up.set(0,0,1); + const distance=sphere.radius/Math.sin(Math.min(camera.fov*Math.PI/360,Math.atan(Math.tan(camera.fov*Math.PI/360)*camera.aspect)))*1.12; + const direction=${JSON.stringify(view)} === 'top' ? new THREE.Vector3(0,-.001,1) : new THREE.Vector3(0,-1,0); + camera.position.copy(sphere.center).addScaledVector(direction.normalize(),distance);camera.lookAt(sphere.center);camera.updateMatrixWorld(); + const graph=await fetch('/api/graph').then(response => response.json()),ids=graph.recipe.nodes.find(node=>node.label===${JSON.stringify(label)}).ids; + const points=ids.filter((_,index)=>index%4===0).map(id=>{ + const p=new THREE.Vector3().fromArray(positions,id*3).project(camera); + return {x:box.left+(p.x+1)*box.width/2,y:box.top+(1-p.y)*box.height/2}; + });geometry.dispose();return points; + })()`); + } + + async function locateRelationshipPick(label, id, candidates) { + for (const point of candidates) { + await moveCursorToPoint(point.x,point.y); + await clickPoint(point.x,point.y); + const found=await evaluate(`(() => { + const popup=document.querySelector('#model-pick-choices'); + const match=popup.matches(':popover-open') && [...popup.querySelectorAll('button')].some(button=>button.textContent.includes(${JSON.stringify(label)})); + const selected=document.querySelector('input[data-participant-id="${id}"]')?.checked; + if(popup.matches(':popover-open'))popup.hidePopover(); + return match||selected; + })()`); + if(found)return point; + await click('#clear-relationship-participants'); + } + throw new Error(`No selectable relationship surface: ${label}`); + } + async function titleCard(kicker, title, subtitle, name) { const html = `