diff --git a/src/lib/solver.ts b/src/lib/solver.ts index 2fa88bb..fe95ba0 100644 --- a/src/lib/solver.ts +++ b/src/lib/solver.ts @@ -265,23 +265,28 @@ type Primary = | { kind: "line"; datum: LineDatum } | { kind: "ellipse"; datum: EllipseDatum } +function flaggedPrimary(d: Datum): Primary | null { + if (d.type === "rectangle" && d.isAxisReference) return { kind: "rect", datum: d } + if (d.type === "line" && d.axisRole) return { kind: "line", datum: d } + if (d.type === "ellipse" && d.isPrimary) return { kind: "ellipse", datum: d } + return null +} + function pickPrimary(datums: Datum[]): Primary { if (datums.length === 0) throw new Error("No datums provided.") - // User-flagged primary wins regardless of type priority. Rect's - // `isAxisReference` and line's `axisRole` carry axis semantics on top - // of "primary"; ellipse's `isPrimary` is a pure primary flag (ellipses - // don't define axis directions on their own). + // User-flagged primary wins regardless of type. The store + // (`setAxisRole`) enforces mutual exclusion across the three flag + // kinds — `RectDatum.isAxisReference`, `LineDatum.axisRole`, and + // `EllipseDatum.isPrimary` — so at most one datum is flagged at any + // time. Within a single datum only one of those fields can be set + // (discriminated union), so the check order inside `flaggedPrimary` + // is moot. If a future caller bypasses `setAxisRole` and creates + // multiple flagged datums, the first one in array order wins — + // deterministic but not semantic. for (const d of datums) { - if (d.type === "rectangle" && d.isAxisReference) { - return { kind: "rect", datum: d } - } - if (d.type === "line" && d.axisRole) { - return { kind: "line", datum: d } - } - if (d.type === "ellipse" && d.isPrimary) { - return { kind: "ellipse", datum: d } - } + const flagged = flaggedPrimary(d) + if (flagged) return flagged } const typeRank = (d: Datum): number => diff --git a/src/stores/app.ts b/src/stores/app.ts index 9037130..9f7c4d8 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -113,16 +113,23 @@ export const useAppStore = defineStore("app", () => { id: string, role: "rect" | "x" | "y" | "ellipse" | null, ) { - // Clear any existing flag on other datums. - for (let i = 0; i < datums.value.length; i++) { - const d = datums.value[i] - if (!d || d.id === id) continue - if (d.type === "rectangle" && d.isAxisReference) { - datums.value[i] = { ...d, isAxisReference: false } - } else if (d.type === "line" && d.axisRole) { - datums.value[i] = { ...d, axisRole: null } - } else if (d.type === "ellipse" && d.isPrimary) { - datums.value[i] = { ...d, isPrimary: false } + // Only clear *other* datums' flags when actually assigning a new + // primary. A pure clear (`role === null`) must be a no-op against + // the rest of the set, otherwise clicking "None" on an unflagged + // line would silently strip the primary off whatever rect/ellipse + // legitimately holds it. The docstring guarantees "no-op if it + // wasn't set" for the target too — see the null branch below. + if (role !== null) { + for (let i = 0; i < datums.value.length; i++) { + const d = datums.value[i] + if (!d || d.id === id) continue + if (d.type === "rectangle" && d.isAxisReference) { + datums.value[i] = { ...d, isAxisReference: false } + } else if (d.type === "line" && d.axisRole) { + datums.value[i] = { ...d, axisRole: null } + } else if (d.type === "ellipse" && d.isPrimary) { + datums.value[i] = { ...d, isPrimary: false } + } } } const idx = datums.value.findIndex((d) => d.id === id) @@ -134,7 +141,7 @@ export const useAppStore = defineStore("app", () => { datums.value[idx] = { ...target, isAxisReference: false } } else if (target.type === "line") { datums.value[idx] = { ...target, axisRole: null } - } else { + } else if (target.type === "ellipse") { datums.value[idx] = { ...target, isPrimary: false } } return