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
149 changes: 116 additions & 33 deletions cuprum-ui/src/components/drill/DrillManualZBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useMachine } from "@/machineStore";
import { useSettings } from "@/settingsStore";
import { useJog } from "@/hooks/useJog";
import { classifyBindZ, type ZBindBand } from "@/lib/drillZHeadroom";
import { parseZTarget } from "@/lib/zbar";
import { isSafeDescentTarget, parseZTarget } from "@/lib/zbar";
import { cn } from "@/lib/utils";

const clamp01 = (f: number) => (f <= 0 ? 0 : f >= 1 ? 1 : f);
Expand All @@ -14,6 +14,48 @@ const clamp01 = (f: number) => (f <= 0 ? 0 : f >= 1 ? 1 : f);
const Z_BTN =
"grid h-9 w-12 shrink-0 place-items-center rounded-md border border-border bg-card text-foreground transition-colors hover:border-primary/40 hover:bg-foreground/5 active:bg-primary/10 disabled:pointer-events-none disabled:opacity-30";

/** Props for DrillManualZBar in default (tool-change) mode. */
interface DefaultModeProps {
/** Machine Z (mm) of the last manual touch-off this session — yellow reference tick. */
lastZMm: number | null;
/** Safe machine-Z bind band (from `zBindBand`). null/unknown → no forbidden zones drawn. */
band?: ZBindBand | null;
/** Safe-descent mode disabled — this is the default tool-change behaviour. */
safeDescent?: false;
zLabel?: never;
caption?: never;
safeSteps?: never;
descentFeedMmMin?: never;
}

/** Props for DrillManualZBar in safe-descent (fiducial capture) mode.
*
* In this mode:
* - Steps are overridden to `safeSteps` (fine only; no "cont").
* - Z− steps use `descentFeedMmMin` (slow); Z+ uses the normal jog feed.
* - Track clicks only move Z upward; downward clicks are silently ignored.
* - The inline readout is read-only (no keyboard jog to an arbitrary height).
* - `lastZMm` and `band` are unused (no reference tick, no forbidden zones). */
interface SafeDescentModeProps {
lastZMm?: never;
band?: never;
/** Enable safe-descent restrictions. */
safeDescent: true;
/** Step sizes (mm) to offer in safe mode. Defaults to [0.05, 0.1, 0.5]. */
safeSteps?: number[];
/** Feed rate for downward steps (mm/min). Defaults to 60. */
descentFeedMmMin?: number;
/** Label next to the Z badge (override for the default tool-change label). */
zLabel?: string;
/** Caption below the bar (override for the default tool-change hint). */
caption?: string;
}

export type DrillManualZBarProps = DefaultModeProps | SafeDescentModeProps;

const DEFAULT_SAFE_STEPS = [0.05, 0.1, 0.5];
const DEFAULT_DESCENT_FEED = 60;

