Treppe: Vorkonfiguration vor Erstellung (Breite/Referenz/Trittmass)

- 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.
This commit is contained in:
2026-07-05 13:14:06 +02:00
parent df0e5e432a
commit 5229174551
4 changed files with 266 additions and 22 deletions
+85
View File
@@ -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);
}
});
});
+172 -19
View File
@@ -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<typeof stairDefaults>): 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 (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<string, number> => {
const s = state as StairCmdState;
const out: Record<string, number> = { width: s.width };
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;
},
};
+3
View File
@@ -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:",
+3
View File
@@ -674,6 +674,9 @@ export const en: Record<TranslationKey, string> = {
"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:",