From c0762d8d42d4cb0beab9c97efcfd403306ee46b0 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Thu, 8 Oct 2026 18:02:05 -0400 Subject: [PATCH 1/2] Update demo video with staged bayonet selections and model picks Focus narration on aligned geometry, shorten idle capture time, and preserve filmed inputs in completed recipes. Increase the default solver budget to 300 with a nozzle convergence regression. --- .../project/planning/demo-video-narration.md | 181 +++--- docs/src/project/planning/demo-video.md | 159 ++++-- scripts/demo-video/README.md | 44 +- scripts/demo-video/capture.mjs | 535 ++++++++++++++++-- scripts/demo-video/render.mjs | 96 +++- src/scansor/nonlinear_least_squares.py | 2 +- tests/test_feature_graph.py | 20 + 7 files changed, 808 insertions(+), 229 deletions(-) diff --git a/docs/src/project/planning/demo-video-narration.md b/docs/src/project/planning/demo-video-narration.md index a1046ae..337bd1e 100644 --- a/docs/src/project/planning/demo-video-narration.md +++ b/docs/src/project/planning/demo-video-narration.md @@ -1,176 +1,163 @@ # 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 +## 12 — Choose the output orientation -**Focus:** Select Output transform, switch to Top, then make one small view adjustment. +**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 = `