/** Manual Z touch-off bar for the tool-change card: a Z badge + live readout, a step
* selector, and a horizontal track `Z−` | bar | `Z+`. The track maps the MACHINE Z
* over the travel `[-maxZMm, 0]` (left = lowest / into the material, right = the homed
Expand All @@ -27,19 +69,21 @@ const Z_BTN =
* the safe/tool-change rapid would punch past Z=0); the safe band stays dark. The thumb
* turns red while the live Z sits in a forbidden zone — the confirm button is gated to match.
*
* Optional `safeDescent` mode restricts controls for safe fiducial capture: only upward
* track clicks, no inline editing, no continuous hold, slow descent feed (see
* SafeDescentModeProps).
*
* It does NOT bind Z — the card's confirm button does that (G10 L20 P1 on Z). */
export function DrillManualZBar({
lastZMm,
band,
}: {
lastZMm: number | null;
/** Safe machine-Z bind band (from `zBindBand`). null/unknown → no forbidden zones drawn. */
band?: ZBindBand | null;
}) {
export function DrillManualZBar(props: DrillManualZBarProps) {
const { t } = useTranslation("drill");

const safeDescent = props.safeDescent === true;
const safeSteps = safeDescent ? (props.safeSteps ?? DEFAULT_SAFE_STEPS) : null;
const descentFeed = safeDescent ? (props.descentFeedMmMin ?? DEFAULT_DESCENT_FEED) : null;

const maxZMm = useSettings((s) => s.cncProfile.workEnvelopeMm.z);
const steps = useSettings((s) => s.cncProfile.jogStepsMm);
const profileSteps = useSettings((s) => s.cncProfile.jogStepsMm);
const steps = safeSteps ?? profileSteps;

// Live machine/work Z — the work offset converts a track target to a work-frame jog.
const mz = useMachine((s) => s.status.mpos[2]);
Expand All @@ -55,6 +99,19 @@ export function DrillManualZBar({
const { enabled, step, setStep, continuous, go, startContinuous, stopContinuous, jogTo } =
useJog({ bounds });

// In safe-descent mode: select the first safe step if the current step is not in the list
// (e.g. user was on a coarse step from the jog pad before switching to capture mode).
// Do this on mount / when safeSteps changes rather than every render to avoid a loop.
const [safeStepInit, setSafeStepInit] = useState(false);
useEffect(() => {
if (!safeDescent) { setSafeStepInit(false); return; }
if (safeStepInit) return;
const validStep = typeof step === "number" && safeSteps!.includes(step);
if (!validStep) setStep(safeSteps![1] ?? safeSteps![0] ?? 0.1);
setSafeStepInit(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [safeDescent]);

// Stop any in-flight continuous jog on unmount.
useEffect(() => () => stopContinuous(), [stopContinuous]);

Expand All @@ -63,25 +120,22 @@ export function DrillManualZBar({
// Thumb / mark fraction: 0 at the bottom (left, -maxZMm) → 1 at the ceiling (right, 0).
const fracOf = (machineZ: number) => clamp01((machineZ + maxZMm) / range);
const thumbFrac = fracOf(mz);

// Default mode: last-Z mark and forbidden zones.
const lastZMm = !safeDescent ? (props as DefaultModeProps).lastZMm : null;
const band = !safeDescent ? (props as DefaultModeProps).band : null;
const lastFrac = lastZMm != null ? fracOf(lastZMm) : null;

// Forbidden-zone fractions from the headroom band: the floor zone fills [0, minZ),
// the ceiling zone fills (maxZ, 1]. fracOf clamps, so a band edge outside the bar's
// travel collapses its zone to zero width. Thumb turns red while Z sits in a zone.
const known = band?.known ?? false;
const floorZoneFrac = known ? fracOf(band!.minZ) : 0; // red from 0 up to here
const ceilZoneFrac = known ? fracOf(band!.maxZ) : 1; // red from here up to 1
const floorZoneFrac = known ? fracOf(band!.minZ) : 0;
const ceilZoneFrac = known ? fracOf(band!.maxZ) : 1;
const thumbBlocked = known && classifyBindZ(band!, mz) != null;
const RED = "#ef4444";

// Hover target (fraction along the track) for the tooltip + ghost line.
const [hoverFrac, setHoverFrac] = useState<number | null>(null);

// Inline-editable readout: click the live Z value to type an exact machine-Z target,
// then jog there (absolute) on commit — the same cancel-then-retarget as a track click.
// Editable only while motion is allowed. Out-of-travel / non-numeric input is rejected
// (invalid → revert, no jog). `doneRef` makes the first commit/cancel win, so the
// synthetic blur fired when the input unmounts on Enter/Escape doesn't double-jog.
// Inline-editable readout (default mode only — disabled in safe-descent mode).
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
Expand All @@ -97,7 +151,7 @@ export function DrillManualZBar({
}, [editing]);

const startEdit = () => {
if (!enabled) return;
if (!enabled || safeDescent) return;
setDraft(mz.toFixed(1));
setEditing(true);
};
Expand Down Expand Up @@ -125,12 +179,18 @@ export function DrillManualZBar({

const onTrackClick = (e: React.MouseEvent) => {
if (!enabled) return;
// jogTo takes WORK coordinates; convert from the machine Z the track maps.
void jogTo({ z: machineZAtFrac(fracAt(e.clientX)) - wcoZ });
const targetMachineZ = machineZAtFrac(fracAt(e.clientX));
const targetWorkZ = targetMachineZ - wcoZ;
if (safeDescent && !isSafeDescentTarget(wz, targetWorkZ)) {
// Downward track click in safe mode: silently ignore.
return;
}
void jogTo({ z: targetWorkZ });
};

// Z± buttons: step jog (click) or continuous (hold) — shares the active step via useJog.
const zProps = (dz: number) =>
// In safe-descent mode: Z− uses slow descent feed, Z+ uses the normal jog feed.
// Neither supports hold/continuous — click-step only.
const zPropsDefault = (dz: number) =>
continuous
? {
onPointerDown: (e: React.PointerEvent) => {
Expand All @@ -143,6 +203,26 @@ export function DrillManualZBar({
}
: { onClick: () => go(0, 0, dz) };

const zPropsSafe = (dz: number) => ({
onClick: () => {
if (!enabled || typeof step !== "number") return;
const { wpos } = useMachine.getState().status;
const targetWorkZ = wpos[2] + dz * step;
const feed = dz < 0 ? descentFeed! : undefined;
void jogTo({ z: targetWorkZ }, feed ?? undefined);
},
});

const zProps = (dz: number) => (safeDescent ? zPropsSafe(dz) : zPropsDefault(dz));

// Labels: safe mode can override both the Z badge label and the caption.
const zLabel = safeDescent
? (props as SafeDescentModeProps).zLabel ?? t("toolChange.zTouchLabel")
: t("toolChange.zTouchLabel");
const caption = safeDescent
? (props as SafeDescentModeProps).caption ?? t("toolChange.manualBarHint")
: t("toolChange.manualBarHint");

return (
<div className="rounded-lg border border-border bg-card/40 p-2.5">
{/* Z badge + live readout */}
Expand All @@ -153,7 +233,7 @@ export function DrillManualZBar({
>
Z
</span>
<span className="text-[12px] font-medium text-foreground">{t("toolChange.zTouchLabel")}</span>
<span className="text-[12px] font-medium text-foreground">{zLabel}</span>
{editing ? (
<input
ref={inputRef}
Expand All @@ -176,10 +256,13 @@ export function DrillManualZBar({
) : (
<button
type="button"
onClick={startEdit}
disabled={!enabled}
title={t("toolChange.zEditHint")}
className="ml-auto rounded px-1 text-[20px] font-bold leading-none tabular-nums text-foreground hover:bg-foreground/5 disabled:hover:bg-transparent enabled:cursor-text"
onClick={safeDescent ? undefined : startEdit}
disabled={!enabled || safeDescent}
title={safeDescent ? undefined : t("toolChange.zEditHint")}
className={cn(
"ml-auto rounded px-1 text-[20px] font-bold leading-none tabular-nums text-foreground",
!safeDescent && "hover:bg-foreground/5 disabled:hover:bg-transparent enabled:cursor-text",
)}
>
{mz.toFixed(1)}
</button>
Expand Down Expand Up @@ -224,7 +307,7 @@ export function DrillManualZBar({
)}
style={{ background: "#0c0e11" }}
>
{/* Forbidden bind zones (floor / ceiling) — painted under the ticks + thumb */}
{/* Forbidden bind zones (floor / ceiling) — default mode only */}
{floorZoneFrac > 0 && (
<div
className="pointer-events-none absolute bottom-0 left-0 top-0 rounded-l-md"
Expand Down Expand Up @@ -253,7 +336,7 @@ export function DrillManualZBar({
))}
</div>

{/* Previous manual-Z mark (vertical line + diamond cap) */}
{/* Previous manual-Z mark (vertical line + diamond cap) — default mode only */}
{lastFrac != null && (
<>
<div
Expand Down Expand Up @@ -301,7 +384,7 @@ export function DrillManualZBar({

{/* Caption */}
<div className="mt-2 text-[10px] leading-relaxed text-muted-foreground">
{t("toolChange.manualBarHint")}
{caption}
{lastFrac != null && (
<>
{" "}
Expand Down
67 changes: 19 additions & 48 deletions cuprum-ui/src/components/drill/FiducialPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { canMove } from "@/lib/machineControls";
import { useUnitFormat } from "@/i18n/useUnitFormat";
import { Button } from "@/components/ui/Button";
import { JogStepControl } from "@/components/machine/JogStepControl";
import { DrillManualZBar } from "@/components/drill/DrillManualZBar";

export interface FiducialPanelProps {
/** Registration tooling holes from the panel manifest. */
Expand Down Expand Up @@ -231,21 +232,6 @@ export function FiducialPanel({
setCapturePhase("idle");
}, [stopContinuous]);

/** One step down in Z at the slow descent feed.
* Targets the current work-frame Z minus one step, clamped by the bounds. */
const handleZDown = useCallback(async () => {
if (!enabled || !homed || typeof step !== "number") return;
const { wpos } = useMachine.getState().status;
await jogTo({ z: wpos[2] - step }, FIDUCIAL_Z_DESCENT_FEED_MM_MIN);
}, [enabled, homed, step, jogTo]);

/** One step up in Z at the normal jog feed. */
const handleZUp = useCallback(async () => {
if (!enabled || !homed || typeof step !== "number") return;
const { wpos } = useMachine.getState().status;
await jogTo({ z: wpos[2] + step });
}, [enabled, homed, step, jogTo]);

/** Raise Z to the machine-frame safe retract height (rapid). */
const handleRaiseToSafeZ = useCallback(async () => {
if (!enabled || !homed) return;
Expand Down Expand Up @@ -558,39 +544,24 @@ export function FiducialPanel({
</button>
</div>

{/* Z controls: step down / step up / raise to safe Z */}
<div className="flex items-center gap-1.5">
<button
type="button"
disabled={!enabled || !homed}
className={padBtn + " flex-1"}
title={t("fiducial.zDownTitle")}
onClick={() => void handleZDown()}
>
<ArrowDown className="size-4" />
<span className="ml-1 text-[11px]">{t("fiducial.zDown")}</span>
</button>
<button
type="button"
disabled={!enabled || !homed}
className={padBtn + " flex-1"}
title={t("fiducial.zUpTitle")}
onClick={() => void handleZUp()}
>
<ArrowUp className="size-4" />
<span className="ml-1 text-[11px]">{t("fiducial.zUp")}</span>
</button>
<button
type="button"
disabled={!enabled || !homed}
className={padBtn + " flex-1"}
title={t("fiducial.raiseZTitle")}
onClick={() => void handleRaiseToSafeZ()}
>
<ChevronsUp className="size-4" />
<span className="ml-1 text-[11px]">{t("fiducial.raiseZ")}</span>
</button>
</div>
{/* Z bar (safe-descent mode) + safe-Z retract button */}
<DrillManualZBar
safeDescent
safeSteps={FIDUCIAL_CAPTURE_STEPS_MM}
descentFeedMmMin={FIDUCIAL_Z_DESCENT_FEED_MM_MIN}
zLabel={t("fiducial.zBarLabel")}
caption={t("fiducial.zBarHint")}
/>
<button
type="button"
disabled={!enabled || !homed}
className={padBtn + " w-full"}
title={t("fiducial.raiseZTitle")}
onClick={() => void handleRaiseToSafeZ()}
>
<ChevronsUp className="size-4" />
<span className="ml-1 text-[11px]">{t("fiducial.raiseZ")}</span>
</button>

{/* Capture button */}
<Button
Expand Down
26 changes: 25 additions & 1 deletion cuprum-ui/src/lib/zbar.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { machineZFromFraction, parseZTarget } from "./zbar";
import { machineZFromFraction, parseZTarget, isSafeDescentTarget } from "./zbar";

describe("machineZFromFraction", () => {
it("maps the bottom of the bar to -envZ and the top to 0", () => {
Expand Down Expand Up @@ -55,3 +55,27 @@ describe("parseZTarget", () => {
expect(parseZTarget("-80.1", -80)).toBeNull();
});
});

describe("isSafeDescentTarget", () => {
it("allows an upward click (target above current)", () => {
expect(isSafeDescentTarget(-10, -5)).toBe(true); // moving up: -5 > -10
expect(isSafeDescentTarget(-20, 0)).toBe(true); // all the way to ceiling
});

it("allows a click at the same level (within epsilon)", () => {
expect(isSafeDescentTarget(-10, -10)).toBe(true); // exact same
expect(isSafeDescentTarget(-10, -10.05)).toBe(true); // within 0.1 mm epsilon
expect(isSafeDescentTarget(-10, -10.09)).toBe(true); // still within epsilon
});

it("rejects a downward click (target below current, beyond epsilon)", () => {
expect(isSafeDescentTarget(-10, -15)).toBe(false); // moving down
expect(isSafeDescentTarget(-10, -10.11)).toBe(false); // just beyond epsilon
expect(isSafeDescentTarget(0, -5)).toBe(false); // from ceiling downward
});

it("works at zero (surface level)", () => {
expect(isSafeDescentTarget(0, 0)).toBe(true);
expect(isSafeDescentTarget(0, -0.2)).toBe(false);
});
});
17 changes: 17 additions & 0 deletions cuprum-ui/src/lib/zbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,20 @@ export function parseZTarget(text: string, envZ: number): number | null {
if (v < lo || v > 0) return null;
return v;
}

/** Safe-descent predicate for the fiducial capture Z bar.
*
* In safe-descent mode only upward track clicks are allowed: the operator may
* raise Z freely but must use the Z− button for controlled descent (slow feed).
* A track click is "safe" when the target is AT or ABOVE the current position.
*
* Both arguments are work-frame Z (mm, negative = below surface). The caller
* passes the clicked target and the live wpos[2]. An upward click (target ≥ current)
* returns true; a downward click (target < current) returns false and must be
* ignored/clamped by the caller.
*
* A tiny epsilon (0.1 mm) guards against floating-point jitter from track clicks
* that land exactly at the live position — they are treated as upward. */
export function isSafeDescentTarget(currentWorkZ: number, targetWorkZ: number): boolean {
return targetWorkZ >= currentWorkZ - 0.1;
}
Loading
Loading