Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@
<style>
:root {
color-scheme: light;
--lab-stage-reserve: 236px;
--lab-dish-radius: min(43vh, calc(43vw - 101.5px));
--lab-dish-diameter: min(86vh, calc(86vw - 203px));
--lab-stage-reserve: 0px;
--lab-dish-radius: 43vmin;
--lab-dish-diameter: 86vmin;
}

html, body {
Expand Down Expand Up @@ -153,8 +153,13 @@
background: #020405;
clip-path: circle(var(--lab-dish-radius) at 50% 50%);
touch-action: none;
cursor: grab;
}

#app:active { cursor: grabbing; }
#app[data-tool='draw'],
#app[data-tool='shove'] { cursor: crosshair; }

#gpu-error {
position: fixed;
inset: 0;
Expand Down
34 changes: 34 additions & 0 deletions src/camera/cameraState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type Vec2,
type WindowSize,
screenToWorld,
worldToScreenNdc,
worldHalfExtent,
} from '../particleSystem/coords.ts';
import { PARITY, assertClose, assertCloseVec2, pair } from '../testing/parity.ts';
Expand Down Expand Up @@ -301,6 +302,39 @@ test('dish rotation normalizes, nudges, and rejects non-finite values', () => {
assertClose(camera.rotation, -Math.PI + ROTATION_STEP, 'NaN is refused');
});

test('dish-centred rotation preserves the world origin after panning', () => {
const camera = new CameraState({ pan: [0.62, -0.37], zoom: 2.4, rotation: 0.35 });
const before = worldToScreenNdc(
[0, 0],
CANVAS,
WINDOW,
camera.pan,
camera.zoom,
camera.rotation,
);

camera.setRotationAroundWorldOrigin(-1.1);

const after = worldToScreenNdc(
[0, 0],
CANVAS,
WINDOW,
camera.pan,
camera.zoom,
camera.rotation,
);
assertCloseVec2(after, before, 'the dish centre must not precess while rotating');
});

test('dish-centred rotation rejects non-finite values without changing pan', () => {
const camera = new CameraState({ pan: [0.4, -0.2], rotation: 0.5 });
const beforePan = camera.pan;
camera.setRotationAroundWorldOrigin(Number.NaN);
camera.rotateAroundWorldOriginBy(Number.POSITIVE_INFINITY);
assert.deepEqual(camera.pan, beforePan);
assertClose(camera.rotation, 0.5, 'invalid rotation is refused');
});

// ---------------------------------------------------------------------------
// Mode and reset
// ---------------------------------------------------------------------------
Expand Down
21 changes: 21 additions & 0 deletions src/camera/cameraState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,27 @@ export class CameraState {
this.setRotation(this.rotation + radians);
}

/**
* Rotate the view without letting the world origin orbit across the screen.
*
* The culture vessel is centred on world [0, 0]. A plain `setRotation`
* rotates an existing pan vector with the scene, so a vessel that has been
* panned away from screen centre appears to precess. Counter-rotating the pan
* by the camera delta preserves the origin's exact screen coordinate.
*/
setRotationAroundWorldOrigin(rotation: number): void {
if (!Number.isFinite(rotation)) return;
const before = this.rotation;
this.setRotation(rotation);
this.pan = rotateVec2(this.pan, before - this.rotation);
}

/** Nudge orientation while keeping world [0, 0] fixed on screen. */
rotateAroundWorldOriginBy(radians: number): void {
if (!Number.isFinite(radians) || radians === 0.0) return;
this.setRotationAroundWorldOrigin(this.rotation + radians);
}

toggleMode(): void {
this.mode = nextCameraMode(this.mode);
}
Expand Down
87 changes: 68 additions & 19 deletions src/lab/cultureConsole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,34 @@
*/

import { ROTATION_STEP, type CameraState } from '../camera/cameraState.ts';
import {
DISH_ROTATION_EVENT,
rotateDishBy,
setDishRotation,
} from './dishOrientation.ts';
import {
announceWorkspaceDrawer,
closeWhenWorkspaceDrawerChanges,
} from '../ui/workspaceDrawer.ts';

const MENU_LABELS: Readonly<Record<string, string>> = Object.freeze({
File: 'Culture',
Share: 'Export',
History: 'Record',
Editor: 'View',
File: 'Project',
Share: 'Share',
History: 'History',
Editor: 'Settings',
Simulation: 'Run',
Help: 'Manual',
Help: 'Help',
});

