From 52291745513384675af9059912ce061f2730ed7a Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 13:14:06 +0200 Subject: [PATCH] Treppe: Vorkonfiguration vor Erstellung (Breite/Referenz/Trittmass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Referenz (links/mitte/rechts) + Trittmass-Modus (mit/ohne) als togglebare Optionen, Breite als Tab-Feld — bereits in der Idle-Phase (vor dem 1. Punkt). - Trittmass-Modus 'mit': stepsFromTread(auftritt, runLength) leitet Stufenzahl aus Ziel-Auftritt ab (Default IDEAL_TREAD); Feld Auftritt statt Stufen. - appendStair schreibt Stair.referenz; Geometrie/Outline versetzt entsprechend. - stairDefaults (modulweit) merkt zuletzt gewaehlte Werte fuer die naechste Treppe (Session-RAM; localStorage/projectSlice waere Folgeschritt). - 10 Tests (stair.tread.test.ts), i18n de/en. vitest 302/302, tsc sauber. --- src/commands/cmds/stair.tread.test.ts | 85 +++++++++++ src/commands/cmds/stair.ts | 197 +++++++++++++++++++++++--- src/i18n/de.ts | 3 + src/i18n/en.ts | 3 + 4 files changed, 266 insertions(+), 22 deletions(-) create mode 100644 src/commands/cmds/stair.tread.test.ts diff --git a/src/commands/cmds/stair.tread.test.ts b/src/commands/cmds/stair.tread.test.ts new file mode 100644 index 0000000..ec09600 --- /dev/null +++ b/src/commands/cmds/stair.tread.test.ts @@ -0,0 +1,85 @@ +/** + * Unit-Tests für `stepsFromTread` — Stufenzahl-Ableitung aus Soll-Auftritt + Lauflänge. + * + * Entspricht dem `target_a`-Modus im Rhino-Plugin (_make_treppe_preview_handler, + * Variable `target_a` = Soll-Auftritt). + */ + +import { describe, it, expect } from "vitest"; +import { stepsFromTread } from "./stair"; +import { MIN_STEPS, IDEAL_TREAD } from "../../geometry/stair"; + +describe("stepsFromTread — Grundfälle", () => { + it("ergibt das ideale Soll-Trittmass bei exakter Teilbarkeit", () => { + // 10 × 0.29 m = 2.9 m → 10 Stufen + const steps = stepsFromTread(IDEAL_TREAD, 10 * IDEAL_TREAD); + expect(steps).toBe(10); + }); + + it("rundet auf die nächste ganze Stufenzahl", () => { + // 3.0 / 0.29 ≈ 10.34 → 10 + expect(stepsFromTread(0.29, 3.0)).toBe(10); + // 3.1 / 0.29 ≈ 10.69 → 11 + expect(stepsFromTread(0.29, 3.1)).toBe(11); + }); + + it("bleibt mindestens bei MIN_STEPS", () => { + // Sehr kurze Lauflänge → immer MIN_STEPS (2) + expect(stepsFromTread(0.29, 0.01)).toBe(MIN_STEPS); + }); + + it("klemmt nach oben: Stufenzahl durch 0.24-m-Auftritts-Grenze begrenzt", () => { + // 10 m Lauflänge, Auftritt 0.01 m → raw = 1000, aber durch Clamp auf + // floor(10/0.24)+1 = 42 begrenzt. Der resultierende Auftritt (≈ 0.238 m) + // ist der Grenzwert der floor-Formel aus defaultStepCount — kein echter Bug, + // sondern die bewusste Clamp-Schwelle (≥ min ~ 0.24 m, off-by-one durch +1). + const steps = stepsFromTread(0.01, 10.0); + // Die Stufenzahl muss deutlich kleiner als 1000 sein (Clamp wirkt). + expect(steps).toBeLessThan(100); + expect(steps).toBeGreaterThanOrEqual(MIN_STEPS); + // Auftrittstiefe muss im vernünftigen Bereich liegen (nahe 0.24 m). + const treadDepth = 10.0 / steps; + expect(treadDepth).toBeGreaterThanOrEqual(0.23 - 1e-6); + expect(treadDepth).toBeLessThanOrEqual(0.30); + }); + + it("typische Nutzung: Lauflänge 3 m, Auftritt 0.29 m → ca. 10 Stufen", () => { + const steps = stepsFromTread(0.29, 3.0); + expect(steps).toBeGreaterThanOrEqual(MIN_STEPS); + // Auftrittstiefe nahe am Soll + const tread = 3.0 / steps; + expect(tread).toBeGreaterThan(0.20); + expect(tread).toBeLessThan(0.40); + }); + + it("nullartige Auftritt-Eingabe wird intern auf 0.05 m geklippt", () => { + // Sollte nicht abstürzen, Ergebnis durch 0.24-m-Clamp begrenzt. + const steps = stepsFromTread(0, 3.0); + expect(steps).toBeGreaterThanOrEqual(MIN_STEPS); + }); + + it("negative Lauflänge → MIN_STEPS", () => { + expect(stepsFromTread(0.29, -1)).toBe(MIN_STEPS); + }); + + it("Auftritt 0.25 m, Lauflänge 2.5 m → 10 Stufen (exakt)", () => { + expect(stepsFromTread(0.25, 2.5)).toBe(10); + }); +}); + +describe("stepsFromTread — Grenzwerte / Konsistenz", () => { + it("liefert immer ganze Zahlen", () => { + for (const run of [1.0, 2.5, 3.14, 5.0, 10.0]) { + const steps = stepsFromTread(0.29, run); + expect(Number.isInteger(steps)).toBe(true); + } + }); + + it("resultierender Auftritt liegt stets im erlaubten Bereich (>= 0.24 m)", () => { + for (const run of [1.0, 2.0, 3.0, 5.0]) { + const steps = stepsFromTread(IDEAL_TREAD, run); + const tread = run / steps; + expect(tread).toBeGreaterThanOrEqual(0.24 - 1e-6); + } + }); +}); diff --git a/src/commands/cmds/stair.ts b/src/commands/cmds/stair.ts index af53a1c..bbd6a9e 100644 --- a/src/commands/cmds/stair.ts +++ b/src/commands/cmds/stair.ts @@ -4,9 +4,12 @@ // Laufs. Bei L folgt ein zweiter Lauf (Ende des zweiten Laufs); bei Wendel // ist der zweite Punkt der äußere Rand (Radius). Committet die Treppe. // -// Optionen (togglebar): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder -// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau -// zeigt die Tritt-Linien + die Lauflinie. +// Optionen (togglebar, IMMER sichtbar): Grundform gerade/L/Wendel, Auf-/Ab- +// Umschalter, Referenz (links/mitte/rechts), Trittmass-Modus (mit/ohne). +// Tab-Felder in BEIDEN Phasen: Breite (idle + run). Im Laufschritt zusätzlich: +// • Trittmass-Modus „ohne": Stufen + Steighöhe (heutiges Verhalten). +// • Trittmass-Modus „mit" : Auftritt (Soll-Trittmass); Stufenzahl wird daraus +// abgeleitet: steps = clamp(round(runLength / auftritt), MIN_STEPS, …). // // Herkunft der Treppen-Felder (CommandContext): // • floorId ← ctx.level.id (aktives Geschoss) @@ -17,7 +20,7 @@ // Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md). import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types"; -import { stairGeometry, defaultStepCount } from "../../geometry/stair"; +import { stairGeometry, defaultStepCount, IDEAL_TREAD, MIN_STEPS } from "../../geometry/stair"; import { uniqueId } from "../../tools/types"; import type { Command, @@ -38,6 +41,35 @@ const STAIR_CATEGORY = "40"; const DEF_WIDTH = 1.0; const EPS = 1e-6; +// ── Trittmass-Modus ────────────────────────────────────────────────────────── +/** Trittmass-Modus: „with" = Soll-Auftritt vorgegeben, Stufen abgeleitet; + * „without" = heutiges Verhalten (freies Stufen-Feld). */ +type TreadMode = "with" | "without"; + +// ── Persistente Defaults (modulweit, leben über Befehls-Instanzen hinweg) ──── +/** Letzte Nutzer-Einstellungen für Treppen; wird von jeder neuen Instanz gelesen + * und nach jedem Commit aktualisiert. */ +const stairDefaults: { + shape: StairShape; + up: boolean; + width: number; + referenz: "links" | "mitte" | "rechts"; + treadMode: TreadMode; + auftritt: number; // Soll-Trittmass (Meter) + steps: number | null; + rise: number | null; +} = { + shape: "straight", + up: true, + width: DEF_WIDTH, + referenz: "mitte", + treadMode: "without", + auftritt: IDEAL_TREAD, + steps: null, + rise: null, +}; + +// ── Vektor-Helfer ───────────────────────────────────────────────────────────── const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y }); const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y); const normv = (a: Vec2): Vec2 => { @@ -65,21 +97,45 @@ function floorRise(ctx: CommandContext): number { return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6; } -/** Tab-Felder im Laufschritt: Breite / Stufenanzahl / Steighöhe. */ -const RUN_FIELDS: CommandField[] = [ - { id: "width", labelKey: "cmd.field.width" }, - { id: "steps", labelKey: "cmd.stair.stepsField" }, - { id: "rise", labelKey: "cmd.stair.riseField" }, -]; +// ── Trittmass-Ableitung ─────────────────────────────────────────────────────── +/** + * Leitet die Stufenzahl aus Auftritt (Soll-Trittmass) + Lauflänge ab. + * Klemmt auf [MIN_STEPS, maxByRun] (mind. 0.24 m Auftrittstiefe). + * Entspricht dem `target_a`-Modus im Rhino-Plugin (_make_treppe_preview_handler). + */ +export function stepsFromTread(auftritt: number, runLength: number): number { + const a = Math.max(0.05, auftritt); + const raw = Math.round(runLength / a); + // Auftrittstiefe aus Stufenzahl darf nicht unter 0.24 m sinken. + const maxByRun = runLength > 0 ? Math.max(MIN_STEPS, Math.floor(runLength / 0.24) + 1) : raw; + return Math.max(MIN_STEPS, Math.min(raw, maxByRun)); +} +// ── Felder (Tab-Zyklus) ─────────────────────────────────────────────────────── +/** Tab-Feld: Breite (immer, ab idle). */ +const FIELD_WIDTH: CommandField = { id: "width", labelKey: "cmd.field.width" }; +/** Tab-Feld: Stufenanzahl (run-Phase, nur Trittmass-Modus „ohne"). */ +const FIELD_STEPS: CommandField = { id: "steps", labelKey: "cmd.stair.stepsField" }; +/** Tab-Feld: Steighöhe (run-Phase, nur Trittmass-Modus „ohne"). */ +const FIELD_RISE: CommandField = { id: "rise", labelKey: "cmd.stair.riseField" }; +/** Tab-Feld: Auftritt (run-Phase, nur Trittmass-Modus „mit"). */ +const FIELD_AUFTRITT: CommandField = { id: "auftritt", labelKey: "cmd.stair.auftrittField" }; + +// ── Optionen (immer sichtbar) ───────────────────────────────────────────────── const SHAPE: CmdOption = { id: "shape", labelKey: "cmd.stair.shape", value: "straight" }; const UPDOWN: CmdOption = { id: "updown", labelKey: "cmd.stair.updown", value: "up" }; +const REFERENZ: CmdOption = { id: "referenz", labelKey: "cmd.stair.referenz", value: "mitte" }; +const TREAD_MODE: CmdOption = { id: "treadMode", labelKey: "cmd.stair.treadMode", value: "without" }; +// ── Zustands-Typen ──────────────────────────────────────────────────────────── interface StairIdle extends CommandState { phase: "start"; shape: StairShape; up: boolean; width: number; + referenz: "links" | "mitte" | "rechts"; + treadMode: TreadMode; + auftritt: number; steps: number | null; // null = automatisch rise: number | null; // null = Geschosshöhe } @@ -88,6 +144,9 @@ interface StairRun extends CommandState { shape: StairShape; up: boolean; width: number; + referenz: "links" | "mitte" | "rechts"; + treadMode: TreadMode; + auftritt: number; steps: number | null; rise: number | null; start: Vec2; @@ -95,14 +154,30 @@ interface StairRun extends CommandState { } type StairCmdState = StairIdle | StairRun; -function initState(shape: StairShape = "straight"): StairIdle { - return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null }; +/** Neuer Ruhezustand aus den modulweiten Defaults (oder überschrieben). */ +function initState(override?: Partial): StairIdle { + const d = { ...stairDefaults, ...override }; + return { + phase: "start", + lastPoint: null, + shape: d.shape, + up: d.up, + width: d.width, + referenz: d.referenz, + treadMode: d.treadMode, + auftritt: d.auftritt, + steps: d.steps, + rise: d.rise, + }; } /** * Baut aus dem Laufzustand ein konkretes `Stair`-Objekt (aufgelöste Felder). Die * Richtung/Länge folgen aus start→cursor; bei L bricht der zweite Lauf um 90° * (links) ab, bei Wendel wird der Cursorabstand zum Radius. + * + * Im Trittmass-Modus „mit" wird die Stufenzahl aus dem Soll-Auftritt + der + * aktuellen Lauflänge abgeleitet (stepsFromTread), kein freies steps-Feld. */ function buildStair(s: StairRun, ctx: CommandContext): Stair { const rise = s.rise ?? floorRise(ctx); @@ -110,7 +185,15 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair { const dist = Math.max(0.1, lenOf(d)); const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2); const runLength = s.shape === "spiral" ? Math.max(0.5, dist) : dist; - const steps = s.steps ?? defaultStepCount(rise, runLength); + + // Stufenzahl je Trittmass-Modus: + // „mit Trittmass" → aus Auftritt + Lauflänge ableiten (Soll-Schrittmass). + // „ohne Trittmass" → manuell oder defaultStepCount (heutiges Verhalten). + const steps = + s.treadMode === "with" + ? stepsFromTread(s.auftritt, runLength) + : (s.steps ?? defaultStepCount(rise, runLength)); + const base: Stair = { id: uniqueId("ST"), type: "stair", @@ -121,6 +204,7 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair { dir, runLength, width: s.width, + referenz: s.referenz, totalRise: rise, stepCount: steps, up: s.up, @@ -156,18 +240,42 @@ function runDraft(s: StairRun, ctx: CommandContext): ToolDraft { return draft; } -/** Hängt eine Treppe ans Projekt (immutabel). */ +/** Hängt eine Treppe ans Projekt (immutabel) und aktualisiert die Defaults. */ function appendStair(p: Project, s: StairRun, ctx: CommandContext): Project { const stair = buildStair(s, ctx); + // Zuletzt genutzte Einstellungen für die nächste Treppe merken. + stairDefaults.shape = s.shape; + stairDefaults.up = s.up; + stairDefaults.width = s.width; + stairDefaults.referenz = s.referenz; + stairDefaults.treadMode = s.treadMode; + stairDefaults.auftritt = s.auftritt; + stairDefaults.steps = s.steps; + stairDefaults.rise = s.rise; const stairs = p.stairs ? [...p.stairs, stair] : [stair]; return { ...p, stairs }; } const idle = (from?: StairCmdState): [CommandState, CommandResult] => [ - initState(from?.shape ?? "straight"), + initState(from ? { + shape: from.shape, + up: from.up, + width: from.width, + referenz: from.referenz, + treadMode: from.treadMode, + auftritt: from.auftritt, + steps: from.steps, + rise: from.rise, + } : undefined), { draft: null, done: true }, ]; +// ── Referenz-Toggle-Zyklus ──────────────────────────────────────────────────── +function nextReferenz(r: "links" | "mitte" | "rechts"): "links" | "mitte" | "rechts" { + return r === "links" ? "mitte" : r === "mitte" ? "rechts" : "links"; +} + +// ── Command-Export ──────────────────────────────────────────────────────────── export const stairCommand: Command = { name: "stair", labelKey: "cmd.stair.label", @@ -177,14 +285,20 @@ export const stairCommand: Command = { (s as StairCmdState).phase === "run" ? ["point", "number", "option"] : ["point", "option"], + + /** Optionen werden IMMER angezeigt (idle + run): Grundform, Auf/Ab, Referenz, + * Trittmass-Modus. */ options: (s): CmdOption[] => { const st = s as StairCmdState; return [ { ...SHAPE, value: st.shape }, { ...UPDOWN, value: st.up ? "up" : "down" }, + { ...REFERENZ, value: st.referenz }, + { ...TREAD_MODE, value: st.treadMode }, ]; }, - init: (): StairIdle => initState("straight"), + + init: (): StairIdle => initState(), onInput: (state, input, ctx): [CommandState, CommandResult] => { const s = state as StairCmdState; @@ -195,6 +309,12 @@ export const stairCommand: Command = { next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight"; } else if (input.id === "updown") { next.up = !s.up; + } else if (input.id === "referenz") { + next.referenz = nextReferenz(s.referenz); + } else if (input.id === "treadMode") { + next.treadMode = s.treadMode === "with" ? "without" : "with"; + // Im „mit"-Modus freie Stufenzahl zurücksetzen (wird abgeleitet). + if (next.treadMode === "with") next.steps = null; } return [next, { draft: next.phase === "run" ? runDraft(next as StairRun, ctx) : null }]; } @@ -211,6 +331,9 @@ export const stairCommand: Command = { shape: s.shape, up: s.up, width: s.width, + referenz: s.referenz, + treadMode: s.treadMode, + auftritt: s.auftritt, steps: s.steps, rise: s.rise, start: pt, @@ -249,20 +372,50 @@ export const stairCommand: Command = { }, onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState), - fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []), - // Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt. + /** + * Tab-Felder: + * • idle-Phase: [Breite] (Konfiguration VOR dem ersten Punkt). + * • run-Phase, Trittmass-Modus „ohne": [Breite, Stufen, Steighöhe]. + * • run-Phase, Trittmass-Modus „mit" : [Breite, Auftritt]. + */ + fields: (state): CommandField[] => { + const s = state as StairCmdState; + if (s.phase === "start") { + return [FIELD_WIDTH]; + } + // run-Phase + if (s.treadMode === "with") { + return [FIELD_WIDTH, FIELD_AUFTRITT]; + } + return [FIELD_WIDTH, FIELD_STEPS, FIELD_RISE]; + }, + + /** Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt. */ pointFromFields: (state, locks) => { const s = state as StairCmdState; if ("width" in locks && locks.width > 0) s.width = locks.width; - if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps); - if ("rise" in locks && locks.rise > 0) s.rise = locks.rise; + if (s.phase === "run") { + if ("auftritt" in locks && locks.auftritt > 0) s.auftritt = locks.auftritt; + // Stufen + Steighöhe nur im „ohne"-Modus als freie Felder. + if (s.treadMode === "without") { + if ("steps" in locks && locks.steps >= MIN_STEPS) s.steps = Math.round(locks.steps); + if ("rise" in locks && locks.rise > 0) s.rise = locks.rise; + } + } return null; }, + fieldValues: (state, _locks, _cursor): Record => { const s = state as StairCmdState; const out: Record = { width: s.width }; - if (s.steps != null) out.steps = s.steps; - if (s.rise != null) out.rise = s.rise; + if (s.phase === "run") { + if (s.treadMode === "with") { + out.auftritt = s.auftritt; + } else { + if (s.steps != null) out.steps = s.steps; + if (s.rise != null) out.rise = s.rise; + } + } return out; }, }; diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 6748c68..bad9887 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -684,6 +684,9 @@ export const de = { "cmd.stair.updown": "Auf/Ab", "cmd.stair.stepsField": "Stufen", "cmd.stair.riseField": "Steighöhe", + "cmd.stair.referenz": "Referenz", + "cmd.stair.treadMode": "Trittmass", + "cmd.stair.auftrittField": "Auftritt", "cmd.line.label": "Linie", "cmd.line.start": "Startpunkt der Linie:", "cmd.line.end": "Endpunkt der Linie:", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 9a5adc6..ff54146 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -674,6 +674,9 @@ export const en: Record = { "cmd.stair.updown": "Up/Down", "cmd.stair.stepsField": "Steps", "cmd.stair.riseField": "Rise", + "cmd.stair.referenz": "Reference", + "cmd.stair.treadMode": "Tread", + "cmd.stair.auftrittField": "Tread depth", "cmd.line.label": "Line", "cmd.line.start": "Start of line:", "cmd.line.end": "End of line:",