diff --git a/.changeset/select-rendered-joints.md b/.changeset/select-rendered-joints.md new file mode 100644 index 0000000..4ed21b0 --- /dev/null +++ b/.changeset/select-rendered-joints.md @@ -0,0 +1,5 @@ +--- +"posecode-render": patch +--- + +Add `Viewer.selectBones()` to highlight canonical bones at their live joint positions without affecting bounds, grounding, exports, or diagnostics. diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index fbc484b..3600ce6 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -111,6 +111,11 @@ export interface Viewer { getConstraintDiagnostics(): readonly ConstraintDiagnostic[]; /** Precise visible world bounds; intended for audits and deterministic export. */ getVisibleBounds(): THREE.Box3; + /** + * Highlight canonical bone ids at their live joint positions. Unknown ids + * are ignored; pass an empty list to clear the selection. + */ + selectBones(boneIds: readonly string[]): void; getMannequin(): any; getCharacter(): any; /** @@ -179,6 +184,46 @@ export function createViewer( scene.background = new THREE.Color(0x0c0f15); scene.fog = new THREE.Fog(0x0c0f15, 9, 18); + // Text-editor selection overlay. Markers live outside the mannequin and + // character trees so they never affect grounding, camera framing, bounds, + // exports, or contact diagnostics. + const boneSelection = new THREE.Group(); + boneSelection.name = "posecode-bone-selection"; + scene.add(boneSelection); + const selectionDotGeometry = new THREE.SphereGeometry(0.018, 16, 12); + const selectionRingGeometry = new THREE.TorusGeometry(0.055, 0.006, 8, 32); + const selectionDotMaterial = new THREE.MeshBasicMaterial({ + color: 0xd4ff3f, + transparent: true, + opacity: 0.96, + depthTest: false, + depthWrite: false, + }); + const selectionRingMaterial = new THREE.MeshBasicMaterial({ + color: 0xd4ff3f, + transparent: true, + opacity: 0.82, + depthTest: false, + depthWrite: false, + }); + let selectedBoneIds: string[] = []; + let selectionMarkers: THREE.Group[] = []; + + function rebuildBoneSelection(): void { + boneSelection.clear(); + selectionMarkers = selectedBoneIds.map(() => { + const marker = new THREE.Group(); + marker.renderOrder = 1000; + const dot = new THREE.Mesh(selectionDotGeometry, selectionDotMaterial); + dot.renderOrder = 1000; + const ring = new THREE.Mesh(selectionRingGeometry, selectionRingMaterial); + ring.renderOrder = 1000; + marker.add(dot, ring); + boneSelection.add(marker); + return marker; + }); + } + // Image-based environment light: soft bounced light that gives the matte // figure materials realistic shading gradients instead of flat CG plastic. const pmrem = new THREE.PMREMGenerator(renderer); @@ -911,6 +956,23 @@ export function createViewer( // geometry; a segmented skin can have a different lowest point as limbs // rotate. Reconcile the actual skinned surface after every animation layer. if (character && solvedInfo && isFloorBound(solvedInfo)) character.reconcileFloor(); + // Follow the visible rig (including mocap and its final floor correction) + // when available; the congruent procedural driver is the fallback. + for (let i = 0; i < selectedBoneIds.length; i++) { + const boneId = selectedBoneIds[i]!; + const marker = selectionMarkers[i]!; + const position = + character?.getJointWorldPosition(boneId) ?? + mannequin.bones.get(boneId)?.getWorldPosition(new THREE.Vector3()) ?? + null; + marker.visible = position !== null; + if (position) { + marker.position.copy(position); + // The torus is a screen-facing selection ring; the centre dot remains + // spherical, so copying the camera frame works for both children. + marker.quaternion.copy(camera.quaternion); + } + } frameDt = 0; if (easeCamera) { controls.target.lerp(desiredTarget, 0.07); @@ -1242,6 +1304,12 @@ export function createViewer( getVisibleBounds() { return character?.getBounds() ?? new THREE.Box3().setFromObject(mannequin.root); }, + selectBones(boneIds) { + selectedBoneIds = [...new Set(boneIds)].filter((id) => + mannequin.bones.has(id), + ); + rebuildBoneSelection(); + }, getMannequin() { return mannequin; }, @@ -1267,6 +1335,10 @@ export function createViewer( clipLayer?.dispose(); character?.dispose(); floorGuide?.dispose(); + selectionDotGeometry.dispose(); + selectionRingGeometry.dispose(); + selectionDotMaterial.dispose(); + selectionRingMaterial.dispose(); renderer.dispose(); }, }; diff --git a/playground/play.html b/playground/play.html index 8d89ecb..12818c6 100644 --- a/playground/play.html +++ b/playground/play.html @@ -199,6 +199,15 @@
+
diff --git a/playground/src/direct-manipulation.ts b/playground/src/direct-manipulation.ts new file mode 100644 index 0000000..503c6dd --- /dev/null +++ b/playground/src/direct-manipulation.ts @@ -0,0 +1,109 @@ +import { + ACTION_NAMES, + JOINT_NAMES, + expandJoint, + romFor, +} from "posecode-parser"; + +const JOINT_SET = new Set(JOINT_NAMES); +const ACTION_SET = new Set(ACTION_NAMES); + +/** + * A directly-manipulable `: ` source line. + * Positions are absolute CodeMirror document offsets and use half-open ranges. + */ +export interface AngleTarget { + joint: string; + action: string; + degrees: number; + jointFrom: number; + jointTo: number; + angleFrom: number; + angleTo: number; +} + +export interface AngleRange { + min: number; + max: number; +} + +// Keep this deliberately stricter than syntax highlighting. Only complete, +// parser-valid joint target lines become controls; comments, turn/travel +// numbers, and half-written source remain ordinary editable text. +const JOINT_TARGET = + /^(\s*)([A-Za-z][\w-]*)(\s*:\s*)([A-Za-z][\w-]*)(\s+)(-?(?:\d+(?:\.\d*)?|\.\d+))(?=\s*(?:(?:#|\/\/).*)?$)/; + +/** Locate every source angle that can safely become an inline control. */ +export function findAngleTargets(source: string): AngleTarget[] { + const targets: AngleTarget[] = []; + let lineFrom = 0; + + for (const line of source.split(/\n/)) { + const match = JOINT_TARGET.exec(line.replace(/\r$/, "")); + if (match && JOINT_SET.has(match[2]!) && ACTION_SET.has(match[4]!)) { + const joint = match[2]!; + const action = match[4]!; + // Unsupported joint/action pairs stay plain text so the parser error + // remains the primary interaction rather than presenting a bogus range. + if (angleRangeFor(joint, action)) { + const jointFrom = lineFrom + match[1]!.length; + const angleFrom = + jointFrom + + match[2]!.length + + match[3]!.length + + match[4]!.length + + match[5]!.length; + const angleText = match[6]!; + targets.push({ + joint, + action, + degrees: Number(angleText), + jointFrom, + jointTo: jointFrom + joint.length, + angleFrom, + angleTo: angleFrom + angleText.length, + }); + } + } + // split() removes the newline, so account for it between every pair. + lineFrom += line.length + 1; + } + + return targets; +} + +/** Find the target whose joint or angle contains a document position. */ +export function angleTargetAt( + source: string, + position: number, + part: "joint" | "angle", +): AngleTarget | null { + for (const target of findAngleTargets(source)) { + const from = part === "joint" ? target.jointFrom : target.angleFrom; + const to = part === "joint" ? target.jointTo : target.angleTo; + if (position >= from && position < to) return target; + } + return null; +} + +/** + * Return the ROM intersection for all bones represented by a DSL joint name. + * Groups therefore get one honest range that is valid for every selected bone. + */ +export function angleRangeFor(joint: string, action: string): AngleRange | null { + const bones = expandJoint(joint); + const limits = bones + .map((bone) => romFor(bone, action)) + .filter((limit): limit is AngleRange => limit !== null); + if (limits.length === 0 || limits.length !== bones.length) return null; + + const min = Math.max(...limits.map((limit) => limit.min)); + const max = Math.min(...limits.map((limit) => limit.max)); + return min <= max ? { min, max } : null; +} + +/** Clamp and format a spinner value without accumulating float noise. */ +export function normalizeAngle(value: number, range: AngleRange): string { + const clamped = Math.min(range.max, Math.max(range.min, value)); + return String(Math.round(clamped * 10) / 10); +} diff --git a/playground/src/editor.ts b/playground/src/editor.ts index a5f6ee2..8acba5f 100644 --- a/playground/src/editor.ts +++ b/playground/src/editor.ts @@ -21,6 +21,7 @@ import { hoverTooltip, placeholder, Decoration, + WidgetType, type DecorationSet, } from "@codemirror/view"; import { @@ -61,7 +62,15 @@ import { MOVEMENT_KINDS, PROP_TYPES, START_POSE_NAMES, + expandJoint, } from "posecode-parser"; +import { + angleRangeFor, + angleTargetAt, + findAngleTargets, + normalizeAngle, + type AngleTarget, +} from "./direct-manipulation.js"; // --- Syntax highlighting ---------------------------------------------------- @@ -254,6 +263,81 @@ const posecodeTheme = EditorView.theme( color: "var(--text-2)", }, ".cm-posecode-hover strong": { color: "var(--text)" }, + ".cm-joint-link": { + cursor: "pointer", + borderBottom: "1px dotted rgba(192, 167, 255, 0.72)", + borderRadius: "2px", + transition: "color 120ms ease, background-color 120ms ease", + }, + ".cm-joint-link:hover": { + color: "#dfd2ff", + backgroundColor: "rgba(192, 167, 255, 0.12)", + }, + ".cm-joint-selected": { + color: "var(--accent)", + backgroundColor: "rgba(212, 255, 63, 0.11)", + borderBottomColor: "var(--accent)", + }, + ".cm-angle-control": { + cursor: "pointer", + color: "#ffb184", + borderBottom: "1px dotted rgba(255, 157, 107, 0.78)", + borderRadius: "2px", + }, + ".cm-angle-control:hover": { + color: "#ffd1b7", + backgroundColor: "rgba(255, 157, 107, 0.12)", + }, + ".cm-angle-spinner": { + display: "inline-flex", + alignItems: "center", + verticalAlign: "middle", + margin: "0 2px", + height: "25px", + color: "var(--text)", + backgroundColor: "var(--panel-3)", + border: "1px solid var(--accent)", + borderRadius: "3px", + boxShadow: "0 0 0 2px rgba(212, 255, 63, 0.08)", + overflow: "hidden", + }, + ".cm-angle-input": { + width: "5.2ch", + height: "100%", + padding: "0 2px 0 5px", + border: "0", + outline: "0", + color: "var(--text)", + backgroundColor: "transparent", + fontFamily: "var(--mono)", + fontSize: "12.5px", + textAlign: "right", + }, + ".cm-angle-degree": { + paddingRight: "3px", + color: "var(--text-2)", + fontSize: "11px", + }, + ".cm-angle-step": { + width: "22px", + height: "100%", + padding: "0", + border: "0", + borderRadius: "0", + color: "var(--text-2)", + backgroundColor: "transparent", + fontFamily: "var(--mono)", + fontSize: "14px", + cursor: "pointer", + }, + ".cm-angle-step:hover": { + color: "var(--bg)", + backgroundColor: "var(--accent)", + }, + ".cm-angle-step:focus-visible, .cm-angle-input:focus-visible": { + outline: "1px solid var(--text)", + outlineOffset: "-2px", + }, ".cm-tooltip-autocomplete > ul > li[aria-selected]": { backgroundColor: "var(--accent-veil)", color: "var(--text)", @@ -294,6 +378,223 @@ const phaseHighlightField = StateField.define({ provide: (f) => EditorView.decorations.from(f), }); +// --- Direct manipulation --------------------------------------------------- +// Joint names and authored degree values are live controls rather than inert +// syntax. Clicking a joint selects its concrete bones in the viewer; clicking +// the angle replaces only that number with a compact, ROM-aware spinner. + +const setSelectedJoint = StateEffect.define(); +const setActiveAngle = StateEffect.define(); + +const selectedJointField = StateField.define({ + create: () => null, + update(selected, tr) { + for (const effect of tr.effects) { + if (effect.is(setSelectedJoint)) selected = effect.value; + } + if ( + selected && + tr.docChanged && + !findAngleTargets(tr.state.doc.toString()).some( + (target) => target.joint === selected, + ) + ) { + return null; + } + return selected; + }, +}); + +const activeAngleField = StateField.define({ + create: () => null, + update(active, tr) { + if (active && tr.docChanged) { + const mapped = tr.changes.mapPos(active.angleFrom, 1); + active = angleTargetAt(tr.state.doc.toString(), mapped, "angle"); + } + for (const effect of tr.effects) { + if (effect.is(setActiveAngle)) active = effect.value; + } + return active; + }, +}); + +function replaceActiveAngle(view: EditorView, requested: number): void { + const active = view.state.field(activeAngleField); + if (!active || !Number.isFinite(requested)) return; + const range = angleRangeFor(active.joint, active.action); + if (!range) return; + const insert = normalizeAngle(requested, range); + const current = view.state.doc.sliceString(active.angleFrom, active.angleTo); + if (insert === current) return; + view.dispatch({ + changes: { + from: active.angleFrom, + to: active.angleTo, + insert, + }, + effects: setActiveAngle.of({ + ...active, + degrees: Number(insert), + angleTo: active.angleFrom + insert.length, + }), + annotations: Transaction.userEvent.of("input"), + }); +} + +class AngleSpinnerWidget extends WidgetType { + constructor( + readonly target: AngleTarget, + readonly min: number, + readonly max: number, + ) { + super(); + } + + eq(other: AngleSpinnerWidget): boolean { + return ( + other.target.joint === this.target.joint && + other.target.action === this.target.action && + other.target.degrees === this.target.degrees && + other.min === this.min && + other.max === this.max + ); + } + + toDOM(view: EditorView): HTMLElement { + const control = document.createElement("span"); + control.className = "cm-angle-spinner"; + control.title = `Safe range: ${this.min}–${this.max}°`; + + const minus = document.createElement("button"); + minus.type = "button"; + minus.className = "cm-angle-step"; + minus.textContent = "−"; + minus.setAttribute("aria-label", `Decrease ${this.target.joint} angle`); + + const input = document.createElement("input"); + input.type = "number"; + input.className = "cm-angle-input"; + input.min = String(this.min); + input.max = String(this.max); + input.step = "1"; + input.value = String(this.target.degrees); + input.setAttribute( + "aria-label", + `${this.target.joint} ${this.target.action} angle in degrees; safe range ${this.min} to ${this.max}`, + ); + + const degree = document.createElement("span"); + degree.className = "cm-angle-degree"; + degree.textContent = "°"; + degree.setAttribute("aria-hidden", "true"); + + const plus = document.createElement("button"); + plus.type = "button"; + plus.className = "cm-angle-step"; + plus.textContent = "+"; + plus.setAttribute("aria-label", `Increase ${this.target.joint} angle`); + + const step = (delta: number): void => { + const current = Number(input.value); + replaceActiveAngle( + view, + (Number.isFinite(current) ? current : this.target.degrees) + delta, + ); + }; + minus.addEventListener("click", () => step(-1)); + plus.addEventListener("click", () => step(1)); + input.addEventListener("input", () => { + if (input.value !== "") replaceActiveAngle(view, Number(input.value)); + }); + input.addEventListener("change", () => { + if (input.value === "") input.value = String(this.target.degrees); + }); + input.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + view.dispatch({ effects: setActiveAngle.of(null) }); + view.focus(); + }); + + control.append(minus, input, degree, plus); + window.setTimeout(() => { + input.focus(); + input.select(); + }, 0); + return control; + } + + updateDOM(dom: HTMLElement): boolean { + const input = dom.querySelector(".cm-angle-input"); + if (!input) return false; + input.min = String(this.min); + input.max = String(this.max); + if (document.activeElement !== input) { + input.value = String(this.target.degrees); + } + dom.title = `Safe range: ${this.min}–${this.max}°`; + return true; + } + + ignoreEvent(): boolean { + return true; + } +} + +const directManipulationDecorations = EditorView.decorations.compute( + ["doc", selectedJointField, activeAngleField], + (state) => { + const selected = state.field(selectedJointField); + const active = state.field(activeAngleField); + const marks = []; + for (const target of findAngleTargets(state.doc.toString())) { + marks.push( + Decoration.mark({ + class: `cm-joint-link${ + selected === target.joint ? " cm-joint-selected" : "" + }`, + attributes: { + "data-posecode-joint": target.joint, + title: `Select ${target.joint} in the 3D viewer`, + }, + }).range(target.jointFrom, target.jointTo), + ); + if ( + !active || + active.angleFrom !== target.angleFrom || + active.angleTo !== target.angleTo + ) { + marks.push( + Decoration.mark({ + class: "cm-angle-control", + attributes: { + "data-posecode-angle": "true", + title: `Adjust ${target.joint} ${target.action}`, + }, + }).range(target.angleFrom, target.angleTo), + ); + } + } + return Decoration.set(marks, true); + }, +); + +const angleSpinnerDecoration = EditorView.decorations.compute( + [activeAngleField], + (state) => { + const active = state.field(activeAngleField); + if (!active) return Decoration.none; + const range = angleRangeFor(active.joint, active.action); + if (!range) return Decoration.none; + return Decoration.set([ + Decoration.replace({ + widget: new AngleSpinnerWidget(active, range.min, range.max), + }).range(active.angleFrom, active.angleTo), + ]); + }, +); + // --- Public API ------------------------------------------------------------- export interface PosecodeEditor { @@ -307,6 +608,7 @@ export interface PosecodeEditor { export interface PosecodeEditorOptions { doc: string; onChange: (value: string, userInitiated: boolean) => void; + onJointSelect?: (joint: string | null, boneIds: readonly string[]) => void; } export function createPosecodeEditor( @@ -328,6 +630,10 @@ export function createPosecodeEditor( new LanguageSupport(posecodeStream), syntaxHighlighting(posecodeHighlight), phaseHighlightField, + selectedJointField, + activeAngleField, + directManipulationDecorations, + angleSpinnerDecoration, autocompletion({ override: [posecodeCompletions], icons: false }), posecodeLinter, lintGutter(), @@ -338,6 +644,46 @@ export function createPosecodeEditor( 'Paste a movement from your AI chat here, or start typing:\nposecode exercise "My movement"', ), EditorView.lineWrapping, + EditorView.domEventHandlers({ + click(event, targetView) { + const element = (event.target as Element | null)?.closest( + "[data-posecode-joint], [data-posecode-angle]", + ); + if (!element || !targetView.dom.contains(element)) { + targetView.dispatch({ + effects: [ + setSelectedJoint.of(null), + setActiveAngle.of(null), + ], + }); + opts.onJointSelect?.(null, []); + return false; + } + + const part = element.dataset.posecodeAngle ? "angle" : "joint"; + const position = targetView.posAtDOM(element, 0); + const target = angleTargetAt( + targetView.state.doc.toString(), + position, + part, + ); + if (!target) return false; + + const selectionFrom = + part === "angle" ? target.angleFrom : target.jointFrom; + const selectionTo = + part === "angle" ? target.angleTo : target.jointTo; + targetView.dispatch({ + selection: { anchor: selectionFrom, head: selectionTo }, + effects: [ + setSelectedJoint.of(target.joint), + setActiveAngle.of(part === "angle" ? target : null), + ], + }); + opts.onJointSelect?.(target.joint, expandJoint(target.joint)); + return true; + }, + }), keymap.of([ ...closeBracketsKeymap, ...defaultKeymap, @@ -368,7 +714,12 @@ export function createPosecodeEditor( setValue: (doc: string) => { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: doc }, + effects: [ + setSelectedJoint.of(null), + setActiveAngle.of(null), + ], }); + opts.onJointSelect?.(null, []); }, focus: () => view.focus(), highlightPhase: (from: number | null, to?: number) => { diff --git a/playground/src/main.ts b/playground/src/main.ts index f6d18f0..27e8092 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -29,6 +29,13 @@ import { ANIMATION_PROGRESS_MESSAGE, PRESETS } from "./presets.js"; import { prioritizeFeaturedMovement } from "./library-order.js"; import { SHOWCASE_CLIPS } from "./clips.js"; +// During source-only typechecks the playground resolves posecode-render's last +// built declaration bundle. Keep the local extension explicit until the normal +// package build regenerates that bundle from the source Viewer interface. +type InteractiveViewer = Viewer & { + selectBones(boneIds: readonly string[]): void; +}; + // Open on a deterministic, fully procedural movement. Mocap-backed or // Experimental presets should never be the product's first impression. const DEFAULT_PRESET = @@ -61,6 +68,8 @@ const cueEl = $("cue"); const floorGuideKey = $("floor-guide-key"); const floorGuideTravel = $("floor-guide-travel"); const floorGuideReset = $("floor-guide-reset"); +const jointSelection = $("joint-selection"); +const jointSelectionName = $("joint-selection-name"); const copyBtn = $("copy-prompt"); const shareBtn = $("share"); const exportMenuButton = $("export-menu-button"); @@ -74,7 +83,7 @@ const tabViewer = $("tab-viewer"); // *after* the editor shell paints (dynamic import → its own chunk) so first // paint and interactivity aren't blocked by it. Until `boot()` resolves the // import, `viewer` is null and viewer-dependent work is skipped/deferred. -let viewer: Viewer | null = null; +let viewer: InteractiveViewer | null = null; let scrubbing = false; let repeat = 1; let rep = 1; @@ -88,6 +97,24 @@ let lastContactRefresh = 0; let scrubDiagnosticsRefresh = 0; let documentRevision = 1; let pendingRenderTrigger: RenderTrigger = "initial"; +let selectedBoneIds: readonly string[] = []; + +/** Keep the source selection and its live 3D joint markers in sync. */ +function handleJointSelect( + joint: string | null, + boneIds: readonly string[], +): void { + selectedBoneIds = boneIds; + viewer?.selectBones(boneIds); + jointSelection.hidden = joint === null; + if (joint) { + const readable = joint.replaceAll("_", " "); + jointSelectionName.textContent = + boneIds.length > 1 ? `${readable} · ${boneIds.length} bones` : readable; + } else { + jointSelectionName.textContent = ""; + } +} function documentKind(): DocumentKind { if (currentPresetId) return "preset"; @@ -904,6 +931,7 @@ void import("./editor.js").then(({ createPosecodeEditor }) => { editorApi = createPosecodeEditor($("editor"), { doc: initialDoc, onChange: handleEditorChange, + onJointSelect: handleJointSelect, }); recompile(); }); @@ -935,7 +963,8 @@ void import("posecode-render").then(({ createViewer }) => { // never slows the default page. Disabled with the classic figure, which has // no skinned mesh to retarget onto. ...(classicFigure || groundingAuditMode ? {} : { clips: SHOWCASE_CLIPS }), - }); + }) as InteractiveViewer; + viewer.selectBones(selectedBoneIds); // Exposed for capture/e2e tooling (frame capture drives README GIFs). (window as unknown as Record).__posecodeViewer = viewer; if (import.meta.env.DEV) { diff --git a/playground/src/style.css b/playground/src/style.css index cc7ec20..81c0280 100644 --- a/playground/src/style.css +++ b/playground/src/style.css @@ -644,6 +644,42 @@ select:hover { background: linear-gradient(180deg, rgba(8, 10, 14, 0.6), transparent); } +.joint-selection { + position: absolute; + z-index: 2; + top: 18px; + left: 50%; + display: inline-flex; + align-items: baseline; + gap: 7px; + max-width: min(42vw, 360px); + padding: 6px 9px; + transform: translateX(-50%); + border: 1px solid rgba(212, 255, 63, 0.42); + border-radius: 3px; + color: var(--text); + background: rgba(11, 12, 13, 0.82); + backdrop-filter: blur(8px); + font-family: var(--mono); + pointer-events: none; +} +.joint-selection[hidden] { + display: none; +} +.joint-selection span { + color: var(--accent); + font-size: 9px; + letter-spacing: 0.9px; + text-transform: uppercase; +} +.joint-selection strong { + overflow: hidden; + font-size: 11px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + .hud { position: absolute; top: 18px; diff --git a/playground/test/direct-manipulation.test.ts b/playground/test/direct-manipulation.test.ts new file mode 100644 index 0000000..40930d8 --- /dev/null +++ b/playground/test/direct-manipulation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + angleRangeFor, + angleTargetAt, + findAngleTargets, + normalizeAngle, +} from "../src/direct-manipulation.js"; + +describe("direct angle manipulation", () => { + const source = [ + 'posecode exercise "Curl"', + " rig humanoid", + " pose start = standing:", + " shoulders: abduct 12.5", + ' step "Curl" 1s flow:', + " elbows: flex 90 # the editable target", + " turn: 45", + " wrists: hold neutral", + " knees: abduct 10", + " // shoulders: flex 30", + ].join("\n"); + + it("finds only complete, supported joint angle lines", () => { + const targets = findAngleTargets(source); + expect(targets.map(({ joint, action, degrees }) => ({ joint, action, degrees }))) + .toEqual([ + { joint: "shoulders", action: "abduct", degrees: 12.5 }, + { joint: "elbows", action: "flex", degrees: 90 }, + ]); + for (const target of targets) { + expect(source.slice(target.jointFrom, target.jointTo)).toBe(target.joint); + expect(Number(source.slice(target.angleFrom, target.angleTo))).toBe(target.degrees); + } + }); + + it("resolves clicks independently for joint names and angle values", () => { + const target = findAngleTargets(source)[1]!; + expect(angleTargetAt(source, target.jointFrom + 2, "joint")?.joint).toBe("elbows"); + expect(angleTargetAt(source, target.angleFrom, "angle")?.degrees).toBe(90); + expect(angleTargetAt(source, target.angleTo + 1, "angle")).toBeNull(); + }); + + it("uses the shared safe range for symmetric groups", () => { + expect(angleRangeFor("elbows", "flex")).toEqual({ min: 0, max: 154 }); + expect(angleRangeFor("ankles", "flex")).toBeNull(); + }); + + it("clamps spinner edits and keeps useful decimal precision", () => { + const range = { min: 0, max: 154 }; + expect(normalizeAngle(80.04, range)).toBe("80"); + expect(normalizeAngle(80.06, range)).toBe("80.1"); + expect(normalizeAngle(999, range)).toBe("154"); + }); +}); diff --git a/playground/test/playground-actions.test.ts b/playground/test/playground-actions.test.ts new file mode 100644 index 0000000..76349d2 --- /dev/null +++ b/playground/test/playground-actions.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = resolve(import.meta.dirname, "../.."); +const playground = readFileSync(resolve(root, "playground/play.html"), "utf8"); +const main = readFileSync(resolve(root, "playground/src/main.ts"), "utf8"); + +describe("playground header actions", () => { + it("keeps both motion formats behind one Export menu", () => { + expect(playground).toContain('id="export-menu-button"'); + expect(playground).toContain('aria-haspopup="menu"'); + expect(playground).toContain('id="export-menu"'); + expect(playground).toContain('id="download-bvh"'); + expect(playground).toContain('id="download-gltf"'); + expect(playground.match(/>Download BVHDownload glTF { + expect(playground).toContain('href="https://github.com/posecode-dev/posecode"'); + expect(playground).not.toContain("posecode-dev/posecode/issues/new"); + expect(playground).toContain("Open the Posecode repository on GitHub"); + }); + + it("supports dismissal and keyboard navigation for the export menu", () => { + expect(main).toContain("function setExportMenu(open: boolean)"); + expect(main).toContain('event.key === "ArrowDown"'); + expect(main).toContain('event.key === "ArrowUp"'); + expect(main).toContain('e.key === "Escape"'); + }); +});