P1d: manual face cuts and trim in the quarry

This commit is contained in:
Emil
2026-11-01 01:51:47 +03:00
parent 88663a384e
commit 346035e869
17 changed files with 534 additions and 41 deletions
+8
View File
@@ -102,6 +102,9 @@
} else if (event.kind === 'history') {
project.can_undo = event.can_undo;
project.can_redo = event.can_redo;
} else if (event.kind === 'core_faces') {
const index = project.cores.findIndex((core) => core.id === event.core.id);
if (index >= 0) project.cores[index] = event.core;
}
}
@@ -193,6 +196,10 @@
void run({ type: 'set_pattern_step', row, step, velocity: on ? 0 : 96 });
}
function setCoreFaces(core: string, boundaries: number[]) {
void run({ type: 'set_core_faces', core, boundaries });
}
function undo() {
if (project?.can_undo) void run({ type: 'undo' });
}
@@ -326,6 +333,7 @@
onassign={assignFace}
onstrikeface={strikeFace}
onstep={toggleStep}
onfaces={setCoreFaces}
/>
{/if}
<Wire {entries} {status} onreconnect={reconnect} />
+10 -1
View File
@@ -1367,17 +1367,26 @@ button.room {
margin-top: 7px;
border-top: 1px solid rgba(155, 133, 121, 0.12);
border-bottom: 1px solid rgba(155, 133, 121, 0.12);
cursor: ew-resize;
touch-action: none;
}
.wave-axis {
display: flex;
justify-content: space-between;
padding: 4px 0 9px;
padding: 4px 0 2px;
font-size: 8px;
color: var(--ash-2);
font-variant-numeric: tabular-nums;
}
.wave-hint {
padding: 2px 0 8px;
font-size: 8px;
letter-spacing: 0.05em;
color: var(--ash-2);
}
.face-wall {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
+129 -19
View File
@@ -18,7 +18,8 @@
onimport,
onassign,
onstrikeface,
onstep
onstep,
onfaces
}: {
cores: CoreSummary[];
pattern: Pattern | null;
@@ -33,6 +34,7 @@
onassign: () => void;
onstrikeface: (core: string, face: number) => void;
onstep: (row: number, step: number, on: boolean) => void;
onfaces: (core: string, boundaries: number[]) => void;
} = $props();
let fileInput: HTMLInputElement | null = null;
@@ -42,6 +44,8 @@
let recordError = $state<string | null>(null);
let finishing = $state(false);
let takeIndex = $state(1);
let draft = $state<number[] | null>(null);
let dragging: { index: number; pointer: number } | null = null;
let observer: ResizeObserver | null = null;
const core = $derived(cores.find((item) => item.id === selectedCore) ?? cores[0] ?? null);
@@ -50,6 +54,8 @@
core?.faces.find((item) => item.rank === 0) ??
null
);
const boundaries = $derived(core ? faceBoundaries(core) : []);
const trim = $derived(draft ?? boundaries);
onMount(() => {
if (!wave) return;
@@ -62,6 +68,7 @@
$effect(() => {
core;
face;
draft;
draw();
});
@@ -82,20 +89,24 @@
const center = height / 2;
const duration = Math.max(0.001, core.duration_sec);
for (const item of core.faces) {
const start = (item.start_sec / duration) * width;
const end = (item.end_sec / duration) * width;
if (face && item.index === face.index) {
context.fillStyle = 'rgba(232,64,42,0.08)';
context.fillRect(start, 0, end - start, height);
}
context.strokeStyle =
item.index % 4 === 0 ? 'rgba(232,64,42,0.34)' : 'rgba(155,133,121,0.14)';
context.lineWidth = 1;
context.beginPath();
context.moveTo(start + 0.5, 0);
context.lineTo(start + 0.5, height);
context.stroke();
const limits = draft ?? boundaries;
const selected = face ? face.index : -1;
if (limits.length) {
context.fillStyle = 'rgba(12,9,8,0.55)';
const left = (limits[0] / duration) * width;
const right = (limits[limits.length - 1] / duration) * width;
if (left > 0) context.fillRect(0, 0, left, height);
if (right < width) context.fillRect(right, 0, width - right, height);
}
if (selected >= 0 && limits[selected] !== undefined && limits[selected + 1] !== undefined) {
context.fillStyle = 'rgba(232,64,42,0.08)';
context.fillRect(
(limits[selected] / duration) * width,
0,
((limits[selected + 1] - limits[selected]) / duration) * width,
height
);
}
const step = width / Math.max(1, core.peaks.length - 1);
@@ -110,9 +121,24 @@
context.lineTo(width, center + 0.5);
context.stroke();
if (face) {
const start = (face.start_sec / duration) * width;
const end = (face.end_sec / duration) * width;
for (let index = 0; index < limits.length; index += 1) {
const x = (limits[index] / duration) * width;
const outer = index === 0 || index === limits.length - 1;
context.strokeStyle = outer
? 'rgba(232,64,42,0.5)'
: index % 4 === 0
? 'rgba(232,64,42,0.28)'
: 'rgba(155,133,121,0.16)';
context.lineWidth = 1;
context.beginPath();
context.moveTo(x + 0.5, 0);
context.lineTo(x + 0.5, height);
context.stroke();
}
if (face && limits[face.index] !== undefined) {
const start = (limits[face.index] / duration) * width;
const end = (limits[face.index + 1] / duration) * width;
context.strokeStyle = 'rgba(239,228,212,0.72)';
context.lineWidth = 1;
context.strokeRect(start + 0.5, 0.5, Math.max(1, end - start - 1), height - 1);
@@ -131,6 +157,77 @@
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
}
function faceBoundaries(item: CoreSummary): number[] {
const faces = [...item.faces].sort((left, right) => left.index - right.index);
if (!faces.length) return [];
return [...faces.map((entry) => entry.start_sec), faces[faces.length - 1].end_sec];
}
function canvasPoint(event: PointerEvent): number {
const canvas = wave;
if (!canvas) return 0;
return event.clientX - canvas.getBoundingClientRect().left;
}
function nearestBoundary(x: number): number | null {
const canvas = wave;
if (!canvas || !core) return null;
const width = Math.max(1, canvas.clientWidth);
const duration = Math.max(0.001, core.duration_sec);
let best: number | null = null;
let distance = 9;
for (let index = 0; index < boundaries.length; index += 1) {
const candidate = Math.abs((boundaries[index] / duration) * width - x);
if (candidate < distance) {
best = index;
distance = candidate;
}
}
return best;
}
function onBoundaryDown(event: PointerEvent) {
if (!core || !wave) return;
const index = nearestBoundary(canvasPoint(event));
if (index === null) return;
event.preventDefault();
wave.setPointerCapture(event.pointerId);
dragging = { index, pointer: event.pointerId };
draft = [...boundaries];
}
function onBoundaryMove(event: PointerEvent) {
const current = dragging;
if (!current || !core || !wave || event.pointerId !== current.pointer) return;
const width = Math.max(1, wave.clientWidth);
const duration = Math.max(0.001, core.duration_sec);
const x = Math.max(0, Math.min(width, canvasPoint(event)));
const seconds = (x / width) * duration;
const limits = draft ?? boundaries;
const min = Math.min(0.02, duration / 64);
const lower = current.index === 0 ? 0 : limits[current.index - 1] + min;
const upper =
current.index === limits.length - 1 ? duration : limits[current.index + 1] - min;
const value = Math.max(lower, Math.min(upper, seconds));
draft = limits.map((entry, index) => (index === current.index ? value : entry));
}
function onBoundaryUp(event: PointerEvent) {
if (!dragging) return;
const limits = draft;
if (wave?.hasPointerCapture(event.pointerId)) wave.releasePointerCapture(event.pointerId);
dragging = null;
draft = null;
if (
limits &&
core &&
limits.length === boundaries.length &&
limits.some((entry, index) => Math.abs(entry - boundaries[index]) > 0.0005)
) {
onfaces(core.id, limits);
}
}
async function toggleRecording() {
if (finishing) return;
if (!recording) {
@@ -271,11 +368,20 @@
</div>
</div>
<canvas class="wave" bind:this={wave} aria-hidden="true"></canvas>
<canvas
class="wave"
bind:this={wave}
aria-hidden="true"
onpointerdown={onBoundaryDown}
onpointermove={onBoundaryMove}
onpointerup={onBoundaryUp}
onpointercancel={onBoundaryUp}
></canvas>
<div class="wave-axis">
<span>0.0 s</span>
<span>{core.duration_sec.toFixed(1)} s</span>
</div>
<div class="wave-hint">drag a seam to cut · outer seams trim</div>
<div class="face-wall" role="group" aria-label="sixteen faces">
{#each core.faces as item}
@@ -349,6 +455,10 @@
rank 01.
</p>
<div class="face-stats">
<div>
<span>trim</span>
<b>{trim.length ? `${trim[0].toFixed(2)}–${trim[trim.length - 1].toFixed(2)} s` : '—'}</b>
</div>
<div><span>rank</span><b>{face ? String(face.rank + 1).padStart(2, '0') : '—'}</b></div>
<div><span>rms</span><b>{face ? face.rms.toFixed(3) : '—'}</b></div>
<div><span>length</span><b>{face ? `${(face.end_sec - face.start_sec).toFixed(2)} s` : '—'}</b></div>
+1
View File
@@ -33,6 +33,7 @@ export function commandLabel(command: Command): string {
case 'strike_face': return `strike ${command.core} f${command.face + 1}`;
case 'set_pattern_step': return `step ${command.row + 1}:${command.step + 1} at ${command.velocity}`;
case 'set_pattern_face': return `${command.core} f${command.face + 1} on row ${command.row + 1}`;
case 'set_core_faces': return `cut ${command.core}`;
case 'undo': return 'undo';
case 'redo': return 'redo';
case 'get_snapshot': return 'snapshot';
+3 -1
View File
@@ -80,7 +80,8 @@ export type Event =
| { kind: 'pattern_step'; row: number; step: number; velocity: number }
| { kind: 'pattern_face'; row: number; core: string; face: number }
| { kind: 'face_struck'; core: string; face: number; velocity: number }
| { kind: 'history'; can_undo: boolean; can_redo: boolean };
| { kind: 'history'; can_undo: boolean; can_redo: boolean }
| { kind: 'core_faces'; core: CoreSummary };
export type Command =
| { type: 'get_snapshot' }
@@ -92,6 +93,7 @@ export type Command =
| { type: 'strike_face'; core: string; face: number; velocity: number }
| { type: 'set_pattern_step'; row: number; step: number; velocity: number }
| { type: 'set_pattern_face'; row: number; core: string; face: number }
| { type: 'set_core_faces'; core: string; boundaries: number[] }
| { type: 'undo' }
| { type: 'redo' };