const CULTURE_ICON =
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
'<path d="M3.5 3.5h17v17h-17z"></path>' +
'<circle cx="12" cy="12" r="6.3"></circle>' +
'<circle cx="9.3" cy="10.1" r="1"></circle>' +
'<circle cx="14.8" cy="11.4" r=".8"></circle>' +
'<circle cx="12.2" cy="15.1" r="1.15"></circle>' +
'</svg>';

function block(label: string, index: string, className: string): HTMLDivElement {
const root = document.createElement('div');
root.className = className;
Expand Down Expand Up @@ -74,7 +92,9 @@ function arrangeMutationControls(mutation: HTMLElement): void {
const mode = block('Manipulation mode', '02.D', 'culture-control-block culture-mode');
const modeBody = document.createElement('div');
modeBody.className = 'culture-mode-body';
modeBody.append(tool, panels);
modeBody.append(tool);
panels.id = 'fluoddity-settings-launcher';
document.body.append(panels);
mode.append(modeBody);

bar.replaceChildren(population, variation, actions, mode);
Expand All @@ -84,6 +104,16 @@ function arrangeMutationControls(mutation: HTMLElement): void {
if (context instanceof HTMLElement) context.classList.add('culture-context');
}

/** Touch keeps its compact mutation bar, but Settings belongs in the launcher dock. */
function detachMobileSettingsLauncher(mutation: HTMLElement): void {
const launcher = mutation.querySelector<HTMLElement>(
'[data-setting="transport.toggleUi"]',
);
if (launcher === null) return;
launcher.id = 'fluoddity-settings-launcher';
document.body.append(launcher);
}

function dishOrientation(camera: CameraState): HTMLDivElement {
const root = block('Dish orientation', '01.B', 'culture-control-block culture-orientation');
const controls = document.createElement('div');
Expand Down Expand Up @@ -119,21 +149,22 @@ function dishOrientation(camera: CameraState): HTMLDivElement {
readout.value = `${degrees >= 0 ? '+' : ''}${degrees}°`;
};
left.addEventListener('click', () => {
camera.rotateBy(ROTATION_STEP);
rotateDishBy(camera, ROTATION_STEP);
sync();
});
right.addEventListener('click', () => {
camera.rotateBy(-ROTATION_STEP);
rotateDishBy(camera, -ROTATION_STEP);
sync();
});
slider.addEventListener('input', () => {
camera.setRotation((Number(slider.value) * Math.PI) / 180);
setDishRotation(camera, (Number(slider.value) * Math.PI) / 180);
sync();
});
reset.addEventListener('click', () => {
camera.setRotation(0);
setDishRotation(camera, 0);
sync();
});
window.addEventListener(DISH_ROTATION_EVENT, sync);

controls.append(left, slider, right, reset, readout);
root.append(controls);
Expand Down Expand Up @@ -161,10 +192,7 @@ export function installCultureConsole(mobile: boolean, camera: CameraState): voi
'<header class="culture-console-head">' +
'<button class="culture-console-mark culture-console-toggle" type="button" ' +
'aria-label="Collapse culture controls" aria-expanded="true">' +
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
'<path class="gear-teeth" d="M12 1.5v3M12 19.5v3M1.5 12h3M19.5 12h3M4.58 4.58 6.7 6.7M17.3 17.3l2.12 2.12M19.42 4.58 17.3 6.7M6.7 17.3l-2.12 2.12"></path>' +
'<circle cx="12" cy="12" r="6.65"></circle><circle cx="12" cy="12" r="2.35"></circle>' +
'</svg>' +
CULTURE_ICON +
'</button>' +
'<span><small>PROCEDURE CONTROL / NODE 01</small><strong>Culture operations</strong></span>' +
'<code>01</code>' +
Expand Down Expand Up @@ -194,6 +222,8 @@ export function installCultureConsole(mobile: boolean, camera: CameraState): voi
arrangeMutationControls(mutation);
manipulationHost.append(mutation);
manipulation.append(manipulationHost);
} else {
detachMobileSettingsLauncher(mutation);
}

const telemetry = block('Run telemetry', '03', 'culture-console-section culture-telemetry');
Expand All @@ -214,8 +244,20 @@ export function installCultureConsole(mobile: boolean, camera: CameraState): voi
document.body.append(rack);

const collapse = rack.querySelector<HTMLButtonElement>('.culture-console-toggle');
const launcher = document.createElement('button');
launcher.id = 'fluoddity-culture-launcher';
launcher.className = 'workspace-launcher';
launcher.type = 'button';
launcher.setAttribute('aria-controls', rack.id);
launcher.innerHTML = CULTURE_ICON;
document.body.append(launcher);

const setCollapsed = (collapsed: boolean): void => {
rack.classList.toggle('is-collapsed', collapsed);
launcher.setAttribute('aria-expanded', String(!collapsed));
launcher.setAttribute('aria-pressed', String(!collapsed));
launcher.title = collapsed ? 'Open culture controls' : 'Collapse culture controls';
launcher.setAttribute('aria-label', launcher.title);
if (collapse === null) return;
collapse.setAttribute('aria-expanded', String(!collapsed));
collapse.title = collapsed ? 'Open culture controls' : 'Collapse culture controls';
Expand All @@ -225,12 +267,19 @@ export function installCultureConsole(mobile: boolean, camera: CameraState): voi
);
};
collapse?.addEventListener('click', () => {
setCollapsed(!rack.classList.contains('is-collapsed'));
const collapsed = !rack.classList.contains('is-collapsed');
setCollapsed(collapsed);
if (!collapsed) announceWorkspaceDrawer('culture');
});
launcher.addEventListener('click', () => {
const collapsed = !rack.classList.contains('is-collapsed');
setCollapsed(collapsed);
if (!collapsed) announceWorkspaceDrawer('culture');
});
// A full rack fits beside the 86% vessel at ordinary desktop widths. Below
// that, open on the reticle tab so the culture remains observable; the tab is
// still one click away and expands to the exact same control surface.
setCollapsed(mobile || window.matchMedia('(max-width: 1120px)').matches);
// Keep the culture observable on every viewport; its launcher is always one
// click away and expands to the same control surface.
setCollapsed(true);
closeWhenWorkspaceDrawerChanges('culture', () => setCollapsed(true));

for (const button of menu.querySelectorAll<HTMLButtonElement>(':scope > div > button')) {
const original = button.textContent?.trim() ?? '';
Expand Down
20 changes: 20 additions & 0 deletions src/lab/dishOrientation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { compactAngleDelta, rotationFromBearingDrag } from './dishOrientation.ts';

const close = (actual: number, expected: number): void => {
assert.ok(Math.abs(actual - expected) < 1e-10, `${actual} != ${expected}`);
};

test('bearing drag follows the shortest angle across the turn boundary', () => {
const nearPositivePi = Math.PI - 0.05;
const nearNegativePi = -Math.PI + 0.08;
close(compactAngleDelta(nearPositivePi, nearNegativePi), 0.13);
close(compactAngleDelta(nearNegativePi, nearPositivePi), -0.13);
});

test('dragging the visible bearing clockwise rotates the camera oppositely', () => {
close(rotationFromBearingDrag(0.4, -Math.PI / 2, 0), 0.4 - Math.PI / 2);
close(rotationFromBearingDrag(-0.2, Math.PI - 0.1, -Math.PI + 0.1), -0.4);
});
36 changes: 36 additions & 0 deletions src/lab/dishOrientation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { CameraState } from '../camera/cameraState.ts';

/** One shared notification keeps the rim handle and the drawer readout in sync. */
export const DISH_ROTATION_EVENT = 'fluoddity:dish-rotation';

/** Compact an angular difference so crossing -pi/pi does not flip the dish. */
export function compactAngleDelta(from: number, to: number): number {
const turn = Math.PI * 2;
return ((to - from + Math.PI) % turn + turn) % turn - Math.PI;
}

/**
* A bearing drawn on the dish rotates opposite the camera transform. Dragging
* that bearing clockwise therefore rotates the camera counter-clockwise.
*/
export function rotationFromBearingDrag(
startRotation: number,
startPointerAngle: number,
pointerAngle: number,
): number {
return startRotation - compactAngleDelta(startPointerAngle, pointerAngle);
}

export function announceDishRotation(): void {
window.dispatchEvent(new Event(DISH_ROTATION_EVENT));
}

export function setDishRotation(camera: CameraState, radians: number): void {
camera.setRotationAroundWorldOrigin(radians);
announceDishRotation();
}

export function rotateDishBy(camera: CameraState, radians: number): void {
camera.rotateAroundWorldOriginBy(radians);
announceDishRotation();
}
Loading
Loading