P1d: manual face cuts and trim in the quarry
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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' };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user