31 Commits

Author SHA1 Message Date
karim 91908b1456 Neu gezeichnete Elemente treten der aktiven Gruppe bei; Fix: m/s/d bei Mehrfachauswahl
Zeichnet man ein neues Element, während man eine Gruppe betreten hat
(Isolation), wird es automatisch Mitglied dieser (innersten) Gruppe — man
zeichnet ja sichtlich "in" ihr. Vergleicht vor/nach jedem Commit über alle
Elementarten auf neu hinzugekommene IDs, additiv zur eigentlichen Mutation.

Separater Fix: Bewegen/Kopieren/Spiegeln/Drehen/Feld (m/s/d-Kürzel und die
gleichnamigen Befehle) funktionierten nicht, sobald mehr als ein 2D-Element
gewählt war — TransformSelection kannte nur ein einzelnes `drawingId`, das
bei Mehrfachauswahl `null` ist. Neues `drawingIds`-Feld (Union mit
`drawingId`, dedupliziert) durchgereicht von allen Aufrufstellen (Move/
Copy/Mirror/Array-Befehle, Tastatur-Kürzel-Pfad).
2026-08-22 11:49:54 +02:00
karim 3be728c80e Isolations-Rahmen respektiert Lineal, Verlassen-Pill neu platziert, Grundriss-Titel-Pills entfernt
- Rahmen + "Gruppe verlassen"-Knopf rücken oben/links um die Lineal-Dicke
  ein (20px) statt darüber zu liegen (Nutzer-Report: "respektiere das
  Lineal, wirklich nur Viewport selber").
- Der Knopf sass ausserdem zu weit oben und überlappte die
  Lineal-Beschriftung — jetzt klar darunter (top: 36px statt 12px).
- Die "Grundriss Erdgeschoss"-Pill oben links und die "Schnitthöhe"-Legende
  unten in der Grundriss-Ansicht sind entfernt (Nutzer: redundant, unklar
  wozu).
2026-08-22 11:42:20 +02:00
karim ca8e56412f Fix: Isolations-Rahmen war unsichtbar (lag hinter der PlanView-SVG)
box-shadow auf .pane selbst lag HINTER der eigenen Kind-SVG von PlanView
(Nutzer-Report "ich seh es nicht") — jetzt ein eigenes Overlay-Element
(.pane-isolation-frame), NACH PlanView im DOM gerendert, pointer-events:
none, damit Klicks/Griffe im Plan weiterhin durchgehen.
2026-08-22 11:36:32 +02:00
karim 77f1fc4f70 Gruppen-Isolation: Rahmen als halbtransparentes Orange-Band statt Kontur
Der Isolations-Rahmen (sichtbar solange eine Gruppe betreten ist) ist jetzt
ein halbtransparentes Orange-Band am Rand der Ansicht statt einer dünnen
Akzentfarb-Kontur — näher an Vectorworks' "Ansichtsbereich"-Darstellung.
2026-08-22 11:31:41 +02:00
karim 53144c8b06 Gruppen-Isolation: Rahmen im Viewport, Knopf dort platziert, Bbox-Auswahl
Drei Verbesserungen nach Vectorworks-Vorbild (Screenshot-Referenz):
- Sichtbarer Rahmen um die ganze Ansicht, solange eine Gruppe betreten ist
  (statt nur der gedimmten Elemente ohne Kontur).
- "Gruppe verlassen" sitzt jetzt oben rechts IM Viewport (wie in
  Vectorworks), nicht mehr als lose schwebender Knopf über der ganzen App.
- Ist eine GRUPPE (nicht einzelne Elemente) gewählt, zeigt die Auswahl nur
  noch eine gestrichelte Bounding-Box mit vier Eckpunkten statt jeden
  einzelnen Mitglieds-Umriss einzeln zu umranden.
2026-08-22 11:28:59 +02:00
karim 91ed2b2454 Gruppieren + Gruppe betreten/verlassen (Vectorworks-artig)
Ctrl/Cmd+G fasst die Auswahl zu einer Gruppe zusammen (Elemente + bereits
gewählte Gruppen werden dabei verschachtelt statt vermischt); Ctrl/Cmd+
Shift+G löst eine Gruppenhülle auf (ein Level, Untergruppen bleiben
bestehen). Eine Gruppe ist reine Mitgliedschaft (IDs je Elementart) ohne
eigenes Transform-Objekt — Verschieben/Kopieren/Löschen/Feld brauchen
dadurch keine Sonderbehandlung, eine Gruppen-Auswahl ist einfach eine
grosse Mehrfachauswahl.

Klick auf ein gruppiertes Element wählt die ganze Gruppe (Einzelklick UND
Auswahlrahmen); Doppelklick betritt sie (Isolation): alles ausserhalb wird
gedimmt und lässt sich nicht mehr anwählen, ein Knopf oben links verlässt
sie wieder. Verschachtelte Gruppen: innerhalb einer betretenen Gruppe sind
nur ihre direkten Untergruppen als Gruppe anwählbar, ihre direkten
Mitglieder einzeln editierbar.
2026-08-22 11:17:24 +02:00
karim 19beb8e632 Feld/Array-Befehl: dupliziert Auswahl N-mal in einer Geste
Neuer Ändern-Befehl "Feld": Basispunkt, Zielpunkt (Länge/Winkel per Tab-
Feld), Anzahl per Option zyklisch (2..12). Nutzt den bestehenden
Transform-Modus "array" (tools/transform.ts), der schon länger existierte
aber bisher über keinen Befehl erreichbar war — nur ein Command-Wrapper
nötig, keine neue Transform-Logik.
2026-08-22 02:55:49 +02:00
karim cad544ebd7 Fix: Auswahlrahmen kann jetzt auch Decken/Räume/Treppen anwählen
Das Marquee (Aufziehen eines Auswahl-Rechtecks) las bisher nur wallId/
drawingId aus den Polygon-Primitiven aus — Decken/Räume/Treppen trugen
zwar längst ceilingId/roomId/stairId in denselben Primitiven, wurden aber
nie ausgewertet und beim Aufziehen sogar aktiv abgewählt (war im Code
bereits als bekannte Lücke kommentiert).
2026-08-22 02:43:16 +02:00
karim 467e9580a7 2D: Masskette ist jetzt EIN Element (verlängerbar + Punkte einfügbar)
Bemassungs-Modell umgebaut: eine Reihenbemassung war bisher N unabhängige
Einzel-Elemente, jetzt EIN Element mit einer Punktreihe (pts statt a/b).
Alle Segmente teilen weiterhin einen Offset, aber Verschieben/Selektieren/
Löschen betrifft die ganze Kette auf einmal.

Ergänzen einer bestehenden Kette folgt dem "erst selektieren, dann Befehl"-
Muster (wie move/copy/offset): ist beim Start eine Masskette selektiert,
sortiert der erste Klick sich per Geraden-Projektion in die Kette ein —
vor dem ersten Punkt (verlängert rückwärts), nach dem letzten (verlängert
vorwärts) oder zwischen zwei bestehenden Punkten (fügt ein) — und der
Commit ersetzt dasselbe Element statt eine Kopie zu erzeugen.
2026-08-22 02:40:05 +02:00
karim 2dc57d01a5 2D: Höhenkoten-Werkzeug + Masskette bleibt gerade + Punkt-Notation
Neues Werkzeug "Höhenkote" (SIA 400 B.5.4, Figur 18): Dreieck-Symbol +
Höhenwert an einem Punkt. Vier Varianten (OK/UK × fertig/roh) bestimmen
Dreieck-Richtung und Füllung, per Option umschaltbar. Die Norm ordnet
Zahlen UNTERHALB einer Masslinie explizit Höhenmassen zu — das war der
eigentliche Grund, warum ein separater Typ statt einer Bemassungs-Variante
nötig war.

Bemassungs-Korrektur: eine Masskette bleibt beim Zeichnen immer eine
GERADE Linie (Klicks ab dem dritten Punkt werden auf die durch Punkt 1/2
festgelegte Gerade projiziert), auch wenn die Bedienung wie eine Polylinie
funktioniert — vorher konnte man versehentlich einen Zickzack-Pfad
erzeugen.

Ausserdem: Masszahlen nutzen jetzt Punkt- statt Komma-Notation ("3.96"
statt "3,96") — die tatsächlich gezeichneten Beispiele in der Norm
(Figur 13/14/18) verwenden durchgängig den Punkt, nicht das Komma aus der
abstrakten B.5.2-Beispielliste.
2026-08-22 02:32:11 +02:00
karim 4c7fc99eb8 2D: Bemassung — Massketten (Reihenbemassung) in einer Geste
Das Bemassungs-Werkzeug sammelt jetzt beliebig viele Punkte wie eine
Polylinie (Doppelklick/Enter beendet, "Zurück" nimmt den letzten Punkt
zurück), statt nur zwei. EIN abschliessender Klick für die Lage der
Masslinie gilt für die ganze Reihe (SIA 400 Figur 14) und erzeugt je
Punktpaar ein eigenes, unabhängig editierbares Bemassungs-Element.
2026-08-22 02:22:47 +02:00
karim 6fd5a50d77 2D: Bemassungs-Werkzeug nach SIA 400 B.5.3
Neue Masslinie (shape:"dimension"): zwei Punkte + Lage der Masslinie. Folgt
der Norm statt DIN-Gewohnheiten: Massbegrenzungslinien als Massstriche
(doppelte Strichstärke, keine Pfeilspitzen), Masszahl mit Komma-
Dezimaltrennzeichen, nie kopfüber lesbar (dreht sich automatisch). Die
Masszahl wird nicht gespeichert, sondern immer aus dem aktuellen Abstand der
beiden Punkte abgeleitet — bleibt beim Griff-Ziehen automatisch korrekt.

Drei Griffe (beide Endpunkte + ein Griff auf der Masslinie zum Verschieben
des Abstands), Verschieben/Rotieren, Georef-Rebase und Plan-Einpassen
entsprechend erweitert.
2026-08-22 02:10:38 +02:00
karim ad16202c12 2D: Bezier-Hilfslinien beim Editieren + Kreis kann zur Ellipse werden
Bezier: Anker→Griff-Verbindungen werden beim Editieren (Selektion) gestrichelt
angezeigt, nicht nur während des Zeichnens — macht sichtbar, welcher Griff
welchen Kurvenabschnitt steuert (wie bei üblichen Vektor-Editoren).

Kreis: bekommt wie die Ellipse zwei Griffe (Ost/Nord). Zieht man einen davon
unabhängig, wird der Kreis zur Ellipse (rotation 0); nähern sich die
Halbachsen beim Ziehen wieder an, wird daraus wieder ein Kreis. Erstellen
bleibt unverändert ein normaler Kreis — der Formwandel passiert nur beim
Bearbeiten.
2026-08-22 01:54:27 +02:00
karim 121cb5a372 2D: Ellipse, Spline und Bezier als neue Zeichenwerkzeuge
Ellipse (zwei Bounding-Box-Ecken, achsparallel), Spline (Wegpunkte, geglättet
via Catmull-Rom, wie Polylinie mit rundem statt geradem Verlauf) und Bezier
(klassische manuelle Kontrollpunkt-Kette: Anker/Griff/Griff je Segment).

Neue Drawing2DGeom-Formen + Mathe-Helfer (tools/curves.ts). Rendering
tessellliert zu Linien-/Polygon-Primitiven (wie Polylinie/Rechteck) statt
eigenem SVG-Primitiv — dadurch funktionieren DXF-/Print-SVG-Export und
Hit-Testing ohne Änderung mit. Griff-Editieren (2D-PlanView + 3D-Viewport),
Verschieben/Skalieren, Snapping (Ellipse-Mittelpunkt, Bezier/Spline-Konturen)
und Georef-Rebase entsprechend erweitert.
2026-08-22 01:45:32 +02:00
karim c8ac5a3d89 Kreis-Mittelpunkt + Quadranten (0/90/180/270°) sind jetzt snapbar
Nutzer-Wunsch: "bei Kreisen wäre ich froh wenn Kreismittelpunkte und
0/90/180/270 Grad auch snappen." Fund: computeSnap() (tools/snapping.ts)
kannte das schon vollständig (Center- + Quadranten-Fang für Kreis/Bogen,
Bogen nur innerhalb seiner Winkel-Spanne) -- gesteuert über
`settings.center`, das im Modell aber auf `false` stand UND im Footer
(StatusBar SnapControl) gar keinen Schalter hatte. Ein bereits fertig
gebautes Feature war dadurch komplett unerreichbar.

DEFAULT_SNAP.center jetzt `true`; neue Checkbox "Kreismitte/Quadranten
(0/90/180/270°)" im Fang-Popover der Statusleiste (analog den übrigen
Snap-Arten), damit es wie alle anderen auch abschaltbar bleibt.

tsc/vitest 940/940 grün (keine bestehenden Tests setzten auf
center:false als Default).
2026-08-22 01:24:51 +02:00
karim 4788ca9261 2D: Erneuter Klick auf das aktive Werkzeug schaltet zurück auf Auswahl
Nutzer-Wunsch: "kannst du machen dass Klick auf X wieder auf Auswahl
geht bei den Werkzeugen" -- bisher startete ein Klick auf das bereits
aktive Werkzeug (ToolsPanel) es einfach neu, ohne Effekt. Zentraler
Fix in onSelectTool (App.tsx, einziger Aufrufer aller Werkzeug-Klicks):
zielt der Klick auf das schon aktive, nicht-Auswahl-Werkzeug, wird die
ID vor der restlichen Logik auf "select" umgebogen -- läuft dadurch
automatisch durch denselben Pfad wie ein echter Auswahl-Klick
(inkl. Abbrechen eines laufenden Engine-Befehls).
2026-08-22 01:21:58 +02:00
karim 8ab1e5194f Vordergrund wirkt jetzt wirklich auf die Schraffurfarbe (war seit
2026-07-04 hart auf Tinte gezwungen)

Nutzer-Report: "Vordergrund funktioniert bei mir jetzt nicht, es ist
und bleibt schwarz, idk y. Hintergrund geht." Ursache gefunden:
resolveHatch() (plan/generatePlan/shared.ts) zwang `color` schon immer
fest auf HATCH_INK, mit explizitem Code-Kommentar "Nutzer-Vorgabe
2026-07-04" -- eine frühere, bewusste Entscheidung, dass Schnitt- UND
Ansichtsschraffur IMMER dieselbe dunkle Tinte zeigen, unabhängig vom
aufgelösten Vordergrund. Der `_foreground`-Parameter wurde entgegen-
genommen, aber nie verwendet (Unterstrich-Präfix als Markierung).
Rückfrage gestellt (Entscheidung von 2026-07-04 aufheben oder als
gewollt so belassen?) -- Nutzer bestätigt: aufheben.

Fix: `color: foreground ?? HATCH_INK` -- ein aufgelöster Vordergrund
gewinnt, fehlt er (der heutige Normalfall, da Component.foreground bei
den meisten Bauteilen nie gesetzt ist), bleibt es exakt beim bisherigen
Verhalten. Betrifft ALLE Aufrufer von resolveHatch (Wand, Decke, Stütze,
Drawing2D, Grundriss UND Schnitt) einheitlich, da sie den Vordergrund
schon vorher korrekt auflösten und durchreichten -- nur eben verworfen
wurde.

+2 Tests in structures.column.test.ts (Default bleibt Tinte, expliziter
Vordergrund-Override schlägt jetzt durch). tsc/vitest 940/940 grün.
2026-08-22 01:20:29 +02:00
karim a917e4149a Stütze (Column) bekommt dasselbe By-Layer/By-Object-Attributsystem wie
Wand/Decke

Nutzer-Wunsch: "generell sollten alle BIM Teile also 3D die weiteren
Attribute haben." Untersucht: Dach/Treppe/Raum/Öffnung haben HEUTE
keine Poché-Füllung/Schraffur im Grundriss (reine Linien-/Symbol-
Darstellung, bei Raum sogar bewusst so entschieden -- "keine
Farbwäsche im Plan"); das Attribut-System dort anzuschliessen würde
neue Poché-/Schraffur-Fähigkeiten erfordern, die es dort noch gar
nicht gibt. Stütze dagegen hat bereits eine Poché UND nutzte bereits
dieselben Resolver wie Wand/Decke (nur mit fest verdrahtetem
override=source=undefined) -- Nutzer-Entscheidung nach Rückfrage: nur
Stütze jetzt fertig verdrahten, der Rest bleibt vorerst unverändert.

model/types.ts: Column bekommt dieselben acht Felder wie Wall
(foreground/background/hatchId/strokeWeight × je einer *Source).

plan/generatePlan/structures.ts (addColumnPoche): resolveHatchId/
resolveForeground lesen jetzt column.hatchId/column.hatchSource bzw.
column.foreground/column.foregroundSource statt hartcodiertem
undefined; neuer resolveBackground-Aufruf lässt einen expliziten
Hintergrund-Override vor die neutrale SIA-Poché (Tinte/Papier je nach
Schraffur-Muster) treten -- exakt das addWallPoche-Muster
(bgOverride ?? neutrale Poché).

plan/generatePlan.ts: die Strichstärke der Stütze lief bisher IMMER
mit hartcodiertem override=source=undefined durch resolveStrokeWeight
-- liest jetzt column.strokeWeight/column.strokeWeightSource.

state/selectionInfo.ts (columnSelection): effectiveForeground/
effectiveBackground/effectiveHatchId analog wallSelection (Bauteil-
Repräsentant ist hier `column.componentId` statt der äussersten
Schicht); weightMm war bisher hart auf WALL_FALLBACK_MM gesetzt,
läuft jetzt korrekt durch resolveStrokeWeight.

panels/AttributesPanel.tsx: weightEditable/fillEditable/pocheEditable
um `sel.kind === "column"` erweitert -- Stütze zeigt jetzt dieselben
vier Attribut-Zeilen mit vollem 3-Quellen-Dropdown (Nach Ebene/Nach
Bauteil/eigener Wert) wie Wand/Decke.

+4 Tests (structures.column.test.ts: Default bleibt Bauteil-Schraffur,
Hintergrund-Override greift, hatchSource "layer" nutzt die Kategorie-
Schraffur, expliziter hatchId-Override gewinnt). tsc/vitest 938/938
grün.
2026-08-22 01:13:47 +02:00
karim 85be7173e1 Korrektur: Wand/Decke bleiben bei "Nach Bauteil" als Default, nur
Drawing2D wechselt auf "Nach Ebene"

Nutzer-Korrektur zum vorigen Commit: "eine Wand usw soll weiterhin
nach Bauteil haben und die weisser Grund und schwarzer Vordergrund
haben. Also nach Bauteil. 2D Elemente haben aber bei Attribute kein
nach Bauteil!!!" -- der vorige Commit hatte den Default global (auch
für Wand/Decke) auf "Nach Ebene" umgestellt, was die neutrale SIA-
Poché-Konvention (weisser Grund/schwarzer Vordergrund über die
Bauteil-Kette) durch die rohe Ebenenfarbe ersetzt hätte.

resolveForeground/resolveBackground/resolveHatchId/resolveStrokeWeight
(plan/generatePlan/shared.ts) sind zurückgesetzt auf ihr ursprüngliches
Verhalten: `source === "layer"` (fehlend/"object" ⇒ weiterhin Bauteil-
Kette, DEFAULT bei Wand/Decke). Der elementart-abhängige Default sitzt
jetzt an den AUFRUFERN statt im generischen Resolver:
  • Wand/Decke (selectionInfo.ts): rohes Source-Feld unverändert
    durchgereicht -- Default bleibt "Nach Bauteil".
  • Drawing2D (selectionInfo.ts drawingSelection): `d.foregroundSource
    ?? "layer"` usw. VOR dem Resolver -- Default wird dort explizit
    "Nach Ebene" (kein eigenes Bauteil, "Nach Bauteil" bietet das Panel
    für 2D-Elemente ohnehin nicht mehr an, s. vorletzter Commit).
  • AttributesPanel.tsx uiSourceOf() bekommt einen isDrawing-Parameter
    für denselben elementart-abhängigen Default in der Dropdown-
    Anzeige.

+Tests in shared.resolve.test.ts auf die jetzt korrekten Erwartungen
umgeschrieben (Default bleibt Bauteil, explizites "layer" liefert die
Kategorie, Drawing2D-Aufrufer-Mapping separat geprüft). tsc/vitest
934/934 grün.
2026-08-22 01:05:20 +02:00
karim bfc064796f Attribut-Panel: Default "Nach Ebene" statt "Nach Bauteil", Wert immer
sichtbar, Bearbeiten schaltet automatisch auf "eigener Wert"

Nutzer-Report: "aktuell zeigt jedes Element standard nach Bauteil. Das
wäre eigentlich 'custom' also eigener Wert. Deshalb es soll nach Ebene
Standard sein bei allen Dingen. Und man sollte immer sehen welche
Stiftdicke oder welche Farbe... und wenn man auf das Farbfeld klickt
und die Farbe ändert dann springt es auf eigener Wert automatisch."

**Default-Umkehr** (plan/generatePlan/shared.ts): resolveForeground/
resolveBackground/resolveHatchId/resolveStrokeWeight prüften bisher
`source === "layer"`, sonst (auch bei fehlendem Source-Feld -- der
Normalfall bei jedem neu erzeugten Element, das nie explizit gesetzt
wird) fiel die Kette auf "Nach Bauteil" zurück. Jetzt `source !==
"object"`: fehlend/"layer" liefert die Kategorie, NUR ein explizites
"object" fällt noch auf das Bauteil zurück. Zentraler Fix in den
Resolver-Funktionen selbst wirkt automatisch überall (Grundriss,
Schnitt, Attribut-Panel-Vorschau) konsistent, nicht nur im Panel.
Rückfrage an den Nutzer zum riskantesten Teil (Hintergrund/Poché hat
eine dokumentierte SIA-neutrale Sonderregel, falls kein Wert gesetzt
ist) -- bestätigt: einheitlich umstellen, "Nach Bauteil" bleibt bei
Wand/Decke als explizite Wahl verfügbar.

**"Nach Bauteil" nur noch bei Wand/Decke** (AttributesPanel.tsx): ein
Drawing2D hat kein eigenes Bauteil (Component) -- die Option wäre dort
bedeutungslos (resolveForeground & Co. fielen auf gar keinen Wert
zurück). Neuer `allowObjectSource`-Schalter blendet die Dropdown-
Option für alle vier Felder bei 2D-Elementen aus.

**Wert immer sichtbar + Auto-Switch auf "eigener Wert"**: das Eingabe-
Element (Farb-Swatch/Zahlenfeld/Schraffur-Dropdown) war bisher nur bei
Quelle "eigener Wert" sichtbar -- jetzt immer, mit dem EFFEKTIVEN Wert
befüllt. Neue Selection-Felder effectiveForeground/effectiveBackground/
effectiveHatchId (selectionInfo.ts, über dieselbe Resolve-Kette wie der
Renderer) liefern dafür den echten Wert statt des oft leeren rohen
Overrides. Editiert man das Feld direkt, greift der bestehende Override-
Setter (setzt z.B. wall.foreground) -- die Quelle springt automatisch
auf "eigener Wert", weil uiSourceOf ausschliesslich davon abhängt, ob
ein Wert gesetzt ist (kein zusätzlicher Umschalt-Schritt nötig). Beim
expliziten Umschalten per Dropdown wird jetzt vom aktuell ANGEZEIGTEN
effektiven Wert gesät statt von sel.color, damit die Farbe dabei nicht
unerwartet springt.

+9 Tests (shared.resolve.test.ts: Default-Umkehr aller vier Resolver,
Override gewinnt immer, Fallback ohne Kategorie). tsc/vitest 934/934
grün.
2026-08-22 00:59:13 +02:00
karim 605e8910d3 2D: Teilpunkte werden auch beim Zeichnen snapbar, aber NUR nach Verweilen
Erweiterung des Hover-Teilpunkte-Features: bisher rein visuell im
Auswahl-Ruhezustand (vorheriger Commit). Nutzer-Wunsch: "wenn der
Snappunkt eines neuen Elements auch [z. B. 200ms] auf der Linie ist,
sollten die Teilpunkte auch für eine gewisse Zeit angezeigt werden und
snapbar sein. Aber nur dann!" -- also auch während des Zeichnens
(Command-Engine, Legacy-Werkzeuge, Griff-Ziehen, Transformieren), aber
bewusst NICHT dauerhaft (sonst würde jede N-tel-Position jeder Linie
den Fang-Kandidatenraum überfluten).

computeSnap() (tools/snapping.ts) bekommt eine modulweite Verweil-
Erkennung (wie lastCoalesceKey in projectSlice.ts) -- bewusst
MODULWEIT statt in SnapInput, weil computeSnap von mehreren
unabhängigen Stellen aufgerufen wird (Command-Engine-Snap-Callback,
Legacy-tool.onMove, Transform, Griff-Editieren) und der Verweil-
Zustand aufrufer-übergreifend gelten muss, damit "verweilt der Cursor
lange genug auf DERSELBEN Linie" unabhängig davon erkannt wird, WELCHER
Aufrufer gerade snappt. Sobald erreicht, werden die `hoverDivideCount`-
gleichen Teilpunkte der verweilten Strecke als normale Snap-Kandidaten
(neuer SnapKind "divide", Priorität wie Mittelpunkt) berücksichtigt --
bei einem Streckenwechsel setzt sich die Verweildauer sofort zurück.

Neuer Marker-Glyph (gefüllte Raute) in SnapMarker (overlays.tsx) für
kind "divide" -- bewusst anders als Mittelpunkt (Linie) und Auf-Kante
(Raute nur Umriss), damit nachvollziehbar bleibt, warum es gerade dort
fängt.

+3 Tests (Fake-Timer-gesteuert: kein Fang vor der Verzögerung, Fang
danach exakt auf dem Drittelpunkt, Verwerfen bei Linienwechsel
zwischendurch). tsc/vitest 925/925 grün.
2026-08-22 00:42:40 +02:00
karim 2af8d6aa1b 2D: Hover-Teilpunkte auf Linien (konfigurierbare Verzögerung + Anzahl)
Neues Feature (Nutzer-Wunsch): ruht der Cursor im Auswahl-Ruhezustand
(kein Werkzeug/Drag) länger als eine einstellbare Zeit (Default 300ms)
auf einem 2D-Linien-Segment, erscheinen dessen gleiche Teilpunkte kurz
mit Akzentfarbe -- bei 2 Teilen (Default) nur der Mittelpunkt, bei 3/4/…
entsprechend mehr. Reine Sichtbarkeits-Hilfe, kein neues Snap-Ziel.

Verzögerung (ms) und Teile-Anzahl sind unten im Footer einstellbar, im
bestehenden Fang-Popover (StatusBar.tsx SnapControl) als zwei weitere
Zahlenfelder -- neue Felder hoverDivideDelayMs/hoverDivideCount auf
SnapSettings (tools/types.ts), NICHT an snap.enabled gekoppelt (eigenes,
vom Fang unabhängiges visuelles Feature).

PlanView.tsx: neue nearestLineSegment()-Suche (dieselbe Bildschirm-Nähe-
Logik wie pickDrawing, liefert aber die zwei Endpunkte des konkret
gehoverten Segments einer Polylinie, nicht nur die drawingId). Ein
Timer verankert/verwirft die Anzeige bei Segment-Wechsel; die beiden
neuen Props (hoverDivideDelayMs/hoverDivideCount) werden wie snapColor
durch viewportContent.tsx bis zu PlanView durchgereicht.

tsc/vitest 922/922 grün. Manuell in der App zu prüfen (Pointer-Hover
ist nicht automatisiert testbar, wie die übrigen Griff-Interaktions-
Fixes dieser Session).
2026-08-22 00:34:59 +02:00
karim c5c60d3df5 2D: nach dem ersten getippten Wert bleibt das andere Feld ebenfalls fix
Nutzer-Feedback zum vorigen Commit: das Einfrieren beim Tab-Öffnen
gefiel, aber sobald man einen Wert (z. B. Länge) eintippte und Enter
drückte, sprang der ANDERE Wert (Winkel) sofort wieder auf die Maus um
-- gewünscht war stattdessen: Enter fixiert BEIDE Werte (den getippten
UND den beim Öffnen eingefrorenen), und erst ein explizites Tab in das
andere Feld gibt die Maus für GENAU dieses Feld wieder frei.

Vereinfachung statt Zusatzmechanismus: `locks` wird beim ÖFFNEN (erstes
Tab) nicht mehr leer gelassen, sondern SOFORT mit beiden aktuellen
Werten (Länge+Winkel bzw. bei geführten Kanten nur der eine Abstands-
Wert) befüllt -- die ohnehin vorhandene Locked/Live-Mischung greift
dadurch von Anfang an vollständig, ein separater "eingefroren"-Zustand
(gripFrozenPointRef/edgeFrozenPointRef aus dem vorigen Commit) wird
dadurch überflüssig und wieder entfernt. Zykeln (weiteres Tab bei schon
offenem Feld) wechselt jetzt zum anderen Feld UND löst NUR dessen Lock
(delete locks[nextActive]) -- das verlassene Feld bleibt exakt auf
seinem letzten Wert (getippt oder eingefroren) fixiert. Getippte Werte
(submitGripEditValue/submitEdgeEditValue) sowie Escape-Verhalten
(global = ganzen Drag verwerfen, in der Befehlszeile = nur Feld
schließen) bleiben unverändert.

tsc/vitest 922/922 grün.
2026-08-22 00:24:51 +02:00
karim c43573859f 2D: Tab in ein Griff-/Kanten-Feld friert die Maus ein, bis der erste Wert getippt ist
Nutzer-Wunsch: "wenn ich mit Tab in die Werte springe, soll die Maus
dann nicht mehr steuern, bis man den ersten Wert eingegeben hat -- das
kann man aber mit Esc lösen." Bisher folgte der Punkt/die Kante nach
Tab weiter der Maus, solange noch kein Wert gelockt war -- ein
Zittern der Hand beim Greifen zur Tastatur konnte die Position also
noch verändern, bevor überhaupt getippt wurde.

Neue gripFrozenPointRef/edgeFrozenPointRef (useGripEditing.ts): beim
ÖFFNEN des Feld-Controllers (erstes Tab, noch kein vorheriges Feld
offen) wird die AKTUELLE Position eingefroren; onGripMove/onEdgeMove
halten den Punkt/die Kante bei aktivem Feld-Controller OHNE Lock exakt
dort fest, unabhängig von der Mausposition. Der erste getippte Wert
(submitGripEditValue/submitEdgeEditValue) löst das Einfrieren -- ab da
gilt wieder die bisherige Locked/Live-Mischung (gelockte Felder fest,
ungelockte folgen der Maus).

Neue closeGripEditField/closeEdgeEditField-Funktionen lösen das
Einfrieren explizit auf und schließen NUR den Feld-Prompt (Punkt/Kante
bleibt bewaffnet, Maus steuert wieder normal) -- ersetzen in App.tsx
die bisherigen direkten setGripEdit(null)/setEdgeEdit(null)-Aufrufe im
CommandLine-onCancel (Escape MIT Fokus in der Befehlszeile). Das
bestehende, GLOBALE Escape (ohne Fokus in einem Eingabefeld) bleibt
unverändert: es verwirft den ganzen Griff-/Kanten-Drag komplett auf
die Ursprungsposition (PlanView.tsx, voriger Commit) -- zwei bewusst
verschiedene Eskalationsstufen.

tsc/vitest 922/922 grün.
2026-08-22 00:16:29 +02:00
karim 0c69162ba6 2D: Shift beim Punkt-Griff-Ziehen snappt jetzt auch auf die Flucht
anliegender schräger Kanten, nicht nur Welt-H/V

Nutzer-Wunsch: "wenn ein Punkt aus zwei Linien besteht, die nicht in
[0°/90°] liegen, sollte auch in diese Richtung mit Shift gesnappt
werden können" -- bisher zwang Shift beim Ziehen eines Vielecks-
Eckpunkts (Drawing2D-Polylinie/rect, Raum-/Deckenumriss) IMMER auf
Welt-Horizontal/Vertikal (applyAngleConstraint mit 90°), unabhängig
davon, in welchem Winkel die beiden an diesem Punkt anliegenden Kanten
tatsächlich standen.

Neue Kandidaten-Achsen-Logik (useGripEditing.ts): Shift wählt jetzt die
NÄCHSTLIEGENDE aus mehreren Achsen (kleinster senkrechter Abstand des
Cursors zur Achse) -- Welt-H/V bleiben als Kandidaten erhalten, dazu
kommen die Richtungen der beiden FIXEN Nachbarkanten (deren jeweils
ANDERER Endpunkt bewegt sich ja nicht), gemessen an der Position des
gezogenen Punkts VOR dem Drag (neuer gripOriginRef, sonst würde die
Flucht-Richtung während des Ziehens mitdriften). resolvePolygonNeighbors
kennt geschlossene Ringe (rect/geschlossene Polylinie/Raum/Decke,
Nachbar 0/n-1 wickelt um) vs. offene Ketten (offene Polylinie/Linie);
Kreis/Bogen/Wand/Öffnung/Treppe haben keine Vielecks-Nachbarn und
bleiben unverändert bei reinem Welt-H/V.

Bewusst NICHT angefasst: Kanten-/Körper-Verschieben (die "M"-Bewegung
und die Kanten-Dreieck-Griffe) -- der Nutzer bezog sich explizit auf
"einen einzelnen Punkt", diese behalten ihr bisheriges Welt-H/V-Ortho.

tsc/vitest 922/922 grün. Manuelle Prüfung nötig (Pointer-Interaktion in
PlanView.tsx ist nicht automatisiert testbar, wie schon bei den
vorherigen Griff-Editier-Fixes dieser Session).
2026-08-22 00:09:30 +02:00
karim 9ce6d86e82 2D: Kanten-Griff bei Vielecken winkeltreu (Nachbarkanten kippten sonst)
Nutzer-Report: bei einem Vieleck mit mehr als 5 Ecken kippten beim
Parallel-Verschieben einer Seite die ANGRENZENDEN Kanten im Winkel, statt
sich nur zu verlängern/verkürzen -- sichtbar bei nicht rechtwinkligen
Anschlüssen (bei Rechtecken fiel es nicht auf, weil deren Nachbarkanten
zufällig immer senkrecht zur bewegten Seite stehen).

Ursache: moveEdge/moveRoomEdge/moveCeilingEdge versetzten die beiden
Eckpunkte der gezogenen Kante stur um dasselbe Delta -- korrekt nur,
wenn die Nachbarkante zufällig senkrecht zur Zugrichtung steht. Bei
schrägen Anschlüssen wandert der gemeinsame Eckpunkt dadurch von der
Nachbarkanten-Linie weg, die Nachbarkante dreht sich mit.

Fix: neue movePolygonEdge/edgeMoveVertex-Helfer (projectSlice.ts)
berechnen die neue Eckpunkt-Position stattdessen als Schnittpunkt der
VERSCHOBENEN Kanten-Linie mit der unendlich verlängerten Nachbarkanten-
Linie durch den FIXEN Nachbarpunkt (lineIntersect, dasselbe Prinzip wie
die Wandstoß-Gehrung in model/joins.ts) -- die Nachbarkante behält ihre
Richtung, nur ihre Länge ändert sich. Fällt bei fehlendem Nachbarn
(offenes Kettenende) oder (fast) paralleler Nachbarkante auf die
bisherige einfache Delta-Verschiebung zurück. Rechtecke bleiben
unverändert (eigener min/max-Pfad, ohnehin immer rechtwinklig).

+3 Tests (Fünfeck-Kante winkeltreu über Drawing2D UND Room, Rechteck-
Regressionsschutz). tsc/vitest 922/922 grün.
2026-08-22 00:01:49 +02:00
karim 31a5b64836 2D: Kanten-Griffe (Seite parallel verschieben) bekommen dieselbe
Bearbeitung wie Punkt-Griffe

Nutzer-Wunsch: "das Ganze soll auch an den Seiten bei diesen Dreiecken
gehen bei denen man eine Fläche parallel verschieben kann" -- also
dieselbe Klick-loslassen-tippen-bestätigen-Geste, dieselbe Cursor-HUD-
Anzeige und dieselbe getippte Werteingabe wie bei den Punkt-Griffen aus
den letzten Commits, jetzt auch für die Kanten-Anfasser.

useGripEditing.ts: onEdgeMove rechnet jetzt mit einem ABSOLUTEN
Zielpunkt relativ zum ORIGINALEN Kanten-Mittelpunkt (Momentaufnahme vom
Grab), nicht mehr rein inkrementell pro Frame -- eine neue
edgeAppliedDeltaRef verankert, wie viel Delta bereits angewandt wurde,
und wendet jeweils nur die Differenz zum neuen Ziel an (moveEdgeOf/
moveRoomEdge/moveCeilingEdge sind additiv). Neuer edgeEdit-Feld-
Controller (Pendant zu gripEdit): geführte Kanten (rect/geschlossene
Polylinie/Wand) haben nur EIN Feld ("Länge" = vorzeichenbehafteter
Abstand entlang der festen Normale, kein Winkel), freie Kanten (offene
Polylinie/Linie) wie ein Punkt-Griff Länge+Winkel relativ zum
Ursprung. Cursor-HUD zeigt bei geführten Kanten "Δ: …m" als Freitext
(das L/W-Schema passt nicht für ein Einzelfeld), bei freien Kanten
L/W wie beim Punkt-Griff. onEdgeCancel verwirft exakt auf den
Ursprung zurück (Ziel = Anker selbst).

PlanView.tsx: Kanten-Griff-Grab läuft jetzt ohne Pointer-Capture
(dasselbe "bewaffnet nach Loslassen"-Verhalten wie beim Punkt-Griff),
Abschluss per erneutem Klick/Enter, Verwerfen per Escape. Neue
confirmEdgeEdit()-Methode am Imperativ-Handle (Pendant zu
confirmGripEdit) fürs Enter-mit-leerem-Text aus der Befehlszeile.

App.tsx: CommandLine-Verdrahtung (active/promptKey/fields/onSubmit/
onCycleField/onCancel) um den edgeEdit-Zweig erweitert, neuer i18n-
Prompt "cmd.edit.edge" (de/en). useToolNumberShortcuts blockt
Zifferntasten jetzt auch während eines bewaffneten Kanten-Griffs
(gleicher Bug wie zuvor bei Punkt-Griffen, präventiv mitgefixt).

useCommandTabShortcut (useKeyboardShortcuts.ts) generalisiert: Tab
zykelt/öffnet jetzt sowohl den Punkt- als auch den neuen Kanten-Feld-
Controller (dieselbe Instanz, zusätzliche optionale Parameter).
2026-08-21 23:56:48 +02:00
karim cdfef949bf 2D: Enter in der Befehlszeile bestätigt Punkt-Griff jetzt auch ohne neuen Wert
Nutzer-Report: Enter sollte den Punkt gemäss Maus-/Feld-Stand platzieren
("die Maus steuert bis man Werte eingibt, dann übernimmt es im Feld den
aktuellen Wert, man kann eigene Werte per Tab eingeben") -- aber Enter
tat nach dem Tippen eines Werts (oder auch ganz ohne Tippen) nichts.

Ursache: die Befehlszeile fokussiert sich selbst, sobald man Tab drückt
(Feld-Controller öffnen). Der GLOBALE Enter/Escape-Tastatur-Handler aus
dem vorigen Commit überspringt bewusst fokussierte Eingabefelder (damit
er sich nicht mit der Befehlszeilen-eigenen Enter-Behandlung beisst) --
aber die Befehlszeile selbst kannte für die Griff-Bearbeitung nur den
Fall "Text eingetippt, als Zahl geparst" (submitGripEditValue). Bei
LEERER Eingabe (Enter ohne neuen Wert -- der Normalfall, wenn man nur
bestätigen will) geschah dadurch schlicht nichts.

Fix: PlanViewHandle bekommt eine neue Methode confirmGripEdit() (liest
den bewaffneten Griff aus der lokalen gripDrag-Ref, ruft
gripHandlers.onGripEnd() -- dieselbe Aktion wie ein bestätigender Klick
auf der Zeichenfläche). Die Befehlszeilen-Eingabe in App.tsx ruft sie
jetzt bei leerem/nicht parsbarem Text während der Griff-Bearbeitung auf.

Da das Imperativ-Handle mit leeren Deps memoisiert ist (stabil für die
Oberleiste), braucht es einen frischen gripHandlersRef-Spiegel statt die
gripHandlers-Prop direkt zu schließen (sonst stale closure vom ersten
Render).
2026-08-21 23:38:05 +02:00
karim f9b202ac25 2D: Zifferntasten während Punkt-Griff-Bearbeitung nicht mehr als Werkzeug-Wechsel
Bug (Nutzer-Report direkt nach dem vorigen Commit): sobald man nach Tab
einen Länge-/Winkel-Wert eintippte, wechselte die App mitten in die
Griff-Bearbeitung ins Zeichenwerkzeug (z.B. "3" -> Kreis-Werkzeug).

Ursache: useToolNumberShortcuts (Vectorworks-Zifferntasten fürs
Werkzeug wählen) erlaubt Zifferntasten auch bei fokussiertem, leerem
Befehlsfeld, SOFERN die Zeichen-Engine gerade keinen Befehl mit
Feldern laufen hat (`!eng.hasFields() && !eng.acceptsFreeText()`).
Genau das trifft während der Griff-Bearbeitung IMMER zu, da dort gar
keine Engine-Befehl läuft, sondern der separate Feld-Controller aus
useGripEditing.ts -- der Wächter kannte diesen zweiten Fall nicht.

Fix: gripDragInfoRef (schon vorhanden, zeigt "ein Punkt-Griff ist
bewaffnet" an) wird von useGripEditing zurückgegeben und an
useToolNumberShortcuts durchgereicht; dessen Handler bricht jetzt ganz
vorne ab, wenn ein Griff bewaffnet ist -- unabhängig davon, ob/wo
gerade Fokus liegt. Der useToolNumberShortcuts-Aufruf in App.tsx
musste dafür hinter den useGripEditing()-Aufruf wandern (Reihenfolge
war vorher umgekehrt, gripDragInfoRef existierte an der alten Stelle
noch nicht).
2026-08-21 23:13:02 +02:00
karim a6c984e517 2D: Punkt-Griff ziehen ohne gehaltene Maustaste, Cursor-HUD dabei
Nutzer-Wunsch: sobald ein Punkt-Griff angewählt ist, soll man die
Maustaste loslassen können und trotzdem weiter per Cursor UND per
getipptem Wert positionieren können -- wie beim Zeichnen (Klick setzt
Punkt, Maus bewegt sich frei, Wert tippbar, nächster Klick/Enter
bestätigt), nicht wie bisher als reine Halten-und-Ziehen-Geste.

PlanView.tsx: der Griff wird beim Klick "bewaffnet" statt per
Pointer-Capture gehalten zu werden; Loslassen der Maus beendet die
Bearbeitung nicht mehr, der Punkt folgt weiter per normalem Hover-
Pointermove. Abgeschlossen wird per erneutem Linksklick (irgendwo,
verhindert dass derselbe Klick eine andere Aktion auslöst) oder Enter,
verworfen per Escape (beide nur ausserhalb von Eingabefeldern, damit
die Befehlszeile ihr eigenes Enter behält). Neuer onGripCancel-Handler
setzt dafür exakt auf die Ausgangsposition zurück.

useGripEditing.ts: die Anker-Auflösung (Nachbar-Ecke bzw. Kreis-/Bogen-
Zentrum) ist jetzt ein gemeinsamer resolveGripAnchor()-Helper, genutzt
vom Tab-Feld-Controller UND einem neuen Cursor-HUD (Länge/Winkel neben
der Maus, dieselbe Darstellung wie beim Zeichnen) -- vorher zeigte das
Cursor-HUD beim Griff-Ziehen gar keine Werte an, nur die Befehlszeile
unten.

App.tsx: das Cursor-HUD-Feld-Objekt zeigt bei aktivem Griff-Tab-Feld
jetzt dieselben Felder wie die Befehlszeile (vorher nur bei laufendem
Zeichenbefehl).

Kanten-Griff und Körper-Verschieben (ganzes Element) bleiben bewusst
unverändert (Halten-und-Ziehen) -- nur explizit angefragt war der
Einzelpunkt-Fall.
2026-08-21 21:50:00 +02:00
karim 13619e9271 2D: Griff-Editieren funktioniert jetzt auch bei Mehrfachauswahl
Bug: grips/edgeGrips in useGripEditing.ts waren über sich gegenseitig
ausschliessende Zweige auf genau ein selektiertes Element zugeschnitten;
bei zwei oder mehr gewählten Elementen (Zeichnungen oder Wänden) traf
kein Zweig, beide Arrays wurden leer -> keine Griffe mehr sichtbar/ziehbar.

Neues GripOwner-Modell (gripOwners/gripOwnerRange parallel zum flachen
grips-Array, EdgeGrip.owner? neu im Modell): jedes gewählte Element
liefert weiterhin einen eigenen, zusammenhängenden Vertex-/Kanten-Block.
applyGrip/applyEdge/cycleGripEditField/onGripMove lösen die Zielroute
(moveGripOf/moveEdgeOf) jetzt pro Griff über dessen owner auf statt über
die singulären selectedDrawingId/selectedWallId. Einzelauswahl ist ein
Spezialfall derselben Logik, kein separater Pfad. Der Tab-Feld-Controller
(getippte Länge/Winkel beim Ziehen) funktioniert dadurch auch bei
Mehrfachauswahl korrekt pro Griff. Öffnung/Treppe/Decke/Raum bleiben
bewusst exklusiv (kein Multi-Select für diese vier).

PlanView.tsx brauchte keine Änderung, da Hit-Test und Rendering dort
bereits generisch über die flachen grips/edgeGrips-Arrays laufen.
2026-08-21 20:50:00 +02:00
59 changed files with 6235 additions and 350 deletions
+206 -28
View File
@@ -115,12 +115,15 @@ import { useGripEditing } from "./state/useGripEditing";
import { usePanelHostBase } from "./state/usePanelHostBase";
import { useDockDragState } from "./state/useDockDragState";
import { useMouseSelectionHandlers } from "./state/useMouseSelectionHandlers";
import { addNewElementsToActiveGroup, containingGroupId, EMPTY_MEMBERSHIP } from "./state/groupActions";
import type { GroupMembership } from "./state/groupActions";
import { useProjectFileActions } from "./state/useProjectFileActions";
import { useProjectActions } from "./state/useProjectActions";
import {
useActiveToolKeys,
useBooleanOpsShortcut,
useDrawingClipboardShortcut,
useGroupShortcut,
useSelectAllAndDeleteShortcut,
useSplitJoinShortcut,
useToolNumberShortcuts,
@@ -219,6 +222,11 @@ const TOOL_COMMAND: Partial<Record<ToolId, string>> = {
rect: "rect",
circle: "circle",
arc: "arc",
ellipse: "ellipse",
spline: "spline",
bezier: "bezier",
dimension: "dimension",
kote: "kote",
text: "text",
textbox: "textbox",
};
@@ -403,6 +411,10 @@ export default function App() {
selectedColumnId,
selectedContextObjectIds,
setSelectedContextObjectIds,
selectedGroupIds,
setSelectedGroupIds,
groupPath,
setGroupPath,
} = useSelectionState();
// ── Kontextmenüs (transienter UI-State, bleibt lokal) ─────────────────────
@@ -609,6 +621,7 @@ export default function App() {
const transformSel: TransformSelection = {
wallIds: selectedWallIds,
drawingId: selectedDrawingId,
drawingIds: selectedDrawingIds,
extrudedSolidId: selectedExtrudedSolidId,
columnId: selectedColumnId,
};
@@ -733,6 +746,7 @@ export default function App() {
selectedDrawingIds,
selectedExtrudedSolidId,
selectedColumnId,
groupPath,
setProject,
setDraft,
});
@@ -749,6 +763,7 @@ export default function App() {
selectedDrawingIds,
selectedExtrudedSolidId,
selectedColumnId,
groupPath,
setProject,
setDraft,
};
@@ -796,7 +811,15 @@ export default function App() {
},
pxPerMeter: () => planViewRef.current?.pxPerMeter() ?? 90,
setDraft: (d) => cmdLatest.current.setDraft(d),
commit: (mutate) => cmdLatest.current.setProject(mutate),
// Zeichnet man WÄHREND man eine Gruppe betreten hat (Isolation), wird
// das neue Element automatisch Mitglied dieser Gruppe (Nutzer-Wunsch:
// „wenn man etwas neu zeichnet ... zeichnet man in der Gruppe") — s.
// state/groupActions.ts::addNewElementsToActiveGroup (Diff vor/nach
// Commit über alle Elementarten, additiv zur eigentlichen Mutation).
commit: (mutate) =>
cmdLatest.current.setProject((p) =>
addNewElementsToActiveGroup(p, mutate(p), cmdLatest.current.groupPath),
),
isFloor: () => cmdLatest.current.activeLevel.kind === "floor",
// Frisch über `text`/`textbox` erzeugtes Element sofort inline editieren
// (kein Lookup nötig — der Commit hat es bereits angehängt, s. engine.ts
@@ -849,18 +872,6 @@ export default function App() {
// Live-Echo des Befehlszeilen-Texts fürs Cursor-HUD: der getippte Wert
// erscheint sofort im aktiven Tab-Feld am Cursor (VW-Verhalten).
const [cmdTyped, setCmdTyped] = useState("");
const hudFields =
commandActive && engineView.fields.length > 0
? {
fields: engineView.fields.map((f) => ({
id: f.id,
value: f.value,
locked: f.locked,
active: f.active,
})),
typed: cmdTyped,
}
: null;
// ── GUI-Werkzeug ↔ Befehls-Engine (ein Eingabe-Hub) ───────────────────────
// Ein Klick auf ein gekoppeltes Zeichen-Werkzeug (Wand/Linie/Rechteck/Polylinie)
@@ -872,6 +883,9 @@ export default function App() {
const LAYOUT_ANNOT_TOOLS: ToolId[] = ["line", "rect", "text", "textbox", "image"];
const onSelectTool = (id: ToolId) => {
// Erneuter Klick auf das bereits AKTIVE Werkzeug schaltet zurück auf
// „Auswahl" (Nutzer-Wunsch) — statt das Werkzeug einfach neu zu starten.
if (id !== "select" && id === activeTool) id = "select";
if (activeLayout) {
// Ein Layout-Blatt ist offen: PlanView/die Befehls-Engine haben keinen
// sichtbaren Canvas (LayoutSheetView ersetzt die Mitte) — Zeichenwerkzeuge
@@ -961,11 +975,11 @@ export default function App() {
// Globaler Undo/Redo-Shortcut — siehe state/useKeyboardShortcuts.ts.
useUndoRedoShortcut(undo, redo);
// Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 1 Text, 2 Linie,
// 3 Kreis, 4 Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum
// — sowie Esc/Enter/Backspace für das aktive Zeichenwerkzeug. Siehe
// state/useKeyboardShortcuts.ts.
useToolNumberShortcuts(onSelectTool, activeLevel.kind === "floor", engineRef);
// Esc/Enter/Backspace für das aktive Zeichenwerkzeug. Siehe
// state/useKeyboardShortcuts.ts. Die Zifferntasten-Werkzeugwahl
// (useToolNumberShortcuts) folgt weiter unten, NACH useGripEditing() — sie
// braucht dessen gripDragInfoRef, um Zifferntasten während einer Griff-
// Bearbeitung nicht als Werkzeug-Wechsel misszuverstehen.
useActiveToolKeys(activeTool, toolCtx, engineRef, toolStateRef, setDraft, setActiveTool, setProject);
// ── Editier-Griffe (Grips) des einzeln selektierten Elements ──────────────
@@ -1159,6 +1173,81 @@ export default function App() {
};
useBooleanOpsShortcut(selectedDrawingIds, project.drawings2d, applyBoolean);
// Ctrl/Cmd+G / Ctrl/Cmd+Shift+G = Gruppieren/Auflösen (Vectorworks-artig,
// s. state/groupActions.ts). `applyGroupSelection` setzt nach dem Gruppieren/
// Auflösen alle betroffenen Auswahl-Kanäle auf einen Schlag.
const applyGroupSelection = (mem: GroupMembership, groupIds: string[]) => {
setSelectedWallIds(mem.wallIds);
setSelectedDrawingIds(mem.drawingIds);
setSelectedCeilingIds(mem.ceilingIds);
setSelectedOpeningIds(mem.openingIds);
setSelectedStairIds(mem.stairIds);
setSelectedRoomIds(mem.roomIds);
setSelectedColumnIds(mem.columnIds);
setSelectedRoofIds(mem.roofIds);
setSelectedGroupIds(groupIds);
};
useGroupShortcut(
project,
setProject,
{
wallIds: selectedWallIds,
drawingIds: selectedDrawingIds,
ceilingIds: selectedCeilingIds,
roomIds: selectedRoomIds,
stairIds: selectedStairIds,
columnIds: selectedColumnIds,
roofIds: selectedRoofIds,
openingIds: selectedOpeningIds,
},
selectedGroupIds,
applyGroupSelection,
);
// Doppelklick auf ein gruppiertes Element → Gruppe betreten (Isolation):
// alles ausserhalb wird gedimmt/gesperrt gerendert (s. viewportContent.tsx/
// PlanView „greyed"-Pass) und lässt sich nicht mehr anwählen (s.
// useMouseSelectionHandlers.ts). Auswahl wird geleert, damit man sauber
// "in" der Gruppe startet.
const onEnterGroup = (hit: {
wallId?: string;
drawingId?: string;
ceilingId?: string;
roomId?: string;
stairId?: string;
columnId?: string;
roofId?: string;
}): boolean => {
const kind = hit.wallId
? "wallIds"
: hit.drawingId
? "drawingIds"
: hit.ceilingId
? "ceilingIds"
: hit.roomId
? "roomIds"
: hit.stairId
? "stairIds"
: hit.columnId
? "columnIds"
: hit.roofId
? "roofIds"
: null;
const id = hit.wallId ?? hit.drawingId ?? hit.ceilingId ?? hit.roomId ?? hit.stairId ?? hit.columnId ?? hit.roofId;
if (!kind || !id) return false;
const gid = containingGroupId(project, kind, id, groupPath);
if (!gid) return false;
setGroupPath([...groupPath, gid]);
applyGroupSelection({ ...EMPTY_MEMBERSHIP }, []);
return true;
};
// "Gruppe verlassen"-Knopf (oben links im Grundriss): verlässt genau EIN
// Isolations-Level (Vectorworks-Verhalten bei verschachtelten Gruppen).
const onLeaveGroup = () => {
setGroupPath(groupPath.slice(0, -1));
applyGroupSelection({ ...EMPTY_MEMBERSHIP }, []);
};
// Alt+Klick im Grundriss = Segment löschen (Schere): PlanView meldet das
// getroffene Drawing2D + den Modellpunkt; App ermittelt daraus die nächst-
// gelegene Kante (nearestEdgeIndex) und entfernt sie. Geschlossene Form →
@@ -1192,6 +1281,7 @@ export default function App() {
setActiveTransform,
selectedWallIds,
selectedDrawingId,
selectedDrawingIds,
selectedExtrudedSolidId,
selectedColumnId,
engine,
@@ -1299,13 +1389,21 @@ export default function App() {
// Auswahl — siehe state/useGripEditing.ts.
const {
grips,
gripHandleLines,
edgeGrips,
gripHandlers,
gripEdit,
setGripEdit,
gripEditFields,
cycleGripEditField,
submitGripEditValue,
closeGripEditField,
edgeEdit,
edgeEditFields,
cycleEdgeEditField,
submitEdgeEditValue,
closeEdgeEditField,
gripDragInfoRef,
edgeDragInfoRef,
} = useGripEditing({
project,
selectedDrawing,
@@ -1339,6 +1437,42 @@ export default function App() {
moveElementByOf,
});
// Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 1 Text, 2 Linie,
// 3 Kreis, 4 Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum.
// Siehe state/useKeyboardShortcuts.ts. Braucht gripDragInfoRef/edgeDragInfoRef
// (s. o.), daher erst hier nach useGripEditing() registriert.
useToolNumberShortcuts(
onSelectTool,
activeLevel.kind === "floor",
engineRef,
gripDragInfoRef,
edgeDragInfoRef,
);
// Feld-Zustand fürs Cursor-HUD (neben der Maus, VW-Stil): läuft ein Punkt-
// ODER Kanten-Griff-Drag, hat er Vorrang (dieselben Felder wie unten in der
// Befehlszeile — Länge/Winkel- bzw. Abstand-Tab-Zyklus), sonst die Befehls-
// Engine-Felder beim Zeichnen. Vorher zeigte das Cursor-HUD beim Griff-Ziehen
// GAR KEINE Länge/Winkel-Werte an (nur die Befehlszeile unten) — Nutzer-
// Wunsch: „du kannst [Länge/Winkel] gleich bei der Maus anzeigen wie beim
// Erstellen".
const hudFields =
gripEdit
? { fields: gripEditFields(), typed: cmdTyped }
: edgeEdit
? { fields: edgeEditFields(), typed: cmdTyped }
: commandActive && engineView.fields.length > 0
? {
fields: engineView.fields.map((f) => ({
id: f.id,
value: f.value,
locked: f.locked,
active: f.active,
})),
typed: cmdTyped,
}
: null;
// Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen — siehe
// state/useKeyboardShortcuts.ts.
useDrawingClipboardShortcut(
@@ -2288,6 +2422,9 @@ export default function App() {
setSelectedContextObjectIds,
selectedSectionLineId,
setSelectedSectionLineId,
selectedGroupIds,
setSelectedGroupIds,
groupPath,
},
contextMenuState: { setMenu, setStampEditorRoomId, editTextId, setEditTextId, setEditTextMinHeightM },
});
@@ -2708,6 +2845,8 @@ export default function App() {
hairline={lineMode === "display"}
marqueeColor={marqueeColor}
snapColor={snapColor}
hoverDivideDelayMs={snap.hoverDivideDelayMs}
hoverDivideCount={snap.hoverDivideCount}
renderMode={renderMode}
view3d={view3d}
fov={fov}
@@ -2730,6 +2869,7 @@ export default function App() {
selectedStairIds={selectedStairIds}
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
selectedColumnIds={selectedColumnIds}
selectedGroupIds={selectedGroupIds}
selectedRoomIds={selectedRoomIds}
selectedRoofIds={selectedRoofIds}
selectedContextObjectIds={selectedContextObjectIds}
@@ -2738,6 +2878,9 @@ export default function App() {
onStampMove={moveRoomStamp}
onStampEdit={onOpenStampEditor}
onEditDrawingText={onEditDrawingText}
onEnterGroup={onEnterGroup}
onLeaveGroup={onLeaveGroup}
groupPath={groupPath}
editTextId={editTextId}
editTextMinHeightM={editTextMinHeightM}
onCommitTextEdit={commitTextEdit}
@@ -2761,6 +2904,7 @@ export default function App() {
toolHandlers={toolHandlers}
hudFields={hudFields}
grips={grips}
gripHandleLines={gripHandleLines}
edgeGrips={edgeGrips}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
@@ -2780,14 +2924,17 @@ export default function App() {
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
<CommandLine
ref={commandLineRef}
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
active={gripEdit ? true : activeTransform ? true : engineView.active}
// (c) Während eines Vertex- ODER Kanten-Griff-Drags belegt der Feld-
// Controller das Befehlsfeld (Länge/Winkel bzw. Abstand). Läuft eine
// Auswahl-Transformation (Drehen etc.), belegt sie Prompt + U/I/O/P +
// Winkel-/Distanzfeld. Sonst gilt die Engine (Zeichnen/Ruhe). Griff-/
// Kanten-Drag haben Vorrang (schließen sich sonst aus).
active={gripEdit ? true : edgeEdit ? true : activeTransform ? true : engineView.active}
promptKey={
gripEdit
? "cmd.edit.point"
: edgeEdit
? "cmd.edit.edge"
: activeTransform
? `transform.hint.${activeTransform.op}.${Math.min(
activeTransform.points.length,
@@ -2795,14 +2942,33 @@ export default function App() {
)}`
: engineView.promptKey
}
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
options={
gripEdit || edgeEdit ? [] : activeTransform ? transformOptions() : engineView.options
}
fields={
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
gripEdit
? gripEditFields()
: edgeEdit
? edgeEditFields()
: activeTransform
? transformFields()
: engineView.fields
}
onSubmit={(text) => {
if (gripEdit) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitGripEditValue(v);
// Leere Eingabe: kein neuer Wert zu locken — Enter bestätigt
// stattdessen die aktuelle Position (Maus- oder bereits
// gelockte Werte) und schließt die Griff-Bearbeitung ab. Vorher
// ein Totdruck (Nutzer-Report: Enter tat nichts).
else planViewRef.current?.confirmGripEdit();
return;
}
if (edgeEdit) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitEdgeEditValue(v);
else planViewRef.current?.confirmEdgeEdit();
return;
}
if (activeTransform) {
@@ -2823,10 +2989,22 @@ export default function App() {
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
}
onCycleField={() =>
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField()
gripEdit
? cycleGripEditField()
: edgeEdit
? cycleEdgeEditField()
: activeTransform
? undefined
: engine.cycleField()
}
onCancel={() =>
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel()
gripEdit
? closeGripEditField()
: edgeEdit
? closeEdgeEditField()
: activeTransform
? cancelTransform()
: engine.cancel()
}
suggest={(prefix) => engine.suggest(prefix)}
onTextChange={setCmdTyped}
+97
View File
@@ -0,0 +1,97 @@
/**
* `arrayCommand` — dupliziert die Auswahl `count`-mal entlang eines Vektors
* in einer Geste. Baut auf dem bestehenden `mode:"array"` von
* `tools/transform.ts` auf (bisher ohne eigenen Befehl erreichbar).
*/
import { describe, it, expect } from "vitest";
import { arrayCommand } from "./array";
import type { CommandContext, Project } from "../types";
import type { Drawing2D } from "../../model/types";
function project(extra: Drawing2D[] = []): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: extra,
context: [],
};
}
function makeCtx(p: Project, drawingId: string | null): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId },
};
}
const source: Drawing2D = {
id: "src",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "circle", center: { x: 0, y: 0 }, r: 1 },
};
describe("arrayCommand", () => {
it("4 Kopien entlang +x im Abstand 3m (Anzahl per Option auf 4 gesetzt)", () => {
const p = project([source]);
const ctx = makeCtx(p, "src");
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
// Anzahl-Option 3× drücken: 3(default egal)→…→4 (zyklt 2..12, wir setzen sie
// deterministisch, indem wir bis 4 zyklen unabhängig vom Startwert testen wir
// stattdessen direkt über den committeten Output).
for (let i = 0; i < 12; i++) {
[state] = arrayCommand.onInput(state, { kind: "option", id: "count" }, ctx);
const opts = arrayCommand.options(state);
if (opts[0].value === "4") break;
}
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 3, y: 0 } }, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
// Original bleibt + 4 neue Kopien.
expect(next.drawings2d.length).toBe(5);
const centers = next.drawings2d
.map((d) => d.geom)
.filter((g) => g.shape === "circle")
.map((g) => (g as { center: { x: number; y: number } }).center.x)
.sort((a, b) => a - b);
expect(centers).toEqual([0, 3, 6, 9, 12]);
});
it("leere Auswahl committet nichts", () => {
const p = project();
const ctx = makeCtx(p, null);
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 5, y: 0 } }, ctx);
expect(result.commit).toBeUndefined();
expect(result.done).toBe(true);
});
it("Zielpunkt gleich Basispunkt (Nullvektor) committet nichts", () => {
const p = project([source]);
const ctx = makeCtx(p, "src");
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 2, y: 2 } }, ctx);
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 2, y: 2 } }, ctx);
expect(result.commit).toBeUndefined();
});
});
+168
View File
@@ -0,0 +1,168 @@
// Array (Feld) — dupliziert die AKTUELLE Auswahl `count`-mal entlang eines
// Vektors, in EINER Geste (baut auf dem bestehenden Transform-Modus
// `"array"` auf, der bisher nie über einen Befehl erreichbar war — s.
// `tools/transform.ts::copyTs`). Schritte:
// 1) „Basispunkt:" → Punkt
// 2) „Zielpunkt ( Anzahl ):" → Punkt/Zahl (Tab length/angle) → committet
// ALLE `count` Kopien auf einmal (Original bleibt). „Anzahl" (Option,
// persistent über Aufrufe wie bei Multiline) zyklt 2..12.
//
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl,
// wie move/copy). Leere Auswahl → No-op.
import { commitTransform, transformPreview } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import type {
CmdOption,
Command,
CommandField,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
const DEG = Math.PI / 180;
const MIN_COUNT = 2;
const MAX_COUNT = 12;
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
/** Persistente Anzahl über Aufrufe hinweg (wie `lastCount` bei Multiline). */
let lastCount = 3;
const ARRAY_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
const COUNT_OPTION: CmdOption = { id: "count", labelKey: "cmd.array.count" };
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? base;
const length = "length" in locks ? locks.length : segLen(base, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
const ang = angleDeg * DEG;
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
}
function hasSelection(sel: CommandSelection): boolean {
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
function toTransformSel(sel: CommandSelection): TransformSelection {
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
interface ArrBase extends CommandState {
phase: "base";
}
interface ArrTarget extends CommandState {
phase: "target";
sel: CommandSelection;
base: Vec2;
cursor: Vec2 | null;
}
type ArrState = ArrBase | ArrTarget;
/** Vorschau ALLER `lastCount` Kopien bei der aktuellen Cursor-Lage. */
function arrayDraft(project: Project, sel: CommandSelection, base: Vec2, target: Vec2): ToolDraft {
const preview = transformPreview(project, toTransformSel(sel), "move", [base, target], "array", lastCount);
return {
preview,
vertices: [base],
hud: {
at: target,
text: `${segLen(base, target).toFixed(2)} m × ${lastCount}`,
},
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "base", lastPoint: null } as ArrBase,
{ draft: null, done: true },
];
export const arrayCommand: Command = {
name: "array",
labelKey: "cmd.array.label",
prompt: (s) => ((s as ArrState).phase === "target" ? "cmd.array.target" : "cmd.array.base"),
accepts: (s) => ((s as ArrState).phase === "target" ? ["point", "number", "option"] : ["point"]),
options: (s) =>
(s as ArrState).phase === "target" ? [{ ...COUNT_OPTION, value: String(lastCount) }] : [],
init: (): ArrBase => ({ phase: "base", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ArrState;
if (!hasSelection(ctx.selection)) return idle();
if (s.phase !== "target") {
if (input.kind !== "point") return [s, { draft: null }];
const next: ArrTarget = {
phase: "target",
sel: ctx.selection,
base: input.point,
cursor: input.point,
lastPoint: input.point,
};
return [next, { draft: { preview: [], vertices: [input.point] } }];
}
if (input.kind === "option") {
if (input.id === "count") {
lastCount = lastCount >= MAX_COUNT ? MIN_COUNT : lastCount + 1;
return [s, { draft: s.cursor ? arrayDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
}
return [s, { draft: null }];
}
let target: Vec2 | null = null;
if (input.kind === "point") target = input.point;
else if (input.kind === "number") target = { x: s.base.x + input.value, y: s.base.y };
if (!target || segLen(s.base, target) < 1e-6) return idle();
const { base, sel } = s;
return [
idle()[0],
{ draft: null, done: true, commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, target!], "array", lastCount) },
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as ArrState;
if (s.phase !== "target") return [s, { draft: null }];
const ns: ArrTarget = { ...s, cursor: point };
return [ns, { draft: arrayDraft(ctx.project, s.sel, s.base, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
fields: (state) => ((state as ArrState).phase === "target" ? ARRAY_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as ArrState;
if (s.phase !== "target") return null;
return targetFromFields(s.base, locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as ArrState;
if (s.phase !== "target" || !cursor) return {};
return {
length: segLen(s.base, cursor),
angle: ((segAngleDeg(s.base, cursor) % 360) + 360) % 360,
};
},
};
+96
View File
@@ -0,0 +1,96 @@
/**
* `bezierCommand` — manuelles Kontrollpunkt-Werkzeug: Anker → Griff 1 →
* Griff 2 → Endpunkt je Segment, „Fertig" committet als `shape:"bezier"`.
*/
import { describe, it, expect } from "vitest";
import { bezierCommand } from "./bezier";
import type { CommandContext, Project } from "../types";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("bezierCommand", () => {
it("ein Segment (4 Klicks) + Fertig → shape:bezier mit 4 Kontrollpunkten", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 1, y: 3 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 3, y: 3 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
const [, result] = bezierCommand.onConfirm(state, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("bezier");
if (g.shape === "bezier") {
expect(g.pts).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 3 },
{ x: 3, y: 3 },
{ x: 4, y: 0 },
]);
}
});
it("zwei Segmente hängen am gemeinsamen Anker (7 Punkte)", () => {
const p = project();
const ctx = makeCtx(p);
const clicks = [
{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 0 },
{ x: 4, y: -1 }, { x: 5, y: -1 }, { x: 6, y: 0 },
];
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: clicks[0] }, ctx);
for (let i = 1; i < clicks.length; i++) {
[state] = bezierCommand.onInput(state, { kind: "point", point: clicks[i] }, ctx);
}
const [, result] = bezierCommand.onInput(state, { kind: "option", id: "finish" }, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("bezier");
if (g.shape === "bezier") expect(g.pts).toEqual(clicks);
});
it("weniger als ein volles Segment: Enter bricht ohne commit ab", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = bezierCommand.onInput(bezierCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = bezierCommand.onInput(state, { kind: "point", point: { x: 1, y: 1 } }, ctx);
const [, result] = bezierCommand.onConfirm(state, ctx);
expect(result.commit).toBeUndefined();
});
});
+157
View File
@@ -0,0 +1,157 @@
// Bezier — klassisches Kontrollpunkt-Werkzeug (manuell gesetzte Griffe, kein
// Auto-Glätten wie `spline`). Jedes Segment braucht 3 Klicks nach dem Anker:
// 1) „Startpunkt:" → Punkt (erster Anker)
// 2) „Griff 1:" → Punkt (Kontrollpunkt 1 dieses Segments)
// 3) „Griff 2:" → Punkt (Kontrollpunkt 2 dieses Segments)
// 4) „Endpunkt:" → Punkt (Segment-Ende, = Anker des nächsten Segments)
// Nach ≥1 fertigem Segment kann „Fertig" (Enter) den Zug abschliessen; jeder
// weitere Anker startet automatisch ein neues Segment (Schritte 2–4).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { sampleBezierChain } from "../../tools/curves";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
interface BezierStart extends CommandState {
phase: "start";
}
interface BezierC1 extends CommandState {
phase: "c1";
pts: Vec2[]; // abgeschlossene Punkte (Anker·Griff·Griff·Anker·…)
anchor: Vec2; // letzter Anker (= pts[pts.length-1])
cursor: Vec2 | null;
}
interface BezierC2 extends CommandState {
phase: "c2";
pts: Vec2[];
anchor: Vec2;
c1: Vec2;
cursor: Vec2 | null;
}
interface BezierEnd extends CommandState {
phase: "end";
pts: Vec2[];
anchor: Vec2;
c1: Vec2;
c2: Vec2;
cursor: Vec2 | null;
}
type BezierState = BezierStart | BezierC1 | BezierC2 | BezierEnd;
const FINISH: CmdOption = { id: "finish", labelKey: "cmd.bezier.finish" };
/** Vorschau: abgeschlossene Kurve (tessellierte Segmente) + Gummiband-Linien des laufenden Segments. */
function bezierDraft(pts: Vec2[], rubber: Vec2[]): ToolDraft {
const preview: DraftShape[] = [];
if (pts.length >= 4) preview.push({ kind: "poly", pts: sampleBezierChain(pts), closed: false });
for (let i = 0; i < rubber.length - 1; i++) preview.push({ kind: "line", a: rubber[i], b: rubber[i + 1] });
return { preview, vertices: pts.length > 0 ? [pts[0]] : [] };
}
function appendBezier(p: Project, pts: Vec2[], ctx: CommandContext): Project {
if (pts.length < 4) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "bezier", pts },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as BezierStart,
{ draft: null, done: true },
];
const finish = (pts: Vec2[], ctx: CommandContext): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as BezierStart,
{ draft: null, done: true, commit: (p) => appendBezier(p, pts, ctx) },
];
export const bezierCommand: Command = {
name: "bezier",
labelKey: "cmd.bezier.label",
prompt: (s) => {
const phase = (s as BezierState).phase;
return phase === "c1"
? "cmd.bezier.c1"
: phase === "c2"
? "cmd.bezier.c2"
: phase === "end"
? "cmd.bezier.end"
: "cmd.bezier.start";
},
accepts: () => ["point", "option"],
options: (s) => ((s as BezierState).phase === "c1" && (s as BezierC1).pts.length >= 4 ? [FINISH] : []),
init: (): BezierStart => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as BezierState;
if (input.kind === "option") {
if (input.id === "finish" && s.phase === "c1" && s.pts.length >= 4) return finish(s.pts, ctx);
return [s, { draft: null }];
}
if (input.kind !== "point") return [s, { draft: null }];
const pt = input.point;
if (s.phase === "start") {
const ns: BezierC1 = { phase: "c1", pts: [pt], anchor: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: bezierDraft(ns.pts, [pt]) }];
}
if (s.phase === "c1") {
const ns: BezierC2 = { phase: "c2", pts: s.pts, anchor: s.anchor, c1: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: bezierDraft(ns.pts, [s.anchor, pt]) }];
}
if (s.phase === "c2") {
const ns: BezierEnd = {
phase: "end",
pts: s.pts,
anchor: s.anchor,
c1: s.c1,
c2: pt,
cursor: pt,
lastPoint: pt,
};
return [ns, { draft: bezierDraft(ns.pts, [s.anchor, s.c1, pt]) }];
}
// phase "end": Segment abschliessen, direkt das nächste Segment beginnen.
const pts = [...s.pts, s.c1, s.c2, pt];
const ns: BezierC1 = { phase: "c1", pts, anchor: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: bezierDraft(pts, [pt]) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as BezierState;
if (s.phase === "start") return [s, { draft: null }];
if (s.phase === "c1") {
const ns: BezierC1 = { ...s, cursor: point };
return [ns, { draft: bezierDraft(s.pts, [s.anchor, point]) }];
}
if (s.phase === "c2") {
const ns: BezierC2 = { ...s, cursor: point };
return [ns, { draft: bezierDraft(s.pts, [s.anchor, s.c1, point]) }];
}
const ns: BezierEnd = { ...s, cursor: point };
// Endschritt: die tatsächliche Bezier-Kurve bis zum Cursor live anzeigen.
return [ns, { draft: bezierDraft([...s.pts, s.c1, s.c2, point], []) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as BezierState;
if (s.phase === "c1" && s.pts.length >= 4) return finish(s.pts, ctx);
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
};
+2
View File
@@ -45,6 +45,7 @@ function hasSelection(sel: CommandSelection): boolean {
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
@@ -53,6 +54,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
+263
View File
@@ -0,0 +1,263 @@
/**
* `dimensionCommand` — Punktreihe (wie Polylinie) + EIN Offset-Klick → EIN
* `shape:"dimension"`-Element für die ganze Kette (Reihenbemassung, SIA 400
* Figur 14). Ist beim Start bereits eine Kette selektiert, ergänzt der erste
* Klick sie (verlängert ODER fügt dazwischen ein) statt eine neue zu
* beginnen. Die Masszahl wird NICHT gespeichert (aus dist(a,b) beim Rendern
* abgeleitet).
*/
import { describe, it, expect } from "vitest";
import { dimensionCommand } from "./dimension";
import type { CommandContext, Project } from "../types";
import type { Drawing2D } from "../../model/types";
function project(extra: Drawing2D[] = []): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: extra,
context: [],
};
}
function makeCtx(p: Project, selectedDrawingId: string | null = null): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: selectedDrawingId },
};
}
describe("dimensionCommand — neu zeichnen", () => {
it("zwei Punkte + Enter + Offset-Klick (links positiv) → ein shape:dimension mit korrektem Vorzeichen", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 0 } }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
// Cursor bei y=2 (oberhalb von a→b, entlang +x) → Links-Normale zeigt nach +y.
const [, result] = dimensionCommand.onInput(state, { kind: "point", point: { x: 5, y: 2 } }, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("dimension");
if (g.shape === "dimension") {
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 10, y: 0 }]);
expect(g.offset).toBeCloseTo(2, 6);
}
});
it("Fertig-Option (statt Enter) beendet die Punktreihe identisch", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
[state] = dimensionCommand.onInput(state, { kind: "option", id: "finish" }, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1.5 }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") expect(g.offset).toBe(1.5);
});
it("Kette aus drei Punkten (Reihenbemassung) → EIN Element mit drei Punkten", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 0 } }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 2 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
expect(g.offset).toBe(2);
}
});
it("dritter Punkt ausserhalb der Geraden wird auf sie projiziert (keine Polylinie, immer gerade)", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
// Richtung: entlang +x (y=0).
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
// Dritter Klick liegt bewusst NICHT auf der Geraden (y=3) → wird projiziert.
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 3 } }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts[2].y).toBeCloseTo(0, 6); // auf die Gerade y=0 projiziert
expect(g.pts[2].x).toBeCloseTo(10, 6); // x-Komponente bleibt (Projektion entlang x)
}
});
it("Zurück-Option entfernt den letzten Punkt", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 9, y: 0 } }, ctx);
[state] = dimensionCommand.onInput(state, { kind: "option", id: "undo" }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }]);
});
it("entartete Kette (identische Punkte) committet nichts", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 3, y: 3 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 3, y: 3 } }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(0);
});
it("Enter mit nur einem Punkt bricht ohne commit ab", () => {
const p = project();
const ctx = makeCtx(p);
const [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
const [, result] = dimensionCommand.onConfirm(state, ctx);
expect(result.commit).toBeUndefined();
expect(result.done).toBe(true);
});
});
describe("dimensionCommand — bestehende Kette ergänzen (erst selektieren, dann Befehl)", () => {
function chain(pts: { x: number; y: number }[], offset = 1): Drawing2D {
return {
id: "dim-existing",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "dimension", pts, offset },
};
}
it("Klick jenseits des letzten Punkts verlängert die Kette (dasselbe Element)", () => {
const existing = chain([{ x: 0, y: 0 }, { x: 4, y: 0 }]);
const p = project([existing]);
const ctx = makeCtx(p, "dim-existing");
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 10, y: 0 } },
ctx,
);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1); // kein neues Element
expect(next.drawings2d[0].id).toBe("dim-existing");
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
}
});
it("Klick vor dem ersten Punkt verlängert rückwärts (wird vorangestellt)", () => {
const existing = chain([{ x: 4, y: 0 }, { x: 10, y: 0 }]);
const p = project([existing]);
const ctx = makeCtx(p, "dim-existing");
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
}
});
it("Klick zwischen zwei bestehenden Punkten fügt DAZWISCHEN ein", () => {
const existing = chain([{ x: 0, y: 0 }, { x: 10, y: 0 }]);
const p = project([existing]);
const ctx = makeCtx(p, "dim-existing");
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 4, y: 0 } },
ctx,
);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
}
});
it("keine Selektion (drawingId null) startet stattdessen eine neue Kette", () => {
const existing = chain([{ x: 0, y: 0 }, { x: 4, y: 0 }]);
const p = project([existing]);
const ctx = makeCtx(p, null);
let [state] = dimensionCommand.onInput(
dimensionCommand.init(),
{ kind: "point", point: { x: 20, y: 20 } },
ctx,
);
[state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 25, y: 20 } }, ctx);
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(2); // bestehendes Element bleibt, neues kommt dazu
});
});
+285
View File
@@ -0,0 +1,285 @@
// Bemassung (lineare Masslinie(n), SIA 400 B.5.3, Figur 14 „Reihenbemassung").
// Bedienung wie eine Polylinie (beliebig viele Klicks, Doppelklick/Enter
// beendet), das ERGEBNIS ist aber IMMER eine GERADE Punktreihe: der erste
// Klick setzt den Startpunkt, der zweite legt die Richtung fest — jeder
// weitere Klick wird auf diese Gerade projiziert (Reihenbemassung misst
// mehrere Punkte entlang EINER Referenzlinie, z. B. einer Wand, nie einen
// Zickzack-Pfad). Die ganze Kette ist EIN Element (`pts.length` ≥ 2).
//
// Neu zeichnen:
// 1) „Erster Punkt:" → Punkt
// 2) „Nächster Punkt ( Fertig Zurück ):" → Punkt … (ab dem 3. Punkt auf die
// durch Punkt 1/2 definierte Gerade projiziert)
// 3) „Lage der Masslinie:" → Punkt/Zahl → commit neues Element
//
// Ergänzen einer bestehenden Kette (Rhino-Muster: erst selektieren, dann
// Befehl — s. `ctx.selection`): ist beim Start eine `dimension`-Kette
// gewählt, lädt der Befehl deren Punkte/Offset vor und der erste Klick wird
// per Geraden-Projektion in die Kette EINSORTIERT (`insertIntoChain`) — vor
// dem ersten, nach dem letzten (verlängert) ODER zwischen zwei bestehenden
// Punkten (fügt ein). Der Commit ersetzt dasselbe Element (keine Kopie).
//
// Die Masszahl wird beim Rendern aus dist(a,b) abgeleitet (nicht gespeichert)
// — bleibt beim späteren Griff-Ziehen automatisch korrekt.
import type { Drawing2D } from "../../model/types";
import { segmentHud, uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
/** Vorzeichenbehafteter Normalabstand von `pt` zur Geraden a→b (links positiv). */
function signedOffset(a: Vec2, b: Vec2, pt: Vec2): number {
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return 0;
const nx = -dy / len, ny = dx / len;
return (pt.x - a.x) * nx + (pt.y - a.y) * ny;
}
/** Projiziert `pt` auf die Gerade durch `p0` in Richtung `p1` (Kette bleibt gerade ab Punkt 3). */
function projectOnLine(p0: Vec2, p1: Vec2, pt: Vec2): Vec2 {
const dx = p1.x - p0.x, dy = p1.y - p0.y;
const len2 = dx * dx + dy * dy;
if (len2 < EPS) return pt;
const t = ((pt.x - p0.x) * dx + (pt.y - p0.y) * dy) / len2;
return { x: p0.x + dx * t, y: p0.y + dy * t };
}
/**
* Sortiert `raw` (auf die Kettengerade projiziert) an die richtige Stelle in
* `pts` ein — VOR dem ersten (verlängert rückwärts), NACH dem letzten
* (verlängert vorwärts) oder ZWISCHEN zwei bestehenden Punkten (fügt ein).
* `pts` muss bereits kollinear sein (≥ 2 Punkte); die Skalarposition entlang
* der durch `pts[0]`/`pts[1]` festgelegten Geraden entscheidet die Reihenfolge.
*/
function insertIntoChain(pts: Vec2[], raw: Vec2): Vec2[] {
if (pts.length < 2) return [...pts, raw];
const p0 = pts[0], p1 = pts[1];
const dx = p1.x - p0.x, dy = p1.y - p0.y;
const len2 = dx * dx + dy * dy || 1;
const tOf = (p: Vec2): number => ((p.x - p0.x) * dx + (p.y - p0.y) * dy) / len2;
const placed = projectOnLine(p0, p1, raw);
const newT = tOf(placed);
let idx = pts.findIndex((p) => newT < tOf(p));
if (idx === -1) idx = pts.length;
const out = [...pts];
out.splice(idx, 0, placed);
return out;
}
/** Bestehende, selektierte Masskette (Rhino: erst selektieren, dann Befehl) oder null. */
function selectedChain(ctx: CommandContext): { id: string; pts: Vec2[]; offset: number } | null {
const id = ctx.selection.drawingId;
if (!id) return null;
const d = ctx.project.drawings2d.find((x) => x.id === id);
if (!d || d.geom.shape !== "dimension") return null;
return { id: d.id, pts: d.geom.pts, offset: d.geom.offset };
}
/** Endpunkte der um `offset` parallel verschobenen Masslinie (Strecke a-b). */
function offsetPoints(a: Vec2, b: Vec2, offset: number): { a: Vec2; b: Vec2 } {
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const nx = (-dy / len) * offset, ny = (dx / len) * offset;
return { a: { x: a.x + nx, y: a.y + ny }, b: { x: b.x + nx, y: b.y + ny } };
}
interface DimStart extends CommandState {
phase: "start";
}
interface DimNext extends CommandState {
phase: "next";
pts: Vec2[];
cursor: Vec2 | null;
/** ID der bearbeiteten Kette (Ergänzen-Modus) — null = neue Kette. */
editId: string | null;
}
interface DimOffset extends CommandState {
phase: "offset";
pts: Vec2[];
cursor: Vec2 | null;
editId: string | null;
}
type DimState = DimStart | DimNext | DimOffset;
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.dimension.undo" };
const FINISH: CmdOption = { id: "finish", labelKey: "cmd.dimension.finish" };
const OFFSET_FIELDS: CommandField[] = [{ id: "offset", labelKey: "cmd.field.offset" }];
/** Vorschau: Punktreihe (Gummiband) während des Sammelns. */
function chainDraft(pts: Vec2[], cursor: Vec2 | null): ToolDraft {
const shown = cursor ? [...pts, cursor] : pts;
const draft: ToolDraft = { preview: [{ kind: "poly", pts: shown, closed: false }], vertices: pts };
const last = pts[pts.length - 1];
if (cursor && last && dist(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
return draft;
}
/** Vorschau: alle Masslinien-Segmente bei gegebenem Offset (grob — Massstriche/Text rendert generatePlan). */
function offsetDraft(pts: Vec2[], offset: number, at: Vec2 | null): ToolDraft {
const preview: DraftShape[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
if (Math.abs(offset) > EPS) {
const off = offsetPoints(a, b, offset);
preview.push({ kind: "line", a, b: off.a });
preview.push({ kind: "line", a: b, b: off.b });
preview.push({ kind: "line", a: off.a, b: off.b });
} else {
preview.push({ kind: "line", a, b });
}
}
const draft: ToolDraft = { preview, vertices: pts };
if (at) {
const total = pts.reduce((sum, p, i) => (i === 0 ? 0 : sum + dist(pts[i - 1], p)), 0);
draft.hud = { at, text: `Σ${total.toFixed(3)}m · Δ${offset.toFixed(3)}m` };
}
return draft;
}
/** Committet eine Kette: neu anhängen (editId null) ODER dasselbe Element ersetzen. */
function commitChain(p: Project, pts: Vec2[], offset: number, editId: string | null, ctx: CommandContext): Project {
const cleaned = pts.filter((pt, i) => i === 0 || dist(pts[i - 1], pt) >= EPS);
if (cleaned.length < 2) return p; // zu wenig gültige Punkte → nichts committen/ändern
if (editId) {
return {
...p,
drawings2d: p.drawings2d.map((d) =>
d.id === editId ? { ...d, geom: { shape: "dimension", pts: cleaned, offset } } : d,
),
};
}
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "dimension", pts: cleaned, offset },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
];
export const dimensionCommand: Command = {
name: "dimension",
labelKey: "cmd.dimension.label",
prompt: (s) => {
const phase = (s as DimState).phase;
return phase === "next" ? "cmd.dimension.next" : phase === "offset" ? "cmd.dimension.offset" : "cmd.dimension.start";
},
accepts: (s) => ((s as DimState).phase === "offset" ? ["point", "number"] : ["point", "option"]),
options: (s) => {
const ds = s as DimState;
if (ds.phase !== "next") return [];
return ds.pts.length >= 2 ? [FINISH, UNDO] : [UNDO];
},
init: (): DimStart => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as DimState;
if (input.kind === "option" && s.phase === "next") {
if (input.id === "undo") {
const pts = s.pts.slice(0, -1);
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
const ns: DimNext = { phase: "next", pts, cursor: s.cursor, editId: s.editId, lastPoint: pts[pts.length - 1] };
return [ns, { draft: chainDraft(pts, s.cursor) }];
}
if (input.id === "finish" && s.pts.length >= 2) {
const ns: DimOffset = { phase: "offset", pts: s.pts, cursor: s.cursor, editId: s.editId, lastPoint: s.lastPoint };
return [ns, { draft: offsetDraft(s.pts, 0, null) }];
}
return [s, { draft: chainDraft(s.pts, s.cursor) }];
}
if (s.phase !== "offset") {
if (input.kind !== "point") return [s, { draft: null }];
const pt = input.point;
if (s.phase !== "next") {
// Erster Klick: bei selektierter Kette wird er EINSORTIERT (Ergänzen);
// sonst startet er eine neue Kette.
const existing = selectedChain(ctx);
const pts = existing ? insertIntoChain(existing.pts, pt) : [pt];
const editId = existing ? existing.id : null;
const ns: DimNext = { phase: "next", pts, cursor: pt, editId, lastPoint: pt };
return [ns, { draft: chainDraft(pts, pt) }];
}
// Ab dem dritten Punkt (bzw. jedem weiteren Klick im Ergänzen-Modus):
// auf die durch Punkt 1/2 festgelegte Gerade projizieren/einsortieren.
const pts = s.pts.length >= 2 ? insertIntoChain(s.pts, pt) : [...s.pts, pt];
const ns: DimNext = { phase: "next", pts, cursor: pt, editId: s.editId, lastPoint: pt };
return [ns, { draft: chainDraft(pts, pt) }];
}
// Offset-Schritt: Zahl = direkter Abstand, Punkt = Abstand zum Cursor.
let offset: number;
if (input.kind === "number") offset = input.value;
else if (input.kind === "point") offset = signedOffset(s.pts[0], s.pts[1], input.point);
else return [s, { draft: offsetDraft(s.pts, 0, null) }];
const pts = s.pts;
const editId = s.editId;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => commitChain(p, pts, offset, editId, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as DimState;
if (s.phase === "next") {
const cursor = s.pts.length >= 2 ? projectOnLine(s.pts[0], s.pts[1], point) : point;
const ns: DimNext = { ...s, cursor };
return [ns, { draft: chainDraft(s.pts, cursor) }];
}
if (s.phase === "offset") {
// Offset relativ zum ERSTEN Segment (repräsentativ für die ganze Kette).
const off = signedOffset(s.pts[0], s.pts[1], point);
const ns: DimOffset = { ...s, cursor: point };
return [ns, { draft: offsetDraft(s.pts, off, point) }];
}
return [s, { draft: null }];
},
// Enter/Space/Rechtsklick: im "next"-Schritt (≥2 Punkte) wie Doppelklick →
// Punktreihe abschliessen; im "offset"-Schritt bricht es ab (kein sinnvoller
// Default-Abstand ohne Zeigegeste).
onConfirm: (state): [CommandState, CommandResult] => {
const s = state as DimState;
if (s.phase === "next" && s.pts.length >= 2) {
const ns: DimOffset = { phase: "offset", pts: s.pts, cursor: s.cursor, editId: s.editId, lastPoint: s.lastPoint };
return [ns, { draft: offsetDraft(s.pts, 0, null) }];
}
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
fields: (state) => ((state as DimState).phase === "offset" ? OFFSET_FIELDS : []),
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as DimState;
if (s.phase !== "offset" || !cursor) return {};
return { offset: signedOffset(s.pts[0], s.pts[1], cursor) };
},
pointFromFields: (state, locks, cursor) => {
const s = state as DimState;
if (s.phase !== "offset") return null;
const offset = "offset" in locks ? locks.offset : cursor ? signedOffset(s.pts[0], s.pts[1], cursor) : 0;
return offsetPoints(s.pts[0], s.pts[1], offset).a;
},
};
+88
View File
@@ -0,0 +1,88 @@
/**
* `ellipseCommand` — zwei Bounding-Box-Ecken → achsparallele Ellipse
* (shape:"ellipse", rotation 0).
*/
import { describe, it, expect } from "vitest";
import { ellipseCommand } from "./ellipse";
import type { CommandContext, Project } from "../types";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("ellipseCommand", () => {
it("zwei Ecken → Zentrum + Halbachsen aus der Bounding-Box, rotation 0", () => {
const p = project();
const ctx = makeCtx(p);
const [afterCorner1] = ellipseCommand.onInput(
ellipseCommand.init(),
{ kind: "point", point: { x: 0, y: 0 } },
ctx,
);
const [, result] = ellipseCommand.onInput(
afterCorner1,
{ kind: "point", point: { x: 8, y: 4 } },
ctx,
);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("ellipse");
if (g.shape === "ellipse") {
expect(g.center).toEqual({ x: 4, y: 2 });
expect(g.rx).toBeCloseTo(4, 6);
expect(g.ry).toBeCloseTo(2, 6);
expect(g.rotation).toBe(0);
}
});
it("entartete Bbox (Punkt=Punkt) committet nichts", () => {
const p = project();
const ctx = makeCtx(p);
const [afterCorner1] = ellipseCommand.onInput(
ellipseCommand.init(),
{ kind: "point", point: { x: 5, y: 5 } },
ctx,
);
const [, result] = ellipseCommand.onInput(
afterCorner1,
{ kind: "point", point: { x: 5, y: 5 } },
ctx,
);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(0);
});
});
+106
View File
@@ -0,0 +1,106 @@
// Ellipse — zwei gegenüberliegende Ecken einer Bounding-Box (achsparallel,
// wie das 2-Punkt-Rechteck). Schritte:
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit (shape:"ellipse", rotation 0)
//
// Bewusst nur die einfache Bounding-Box-Methode (kein Dreh-Winkel über das
// Werkzeug) — `rotation` bleibt 0, das Feld existiert im Modell für spätere
// Dreh-Bearbeitung/andere Quellen (s. `model/types.ts`).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
import { ellipsePts } from "../../tools/curves";
const EPS = 1e-6;
interface EllipseIdle extends CommandState {
phase: "corner1";
}
interface EllipseDrawing extends CommandState {
phase: "corner2";
p0: Vec2;
cursor: Vec2 | null;
}
type EllipseState = EllipseIdle | EllipseDrawing;
/** Zentrum + Halbachsen aus zwei gegenüberliegenden Bounding-Box-Ecken. */
function ellipseFromCorners(p0: Vec2, p1: Vec2): { center: Vec2; rx: number; ry: number } {
return {
center: { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 },
rx: Math.abs(p1.x - p0.x) / 2,
ry: Math.abs(p1.y - p0.y) / 2,
};
}
/** Vorschau (tessellierte Ellipse) + HUD (Breite×Höhe). */
function ellipseDraft(p0: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [p0] };
const { center, rx, ry } = ellipseFromCorners(p0, cursor);
if (rx < EPS || ry < EPS) return { preview: [], vertices: [p0] };
const preview: DraftShape[] = [{ kind: "poly", pts: ellipsePts(center, rx, ry, 0), closed: true }];
return {
preview,
vertices: [p0],
hud: { at: cursor, text: `${(rx * 2).toFixed(3)}×${(ry * 2).toFixed(3)}m` },
};
}
function appendEllipse(p: Project, center: Vec2, rx: number, ry: number, ctx: CommandContext): Project {
if (rx < EPS || ry < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "ellipse", center, rx, ry, rotation: 0 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true },
];
export const ellipseCommand: Command = {
name: "ellipse",
labelKey: "cmd.ellipse.label",
prompt: (s) => ((s as EllipseState).phase === "corner2" ? "cmd.ellipse.corner2" : "cmd.ellipse.corner1"),
accepts: () => ["point"],
options: () => [],
init: (): EllipseIdle => ({ phase: "corner1", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as EllipseState;
if (input.kind !== "point") return [s, { draft: null }];
if (s.phase !== "corner2") {
const ns: EllipseDrawing = { phase: "corner2", p0: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: ellipseDraft(input.point, null) }];
}
const { center, rx, ry } = ellipseFromCorners(s.p0, input.point);
return [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendEllipse(p, center, rx, ry, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as EllipseState;
if (s.phase !== "corner2") return [s, { draft: null }];
const ns: EllipseDrawing = { ...s, cursor: point };
return [ns, { draft: ellipseDraft(s.p0, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+86
View File
@@ -0,0 +1,86 @@
/**
* `koteCommand` — Punkt + getippter Höhenwert → `shape:"kote"` (SIA 400
* B.5.4, Figur 18). Default-Art „OK fertig"; per Option zyklisch umschaltbar.
*/
import { describe, it, expect } from "vitest";
import { koteCommand } from "./kote";
import type { CommandContext, Project } from "../types";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("koteCommand", () => {
it("Punkt + Zahl → shape:kote mit Default-Art OK fertig, rotation 0", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 2, y: 3 } }, ctx);
const [, result] = koteCommand.onInput(state, { kind: "number", value: 3.25 }, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("kote");
if (g.shape === "kote") {
expect(g.at).toEqual({ x: 2, y: 3 });
expect(g.value).toBe(3.25);
expect(g.kind).toBe("okFertig");
expect(g.rotation).toBe(0);
}
});
it("Art-Option zyklt OK fertig → UK fertig → OK roh → UK roh → OK fertig", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
const seen: string[] = [];
for (let i = 0; i < 4; i++) {
[state] = koteCommand.onInput(state, { kind: "option", id: "kind" }, ctx);
const opts = koteCommand.options(state);
seen.push(opts[0].value!);
}
expect(seen).toEqual(["ukFertig", "okRoh", "ukRoh", "okFertig"]);
});
it("negativer Wert wird unverändert übernommen (Formatierung passiert erst beim Rendern)", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
const [, result] = koteCommand.onInput(state, { kind: "number", value: -0.1 }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
if (g.shape === "kote") expect(g.value).toBe(-0.1);
});
});
+111
View File
@@ -0,0 +1,111 @@
// Höhenkote (SIA 400 B.5.4, Figur 18) — Dreieck-Symbol + Höhenwert an einem
// Punkt. Schritte:
// 1) „Punkt:" → Punkt (Dreieck-Spitze)
// 2) „Höhenwert ( Art ):" → Zahl (Meter, z. B. 3.25 oder -0.1) →
// commit. „Art" (Option, zyklisch: OK fertig/UK fertig/OK roh/UK roh)
// wechselt VOR dem Tippen die Dreieck-Variante (Default OK fertig).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
import type { TranslationKey } from "../../i18n";
type KoteKind = "okFertig" | "ukFertig" | "okRoh" | "ukRoh";
const KIND_ORDER: KoteKind[] = ["okFertig", "ukFertig", "okRoh", "ukRoh"];
const nextKind = (k: KoteKind): KoteKind => KIND_ORDER[(KIND_ORDER.indexOf(k) + 1) % KIND_ORDER.length];
const KIND_LABEL: Record<KoteKind, TranslationKey> = {
okFertig: "cmd.kote.kind.okFertig",
ukFertig: "cmd.kote.kind.ukFertig",
okRoh: "cmd.kote.kind.okRoh",
ukRoh: "cmd.kote.kind.ukRoh",
};
const TRI_SIZE = 0.15; // Modell-Meter, Vorschau-Grösse (Rendering: DIM_… in drawing2d.ts)
const HALF_W = TRI_SIZE * 0.6;
/** Dreieck-Punkte für die Vorschau: `at` = Spitze; OK zeigt nach unten, UK nach oben. */
function triPts(at: Vec2, kind: KoteKind): Vec2[] {
const up = kind === "okFertig" || kind === "okRoh" ? 1 : -1; // Basis liegt in +up-Richtung von `at`
return [
at,
{ x: at.x - HALF_W, y: at.y + up * TRI_SIZE },
{ x: at.x + HALF_W, y: at.y + up * TRI_SIZE },
];
}
interface KotePoint extends CommandState {
phase: "point";
}
interface KoteValue extends CommandState {
phase: "value";
at: Vec2;
kind: KoteKind;
}
type KoteState = KotePoint | KoteValue;
function kindOption(kind: KoteKind): CmdOption {
return { id: "kind", labelKey: KIND_LABEL[kind], value: kind };
}
function valueDraft(at: Vec2, kind: KoteKind): ToolDraft {
const preview: DraftShape[] = [{ kind: "poly", pts: triPts(at, kind), closed: true }];
return { preview, vertices: [at] };
}
function appendKote(p: Project, at: Vec2, value: number, kind: KoteKind, ctx: CommandContext): Project {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "kote", at, value, kind, rotation: 0 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null },
{ draft: null, done: true },
];
export const koteCommand: Command = {
name: "kote",
labelKey: "cmd.kote.label",
prompt: (s) => ((s as KoteState).phase === "value" ? "cmd.kote.value" : "cmd.kote.point"),
accepts: (s) => ((s as KoteState).phase === "value" ? ["number", "option"] : ["point"]),
options: (s) => ((s as KoteState).phase === "value" ? [kindOption((s as KoteValue).kind)] : []),
init: (): KotePoint => ({ phase: "point", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as KoteState;
if (s.phase === "point") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: KoteValue = { phase: "value", at: input.point, kind: "okFertig", lastPoint: input.point };
return [ns, { draft: valueDraft(input.point, "okFertig") }];
}
if (input.kind === "option" && input.id === "kind") {
const ns: KoteValue = { ...s, kind: nextKind(s.kind) };
return [ns, { draft: valueDraft(s.at, ns.kind) }];
}
if (input.kind !== "number") return [s, { draft: valueDraft(s.at, s.kind) }];
const { at, kind } = s;
return [
{ phase: "point", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendKote(p, at, input.value, kind, ctx) },
];
},
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+2
View File
@@ -35,6 +35,7 @@ function hasSelection(sel: CommandSelection): boolean {
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
@@ -45,6 +46,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
+2
View File
@@ -52,6 +52,7 @@ function hasSelection(sel: CommandSelection): boolean {
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
(sel.drawingIds?.length ?? 0) > 0 ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
@@ -62,6 +63,7 @@ function toTransformSel(sel: CommandSelection): TransformSelection {
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
drawingIds: sel.drawingIds,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
+77
View File
@@ -0,0 +1,77 @@
/**
* `splineCommand` — Wegpunkte klicken (wie polyline), Enter/„Schliessen"
* committet als `shape:"spline"` mit den ROHEN Wegpunkten (Tessellierung
* passiert erst beim Rendern/Snapping, s. `tools/curves.ts`).
*/
import { describe, it, expect } from "vitest";
import { splineCommand } from "./spline";
import type { CommandContext, Project } from "../types";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(p: Project): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint: null,
selection: { wallIds: [], drawingId: null },
};
}
describe("splineCommand", () => {
it("drei Punkte + Enter → offene Spline mit den drei Wegpunkten", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = splineCommand.onInput(splineCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 2, y: 3 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 5, y: 1 } }, ctx);
const [, result] = splineCommand.onConfirm(state, ctx);
expect(result.commit).toBeDefined();
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(1);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("spline");
if (g.shape === "spline") {
expect(g.closed).toBe(false);
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 5, y: 1 }]);
}
});
it("Schliessen-Option committet als Ring (closed: true)", () => {
const p = project();
const ctx = makeCtx(p);
let [state] = splineCommand.onInput(splineCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx);
[state] = splineCommand.onInput(state, { kind: "point", point: { x: 4, y: 4 } }, ctx);
const [, result] = splineCommand.onInput(state, { kind: "option", id: "close" }, ctx);
const next = result.commit!(p);
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("spline");
if (g.shape === "spline") expect(g.closed).toBe(true);
});
});
+160
View File
@@ -0,0 +1,160 @@
// Spline — glatte Kurve DURCH mehrere Wegpunkte (uniformer Catmull-Rom),
// Bedienung 1:1 wie `polyline` (s. dort), nur dass die Vorschau/das Ergebnis
// zwischen den Punkten rundet statt gerade Kanten zu ziehen. Schritte:
// 1) „Startpunkt:" → Punkt
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … (Enter beendet offen,
// „Schliessen" schließt; Klick auf den Startpunkt schließt ebenfalls)
import type { Drawing2D } from "../../model/types";
import { segmentHud, uniqueId } from "../../tools/types";
import { sampleCatmullRom } from "../../tools/curves";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Zug
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
interface SplineIdle extends CommandState {
phase: "start";
closed: boolean;
}
interface SplineDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
closed: boolean;
}
type SplineState = SplineIdle | SplineDrawing;
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
const closedOption = (closed: boolean): CmdOption => ({
id: "mode",
labelKey: "cmd.polyline.closedOpt",
value: closed ? "on" : "off",
});
/** Vorschau (geglätteter Zug + Gummiband zum Cursor) + HUD. */
function splineDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const showClosed = closed && pts.length >= 3;
const curve = sampleCatmullRom(pts, showClosed);
const preview: DraftShape[] = [{ kind: "poly", pts: curve, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
return draft;
}
function appendSpline(p: Project, pts: Vec2[], closed: boolean, ctx: CommandContext): Project {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "spline", pts, closed },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (closed = false): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, closed } as SplineIdle,
{ draft: null, done: true },
];
export const splineCommand: Command = {
name: "spline",
labelKey: "cmd.spline.label",
prompt: (s) => ((s as SplineState).phase === "next" ? "cmd.spline.next" : "cmd.spline.start"),
accepts: () => ["point", "option"],
options: (s) => {
const ps = s as SplineState;
const toggle = closedOption(ps.closed);
if (ps.phase !== "next") return [toggle];
return ps.points.length >= 2 ? [CLOSE, toggle, UNDO] : [toggle, UNDO];
},
init: (): SplineIdle => ({ phase: "start", lastPoint: null, closed: false }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as SplineState;
if (input.kind === "option") {
if (input.id === "mode") {
const ns = { ...s, closed: !s.closed } as SplineState;
return [ns, { draft: s.phase === "next" ? splineDraft(s.points, s.cursor, ns.closed) : null }];
}
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle, { draft: null }];
const ns: SplineDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
closed: s.closed,
};
return [ns, { draft: splineDraft(pts, s.cursor, s.closed) }];
}
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, true, ctx) },
];
}
return [s, { draft: splineDraft(s.points, s.cursor, s.closed) }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "next" ? splineDraft(s.points, s.cursor, s.closed) : null }];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: SplineDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: splineDraft([pt], pt, s.closed) }];
}
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, true, ctx) },
];
}
const points = [...s.points, pt];
const ns: SplineDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: splineDraft(points, pt, s.closed) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as SplineState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: SplineDrawing = { ...s, cursor: point };
return [ns, { draft: splineDraft(s.points, point, s.closed) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as SplineState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
const close = s.closed && pts.length >= 3;
return [
{ phase: "start", lastPoint: null, closed: s.closed } as SplineIdle,
{ draft: null, done: true, commit: (p) => appendSpline(p, pts, close, ctx) },
];
}
return idle(s.closed);
},
onCancel: (state): [CommandState, CommandResult] => idle((state as SplineState).closed),
};
+21
View File
@@ -17,12 +17,18 @@ import { roomCommand } from "./cmds/room";
import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle";
import { arcCommand } from "./cmds/arc";
import { ellipseCommand } from "./cmds/ellipse";
import { splineCommand } from "./cmds/spline";
import { bezierCommand } from "./cmds/bezier";
import { dimensionCommand } from "./cmds/dimension";
import { koteCommand } from "./cmds/kote";
import { textCommand } from "./cmds/text";
import { textboxCommand } from "./cmds/textbox";
import { moveCommand } from "./cmds/move";
import { mirrorCommand } from "./cmds/mirror";
import { joinCommand } from "./cmds/join";
import { copyCommand } from "./cmds/copy";
import { arrayCommand } from "./cmds/array";
import { offsetCommand } from "./cmds/offset";
import { extrudeCommand } from "./cmds/extrude";
import { trimCommand } from "./cmds/trim";
@@ -53,12 +59,18 @@ export const COMMANDS: Record<string, Command> = {
rect: rectCommand,
circle: circleCommand,
arc: arcCommand,
ellipse: ellipseCommand,
spline: splineCommand,
bezier: bezierCommand,
dimension: dimensionCommand,
kote: koteCommand,
text: textCommand,
textbox: textboxCommand,
move: moveCommand,
mirror: mirrorCommand,
join: joinCommand,
copy: copyCommand,
array: arrayCommand,
offset: offsetCommand,
extrude: extrudeCommand,
trim: trimCommand,
@@ -104,6 +116,13 @@ export const ALIASES: Record<string, string> = {
c: "circle",
a: "arc",
bogen: "arc",
el: "ellipse",
sp: "spline",
dim: "dimension",
bemassung: "dimension",
kt: "kote",
hoehenkote: "kote",
bz: "bezier",
tx: "text",
txt: "text",
beschriftung: "text",
@@ -113,6 +132,8 @@ export const ALIASES: Record<string, string> = {
j: "join",
verbinden: "join",
cp: "copy",
ar: "array",
feld: "array",
o: "offset",
ex: "extrude",
tr: "trim",
+46
View File
@@ -178,6 +178,16 @@ export const de = {
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
"tool.arc": "Bogen",
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
"tool.ellipse": "Ellipse",
"tool.ellipse.hint": "Ellipse: zwei gegenüberliegende Ecken der Bounding-Box",
"tool.spline": "Spline",
"tool.spline.hint": "Spline: Wegpunkte klicken, Enter beendet (glatte Kurve durch die Punkte)",
"tool.bezier": "Bezier",
"tool.bezier.hint": "Bezier: Anker, Griff 1, Griff 2, Endpunkt — je Segment; Enter beendet",
"tool.dimension": "Bemassung",
"tool.dimension.hint": "Bemassung: Punkte wie Polylinie klicken (ab Punkt 3 auf einer Geraden), Doppelklick/Enter beendet, dann Lage der Masslinie (Massketten in einer Geste)",
"tool.kote": "Höhenkote",
"tool.kote.hint": "Höhenkote: Punkt setzen, dann Höhenwert eintippen (Art umschaltbar: OK/UK, fertig/roh)",
"tool.text": "Text",
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
"tool.textbox": "Textspalte",
@@ -200,6 +210,8 @@ export const de = {
"ribbon.group.custom": "Eigene Leiste",
"tools.view.label": "Darstellung",
"tools.view.list": "Liste",
"group.leave": "Gruppe verlassen",
"group.leave.hint": "Isolation verlassen — zurück zur normalen Ansicht (ein Level)",
"tools.view.icons": "Symbole",
"tools.cat.2d": "2D",
"tools.cat.3d": "3D",
@@ -233,6 +245,7 @@ export const de = {
"snap.enabled": "Fang aktiv",
"snap.endpoint": "Endpunkt",
"snap.midpoint": "Mittelpunkt",
"snap.center": "Kreismitte/Quadranten (0/90/180/270°)",
"snap.intersection": "Schnittpunkt",
"snap.onEdge": "Auf Kante (Lot)",
"snap.grid": "Raster",
@@ -241,6 +254,8 @@ export const de = {
"snap.angleStep": "Winkelraster",
"snap.commonAngles": "Gängige Winkel",
"snap.commonAngleTolerance": "Winkeltoleranz (°)",
"snap.hoverDivideDelayMs": "Hover-Verzögerung (ms)",
"snap.hoverDivideCount": "Teilpunkte (Anzahl)",
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
"transform.bar": "Transformation",
@@ -1139,6 +1154,32 @@ export const de = {
"cmd.arc.center": "Mittelpunkt:",
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
"cmd.arc.end": "Endpunkt (Endwinkel):",
"cmd.ellipse.label": "Ellipse",
"cmd.ellipse.corner1": "Erste Ecke:",
"cmd.ellipse.corner2": "Gegenüberliegende Ecke:",
"cmd.spline.label": "Spline",
"cmd.spline.start": "Startpunkt:",
"cmd.spline.next": "Nächster Punkt ( Schliessen Zurück ):",
"cmd.bezier.label": "Bezier",
"cmd.bezier.start": "Startpunkt:",
"cmd.bezier.c1": "Griff 1:",
"cmd.bezier.c2": "Griff 2:",
"cmd.bezier.end": "Endpunkt ( Fertig ):",
"cmd.bezier.finish": "Fertig",
"cmd.dimension.label": "Bemassung",
"cmd.dimension.start": "Erster Punkt:",
"cmd.dimension.next": "Nächster Punkt ( Fertig Zurück ):",
"cmd.dimension.offset": "Lage der Masslinie:",
"cmd.dimension.undo": "Zurück",
"cmd.dimension.finish": "Fertig",
"cmd.kote.label": "Höhenkote",
"cmd.kote.point": "Punkt:",
"cmd.kote.value": "Höhenwert ( Art ):",
"cmd.kote.kind.okFertig": "OK fertig",
"cmd.kote.kind.ukFertig": "UK fertig",
"cmd.kote.kind.okRoh": "OK roh",
"cmd.kote.kind.ukRoh": "UK roh",
"cmd.field.offset": "Abstand",
"cmd.text.label": "Text",
"cmd.text.point": "Ankerpunkt:",
"cmd.textbox.label": "Textspalte",
@@ -1170,6 +1211,10 @@ export const de = {
"cmd.copy.base": "Basispunkt:",
"cmd.copy.target": "Zielpunkt (Enter beendet):",
"cmd.copy.empty": "Nichts gewählt (erst Objekte wählen).",
"cmd.array.label": "Feld",
"cmd.array.base": "Basispunkt:",
"cmd.array.target": "Zielpunkt ( Anzahl ):",
"cmd.array.count": "Anzahl",
"cmd.offset.label": "Versatz",
"cmd.offset.pick": "Kurve wählen:",
"cmd.offset.side": "Seite oder Distanz:",
@@ -1211,6 +1256,7 @@ export const de = {
"cmd.field.distance": "Distanz",
"cmd.field.thickness": "Dicke",
"cmd.edit.point": "Griff ziehen (Tab: Länge/Winkel):",
"cmd.edit.edge": "Seite verschieben (Tab: Abstand):",
// ── Befehle: Import / Gelände ────────────────────────────────────────────
"cmd.import.label": "Importieren",
+46
View File
@@ -176,6 +176,16 @@ export const en: Record<TranslationKey, string> = {
"tool.circle.hint": "Circle: set center, then radius",
"tool.arc": "Arc",
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
"tool.ellipse": "Ellipse",
"tool.ellipse.hint": "Ellipse: two opposite corners of the bounding box",
"tool.spline": "Spline",
"tool.spline.hint": "Spline: click waypoints, Enter finishes (smooth curve through the points)",
"tool.bezier": "Bezier",
"tool.bezier.hint": "Bezier: anchor, handle 1, handle 2, end point — per segment; Enter finishes",
"tool.dimension": "Dimension",
"tool.dimension.hint": "Dimension: click points like a polyline (point 3+ snaps to a straight line), double-click/Enter finishes, then set the dimension line position (chains in one gesture)",
"tool.kote": "Elevation mark",
"tool.kote.hint": "Elevation mark: set a point, then type the height value (type toggle: top/bottom, finished/raw)",
"tool.text": "Text",
"tool.text.hint": "Text: set anchor point, then type the text",
"tool.textbox": "Text column",
@@ -198,6 +208,8 @@ export const en: Record<TranslationKey, string> = {
"ribbon.group.custom": "Custom bar",
"tools.view.label": "Display",
"tools.view.list": "List",
"group.leave": "Leave group",
"group.leave.hint": "Leave isolation — back to normal view (one level)",
"tools.view.icons": "Icons",
"tools.cat.2d": "2D",
"tools.cat.3d": "3D",
@@ -231,6 +243,7 @@ export const en: Record<TranslationKey, string> = {
"snap.enabled": "Snap on",
"snap.endpoint": "Endpoint",
"snap.midpoint": "Midpoint",
"snap.center": "Circle center/quadrants (0/90/180/270°)",
"snap.intersection": "Intersection",
"snap.onEdge": "On edge (perp.)",
"snap.grid": "Grid",
@@ -239,6 +252,8 @@ export const en: Record<TranslationKey, string> = {
"snap.angleStep": "Angle step",
"snap.commonAngles": "Common angles",
"snap.commonAngleTolerance": "Angle tolerance (°)",
"snap.hoverDivideDelayMs": "Hover delay (ms)",
"snap.hoverDivideCount": "Division points (count)",
// Transform (move/mirror/rotate) + mode bar.
"transform.bar": "Transform",
@@ -1128,6 +1143,32 @@ export const en: Record<TranslationKey, string> = {
"cmd.arc.center": "Center:",
"cmd.arc.start": "Start point (radius + start angle):",
"cmd.arc.end": "End point (end angle):",
"cmd.ellipse.label": "Ellipse",
"cmd.ellipse.corner1": "First corner:",
"cmd.ellipse.corner2": "Opposite corner:",
"cmd.spline.label": "Spline",
"cmd.spline.start": "Start point:",
"cmd.spline.next": "Next point ( Close Undo ):",
"cmd.bezier.label": "Bezier",
"cmd.bezier.start": "Start point:",
"cmd.bezier.c1": "Handle 1:",
"cmd.bezier.c2": "Handle 2:",
"cmd.bezier.end": "End point ( Finish ):",
"cmd.bezier.finish": "Finish",
"cmd.dimension.label": "Dimension",
"cmd.dimension.start": "First point:",
"cmd.dimension.next": "Next point ( Finish Undo ):",
"cmd.dimension.offset": "Dimension line position:",
"cmd.dimension.undo": "Undo",
"cmd.dimension.finish": "Finish",
"cmd.kote.label": "Elevation mark",
"cmd.kote.point": "Point:",
"cmd.kote.value": "Height value ( Type ):",
"cmd.kote.kind.okFertig": "Top, finished",
"cmd.kote.kind.ukFertig": "Bottom, finished",
"cmd.kote.kind.okRoh": "Top, raw",
"cmd.kote.kind.ukRoh": "Bottom, raw",
"cmd.field.offset": "Offset",
"cmd.text.label": "Text",
"cmd.text.point": "Anchor point:",
"cmd.textbox.label": "Text column",
@@ -1158,6 +1199,10 @@ export const en: Record<TranslationKey, string> = {
"cmd.copy.base": "Base point:",
"cmd.copy.target": "Target point (Enter to finish):",
"cmd.copy.empty": "Nothing selected (select objects first).",
"cmd.array.label": "Array",
"cmd.array.base": "Base point:",
"cmd.array.target": "Target point ( Count ):",
"cmd.array.count": "Count",
"cmd.offset.label": "Offset",
"cmd.offset.pick": "Select curve:",
"cmd.offset.side": "Side or distance:",
@@ -1199,6 +1244,7 @@ export const en: Record<TranslationKey, string> = {
"cmd.field.distance": "Distance",
"cmd.field.thickness": "Thickness",
"cmd.edit.point": "Drag grip (Tab: length/angle):",
"cmd.edit.edge": "Move side (Tab: offset):",
// Commands: import / terrain.
"cmd.import.label": "Import",
+10
View File
@@ -106,6 +106,16 @@ export function translateProject(project: Project, dx: number, dy: number): Proj
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
case "arc":
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
case "ellipse":
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
case "bezier":
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
case "spline":
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
case "dimension":
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
case "kote":
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
case "text":
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
case "image":
+137 -3
View File
@@ -833,10 +833,18 @@ export interface LayerCategory {
* • "layer" — „Nach Ebene": die LayerCategory des Elements erzwingt den Wert
* (`color`/`lw`/`hatch`).
* • "object" — „Nach Bauteil": erbt vom Bauteil (Component) bzw. dessen
* bisheriger Fallback-Kette. Das ist auch der Default, wenn das Source-Feld
* fehlt (`undefined`) — damit bleibt das heutige Verhalten unverändert.
* bisheriger Fallback-Kette (bei Wand/Decke u. a. die neutrale SIA-Poché-
* Konvention, s. `resolveBackground`). Das ist auch der DEFAULT bei Wand/
* Decke, wenn das Source-Feld fehlt (`undefined`) — bewusst NICHT „Nach
* Ebene": eine frisch erzeugte Wand soll die neutrale Bauteil-Konvention
* zeigen (weisser Grund, schwarzer Vordergrund), nicht die rohe
* Ebenenfarbe.
* Ein Drawing2D hat KEIN eigenes Bauteil — „Nach Bauteil" ist dort im
* Attribut-Panel gar nicht erst wählbar, ein fehlendes Source-Feld wird dort
* vom Aufrufer (`selectionInfo.ts`) auf „Nach Ebene" gemappt (die einzig
* sinnvolle Alternative zum expliziten Wert bei einem bauteillosen Element).
* Ein gesetzter expliziter Wert (z. B. `foreground`) gewinnt IMMER, unabhängig
* von der Source (s. Resolve-Reihenfolge in `plan/generatePlan.ts`).
* von der Source (s. Resolve-Reihenfolge in `plan/generatePlan/shared.ts`).
*/
export type AttributeSource = "layer" | "object";
@@ -1494,6 +1502,62 @@ export type Drawing2DGeom =
| { shape: "rect"; min: Vec2; max: Vec2 }
| { shape: "circle"; center: Vec2; r: number }
| { shape: "arc"; center: Vec2; r: number; a0: number; a1: number }
/**
* Ellipse — achsparalleles Zentrum + zwei Halbachsen + Drehung (Radiant,
* CCW). Das Zeichen-Werkzeug erzeugt bewusst nur `rotation: 0`; das Feld
* existiert für zukünftige Dreh-Bearbeitung bzw. gedrehte Ellipsen aus
* anderen Quellen. Rendering/Snapping/Griffe tessellieren sie (wie ehemals
* `circle`), kein eigenes glattes SVG-Primitiv (s. `generatePlan/drawing2d.ts`).
*/
| { shape: "ellipse"; center: Vec2; rx: number; ry: number; rotation: number }
/**
* Kubische Bezier-Kette: `pts.length` ≡ 1 (mod 3), ≥ 4 — Punkte im Muster
* Anker·Griff·Griff·Anker·Griff·Griff·Anker·… (jedes weitere Segment fügt
* 3 Punkte an). Manuell gesetzte Kontrollpunkte (klassisches Bezier-
* Werkzeug), im Unterschied zu `spline` (Kurve DURCH Punkte, automatisch
* geglättet). Immer offen (kein `closed`-Feld).
*/
| { shape: "bezier"; pts: Vec2[] }
/**
* Glatte Kurve DURCH `pts` (uniformer Catmull-Rom), analog `polyline`, aber
* mit gerundetem statt geradem Verlauf zwischen den Wegpunkten. `closed`
* schliesst den Ring (letzter Wegpunkt → erster).
*/
| { shape: "spline"; pts: Vec2[]; closed: boolean }
/**
* Masslinie (lineare Bemassung, SIA 400 B.5.3): misst den Abstand `a`↔`b`.
* `offset` ist der VORZEICHENBEHAFTETE Normalabstand (Modell-Meter, links
* von a→b positiv), in dem die eigentliche Masslinie parallel zu a-b liegt
* — Masshilfslinien laufen von a/b bis zu dieser Linie. Die Masszahl wird
* beim Rendern aus `dist(a,b)` abgeleitet (nicht gespeichert), bleibt also
* beim Verschieben der Griffe automatisch korrekt. Rein 2D (kein Bezug auf
* andere Elemente — Assoziativität nur über die eigenen Endpunkte).
*/
/**
* `pts.length` ≥ 2, IMMER kollinear (Werkzeug erzwingt das beim Zeichnen/
* Ergänzen — eine Masskette ist eine Gerade, kein Zickzack-Pfad). Jedes
* aufeinanderfolgende Punktpaar ist EIN gemessenes Segment (Reihenbemassung,
* SIA 400 Figur 14), alle teilen denselben `offset`. EIN Element für die
* ganze Kette (nicht mehrere) — spätere Bearbeitung kann Punkte ergänzen
* (verlängern ODER dazwischen einfügen, s. `commands/cmds/dimension.ts`).
*/
| { shape: "dimension"; pts: Vec2[]; offset: number }
/**
* Höhenkote (SIA 400 B.5.4, Figur 18): Dreieck-Symbol + Höhenwert (Meter,
* ±0.00-Referenz frei wählbar, s. Norm) an einem Punkt. `kind` bestimmt
* Dreieck-Richtung (OK = Spitze nach unten, UK = Spitze nach oben) UND
* Füllung (fertig = Kontur/hohl, roh = voll gefüllt) — vier norm-genaue
* Varianten, die fünfte („OK fertig UND roh kombiniert") ist nicht gebaut.
* `at` ist die SPITZE des Dreiecks (der exakte referenzierte Punkt).
*/
| {
shape: "kote";
at: Vec2;
value: number;
kind: "okFertig" | "ukFertig" | "okRoh" | "ukRoh";
/** Drehung (Radiant); Werkzeug erzeugt bewusst nur 0, s. Ellipse-Muster. */
rotation: number;
}
| {
shape: "text";
at: Vec2;
@@ -1616,6 +1680,13 @@ export interface EdgeGrip {
* entlang der Außennormale wie bisher.
*/
free?: boolean;
/**
* Zu welchem Element diese Kante gehört (für Mehrfachauswahl — mehrere
* gewählte Elemente liefern je eigene Kanten-Griffe). Fehlt bei den
* exklusiven Einzel-Fällen (Decke/Raum), die weiterhin über die separat
* gehaltene `selectedCeilingId`/`selectedRoomId` aufgelöst werden.
*/
owner?: { drawingId?: string; wallId?: string };
}
// ── Extrudierter Körper (truck-Integration, docs/design/truck-plan.md) ─────
@@ -1706,6 +1777,36 @@ export interface Column {
* Kategorie-Farbe.
*/
color?: string;
/**
* Attribut-Override der Muster-/Schraffurfarbe (Vordergrund) DIESER Stütze.
* `undefined` = „Nach System" (erben → Component.foreground), analog `Wall.
* foreground`.
*/
foreground?: string;
/**
* Attribut-Override der Füllfarbe (Hintergrund/Poché) DIESER Stütze.
* `undefined` = „Nach System" (neutrale SIA-Poché HATCH_INK/HATCH_PAPER je
* nach Schraffur-Muster), analog `Wall.background`.
*/
background?: string;
/**
* Attribut-Override der Strichstärke (mm Papier) DIESER Stütze. `undefined`
* = „Nach System" (erben → `strokeWeightSource`), analog `Wall.strokeWeight`.
*/
strokeWeight?: number;
/**
* Attribut-Override der Schnitt-Schraffur (Hatch Manager) DIESER Stütze.
* `undefined` = „Nach System" (erben → `hatchSource`), analog `Wall.hatchId`.
*/
hatchId?: string;
/** Quelle des Vordergrunds, wenn kein explizites `foreground` gesetzt ist — analog `Wall.foregroundSource`. */
foregroundSource?: AttributeSource;
/** Quelle des Hintergrunds, analog {@link Column.foregroundSource}. */
backgroundSource?: AttributeSource;
/** Quelle der Strichstärke, analog {@link Column.foregroundSource}. */
strokeWeightSource?: AttributeSource;
/** Quelle der Schraffur, analog {@link Column.foregroundSource}. */
hatchSource?: AttributeSource;
}
// ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ───────────
@@ -2131,6 +2232,27 @@ export interface LayoutFolder {
kind?: "layout" | "master";
}
/**
* Gruppe (Vectorworks-artig) — reine Mitgliedschaft je Elementart, siehe
* {@link Project.groups}. Alle ID-Listen sind additiv; ein Element gehört
* NIE zu mehr als einer Gruppe auf derselben Ebene (das Werkzeug/der Befehl
* stellt das beim Gruppieren sicher, nicht das Modell selbst).
*/
export interface Group {
id: string;
name?: string;
wallIds: string[];
drawingIds: string[];
ceilingIds: string[];
roomIds: string[];
stairIds: string[];
columnIds: string[];
roofIds: string[];
openingIds: string[];
/** Verschachtelte Gruppen (diese Gruppe enthält andere Gruppen als Ganzes). */
groupIds: string[];
}
export interface Project {
id: string;
name: string;
@@ -2217,6 +2339,18 @@ export interface Project {
* Projekte/Tests ohne `columns` gültig bleiben (Default: leer behandeln).
*/
columns?: Column[];
/**
* Gruppen (Vectorworks-artig: Cmd+G fasst die Auswahl zu einer Einheit
* zusammen; Doppelklick betritt die Gruppe zum Bearbeiten, alles andere
* wird dabei gedimmt/gesperrt — s. `state/useGroupState.ts`). Eine Gruppe
* ist reine MITGLIEDSCHAFT (IDs je Elementart, wie eine gespeicherte
* Mehrfachauswahl) — kein eigenes Geometrie-/Transform-Objekt, damit
* Verschieben/Kopieren/Löschen weiterhin dieselbe Maschinerie nutzen wie
* eine normale Mehrfachauswahl. `groupIds` erlaubt Verschachtelung (eine
* Gruppe kann andere Gruppen enthalten). Optional, damit bestehende
* Projekte/Tests ohne `groups` gültig bleiben (Default: leer behandeln).
*/
groups?: Group[];
/**
* Bibliothek parametrischer Wand-Regelwerke. Optional, damit bestehende
* Projekte ohne `parametricWalls` gültig bleiben (Default: leer behandeln).
+81 -35
View File
@@ -50,10 +50,19 @@ import {
* selbst gespeichert). */
type UiSource = "layer" | "object" | "custom";
/** Leitet den UI-Quellen-Zustand ab: ein gesetzter Wert gewinnt immer („eigener
* Wert"), sonst das rohe Source-Feld (`undefined` ⇒ „Nach Bauteil", Default). */
function uiSourceOf(hasValue: boolean, raw: AttributeSource | undefined): UiSource {
return hasValue ? "custom" : raw ?? "object";
/**
* Leitet den UI-Quellen-Zustand ab: ein gesetzter Wert gewinnt immer („eigener
* Wert"), sonst das rohe Source-Feld — dessen DEFAULT unterscheidet sich nach
* Elementart (s. AttributeSource in model/types.ts): Wand/Decke „Nach Bauteil"
* (neutrale SIA-Poché), Drawing2D „Nach Ebene" (kein eigenes Bauteil, „Nach
* Bauteil" wird dort im Dropdown gar nicht erst angeboten).
*/
function uiSourceOf(
hasValue: boolean,
raw: AttributeSource | undefined,
isDrawing: boolean,
): UiSource {
return hasValue ? "custom" : raw ?? (isDrawing ? "layer" : "object");
}
export function AttributesPanel() {
@@ -112,32 +121,47 @@ export function AttributesPanel() {
// sind IMMER ein eigener Wert (kein 3-Quellen-Dropdown, keine Füllung/
// Schraffur/Masse — s. Ausblendungen weiter unten).
const isAnnotation = sel.kind === "layoutAnnotation";
// Strichstärke: Wand/Decke/Drawing2D tragen einen eigenen Override
// Strichstärke: Wand/Decke/Stütze/Drawing2D tragen einen eigenen Override
// (`strokeWeight`/`weightMm`) + Quellen-Dropdown; andere Elementarten erben
// stets aus der Kategorie (kein Setter im Kontrakt → nicht editierbar). Bei
// Annotationen nur Linie/Rechteck (Text/Bild kennen keine Strichstärke).
const weightEditable =
sel.kind === "wall" ||
sel.kind === "ceiling" ||
sel.kind === "column" ||
isDrawing ||
(isAnnotation && (sel.annotKind === "line" || sel.annotKind === "rect"));
// Schraffur: Wand/Decke (Schnitt-/Ansichts-Schraffur) oder geschlossene 2D-Form.
// Schraffur: Wand/Decke/Stütze (Schnitt-/Ansichts-Schraffur) oder
// geschlossene 2D-Form.
const fillEditable =
sel.kind === "wall" || sel.kind === "ceiling" || (isDrawing && sel.closed === true);
// Vordergrund/Hintergrund (Muster-/Füllfarbe) tragen Wand, Decke und
sel.kind === "wall" ||
sel.kind === "ceiling" ||
sel.kind === "column" ||
(isDrawing && sel.closed === true);
// Vordergrund/Hintergrund (Muster-/Füllfarbe) tragen Wand, Decke, Stütze und
// geschlossene 2D-Formen als Override; `undefined` = „Nach System".
const pocheEditable =
sel.kind === "wall" ||
sel.kind === "ceiling" ||
sel.kind === "column" ||
(isDrawing && sel.closed === true);
// Der 3-Optionen-Quellen-Dropdown, gemeinsam für alle vier Felder. Deaktiviert,
// wenn das Feld für diese Elementart gar nicht gilt (z. B. Strichstärke bei
// einer Treppe).
// Ein Drawing2D hat KEIN eigenes Bauteil (Component) — „Nach Bauteil" wäre
// dort bedeutungslos (resolveForeground & Co. fallen dann auf gar keinen
// Wert zurück, s. selectionInfo.ts `noComponent`). Wand/Decke/Stütze haben
// alle ein echtes Bauteil (Schicht-Component bzw. `Column.componentId`) und
// bieten die Option daher an.
const allowObjectSource = !isDrawing;
// Der Quellen-Dropdown (Nach Ebene/Nach Bauteil/eigener Wert), gemeinsam für
// alle vier Felder. Deaktiviert, wenn das Feld für diese Elementart gar
// nicht gilt (z. B. Strichstärke bei einer Treppe); „Nach Bauteil" fehlt bei
// Drawing2D (s. o.).
const sourceSelect = (
editable: boolean,
ui: UiSource,
onChange: (next: UiSource) => void,
allowObject: boolean,
) => (
<Dropdown
value={ui}
@@ -145,54 +169,71 @@ export function AttributesPanel() {
onChange={(v) => onChange(v as UiSource)}
options={[
{ value: "layer", label: t("attr.source.layer") },
{ value: "object", label: t("attr.source.object") },
...(allowObject ? [{ value: "object", label: t("attr.source.object") }] : []),
{ value: "custom", label: t("attr.source.custom") },
]}
/>
);
// Farb-Swatch-Eingabe für „eigener Wert" (Vordergrund/Hintergrund). Erscheint
// nur, wenn die Quelle „custom" ist — sonst zeigt allein der Dropdown den
// Zustand.
const colorValue = (value: string | undefined, onSet: (color: string) => void) => (
<ColorHexField value={value ?? "#808080"} onChange={onSet} />
);
// Farb-Swatch — IMMER sichtbar (Nutzer-Wunsch: den aktuell EFFEKTIVEN Wert
// (welche Farbe auch immer Ebene/Bauteil/Override gerade liefert) jederzeit
// sehen, nicht nur bei „eigener Wert"). Ändert man ihn direkt, wird
// automatisch „eigener Wert": der Setter schreibt den Override, wodurch
// `uiSourceOf` beim nächsten Render „custom" ableitet (kein separater
// Umschalt-Schritt nötig).
const colorValue = (
value: string | undefined,
onSet: (color: string) => void,
editable: boolean,
) => <ColorHexField value={value ?? "#808080"} onChange={onSet} disabled={!editable} />;
// Eine Attribut-Zeile: Label + Quellen-Dropdown + (nur bei „eigener Wert")
// das passende Eingabe-Element.
// Eine Attribut-Zeile: Label + IMMER sichtbares Eingabe-Element (zeigt den
// effektiven Wert) + Quellen-Dropdown.
const sourceRow = (
labelKey: string,
editable: boolean,
ui: UiSource,
onSource: (next: UiSource) => void,
valueEditor: ReactNode,
allowObject: boolean = allowObjectSource,
) => (
<>
<span className="attr-key">{t(labelKey)}</span>
<span className="attr-val">
{ui === "custom" && valueEditor}
{sourceSelect(editable, ui, onSource)}
{valueEditor}
{sourceSelect(editable, ui, onSource, allowObject)}
</span>
</>
);
// Vordergrund/Hintergrund: „eigener Wert" ⇒ auf Quelle "object" (Nach
// Bauteil, Default) zurückfallen; „Nach Ebene"/„Nach Bauteil" schreiben die
// Quelle direkt (der Host löscht dabei den expliziten Wert).
const fgUi = uiSourceOf(sel.foreground !== undefined, sel.foregroundSource);
const bgUi = uiSourceOf(sel.background !== undefined, sel.backgroundSource);
const weightUi = uiSourceOf(sel.strokeWeightOverride !== undefined, sel.strokeWeightSource);
// Vordergrund/Hintergrund: „eigener Wert" ⇒ auf den elementart-abhängigen
// Default zurückfallen (Wand/Decke „Nach Bauteil", Drawing2D „Nach Ebene",
// s. uiSourceOf); „Nach Ebene"/„Nach Bauteil" schreiben die Quelle direkt
// (der Host löscht dabei den expliziten Wert).
const fgUi = uiSourceOf(sel.foreground !== undefined, sel.foregroundSource, isDrawing);
const bgUi = uiSourceOf(sel.background !== undefined, sel.backgroundSource, isDrawing);
const weightUi = uiSourceOf(
sel.strokeWeightOverride !== undefined,
sel.strokeWeightSource,
isDrawing,
);
const hatchUi = uiSourceOf(
sel.fillHatchId !== undefined && sel.fillHatchId !== null,
sel.hatchSource,
isDrawing,
);
// Wechsel auf „eigener Wert" per Dropdown (nicht durchs Editieren des Felds
// selbst, s. colorValue-Kommentar oben) sät den Override mit dem gerade
// ANGEZEIGTEN effektiven Wert — sonst würde die Farbe beim Umschalten
// unerwartet springen (z. B. auf sel.color statt der tatsächlich sichtbaren
// Ebenen-/Bauteilfarbe).
const onForegroundSourceChange = (next: UiSource) => {
if (next === "custom") host.onSetSelectionForeground(sel.foreground ?? sel.color);
if (next === "custom") host.onSetSelectionForeground(sel.effectiveForeground ?? sel.color);
else host.onSetSelectionForegroundSource(next);
};
const onBackgroundSourceChange = (next: UiSource) => {
if (next === "custom") host.onSetSelectionBackground(sel.background ?? sel.color);
if (next === "custom") host.onSetSelectionBackground(sel.effectiveBackground ?? sel.color);
else host.onSetSelectionBackgroundSource(next);
};
const onWeightSourceChange = (next: UiSource) => {
@@ -200,8 +241,11 @@ export function AttributesPanel() {
else host.onSetSelectionStrokeWeightSource(next);
};
const onHatchSourceChange = (next: UiSource) => {
if (next === "custom") host.onSetSelectionFill(project.hatches[0]?.id ?? null);
else host.onSetSelectionHatchSource(next);
if (next === "custom") {
host.onSetSelectionFill(sel.effectiveHatchId ?? project.hatches[0]?.id ?? null);
} else {
host.onSetSelectionHatchSource(next);
}
};
// Effektiver Linienstil rein informativ (kein Setter im Kontrakt → kein Fake).
@@ -272,6 +316,7 @@ export function AttributesPanel() {
type="number"
step={0.01}
min={0}
disabled={!weightEditable}
list="attr-pen-weights"
value={sel.weightMm}
onChange={(e) => {
@@ -352,7 +397,7 @@ export function AttributesPanel() {
pocheEditable,
fgUi,
onForegroundSourceChange,
colorValue(sel.foreground, host.onSetSelectionForeground),
colorValue(sel.effectiveForeground, host.onSetSelectionForeground, pocheEditable),
)}
{sourceRow(
@@ -360,7 +405,7 @@ export function AttributesPanel() {
pocheEditable,
bgUi,
onBackgroundSourceChange,
colorValue(sel.background, host.onSetSelectionBackground),
colorValue(sel.effectiveBackground, host.onSetSelectionBackground, pocheEditable),
)}
{sourceRow(
@@ -369,7 +414,8 @@ export function AttributesPanel() {
hatchUi,
onHatchSourceChange,
<Dropdown
value={sel.fillHatchId ?? ""}
value={sel.effectiveHatchId ?? ""}
disabled={!fillEditable}
onChange={(v) => host.onSetSelectionFill(v || null)}
options={[
{ value: "", label: t("attr.none") },
+66 -2
View File
@@ -138,6 +138,56 @@ export function ToolIcon({ id }: { id: ToolId }) {
<circle cx="13.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
case "ellipse":
// Ellipse-Umriss (geneigt, unterscheidbar vom Kreis).
return (
<svg {...common}>
<ellipse cx="8" cy="8" rx="6" ry="4" />
</svg>
);
case "spline":
// Glatte S-Kurve durch drei Wegpunkte.
return (
<svg {...common}>
<path d="M 2.5 12.5 C 2.5 6 6 10 8 8 C 10 6 10.5 3.5 13.5 3.5" fill="none" />
<circle cx="2.5" cy="12.5" r="0.9" fill="currentColor" stroke="none" />
<circle cx="8" cy="8" r="0.9" fill="currentColor" stroke="none" />
<circle cx="13.5" cy="3.5" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
case "bezier":
// Kubische Bezier-Kurve mit sichtbaren Kontrollpunkt-Griffen.
return (
<svg {...common}>
<path d="M 2.5 12.5 C 5 4 8 13 13.5 3.5" fill="none" />
<line x1="2.5" y1="12.5" x2="5" y2="4" strokeDasharray="1.5 1.2" />
<line x1="13.5" y1="3.5" x2="8" y2="13" strokeDasharray="1.5 1.2" />
<circle cx="2.5" cy="12.5" r="0.9" fill="currentColor" stroke="none" />
<circle cx="13.5" cy="3.5" r="0.9" fill="currentColor" stroke="none" />
<rect x="4.2" y="2.7" width="1.6" height="1.6" fill="currentColor" stroke="none" />
<rect x="7.2" y="12.2" width="1.6" height="1.6" fill="currentColor" stroke="none" />
</svg>
);
case "dimension":
// Masslinie mit Massstrichen (Diagonalstriche) an beiden Enden.
return (
<svg {...common}>
<line x1="2.5" y1="4" x2="2.5" y2="11" strokeWidth="0.9" />
<line x1="13.5" y1="4" x2="13.5" y2="11" strokeWidth="0.9" />
<line x1="2.5" y1="8" x2="13.5" y2="8" />
<line x1="1.7" y1="6.6" x2="3.3" y2="9.4" strokeWidth="1.6" />
<line x1="12.7" y1="6.6" x2="14.3" y2="9.4" strokeWidth="1.6" />
</svg>
);
case "kote":
// Höhenkote: Dreieck (Spitze unten, OK) + Zahl.
return (
<svg {...common}>
<polygon points="8,10 5,5 11,5" fill="none" />
<line x1="10.5" y1="6.5" x2="14" y2="6.5" />
<line x1="10.5" y1="9" x2="14" y2="9" />
</svg>
);
case "text":
// Serifen-„A" als Text-Symbol.
return (
@@ -174,10 +224,24 @@ export function ToolIcon({ id }: { id: ToolId }) {
// Werkzeuge/Befehle in drei Rubriken sortiert (Nutzerwunsch: „sortiere nach 2D
// und 3D und Bearbeiten"): reine Panel-Gruppierung (2D-Zeichnen · 3D/BIM-
// Bauteile · Bearbeiten-Aktionen).
const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox", "image"];
const TOOLS_2D: ToolId[] = [
"line",
"polyline",
"rect",
"circle",
"arc",
"ellipse",
"spline",
"bezier",
"dimension",
"kote",
"text",
"textbox",
"image",
];
const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "multiline", "trim", "extend", "fillet", "chamfer", "join", "measure"];
const COMMANDS_EDIT = ["move", "copy", "array", "rotate", "mirror", "offset", "multiline", "trim", "extend", "fillet", "chamfer", "join", "measure"];
type ToolsView = "list" | "icons";
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
+420 -24
View File
@@ -37,6 +37,9 @@ import {
pointSegDistPx,
marqueeHit,
marqueeHitDrawings,
marqueeHitCeilings,
marqueeHitRooms,
marqueeHitStairs,
} from "./planView/geometry";
import {
polyUsesPattern,
@@ -111,6 +114,16 @@ export interface PlanViewHandle {
* Leere Punktmenge → No-op (kein Fit, kein Crash).
*/
fitBounds: (pts: Vec2[]) => void;
/**
* Einen bewaffneten Punkt-Griff (s. gripDrag) an seiner AKTUELLEN Position
* abschließen — dieselbe Aktion wie ein bestätigender Klick/Enter auf der
* Zeichenfläche, aber von der Befehlszeile aus angestoßen (Enter dort MIT
* leerem Eingabetext: kein neuer Wert zu locken, nur „fertig, so lassen").
* No-op ohne bewaffneten Griff.
*/
confirmGripEdit: () => void;
/** Dasselbe für einen bewaffneten Kanten-Griff (s. edgeDrag). */
confirmEdgeEdit: () => void;
}
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
@@ -198,6 +211,10 @@ export interface MarqueeSelection {
wallIds: string[];
/** Getroffene 2D-Zeichenelemente (gleiche window/crossing-Konvention). */
drawingIds: string[];
/** Getroffene Decken/Räume/Treppen (gleiche window/crossing-Konvention). */
ceilingIds: string[];
roomIds: string[];
stairIds: string[];
/**
* Zieh-Richtung: `false` = links→rechts (positiv Δx) → nur VOLLSTÄNDIG
* eingeschlossene Wände; `true` = rechts→links (negativ Δx) → auch berührte/
@@ -206,6 +223,23 @@ export interface MarqueeSelection {
crossing: boolean;
}
/**
* Ein per Doppelklick getroffenes, potenziell gruppiertes Element — genau
* EIN Feld gesetzt (analog `PlanSelection`, aber ohne die Klick-spezifischen
* Felder). Für Gruppen-Eintritt (`onEnterGroup`); Formen ohne eigenes
* Polygon-Primitiv (z. B. reine Linien-2D-Elemente) werden hier nicht
* erkannt (kleine, bewusste Vereinfachung — s. `anyGroupableHit`).
*/
export interface GroupableHit {
wallId?: string;
drawingId?: string;
ceilingId?: string;
roomId?: string;
stairId?: string;
columnId?: string;
roofId?: string;
}
export interface PlanViewProps {
plan: Plan;
/**
@@ -293,6 +327,13 @@ export interface PlanViewProps {
* markiert den Querschnitt-Umriss JEDER dieser Stützen mit einer Akzentkontur.
*/
selectedColumnIds?: string[];
/**
* IDs der gewählten GRUPPEN (Vectorworks-artig). Nicht-leer → die Ansicht
* zeigt statt der einzelnen Mitglieds-Umrisse nur eine Bounding-Box mit vier
* Eckpunkten (s. `groupBBox`); die per-Kind-`selected…Ids`-Felder tragen
* weiterhin die volle Mitgliedschaft (für Verschieben/Griffe/Löschen).
*/
selectedGroupIds?: string[];
/**
* Der Stempel-Griff des (einzeln) gewählten Raums: sein Anker (Meter) + ID.
* PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick.
@@ -305,6 +346,17 @@ export interface PlanViewProps {
onStampEdit?: (roomId: string) => void;
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
onEditDrawingText?: (drawingId: string) => void;
/**
* Doppelklick auf IRGENDEIN Element (Wand/2D/Decke/Raum/Treppe/Stütze/
* Dach): meldet, welches Element getroffen wurde (genau EIN Feld gesetzt).
* Der Aufrufer prüft, ob es zu einer Gruppe gehört (Vectorworks „Gruppe
* betreten") und liefert `true`, wenn er die Gruppe betreten hat — dann
* bricht `onDoubleClick` sofort ab, VOR allen anderen Doppelklick-Aktionen
* (Gruppen-Eintritt hat Vorrang, wie in Vectorworks). `false`/kein Handler
* → die übrigen Doppelklick-Prüfungen (Text/Raum-Stempel/Schnittlinie) laufen
* normal weiter.
*/
onEnterGroup?: (hit: GroupableHit) => boolean;
/**
* Drawing2D-ID, deren Inhalt gerade inline auf der Zeichenfläche editiert
* wird (frisch über `text`/`textbox` erzeugt ODER per Doppelklick auf ein
@@ -372,6 +424,12 @@ export interface PlanViewProps {
* Auswahl-Werkzeug sichtbar/aktiv.
*/
grips?: Vec2[];
/**
* Bezier-Hilfslinien (Anker → Kontrollpunkt-Griff) des selektierten
* Elements — gestrichelt gerendert, damit sichtbar bleibt, welcher Griff
* welchen Kurvenabschnitt steuert (s. `useGripEditing.ts`).
*/
gripHandleLines?: { a: Vec2; b: Vec2 }[];
/**
* Kanten-/Seiten-Griffe (Edge Grips) des selektierten Elements: je Seite ein
* dreieckiger Anfasser am Kanten-Mittelpunkt, dessen Spitze nach AUSSEN zeigt.
@@ -400,6 +458,19 @@ export interface PlanViewProps {
* Einstellungs-Fenster (State in ViewSlice); Default = Akzent „Sora".
*/
snapColor?: string;
/**
* Ruht der Cursor (kein Werkzeug/Drag aktiv) länger als diese Zeit (ms) auf
* einer 2D-Linie, erscheinen ihre Teilpunkte (s. `hoverDivideCount`) kurz
* mit Akzentfarbe — reine Sichtbarkeits-Hilfe, kein Snap-Ziel. Einstellbar
* im Footer (`SnapSettings.hoverDivideDelayMs`); Default 300 ms.
*/
hoverDivideDelayMs?: number;
/**
* In wie viele gleiche Teile die gehoverte Linie dabei angezeigt wird
* (2 = nur der Mittelpunkt). Einstellbar im Footer
* (`SnapSettings.hoverDivideCount`); Default 2.
*/
hoverDivideCount?: number;
}
/** Treiber für das Ziehen von Editier-Griffen (von App bereitgestellt). */
@@ -424,6 +495,20 @@ export interface GripHandlers {
*/
onEdgeMove(edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods): void;
onGripEnd(): void;
/**
* Bewaffneten Punkt-Griff verwerfen (Escape): setzt den Punkt exakt auf
* `origin` zurück (ohne Snap/Ortho-Neuauswertung) und räumt danach wie
* {@link onGripEnd} auf.
*/
onGripCancel(index: number, origin: Vec2): void;
/** Bewaffneten Kanten-Griff an seiner aktuellen Position abschließen. */
onEdgeEnd(): void;
/**
* Bewaffneten Kanten-Griff verwerfen (Escape): setzt ihn exakt auf `anchor`
* (den originalen Kanten-Mittelpunkt) zurück und räumt danach wie
* {@link onEdgeEnd} auf.
*/
onEdgeCancel(edge: EdgeGrip, anchor: Vec2): void;
}
export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
@@ -447,10 +532,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
selectedSectionLineIds,
selectedExtrudedSolidIds,
selectedColumnIds,
selectedGroupIds,
roomStamp,
onStampMove,
onStampEdit,
onEditDrawingText,
onEnterGroup,
editTextId,
editTextMinHeightM,
onCommitTextEdit,
@@ -462,6 +549,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
showRulers = true,
hudFields,
grips,
gripHandleLines,
edgeGrips,
hairline,
selectedDrawingId,
@@ -469,6 +557,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
gripHandlers,
marqueeColor = DEFAULT_MARQUEE_COLOR,
snapColor = DEFAULT_SNAP_COLOR,
hoverDivideDelayMs = 300,
hoverDivideCount = 2,
},
ref,
) {
@@ -701,12 +791,34 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetKey]);
// Frischer Spiegel der gripHandlers-Prop für das (unten stabil mit leeren
// Deps memoisierte) Imperativ-Handle — direkt referenzieren würde die Prop
// vom ERSTEN Render einfrieren (stale closure, s. Kommentar dort).
const gripHandlersRef = useRef(gripHandlers);
gripHandlersRef.current = gripHandlers;
// Imperatives Steuerhandle für die Oberleiste (Einpassen / Auswahl / Massstab).
// Stabil (leere Deps); liest den frischen Ausschnitt + die Auswahl aus Refs.
useImperativeHandle(
ref,
() => ({
fit: () => setView(defaultViewRef.current),
confirmGripEdit: () => {
// Von der Befehlszeile aus angestoßen (Enter mit leerem Eingabetext,
// s. App.tsx) — dieselbe Aktion wie ein bestätigender Klick auf der
// Zeichenfläche (siehe onPointerDown-Kopf unten), nur eben nicht über
// ein Pointer-Event.
if (gripDrag.current) {
gripDrag.current = null;
gripHandlersRef.current?.onGripEnd();
}
},
confirmEdgeEdit: () => {
if (edgeDrag.current) {
edgeDrag.current = null;
gripHandlersRef.current?.onEdgeEnd();
}
},
fitSelection: () => {
const sel = selectedRef.current;
const prim = sel !== null ? planRef.current.primitives[sel] : undefined;
@@ -767,11 +879,66 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
crossing: boolean;
} | null>(null);
// Hover-Teilpunkte (Nutzer-Wunsch): ruht der Cursor im Auswahl-Ruhezustand
// (kein Werkzeug/Drag/Marquee — s. Aufruf in onPointerMove) länger als
// `hoverDivideDelayMs` auf einem 2D-Linien-Segment, werden dessen gleiche
// Teilpunkte (Anzahl `hoverDivideCount`, 2 = nur Mitte) kurz mit
// Akzentfarbe eingeblendet. Reine Sichtbarkeits-Hilfe, kein Snap-Ziel.
const [hoverDivideSeg, setHoverDivideSeg] = useState<{ a: Vec2; b: Vec2 } | null>(null);
// Zuletzt erkanntes Segment (Serialisiert als Schlüssel) — erkennt einen
// Wechsel, ohne bei jedem Move-Event neu zu timern.
const hoverDivideKeyRef = useRef<string | null>(null);
const hoverDivideTimerRef = useRef<number | null>(null);
// Nächstliegendes Linien-Segment unterm Cursor ermitteln und die
// Hover-Teilpunkte-Anzeige entsprechend (neu) timern/verwerfen.
const updateHoverDivide = (clientX: number, clientY: number) => {
const seg = nearestLineSegment(clientX, clientY);
const key = seg ? `${seg.a.x},${seg.a.y}|${seg.b.x},${seg.b.y}` : null;
if (key === hoverDivideKeyRef.current) return; // dasselbe Segment (oder weiter keins)
hoverDivideKeyRef.current = key;
if (hoverDivideTimerRef.current !== null) {
window.clearTimeout(hoverDivideTimerRef.current);
hoverDivideTimerRef.current = null;
}
setHoverDivideSeg(null); // Segment gewechselt → alte Anzeige sofort weg
if (!seg) return;
hoverDivideTimerRef.current = window.setTimeout(() => {
hoverDivideTimerRef.current = null;
setHoverDivideSeg(seg);
}, hoverDivideDelayMs);
};
const clearHoverDivide = () => {
if (hoverDivideTimerRef.current !== null) {
window.clearTimeout(hoverDivideTimerRef.current);
hoverDivideTimerRef.current = null;
}
hoverDivideKeyRef.current = null;
setHoverDivideSeg(null);
};
// Weg, sobald ein Werkzeug aktiv wird (sonst könnte eine veraltete Anzeige
// ohne weiteres Move-Event über einem neuen Werkzeug-Entwurf stehen bleiben).
useEffect(() => {
if (toolActive) clearHoverDivide();
}, [toolActive]);
useEffect(() => () => clearHoverDivide(), []);
// Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je
// Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove.
const gripDrag = useRef<{ pointerId: number; index: number } | null>(null);
// Anders als bei Kante/Körper braucht das Loslassen der Maus die Bearbeitung
// NICHT mehr zu beenden: der Griff bleibt „bewaffnet" (kein Pointer-Capture),
// folgt dem Cursor beim Schweben weiter — wie beim Zeichnen (Klick → Punkt
// setzen, Maus bewegt sich frei, Wert tippbar, nächster Klick/Enter bestätigt,
// Escape verwirft zur ursprünglichen Position `origin` zurück).
const gripDrag = useRef<{ pointerId: number; index: number; origin: Vec2 } | null>(
null,
);
// Transiente Kanten-Griff-Zieh-Daten (linke Taste auf einem Dreieck-Anfasser).
// Hält den gegriffenen EdgeGrip; die Mutation läuft live über onEdgeMove.
// Hält den gegriffenen EdgeGrip (Momentaufnahme vom Grab — sein `.mid` ist der
// Anker für Feld-Eingabe/Escape-Revert); die Mutation läuft live über
// onEdgeMove. Dasselbe „bewaffnet"-Verhalten wie gripDrag oben: Loslassen
// beendet die Bearbeitung NICHT, erst ein weiterer Klick/Enter (bestätigt)
// oder Escape (verwirft zurück auf `edge.mid`).
const edgeDrag = useRef<{ pointerId: number; edge: EdgeGrip } | null>(null);
// Transiente „Körper verschieben"-Daten: letzter Modellpunkt, gegen den das
// inkrementelle Delta gebildet wird (parallel-Verschiebung des Elements).
@@ -789,6 +956,36 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// Erkennung auf Raum-Stempel (Pointer-Capture unterdrückt natives dblclick).
const lastClickRef = useRef<{ t: number; x: number; y: number } | null>(null);
// Tastatur-Abschluss eines bewaffneten Punkt- ODER Kanten-Griffs (s. gripDrag/
// edgeDrag oben): Escape verwirft (zurück auf die Ursprungsposition), Enter
// bestätigt die aktuelle Position — beide NUR wenn kein Eingabefeld
// fokussiert ist (die Befehlszeile behandelt ihr eigenes Enter für getippte
// Werte selbst, s. App.tsx).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const gd = gripDrag.current;
const ed = edgeDrag.current;
if ((!gd && !ed) || !gripHandlers) return;
if (e.key !== "Escape" && e.key !== "Enter") return;
const el = e.target as HTMLElement | null;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) {
return;
}
e.preventDefault();
if (gd) {
gripDrag.current = null;
if (e.key === "Escape") gripHandlers.onGripCancel(gd.index, gd.origin);
else gripHandlers.onGripEnd();
} else if (ed) {
edgeDrag.current = null;
if (e.key === "Escape") gripHandlers.onEdgeCancel(ed.edge, ed.edge.mid);
else gripHandlers.onEdgeEnd();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [gripHandlers]);
/** Bildschirm-px-Distanz Cursor → Stempel-Griff; null = kein einzelner Raum. */
const stampHandleAt = (clientX: number, clientY: number): string | null => {
if (!roomStamp) return null;
@@ -927,6 +1124,30 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return null;
};
// Irgendein Element (Wand/Decke/Raum/Treppe/Stütze/Dach/2D) unter dem
// Cursor — für Gruppen-Eintritt per Doppelklick (`onEnterGroup`). Nutzt
// `hitTest` (Polygon-Primitive, deckt alle flächigen Bauteile ab) und
// fällt für linienbasierte 2D-Elemente auf `pickDrawing` zurück (das
// selbst erst nach dessen Definition unten verfügbar ist, daher als
// separate Closure statt inline hier).
const anyGroupableHit = (clientX: number, clientY: number): GroupableHit | null => {
const idx = hitTest(clientX, clientY);
if (idx !== null) {
const p = plan.primitives[idx];
if (p.kind === "polygon") {
if (p.wallId) return { wallId: p.wallId };
if (p.ceilingId) return { ceilingId: p.ceilingId };
if (p.roomId) return { roomId: p.roomId };
if (p.stairId) return { stairId: p.stairId };
if (p.columnId) return { columnId: p.columnId };
if (p.roofId) return { roofId: p.roofId };
if (p.drawingId) return { drawingId: p.drawingId };
}
}
const did = pickDrawing(clientX, clientY);
return did ? { drawingId: did } : null;
};
// Abstand (Bildschirm-Pixel) eines Klicks vom Ring eines Kreis/Bogen-2D-
// Elements — bei gefüllten Kreisen zählt auch INNERHALB als Treffer (d=0).
// Bögen zusätzlich auf ihre Winkel-Spanne begrenzt (CCW, Modell-Radiant, s.
@@ -980,6 +1201,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return pickDrawingText(clientX, clientY) ?? pickDrawingImage(clientX, clientY);
};
// Nächstgelegenes EINZELNES Geraden-Segment (nicht das ganze Element) für
// die Hover-Teilpunkte-Anzeige (s. hoverDivideSeg unten) — dieselbe Nähe-
// Logik wie pickDrawing, liefert aber die zwei Endpunkte des getroffenen
// Segments selbst: eine Polylinie besteht aus mehreren "line"-Primitiven
// (eins je Segment, gleiche drawingId), unterteilt werden soll nur das
// konkret gehoverte Segment, nicht das ganze Element.
const nearestLineSegment = (clientX: number, clientY: number): { a: Vec2; b: Vec2 } | null => {
let best: { a: Vec2; b: Vec2 } | null = null;
let bestDist = LINE_PICK_PX;
for (const p of plan.primitives) {
if (p.kind !== "line" || !p.drawingId) continue;
const a = vbToClient(toScreen(p.a).x, toScreen(p.a).y);
const b = vbToClient(toScreen(p.b).x, toScreen(p.b).y);
const d = pointSegDistPx({ x: clientX, y: clientY }, a, b);
if (d < bestDist) {
bestDist = d;
best = { a: p.a, b: p.b };
}
}
return best;
};
// Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
// Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen);
// Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null.
@@ -1199,6 +1442,23 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan),
// LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom.
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
// Ein Punkt- ODER Kanten-Griff ist bereits „bewaffnet" (Maus wurde
// losgelassen, der Griff folgt seither dem Cursor) — JEDER weitere
// Linksklick bestätigt nur die aktuelle Position, statt irgendeine andere
// Aktion (Doppelklick, Schere, neue Auswahl, …) an derselben Stelle
// auszulösen.
if (e.button === 0 && gripDrag.current && gripHandlers) {
e.preventDefault();
gripDrag.current = null;
gripHandlers.onGripEnd();
return;
}
if (e.button === 0 && edgeDrag.current && gripHandlers) {
e.preventDefault();
edgeDrag.current = null;
gripHandlers.onEdgeEnd();
return;
}
// Doppelklick (zweiter Klick der Sequenz) auf einen Raum-Stempel/-Fläche →
// Stempel-Editor öffnen. Manuell über Zeit/Abstand erkannt, weil PlanView
// Pointer-Capture nutzt und der Browser dann kein natives `dblclick`
@@ -1278,16 +1538,19 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const gi = gripAt(e.clientX, e.clientY);
if (gi !== null) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
gripDrag.current = { pointerId: e.pointerId, index: gi };
// KEIN Pointer-Capture: der Griff bleibt nach dem Loslassen bewaffnet
// und folgt via normalem Hover-onPointerMove weiter dem Cursor (s.
// Kommentar an der gripDrag-Deklaration).
gripDrag.current = { pointerId: e.pointerId, index: gi, origin: grips![gi] };
return;
}
// Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen.
// Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen. KEIN
// Pointer-Capture — dasselbe „bewaffnet nach Loslassen"-Verhalten wie
// beim Punkt-Griff (s. Kommentar an der edgeDrag-Deklaration).
if (edgeGripsActive) {
const eg = edgeGripAt(e.clientX, e.clientY);
if (eg) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
edgeDrag.current = { pointerId: e.pointerId, edge: eg };
return;
}
@@ -1508,7 +1771,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
}
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
if (!d || d.pointerId !== e.pointerId) {
// Echter Ruhezustand (kein Drag/Werkzeug/Marquee/Pan) — Hover-
// Teilpunkte-Erkennung.
updateHoverDivide(e.clientX, e.clientY);
return;
}
const { scale } = measure(d.startView);
// Bildschirm-Delta → viewBox-Einheiten; Inhalt folgt dem Cursor, also
// verschiebt sich der Ausschnitt entgegengesetzt.
@@ -1560,25 +1828,19 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
stampDrag.current = null;
return;
}
// Kanten-Griff loslassen: Ziehen abschließen.
const ed = edgeDrag.current;
if (ed && ed.pointerId === e.pointerId) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
edgeDrag.current = null;
gripHandlers!.onGripEnd();
// Kanten-Griff: Maus loslassen beendet die Bearbeitung NICHT mehr — kein
// Pointer-Capture gehalten, der Griff bleibt bewaffnet und folgt weiter dem
// Cursor. Abschluss erst per erneutem Klick (oben in onPointerDown) oder
// Enter, Verwerfen per Escape (beides im Tastatur-Effekt unten).
if (edgeDrag.current && edgeDrag.current.pointerId === e.pointerId) {
return;
}
// Editier-Griff loslassen: Ziehen abschließen.
const gd = gripDrag.current;
if (gd && gd.pointerId === e.pointerId) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
gripDrag.current = null;
gripHandlers!.onGripEnd();
// Editier-Griff: Maus loslassen beendet die Bearbeitung NICHT mehr — kein
// Pointer-Capture gehalten, der Griff bleibt bewaffnet und folgt weiter dem
// Cursor. Abschluss erst per erneutem Klick (oben in onPointerDown) oder
// Enter, Verwerfen per Escape (beides im Tastatur-Effekt unten).
if (gripDrag.current && gripDrag.current.pointerId === e.pointerId) {
return;
}
@@ -1631,6 +1893,9 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
onMarquee({
wallIds: marqueeHit(plan.primitives, rect, crossing),
drawingIds: marqueeHitDrawings(plan.primitives, rect, crossing),
ceilingIds: marqueeHitCeilings(plan.primitives, rect, crossing),
roomIds: marqueeHitRooms(plan.primitives, rect, crossing),
stairIds: marqueeHitStairs(plan.primitives, rect, crossing),
crossing,
});
}
@@ -1798,6 +2063,13 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
toolHandlers!.onToolCommit();
return;
}
// Gruppen betreten hat VORRANG vor allen anderen Doppelklick-Aktionen
// (Vectorworks: ein gruppiertes Element öffnet zuerst die Gruppe — erst
// ein weiterer Doppelklick INNERHALB träfe dann z. B. den Text-Editor).
if (onEnterGroup) {
const hit = anyGroupableHit(e.clientX, e.clientY);
if (hit && onEnterGroup(hit)) return;
}
// Doppelklick auf ein Text-2D-Element → Inhalt bearbeiten (Vorrang vor Raum).
if (onEditDrawingText) {
const tid = pickDrawingText(e.clientX, e.clientY);
@@ -2006,6 +2278,48 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
);
}, [plan, selectedColumnIds]);
// Ist eine GRUPPE gewählt (statt einzelner Elemente), zeigt die Auswahl NUR
// eine einfache Bounding-Box mit vier Eckpunkten statt jedes einzelnen
// Mitglieds-Umrisses (Nutzer-Wunsch, Vectorworks-Vorbild: „einfach vier
// Aussenpunkte zeigen") — vereint dieselben Hervorhebungs-Listen, die sonst
// einzeln gezeichnet würden.
const groupBBox = useMemo(() => {
if (!selectedGroupIds || selectedGroupIds.length === 0) return null;
const pts: Vec2[] = [];
for (const poly of highlightPolys) pts.push(...poly.pts);
for (const poly of highlightCeilingPolys) pts.push(...poly.pts);
for (const poly of highlightRoomPolys) pts.push(...poly.pts);
for (const poly of highlightRoofPolys) pts.push(...poly.pts);
for (const poly of highlightColumnPolys) pts.push(...poly.pts);
const fromLineOrPoly = (p: { kind: string; a?: Vec2; b?: Vec2; from?: Vec2; to?: Vec2; center?: Vec2; pts?: Vec2[] }) => {
if (p.kind === "line" && p.a && p.b) pts.push(p.a, p.b);
else if (p.kind === "arc" && p.from && p.to) pts.push(p.from, p.to);
else if (p.pts) pts.push(...p.pts);
};
for (const p of highlightDrawPrims) fromLineOrPoly(p);
for (const p of highlightOpeningPrims) fromLineOrPoly(p);
for (const p of highlightStairPrims) fromLineOrPoly(p);
if (pts.length === 0) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
return { minX, minY, maxX, maxY };
}, [
selectedGroupIds,
highlightPolys,
highlightCeilingPolys,
highlightRoomPolys,
highlightRoofPolys,
highlightColumnPolys,
highlightDrawPrims,
highlightOpeningPrims,
highlightStairPrims,
]);
// Zusammenhängende 2D-Zeichen-Linien (gleiche drawingId + Stil) zu Zügen
// bündeln, damit sie als EIN <polyline>/<polygon> gezeichnet werden können —
// so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die
@@ -2123,7 +2437,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); }}
onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); clearHoverDivide(); }}
onWheel={onWheel}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenuHandler}
@@ -2197,6 +2511,11 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
CAD-typische Gehrung an den Ecken (stroke-linejoin), keine Stufe bei
dicken Linien. Nach der Haupt-Ebene gezeichnet (2D liegt ohnehin oben). */}
{drawingRunEls}
{/* Ist eine GRUPPE gewählt, ersetzt die Bounding-Box unten die einzelnen
Mitglieds-Hervorhebungen (s. `groupBBox`) — dieser ganze Block bleibt
dann aus. */}
{!groupBBox && (
<>
{/* Auswahl-Hervorhebung: alle Bänder der gewählten Wand (bzw. das lokal
getroffene Polygon) mit Akzentkontur überzeichnen. */}
{highlightPolys.map((poly, i) => (
@@ -2376,6 +2695,38 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
/>
),
)}
</>
)}
{/* Gruppen-Auswahl: nur eine Bounding-Box mit vier Eckpunkten (kein
Umriss jedes einzelnen Mitglieds) — s. `groupBBox`. */}
{groupBBox && (() => {
const c1 = toScreen({ x: groupBBox.minX, y: groupBBox.minY });
const c2 = toScreen({ x: groupBBox.maxX, y: groupBBox.minY });
const c3 = toScreen({ x: groupBBox.maxX, y: groupBBox.maxY });
const c4 = toScreen({ x: groupBBox.minX, y: groupBBox.maxY });
const size = 5 / (meetScale(view, svgRef.current) ?? 1);
const corners = [c1, c2, c3, c4];
return (
<>
<polygon
className="plan-group-bbox"
points={corners.map((s) => `${s.x},${s.y}`).join(" ")}
pointerEvents="none"
/>
{corners.map((s, i) => (
<rect
key={`gbb-${i}`}
className="plan-group-bbox-corner"
x={s.x - size}
y={s.y - size}
width={size * 2}
height={size * 2}
pointerEvents="none"
/>
))}
</>
);
})()}
{/* Auswahl-Rechteck (Marquee). Gestrichelt; die crossing-Variante (rechts→
links, berührend) hebt sich farblich/strichlich ab. Das Rechteck steht
bereits in viewBox-Einheiten → direkt setzbar. */}
@@ -2394,6 +2745,21 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
pointerEvents="none"
/>
)}
{/* Bezier-Hilfslinien (Anker → Griff), gestrichelt — VOR den Griff-
Quadraten gezeichnet, damit die Griffe oben liegen (anklickbar). */}
{gripsActive &&
gripHandleLines &&
gripHandleLines.map((l, i) => (
<line
key={`gripline-${i}`}
className="plan-grip-line"
x1={toScreen(l.a).x}
y1={toScreen(l.a).y}
x2={toScreen(l.b).x}
y2={toScreen(l.b).y}
pointerEvents="none"
/>
))}
{/* Editier-Griffe des selektierten Elements (Wand-Enden / 2D-Vertices):
ziehbare Quadrate. Bildschirmkonstante Größe über die meet-Skala. */}
{gripsActive &&
@@ -2476,6 +2842,36 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
hudFields={hudFields ?? null}
/>
)}
{/* Hover-Teilpunkte (s. hoverDivideSeg oben): kleine Punkte in Akzentfarbe
an den `hoverDivideCount`-gleichen Teilpunkten des gehoverten Segments
(2 = nur Mitte). Nur im Ruhezustand (kein Werkzeug aktiv). */}
{hoverDivideSeg && !toolActive && (() => {
const vbPerPx = 1 / (meetScale(view, svgRef.current) ?? 1);
const r = 3.5 * vbPerPx;
const n = Math.max(2, Math.round(hoverDivideCount));
const { a, b } = hoverDivideSeg;
const pts = Array.from({ length: n - 1 }, (_, i) => {
const t = (i + 1) / n;
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
});
return (
<g className="plan-hover-divide" pointerEvents="none">
{pts.map((pt, i) => {
const s = toScreen(pt);
return (
<circle
key={i}
cx={s.x}
cy={s.y}
r={r}
style={{ fill: snapColor, stroke: snapColor }}
vectorEffect="non-scaling-stroke"
/>
);
})}
</g>
);
})()}
</svg>
{/* Inline-Text-Editor DIREKT auf der Zeichenfläche (kein Dialog, Nutzer-
Feedback) — für ein frisch über `text`/`textbox` erzeugtes Element
+19 -4
View File
@@ -121,8 +121,9 @@ export function resolveWallSectionStyle(project: Project, wall: Wall): SectionCu
wallAngleDeg,
resolveForeground(best, wall.foreground, category, wall.foregroundSource),
);
// Poché-Hintergrund neutral (Papier) statt Bauteil-Albedo; Vollmuster → Tinte.
// `hatch.color` ist bereits über {@link resolveHatch} auf HATCH_INK gezwungen.
// Poché-Hintergrund neutral (Papier) statt Bauteil-Albedo; Vollmuster → Tinte
// (unabhängig von `hatch.color`, das jetzt den aufgelösten Vordergrund tragen
// kann, s. {@link resolveHatch}).
const solid = hatch.pattern === "solid";
return {
fill: solid ? HATCH_INK : HATCH_PAPER,
@@ -437,7 +438,13 @@ export function generatePlan(
const category = catByCode.get(column.categoryCode);
const ov = overrideFor(column.categoryCode, columnLabel(column));
const lwMm =
ov.lineweight ?? resolveStrokeWeight(undefined, undefined, category, lwByCode.get(column.categoryCode) ?? WALL_FALLBACK_MM);
ov.lineweight ??
resolveStrokeWeight(
column.strokeWeight,
column.strokeWeightSource,
category,
lwByCode.get(column.categoryCode) ?? WALL_FALLBACK_MM,
);
const effCol = ov.color !== undefined ? { ...column, color: ov.color } : column;
addColumnPoche(primitives, project, effCol, greyed, lwMm, category);
}
@@ -717,7 +724,15 @@ function computeBounds(
else if (g.shape === "circle" || g.shape === "arc") {
acc({ x: g.center.x - g.r, y: g.center.y - g.r });
acc({ x: g.center.x + g.r, y: g.center.y + g.r });
} else if (g.shape === "text") acc(g.at);
} else if (g.shape === "ellipse") {
acc({ x: g.center.x - g.rx, y: g.center.y - g.ry });
acc({ x: g.center.x + g.rx, y: g.center.y + g.ry });
} else if (g.shape === "bezier" || g.shape === "spline") {
g.pts.forEach(acc);
} else if (g.shape === "dimension") {
g.pts.forEach(acc);
} else if (g.shape === "kote") acc(g.at);
else if (g.shape === "text") acc(g.at);
else if (g.shape === "image") { acc(g.min); acc(g.max); }
}
// GESAMTE Kontext-Schicht (Konturen, importierte Gebäude/Terrain, Luftbild)
+233
View File
@@ -0,0 +1,233 @@
/**
* `addDrawing2D` — Rendering der neuen Freiform-Geometrien (ellipse/bezier/
* spline): tessellierte Linien-/Polygon-Primitive wie bei polyline/rect,
* kein eigenes SVG-Primitiv (s. `model/types.ts` Drawing2DGeom-Doku).
*/
import { describe, it, expect } from "vitest";
import { addDrawing2D } from "./drawing2d";
import type { Drawing2D, Project } from "../../model/types";
import type { Primitive } from "./shared";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
const lwByCode = new Map<string, number>();
const colorByCode = new Map<string, string>();
const catByCode = new Map<string, Project["layers"][number]>();
describe("addDrawing2D — ellipse", () => {
it("gibt eine gefüllte Fläche + geschlossenen Linienzug (64 Segmente) aus", () => {
const d: Drawing2D = {
id: "e1",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "ellipse", center: { x: 0, y: 0 }, rx: 4, ry: 2, rotation: 0 },
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
const polys = out.filter((p) => p.kind === "polygon");
const lines = out.filter((p) => p.kind === "line");
expect(polys.length).toBe(1);
expect(lines.length).toBe(64);
for (const l of lines) expect(l.drawingId).toBe("e1");
// Alle Punkte liegen auf der (unrotierten) Ellipse.
for (const l of lines) {
const dx = l.a.x / 4, dy = l.a.y / 2;
expect(dx * dx + dy * dy).toBeCloseTo(1, 3);
}
});
});
describe("addDrawing2D — bezier", () => {
it("ein Segment: tessellierte offene Linienkette, kein Füll-Polygon", () => {
const d: Drawing2D = {
id: "b1",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: {
shape: "bezier",
pts: [{ x: 0, y: 0 }, { x: 1, y: 3 }, { x: 3, y: 3 }, { x: 4, y: 0 }],
},
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
expect(out.every((p) => p.kind === "line")).toBe(true);
expect(out.length).toBe(24); // segsPerCurve Default
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
expect(lines[0].a).toEqual({ x: 0, y: 0 });
const last = lines[lines.length - 1];
expect(last.b.x).toBeCloseTo(4, 6);
expect(last.b.y).toBeCloseTo(0, 6);
});
});
describe("addDrawing2D — spline", () => {
it("offen: tessellierte Linienkette durch die Wegpunkte, kein Füll-Polygon", () => {
const d: Drawing2D = {
id: "s1",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "spline", pts: [{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 5, y: 1 }], closed: false },
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
expect(out.some((p) => p.kind === "polygon")).toBe(false);
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
expect(lines[0].a).toEqual({ x: 0, y: 0 });
const last = lines[lines.length - 1];
expect(last.b.x).toBeCloseTo(5, 6);
expect(last.b.y).toBeCloseTo(1, 6);
});
it("geschlossen: Füll-Polygon + Linienring schliesst zum ersten Punkt", () => {
const d: Drawing2D = {
id: "s2",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: {
shape: "spline",
pts: [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }, { x: 0, y: 4 }],
closed: true,
},
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
expect(out.filter((p) => p.kind === "polygon").length).toBe(1);
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
const last = lines[lines.length - 1];
expect(last.b).toEqual(lines[0].a);
});
});
describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => {
it("gibt 2 Masshilfslinien + 1 Masslinie (dünn) + 2 Massstriche (doppelte Stärke) + 1 Masszahl-Text aus", () => {
const d: Drawing2D = {
id: "dim1",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "dimension", pts: [{ x: 0, y: 0 }, { x: 4, y: 0 }], offset: 1 },
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
const lines = out.filter((p): p is Extract<Primitive, { kind: "line" }> => p.kind === "line");
const texts = out.filter((p) => p.kind === "drawingText");
expect(lines.length).toBe(5); // 2 Masshilfslinien + 1 Masslinie + 2 Massstriche
expect(texts.length).toBe(1);
const weights = lines.map((l) => l.weightMm).sort((a, b) => a - b);
const thin = weights[0];
const thick = weights[weights.length - 1];
// 3× dünn (Masshilfslinien + Masslinie), 2× doppelt so dick (Massstriche).
expect(weights.filter((w) => w === thin).length).toBe(3);
expect(weights.filter((w) => w === thick).length).toBe(2);
expect(thick).toBeCloseTo(thin * 2, 6);
});
it("Masszahl entspricht dist(a,b), 2 Dezimalstellen (SIA 400 Figur 13/14: Punkt-Notation)", () => {
const d: Drawing2D = {
id: "dim2",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "dimension", pts: [{ x: 0, y: 0 }, { x: 3, y: 4 }], offset: 0.5 }, // 3-4-5-Dreieck → 5m
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
const text = out.find((p) => p.kind === "drawingText");
expect(text).toBeDefined();
if (text?.kind === "drawingText") expect(text.text).toBe("5.00");
});
it("entartete Strecke (a===b) erzeugt keine Primitive", () => {
const d: Drawing2D = {
id: "dim3",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "dimension", pts: [{ x: 2, y: 2 }, { x: 2, y: 2 }], offset: 1 },
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
expect(out.length).toBe(0);
});
});
describe("addDrawing2D — kote (SIA 400 B.5.4)", () => {
it("OK fertig: hohles Dreieck (Spitze nach unten) + formatierte Zahl mit +", () => {
const d: Drawing2D = {
id: "k1",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "kote", at: { x: 0, y: 0 }, value: 3.25, kind: "okFertig", rotation: 0 },
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
const poly = out.find((p) => p.kind === "polygon");
const text = out.find((p) => p.kind === "drawingText");
expect(poly).toBeDefined();
expect(text).toBeDefined();
if (poly?.kind === "polygon") {
expect(poly.fill).toBe("none"); // fertig = hohl
expect(poly.pts[0]).toEqual({ x: 0, y: 0 }); // Spitze = at
expect(poly.pts[1].y).toBeGreaterThan(0); // OK: Basis oberhalb der Spitze
}
if (text?.kind === "drawingText") expect(text.text).toBe("+3.25");
});
it("UK roh: gefülltes Dreieck (Spitze nach oben), Basis unterhalb", () => {
const d: Drawing2D = {
id: "k2",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "kote", at: { x: 0, y: 0 }, value: -0.1, kind: "ukRoh", rotation: 0 },
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
const poly = out.find((p) => p.kind === "polygon");
const text = out.find((p) => p.kind === "drawingText");
if (poly?.kind === "polygon") {
expect(poly.fill).not.toBe("none"); // roh = gefüllt
expect(poly.pts[1].y).toBeLessThan(0); // UK: Basis unterhalb der Spitze
}
if (text?.kind === "drawingText") expect(text.text).toBe("-0.10");
});
it("Wert 0 wird als ±0.00 formatiert", () => {
const d: Drawing2D = {
id: "k3",
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "kote", at: { x: 0, y: 0 }, value: 0, kind: "okFertig", rotation: 0 },
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
const text = out.find((p) => p.kind === "drawingText");
if (text?.kind === "drawingText") expect(text.text).toBe("±0.00");
});
});
+160
View File
@@ -13,6 +13,105 @@ import {
resolveStrokeWeight,
type Primitive,
} from "./shared";
import { ellipsePts, sampleBezierChain, sampleCatmullRom } from "../../tools/curves";
// ── Bemassung (SIA 400 B.5.3) ────────────────────────────────────────────────
// Modell-Meter-Konstanten für rein grafische (nicht reale) Bemassungs-Details
// — analog zu DEFAULT_TEXT_HEIGHT_M (text.ts): fest gewählt, damit sie bei der
// üblichen Plan-Massstäben (1:50…1:100) lesbar wirken, nicht dynamisch an den
// Massstab gekoppelt (wie auch die übrige Textgrösse im Plan).
const DIM_GAP_M = 0.05; // Abstand Masshilfslinie → bemessener Punkt
const DIM_OVERSHOOT_M = 0.05; // Überstand der Masshilfslinie über die Masslinie hinaus
const DIM_TICK_LEN_M = 0.15; // Länge des Massstrichs (Diagonale an den Masslinien-Enden)
const DIM_TEXT_HEIGHT_M = 0.2; // Schrifthöhe der Masszahl
/** Formatiert eine Länge (Meter) als Masszahl: 2 Dezimalstellen (SIA 400 B.5.2/B.5.4, „3.25"/„518.60"). */
function formatDimLabel(m: number): string {
return m.toFixed(2);
}
/**
* Ein einzelnes Mass-Segment (Masshilfslinien + Masslinie + Massstriche +
* Masszahl) zwischen `a` und `b` bei gegebenem `offset` — s. `formatDimLabel`.
* Extrahiert, damit eine Masskette (`shape:"dimension"`, `pts.length` ≥ 2)
* je aufeinanderfolgendem Punktpaar dasselbe Rendering wiederverwenden kann.
*/
function addDimensionSegment(
out: Primitive[],
a: Vec2,
b: Vec2,
offset: number,
weightMm: number,
dash: number[] | null,
color: string,
greyed: boolean,
drawingId: string,
): void {
const dx = b.x - a.x, dy = b.y - a.y;
const lineLen = Math.hypot(dx, dy);
if (lineLen < 1e-6) return;
const ux = dx / lineLen, uy = dy / lineLen; // Einheitsrichtung a→b
const nx = -uy, ny = ux; // Links-Normale (Einheit) — Richtung des Offsets
const sign = offset >= 0 ? 1 : -1;
const dimA: Vec2 = { x: a.x + nx * offset, y: a.y + ny * offset };
const dimB: Vec2 = { x: b.x + nx * offset, y: b.y + ny * offset };
const extLine = (from: Vec2, to: Vec2) =>
out.push({ kind: "line", a: from, b: to, cls: "draw2d", weightMm, dash, color, greyed, drawingId });
extLine(
{ x: a.x + nx * DIM_GAP_M * sign, y: a.y + ny * DIM_GAP_M * sign },
{ x: dimA.x + nx * DIM_OVERSHOOT_M * sign, y: dimA.y + ny * DIM_OVERSHOOT_M * sign },
);
extLine(
{ x: b.x + nx * DIM_GAP_M * sign, y: b.y + ny * DIM_GAP_M * sign },
{ x: dimB.x + nx * DIM_OVERSHOOT_M * sign, y: dimB.y + ny * DIM_OVERSHOOT_M * sign },
);
extLine(dimA, dimB);
// Massstriche: 45°-Diagonale (Winkelhalbierende von Richtung/Normale), an
// beiden Enden PARALLEL (nicht gespiegelt) — wie in SIA 400 Figur 13.
const tx = (ux + nx) / Math.SQRT2, ty = (uy + ny) / Math.SQRT2;
const half = DIM_TICK_LEN_M / 2;
const tick = (pt: Vec2) =>
out.push({
kind: "line",
a: { x: pt.x - tx * half, y: pt.y - ty * half },
b: { x: pt.x + tx * half, y: pt.y + ty * half },
cls: "draw2d",
weightMm: weightMm * 2,
dash: null,
color,
greyed,
drawingId,
});
tick(dimA);
tick(dimB);
// Masszahl: Mitte der Masslinie, leicht abgesetzt, nie kopfüber lesbar.
let angle = Math.atan2(dy, dx);
if (Math.cos(angle) < 0) angle += Math.PI;
const upx = -Math.sin(angle), upy = Math.cos(angle); // Links-Normale der ENDGÜLTIGEN Richtung
const mid: Vec2 = { x: (dimA.x + dimB.x) / 2, y: (dimA.y + dimB.y) / 2 };
out.push({
kind: "drawingText",
at: { x: mid.x + upx * DIM_TEXT_HEIGHT_M * 0.6, y: mid.y + upy * DIM_TEXT_HEIGHT_M * 0.6 },
text: formatDimLabel(lineLen),
heightM: DIM_TEXT_HEIGHT_M,
angle,
color,
greyed,
drawingId,
});
}
// ── Höhenkote (SIA 400 B.5.4) ────────────────────────────────────────────────
const KOTE_TRI_SIZE_M = 0.15; // Dreieck-Grösse (Spitze bis Basis)
const KOTE_TRI_HALF_W_M = KOTE_TRI_SIZE_M * 0.6;
const KOTE_TEXT_HEIGHT_M = 0.2;
const KOTE_TEXT_GAP_M = 0.08; // Abstand Dreieck-Basis → Zahl
/** Formatiert einen Höhenwert (SIA 400 B.5.4, Figur 18): „+3.25" · „±0.00" · „-0.10". */
function formatKoteLabel(value: number): string {
if (Math.abs(value) < 1e-6) return "±0.00";
return (value > 0 ? "+" : "") + value.toFixed(2);
}
export function addDrawing2D(
out: Primitive[],
@@ -135,6 +234,67 @@ export function addDrawing2D(
drawingId: d.id,
});
break;
case "ellipse": {
// Tessellierte Kontur (wie polyline/rect) statt eigenem SVG-Primitiv —
// konsistent mit dem übrigen 2D-Zeichnen-Werkzeugsatz, keine eigene
// Grip-/Snap-/Export-Sonderbehandlung nötig.
const pts = ellipsePts(g.center, g.rx, g.ry, g.rotation);
fillPoly(pts);
for (let i = 0; i < pts.length; i++) seg(pts[i], pts[(i + 1) % pts.length]);
break;
}
case "bezier": {
const pts = sampleBezierChain(g.pts);
for (let i = 0; i < pts.length - 1; i++) seg(pts[i], pts[i + 1]);
break;
}
case "spline": {
const pts = sampleCatmullRom(g.pts, g.closed && g.pts.length > 2);
if (g.closed && g.pts.length > 2) fillPoly(pts);
for (let i = 0; i < pts.length - 1; i++) seg(pts[i], pts[i + 1]);
if (g.closed && g.pts.length > 2) seg(pts[pts.length - 1], pts[0]);
break;
}
case "dimension": {
// SIA 400 B.5.3, Figur 14 (Reihenbemassung): jedes aufeinanderfolgende
// Punktpaar der Kette ist ein eigenes Mass-Segment, alle teilen `offset`.
for (let i = 0; i < g.pts.length - 1; i++) {
addDimensionSegment(out, g.pts[i], g.pts[i + 1], g.offset, weightMm, dash, color, greyed, d.id);
}
break;
}
case "kote": {
// SIA 400 B.5.4/Figur 18: OK = Spitze nach unten, UK = Spitze nach oben;
// fertig = Kontur (hohl), roh = voll gefüllt. `at` ist die Spitze.
const up = g.kind === "okFertig" || g.kind === "okRoh" ? 1 : -1;
const filled = g.kind === "okRoh" || g.kind === "ukRoh";
const pts: Vec2[] = [
g.at,
{ x: g.at.x - KOTE_TRI_HALF_W_M, y: g.at.y + up * KOTE_TRI_SIZE_M },
{ x: g.at.x + KOTE_TRI_HALF_W_M, y: g.at.y + up * KOTE_TRI_SIZE_M },
];
out.push({
kind: "polygon",
pts,
fill: filled ? color : "none",
stroke: color,
strokeWidthMm: weightMm,
hatch: NO_HATCH,
greyed,
drawingId: d.id,
});
out.push({
kind: "drawingText",
at: { x: g.at.x + KOTE_TRI_HALF_W_M + KOTE_TEXT_GAP_M, y: g.at.y + up * KOTE_TRI_SIZE_M * 0.5 },
text: formatKoteLabel(g.value),
heightM: KOTE_TEXT_HEIGHT_M,
angle: 0,
color,
greyed,
drawingId: d.id,
});
break;
}
case "text":
out.push({
kind: "drawingText",
@@ -0,0 +1,90 @@
/**
* `resolveForeground`/`resolveBackground`/`resolveHatchId`/`resolveStrokeWeight`
* — By-Layer/By-Object-Auflösung. Der Resolver selbst bleibt „Nach Bauteil"
* (Component) als Default, wenn `source` fehlt/`"object"` ist — das liefert
* bei Wand/Decke die neutrale SIA-Poché-Konvention (weisser Grund, schwarzer
* Vordergrund), NICHT die rohe Ebenenfarbe (Nutzer-Bestätigung: „eine Wand
* usw soll weiterhin nach Bauteil haben"). Ein Drawing2D hat kein eigenes
* Bauteil und bietet „Nach Bauteil" im Attribut-Panel gar nicht an — DORT
* mappt der Aufrufer (`selectionInfo.ts`) ein fehlendes Source-Feld explizit
* auf `"layer"`, bevor er diese Resolver aufruft (separat unten geprüft).
*/
import { describe, it, expect } from "vitest";
import {
resolveForeground,
resolveBackground,
resolveHatchId,
resolveStrokeWeight,
} from "./shared";
import type { Component, LayerCategory } from "../../model/types";
const comp: Component = {
id: "c1",
name: "Beton",
color: "#888888",
foreground: "#111111",
background: "#222222",
hatchId: "sia-concrete",
joinPriority: 10,
};
const category: LayerCategory = {
code: "20",
name: "Wände",
color: "#0a0a0a",
lw: 0.5,
visible: true,
locked: false,
hatch: "sia-wall",
};
describe("resolveForeground/resolveBackground — Default bleibt 'Nach Bauteil'", () => {
it("kein Override, Source fehlt: liefert weiterhin die Bauteilfarbe (Default unverändert)", () => {
expect(resolveForeground(comp, undefined, category, undefined)).toBe(comp.foreground);
expect(resolveBackground(comp, undefined, category, undefined)).toBe(comp.background);
});
it("kein Override, Source explizit 'object': unverändert die Bauteilfarbe", () => {
expect(resolveForeground(comp, undefined, category, "object")).toBe(comp.foreground);
expect(resolveBackground(comp, undefined, category, "object")).toBe(comp.background);
});
it("kein Override, Source explizit 'layer': liefert die Kategorie-Farbe", () => {
expect(resolveForeground(comp, undefined, category, "layer")).toBe(category.color);
expect(resolveBackground(comp, undefined, category, "layer")).toBe(category.color);
});
it("ein gesetzter Override gewinnt immer, unabhängig von der Source", () => {
expect(resolveForeground(comp, "#ff00ff", category, "layer")).toBe("#ff00ff");
expect(resolveForeground(comp, "#ff00ff", category, undefined)).toBe("#ff00ff");
});
});
describe("resolveHatchId — Default bleibt 'Nach Bauteil'", () => {
it("kein Override, Source fehlt: liefert den Fallback (Bauteil-Schraffur)", () => {
expect(resolveHatchId(undefined, undefined, category, comp.hatchId)).toBe(comp.hatchId);
});
it("kein Override, Source explizit 'layer': liefert die Kategorie-Schraffur", () => {
expect(resolveHatchId(undefined, "layer", category, comp.hatchId)).toBe(category.hatch);
});
});
describe("resolveStrokeWeight — Default bleibt 'Nach Bauteil'", () => {
it("kein Override, Source fehlt: liefert den Fallback", () => {
expect(resolveStrokeWeight(undefined, undefined, category, 0.13)).toBe(0.13);
});
it("kein Override, Source explizit 'layer': liefert die Kategorie-Strichstärke", () => {
expect(resolveStrokeWeight(undefined, "layer", category, 0.13)).toBe(category.lw);
});
});
describe("Drawing2D-Panel-Vorschau (selectionInfo.ts) — mappt fehlende Source explizit auf 'layer'", () => {
it("simuliert das Aufrufer-Mapping `d.foregroundSource ?? \"layer\"`: liefert die Kategorie-Farbe für ein Drawing2D ohne Bauteil", () => {
const noComponent: Component = { id: "", name: "", color: "#000", hatchId: "", joinPriority: 0 };
const drawingSource: "layer" | undefined = undefined; // wie ein frisches Drawing2D
expect(resolveForeground(noComponent, undefined, category, drawingSource ?? "layer")).toBe(
category.color,
);
});
});
+25 -19
View File
@@ -369,20 +369,20 @@ export type Primitive =
* {@link HatchRender} auf. Der Linienstil der Musterlinien wird, falls gesetzt,
* aus dem Line Manager nachgeschlagen; sonst gilt ein dünner Default.
*
* VORDERGRUND (Muster-/Schraffurlinienfarbe bzw. Vollfüllung): AKTUELL fest auf
* {@link HATCH_INK} gezwungen (Nutzer-Vorgabe 2026-07-04) — Schnitt- WIE
* Oberflächen-/Ansichtsschraffur zeigen dieselbe dunkle Tinte, unabhängig von
* Material-/Bauteilfarbe, HatchStyle.color oder einem By-Layer/By-Object-
* Vordergrund-Override. `_foreground` bleibt Teil der Signatur — alle Aufrufer
* reichen weiterhin den über {@link resolveForeground} aufgelösten Bauteil-
* Vordergrund durch (z. B. für eine spätere Wiederherstellung der materialtreuen
* Schraffur) —, fließt aber bewusst nicht mehr in `color` ein.
* VORDERGRUND (Muster-/Schraffurlinienfarbe bzw. Vollfüllung): der über
* {@link resolveForeground} aufgelöste Vordergrund gewinnt, wenn gesetzt (Nach
* Ebene/Nach Bauteil/eigener Wert); OHNE aufgelösten Wert bleibt es bei der
* neutralen Tinte {@link HATCH_INK} (bisheriges Verhalten — 2026-07-04 war die
* Vordergrund-Kette hier noch fest auf HATCH_INK gezwungen, unabhängig vom
* Ergebnis; auf Nutzer-Wunsch wieder aufgehoben, da „Vordergrund" im Attribut-
* Panel sonst wirkungslos bliebe — betrifft nur Elemente mit tatsächlich
* gesetztem Vordergrund, unverändertes Verhalten für alle anderen).
*/
export function resolveHatch(
project: Project,
hatchId: string,
wallAngleDeg?: number,
_foreground?: string,
foreground?: string,
): HatchRender {
const h = getHatch(project, hatchId);
const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
@@ -399,9 +399,8 @@ export function resolveHatch(
pattern: h.pattern,
scale: h.scale,
angle,
// Fest auf die Monochrom-Tinte gezwungen (siehe JSDoc oben) — weder
// `_foreground` noch `HatchStyle.color` (Material-/Bauteilfarbe) fließen ein.
color: HATCH_INK,
// Aufgelöster Vordergrund gewinnt (s. JSDoc oben), sonst neutrale Tinte.
color: foreground ?? HATCH_INK,
lineWeight: ls?.weight ?? 0.13,
dash: ls?.dash ?? null,
kind: h.kind,
@@ -421,10 +420,13 @@ export function resolveHatch(
* (By-Layer/By-Object, siehe `docs`/Attribut-Panel):
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ??
* (`Component.foreground`) ?? `undefined`.
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
* `undefined` als Endergebnis heißt „Nach System" — die Kette läuft dann in
* resolveHatch auf den HatchStyle.color-Backward-Compat-Fallback weiter
* (Sample-Verhalten: kein Element setzt `override`/`source`, also identisch).
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" — bei WAND/DECKE bewusst der
* DEFAULT (die Bauteil-Kette liefert die neutrale SIA-Poché, s.
* {@link resolveBackground}). Ein Drawing2D hat KEIN eigenes Bauteil und
* bietet „Nach Bauteil" im Attribut-Panel gar nicht erst an — dort wird ein
* fehlendes Source-Feld vom AUFRUFER (`selectionInfo.ts`) explizit auf
* `"layer"` gemappt, bevor er hier ankommt (dieser Resolver selbst bleibt
* Bauteil-Default, unabhängig von der Elementart).
*/
export function resolveForeground(
comp: Component,
@@ -446,7 +448,9 @@ export function resolveForeground(
* dann auf ihren eigenen Default zurück (bei Wand/Decke die neutrale SIA-
* Poché-Farbe HATCH_INK/HATCH_PAPER, NICHT `Component.color` — die reale
* Bauteilfarbe soll die Schnitt-Konvention nicht überschreiben, nur ein
* bewusster Override).
* bewusster Override). Genau DIESE neutrale Poché ist bei Wand/Decke der
* gewünschte Default („weisser Grund, schwarzer Vordergrund") — deshalb
* bleibt „Nach Bauteil" dort der Default, nicht „Nach Ebene".
*/
export function resolveBackground(
comp: Component,
@@ -466,7 +470,8 @@ export function resolveBackground(
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.hatch`) ??
* `fallback` (Bauteil-Schraffur, z. B. `Component.hatchId`/`viewHatchId`, oder
* `undefined` bei Drawing2D ohne Bauteil-Bezug).
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (DEFAULT bei Wand/Decke, s.
* {@link resolveForeground}).
* Ergebnis kann `undefined` sein — der Aufrufer fällt dann auf `NO_HATCH` zurück.
*/
export function resolveHatchId(
@@ -486,7 +491,8 @@ export function resolveHatchId(
* (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.lw`) ??
* `fallback` (bisherige Bauteil-/LineStyle-/Kategorie-Default-Kette des
* Aufrufers — heutiges Verhalten).
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert).
* `source` fehlt/`"object"` ⇒ „Nach Bauteil" (DEFAULT bei Wand/Decke, s.
* {@link resolveForeground}).
*/
export function resolveStrokeWeight(
override: number | undefined,
@@ -0,0 +1,131 @@
/**
* `addColumnPoche` — By-Layer/By-Object-Override für Stützen (Nutzer-Wunsch:
* "generell sollten alle BIM Teile ... die weiteren Attribute haben" — Stütze
* hat bereits eine Poché UND nutzt dieselben Resolver wie Wand/Decke, daher
* hier zuerst fertig verdrahtet). Regressionsschutz: die neuen
* foreground/background/hatchId/strokeWeight(+Source)-Felder auf `Column`
* müssen tatsächlich bis in die gerenderte Poché durchschlagen.
*/
import { describe, it, expect } from "vitest";
import { addColumnPoche } from "./structures";
import type { Column, Component, LayerCategory, Project } from "../../model/types";
import type { Primitive } from "./shared";
const comp: Component = {
id: "beton",
name: "Beton",
color: "#888888",
hatchId: "sia-concrete",
joinPriority: 10,
};
const category: LayerCategory = {
code: "50",
name: "Tragwerk",
color: "#333333",
lw: 0.35,
visible: true,
locked: false,
};
function project(overrides: Partial<Project> = {}): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [
{ id: "sia-concrete", name: "Beton", pattern: "solid" },
{ id: "sia-other", name: "Sonstiges", pattern: "none" },
] as unknown as Project["hatches"],
components: [comp],
wallTypes: [],
drawingLevels: [],
layers: [category],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
columns: [],
...overrides,
};
}
function column(overrides: Partial<Column> = {}): Column {
return {
id: "col1",
type: "column",
floorId: "eg",
categoryCode: "50",
position: { x: 0, y: 0 },
profile: { kind: "rect", width: 0.3, depth: 0.3 },
rotation: 0,
height: 2.6,
componentId: "beton",
...overrides,
};
}
function polygonOf(out: Primitive[]): Extract<Primitive, { kind: "polygon" }> {
const p = out.find((x) => x.kind === "polygon");
if (!p || p.kind !== "polygon") throw new Error("kein Poché-Polygon gefunden");
return p;
}
describe("addColumnPoche — Attribut-Override greift", () => {
it("ohne Override: Bauteil-Schraffur/-Farbe bestimmen die Poché (Default 'Nach Bauteil')", () => {
const out: Primitive[] = [];
addColumnPoche(out, project(), column(), false, 0.35, category);
const poly = polygonOf(out);
expect(poly.hatch.pattern).toBe("solid");
});
it("mit explizitem background-Override: die Poché-Füllung wird die Override-Farbe", () => {
const out: Primitive[] = [];
addColumnPoche(out, project(), column({ background: "#ff00ff" }), false, 0.35, category);
expect(polygonOf(out).fill).toBe("#ff00ff");
});
it("mit hatchSource:'layer': die Kategorie-Schraffur wird verwendet, nicht die Bauteil-Schraffur", () => {
const out: Primitive[] = [];
const catWithHatch: LayerCategory = { ...category, hatch: "sia-other" };
addColumnPoche(
out,
project({ layers: [catWithHatch] }),
column({ hatchSource: "layer" }),
false,
0.35,
catWithHatch,
);
expect(polygonOf(out).hatch.pattern).toBe("none");
});
it("mit explizitem hatchId-Override: gewinnt gegenüber Bauteil UND Kategorie", () => {
const out: Primitive[] = [];
addColumnPoche(
out,
project(),
column({ hatchId: "sia-other" }),
false,
0.35,
category,
);
expect(polygonOf(out).hatch.pattern).toBe("none");
});
it("ohne gesetzten Vordergrund: Schraffurfarbe bleibt die neutrale Tinte (Default unverändert)", () => {
const out: Primitive[] = [];
addColumnPoche(out, project(), column(), false, 0.35, category);
expect(polygonOf(out).hatch.color).toBe("#0f0f0f");
});
it("mit explizitem foreground-Override: die Schraffurfarbe wird die Override-Farbe (vorher wirkungslos)", () => {
const out: Primitive[] = [];
addColumnPoche(out, project(), column({ foreground: "#00ff88" }), false, 0.35, category);
expect(polygonOf(out).hatch.color).toBe("#00ff88");
});
});
+8 -3
View File
@@ -12,6 +12,7 @@ import {
MONO_INK,
NO_HATCH,
SYMBOL_HAIRLINE_MM,
resolveBackground,
resolveForeground,
resolveHatch,
resolveHatchId,
@@ -67,15 +68,19 @@ export function addColumnPoche(
try {
const comp = getComponent(project, column.componentId);
const hatchId =
resolveHatchId(undefined, undefined, category, comp.hatchId) ?? comp.hatchId;
resolveHatchId(column.hatchId, column.hatchSource, category, comp.hatchId) ?? comp.hatchId;
const resolved = resolveHatch(
project,
hatchId,
undefined,
resolveForeground(comp, undefined, category, undefined),
resolveForeground(comp, column.foreground, category, column.foregroundSource),
);
hatch = resolved;
fill = resolved.pattern === "solid" ? HATCH_INK : HATCH_PAPER;
// SIA-Poché: neutraler Hintergrund (Tinte/Papier je nach Muster) statt
// Bauteil-Albedo, es sei denn ein expliziter Hintergrund-Override greift
// (analog addWallPoche: bgOverride ?? neutrale Poché).
const bgOverride = resolveBackground(comp, column.background, category, column.backgroundSource);
fill = bgOverride ?? (resolved.pattern === "solid" ? HATCH_INK : HATCH_PAPER);
} catch {
// Bauteil nicht auflösbar → neutrale Poché (Fallback bleibt bestehen).
}
+39 -7
View File
@@ -222,24 +222,56 @@ export function marqueeHit(
rect: ModelRect,
crossing: boolean,
): string[] {
// Polygone je Wand sammeln (nur Primitive mit Wandbezug zählen für die Auswahl).
const byWall = new Map<string, Vec2[][]>();
return marqueeHitByField(prims, "wallId", rect, crossing);
}
/**
* Generische Variante von {@link marqueeHit} für ein beliebiges Polygon-ID-
* Feld (`ceilingId`/`roomId`/`stairId`/`columnId`/`roofId`/…) — dieselbe
* window/crossing-Konvention, nur auf ein anderes Element bezogen. Deckt
* Nutzer-Report: „Auswahlrahmen kann Decken/Räume/Treppen nicht anwählen"
* (bisher nur Wände über `marqueeHit`, alles andere fehlte komplett).
*/
function marqueeHitByField(
prims: Primitive[],
field: "wallId" | "ceilingId" | "roomId" | "stairId" | "columnId" | "roofId",
rect: ModelRect,
crossing: boolean,
): string[] {
const byId = new Map<string, Vec2[][]>();
for (const p of prims) {
if (p.kind !== "polygon" || p.wallId == null) continue;
const list = byWall.get(p.wallId);
if (p.kind !== "polygon") continue;
const id = p[field];
if (id == null) continue;
const list = byId.get(id);
if (list) list.push(p.pts);
else byWall.set(p.wallId, [p.pts]);
else byId.set(id, [p.pts]);
}
const out: string[] = [];
for (const [wallId, polys] of byWall) {
for (const [id, polys] of byId) {
const hit = crossing
? polys.some((poly) => polyTouchesRect(poly, rect))
: polys.every((poly) => polyEnclosedByRect(poly, rect));
if (hit) out.push(wallId);
if (hit) out.push(id);
}
return out;
}
/** Vom Auswahl-Rechteck getroffene Decken-IDs (gleiche Konvention wie {@link marqueeHit}). */
export function marqueeHitCeilings(prims: Primitive[], rect: ModelRect, crossing: boolean): string[] {
return marqueeHitByField(prims, "ceilingId", rect, crossing);
}
/** Vom Auswahl-Rechteck getroffene Raum-IDs (gleiche Konvention wie {@link marqueeHit}). */
export function marqueeHitRooms(prims: Primitive[], rect: ModelRect, crossing: boolean): string[] {
return marqueeHitByField(prims, "roomId", rect, crossing);
}
/** Vom Auswahl-Rechteck getroffene Treppen-IDs (gleiche Konvention wie {@link marqueeHit}). */
export function marqueeHitStairs(prims: Primitive[], rect: ModelRect, crossing: boolean): string[] {
return marqueeHitByField(prims, "stairId", rect, crossing);
}
/**
* Vom Auswahl-Rechteck getroffene 2D-Zeichenelement-IDs (gleiche window/crossing-
* Konvention wie {@link marqueeHit}). Sammelt je Drawing2D ALLE seine Punkte aus
+68
View File
@@ -0,0 +1,68 @@
/**
* `marqueeHitCeilings`/`marqueeHitRooms`/`marqueeHitStairs` — Nutzer-Report:
* "Auswahlrahmen kann nicht alle Elemente anwählen, z.B. Decken, Räume und
* Treppen nicht". Vorher las das Auswahl-Rechteck nur `wallId`/`drawingId`
* aus den Polygon-Primitiven; die anderen ID-Felder (die dieselben
* Primitive längst tragen) wurden ignoriert.
*/
import { describe, it, expect } from "vitest";
import { marqueeHit, marqueeHitCeilings, marqueeHitRooms, marqueeHitStairs } from "./geometry";
import type { Primitive } from "../generatePlan";
const NO_HATCH = { pattern: "none" as const, color: "#000" };
function poly(id: Partial<Record<"wallId" | "ceilingId" | "roomId" | "stairId", string>>, pts: { x: number; y: number }[]): Primitive {
return {
kind: "polygon",
pts,
fill: "none",
stroke: "#000",
strokeWidthMm: 0.18,
hatch: NO_HATCH,
...id,
} as Primitive;
}
const square = (x0: number, y0: number, x1: number, y1: number) => [
{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 },
];
describe("Marquee — Decken/Räume/Treppen", () => {
it("marqueeHitCeilings findet eine vollständig eingeschlossene Decke (window)", () => {
const prims = [poly({ ceilingId: "c1" }, square(1, 1, 3, 3))];
const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 };
expect(marqueeHitCeilings(prims, rect, false)).toEqual(["c1"]);
});
it("marqueeHitRooms ignoriert einen Raum, der nur teilweise im Fenster liegt (window, kein crossing)", () => {
const prims = [poly({ roomId: "r1" }, square(1, 1, 10, 10))];
const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 };
expect(marqueeHitRooms(prims, rect, false)).toEqual([]);
// Dieselbe Geometrie im Crossing-Modus (rechts→links) trifft trotzdem.
expect(marqueeHitRooms(prims, rect, true)).toEqual(["r1"]);
});
it("marqueeHitStairs findet eine Treppe über mehrere Tritt-Polygone (alle müssen im Fenster liegen)", () => {
const prims = [
poly({ stairId: "s1" }, square(1, 1, 2, 2)),
poly({ stairId: "s1" }, square(2, 1, 3, 2)),
];
const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 };
expect(marqueeHitStairs(prims, rect, false)).toEqual(["s1"]);
});
it("Wände (marqueeHit) und Decken/Räume/Treppen sind unabhängig voneinander auswertbar", () => {
const prims = [
poly({ wallId: "w1" }, square(0, 0, 1, 1)),
poly({ ceilingId: "c1" }, square(1, 1, 2, 2)),
poly({ roomId: "r1" }, square(2, 2, 3, 3)),
poly({ stairId: "s1" }, square(3, 3, 4, 4)),
];
const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 };
expect(marqueeHit(prims, rect, false)).toEqual(["w1"]);
expect(marqueeHitCeilings(prims, rect, false)).toEqual(["c1"]);
expect(marqueeHitRooms(prims, rect, false)).toEqual(["r1"]);
expect(marqueeHitStairs(prims, rect, false)).toEqual(["s1"]);
});
});
+11
View File
@@ -356,6 +356,17 @@ function SnapMarker({
vectorEffect="non-scaling-stroke"
/>
)}
{/* Teilpunkt (nur fangbar nach Verweilen, s. computeSnap-Dwell) — kleine
gefüllte Raute, bewusst anders als Mittelpunkt (Linie) und Raster
(Kreis), damit „warum snappt es hier" nachvollziehbar bleibt. */}
{snap.kind === "divide" && (
<polygon
className="snap-glyph snap-fill"
points={`${s.x},${s.y - r} ${s.x + r},${s.y} ${s.x},${s.y + r} ${s.x - r},${s.y}`}
style={fillStyle}
vectorEffect="non-scaling-stroke"
/>
)}
{snap.kind === "intersection" && (
<>
<line className="snap-glyph" x1={s.x - r} y1={s.y - r} x2={s.x + r} y2={s.y + r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
+249
View File
@@ -0,0 +1,249 @@
/**
* `groupActions.ts` — reine Gruppen-Logik (Mitgliedschaft/Verschachtelung/
* Erzeugen/Auflösen), Vectorworks-artig: Cmd+G fasst die Auswahl zusammen,
* Doppelklick betritt eine Gruppe (Isolation), alles ausserhalb wird
* gedimmt/gesperrt.
*/
import { describe, it, expect } from "vitest";
import {
addNewElementsToActiveGroup,
applyGroupDimming,
containingGroupId,
expandToGroups,
groupSelection,
isInsideActiveGroup,
topLevelGroups,
transitiveMembership,
ungroup,
} from "./groupActions";
import type { Primitive } from "../plan/generatePlan/shared";
import type { Group, Project } from "../model/types";
const EMPTY_MEM = {
wallIds: [] as string[],
drawingIds: [] as string[],
ceilingIds: [] as string[],
roomIds: [] as string[],
stairIds: [] as string[],
columnIds: [] as string[],
roofIds: [] as string[],
openingIds: [] as string[],
};
function project(groups: Group[]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [],
layers: [],
walls: [],
doors: [],
drawings2d: [],
groups,
};
}
describe("groupSelection", () => {
it("erzeugt eine Gruppe aus ≥2 gewählten Elementen", () => {
const p = project([]);
const res = groupSelection(p, { ...EMPTY_MEM, wallIds: ["w1", "w2"] }, []);
expect(res).not.toBeNull();
expect(res!.project.groups).toHaveLength(1);
expect(res!.project.groups![0]).toMatchObject({ wallIds: ["w1", "w2"], groupIds: [] });
});
it("weniger als 2 Elemente gruppiert nicht (Vectorworks-Konvention)", () => {
const p = project([]);
expect(groupSelection(p, { ...EMPTY_MEM, wallIds: ["w1"] }, [])).toBeNull();
expect(groupSelection(p, EMPTY_MEM, [])).toBeNull();
});
it("bereits gewählte Gruppen werden als Untergruppen verschachtelt, nicht vermischt", () => {
const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
const p = project([inner]);
const res = groupSelection(p, { ...EMPTY_MEM, wallIds: ["w2"] }, ["g1"]);
expect(res).not.toBeNull();
const outer = res!.project.groups!.find((g) => g.id === res!.groupId)!;
expect(outer.wallIds).toEqual(["w2"]);
expect(outer.groupIds).toEqual(["g1"]);
});
});
describe("transitiveMembership", () => {
it("löst verschachtelte Gruppen rekursiv auf", () => {
const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: ["d1"], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
const outer: Group = { id: "g2", wallIds: ["w2"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] };
const p = project([inner, outer]);
const mem = transitiveMembership(p, "g2");
expect(mem.wallIds.sort()).toEqual(["w1", "w2"]);
expect(mem.drawingIds).toEqual(["d1"]);
});
it("unbekannte Gruppen-ID liefert leere Mitgliedschaft", () => {
const mem = transitiveMembership(project([]), "missing");
expect(mem).toEqual(EMPTY_MEM);
});
});
describe("topLevelGroups / containingGroupId", () => {
const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
const outer: Group = { id: "g2", wallIds: ["w2"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] };
const p = project([inner, outer]);
it("topLevelGroups schliesst verschachtelte Untergruppen aus", () => {
expect(topLevelGroups(p).map((g) => g.id)).toEqual(["g2"]);
});
it("ohne Isolation (groupPath leer): findet nur die ÄUSSERE Gruppe für ein Element in g1", () => {
expect(containingGroupId(p, "wallIds", "w1", [])).toBe("g2");
});
it("innerhalb von g2 isoliert: findet g1 (direkte Untergruppe) für w1", () => {
expect(containingGroupId(p, "wallIds", "w1", ["g2"])).toBe("g1");
});
it("innerhalb von g2 isoliert: w2 (direktes Mitglied von g2 selbst) gehört zu KEINER Gruppe mehr (einzeln editierbar)", () => {
expect(containingGroupId(p, "wallIds", "w2", ["g2"])).toBeNull();
});
it("Element ausserhalb jeder Gruppe liefert null", () => {
expect(containingGroupId(p, "wallIds", "w999", [])).toBeNull();
});
});
describe("isInsideActiveGroup", () => {
const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
const outer: Group = { id: "g2", wallIds: ["w2"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] };
const p = project([inner, outer]);
it("ohne Isolation ist alles editierbar", () => {
expect(isInsideActiveGroup(p, "wallIds", "w1", [])).toBe(true);
expect(isInsideActiveGroup(p, "wallIds", "irgendwas", [])).toBe(true);
});
it("isoliert in g2: w2 und (transitiv über g1) w1 sind editierbar", () => {
expect(isInsideActiveGroup(p, "wallIds", "w2", ["g2"])).toBe(true);
expect(isInsideActiveGroup(p, "wallIds", "w1", ["g2"])).toBe(true);
});
it("isoliert in g2: ein Element ausserhalb der Gruppe ist gesperrt", () => {
expect(isInsideActiveGroup(p, "wallIds", "w-outside", ["g2"])).toBe(false);
});
});
describe("expandToGroups", () => {
const g1: Group = { id: "g1", wallIds: ["w1", "w2"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
const p = project([g1]);
it("Klick/Marquee-Treffer, der zu einer Gruppe gehört, wird auf die ganze Gruppe erweitert", () => {
const { expanded, groupIds } = expandToGroups(p, { ...EMPTY_MEM, wallIds: ["w1"] }, []);
expect(expanded.wallIds.sort()).toEqual(["w1", "w2"]); // w2 kommt über die Gruppe dazu
expect(groupIds).toEqual(["g1"]);
});
it("Treffer ohne Gruppe bleibt einzeln, gemischt mit einem gruppierten Treffer", () => {
const { expanded, groupIds } = expandToGroups(p, { ...EMPTY_MEM, wallIds: ["w1", "w-loose"] }, []);
expect(expanded.wallIds.sort()).toEqual(["w-loose", "w1", "w2"]);
expect(groupIds).toEqual(["g1"]);
});
it("Marquee über mehrere Elementarten hinweg erweitert jede Art unabhängig", () => {
const g2: Group = { id: "g2", wallIds: [], drawingIds: ["d1"], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
const p2 = project([g1, g2]);
const { expanded, groupIds } = expandToGroups(p2, { ...EMPTY_MEM, wallIds: ["w1"], drawingIds: ["d1"] }, []);
expect(expanded.wallIds.sort()).toEqual(["w1", "w2"]);
expect(expanded.drawingIds).toEqual(["d1"]);
expect(groupIds.sort()).toEqual(["g1", "g2"]);
});
});
describe("applyGroupDimming", () => {
const g1: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
const p = project([g1]);
const wallInside = { kind: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 0 }, cls: "wall", weightMm: 0.2, wallId: "w1" } as unknown as Primitive;
const wallOutside = { kind: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 0 }, cls: "wall", weightMm: 0.2, wallId: "w-outside" } as unknown as Primitive;
const noOwner = { kind: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 0 }, cls: "grid", weightMm: 0.1 } as unknown as Primitive;
it("ohne Isolation (groupPath leer) bleibt alles unverändert", () => {
const out = applyGroupDimming([wallInside, wallOutside], p, []);
expect(out).toEqual([wallInside, wallOutside]);
});
it("isoliert in g1: das Mitglied bleibt normal, alles andere mit ID wird gedimmt", () => {
const out = applyGroupDimming([wallInside, wallOutside], p, ["g1"]);
expect(out[0].greyed).toBeUndefined();
expect(out[1].greyed).toBe(true);
});
it("Primitive ohne jede ID (Raster o. Ä.) werden nie gedimmt", () => {
const out = applyGroupDimming([noOwner], p, ["g1"]);
expect(out[0].greyed).toBeUndefined();
});
});
describe("addNewElementsToActiveGroup", () => {
const g1: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
const line = (id: string) =>
({ id, type: "drawing2d", levelId: "eg", categoryCode: "20", geom: { shape: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 0 } } }) as Project["drawings2d"][number];
it("neu gezeichnetes Element wird Mitglied der aktiven (innersten) Gruppe", () => {
const before = project([g1]);
const after: Project = { ...before, drawings2d: [line("d1")] };
const result = addNewElementsToActiveGroup(before, after, ["g1"]);
const g = result.groups!.find((x) => x.id === "g1")!;
expect(g.drawingIds).toEqual(["d1"]);
expect(g.wallIds).toEqual(["w1"]); // bestehende Mitgliedschaft bleibt
});
it("ohne aktive Gruppe (groupPath leer) bleibt das Projekt unverändert", () => {
const before = project([g1]);
const after: Project = { ...before, drawings2d: [line("d1")] };
const result = addNewElementsToActiveGroup(before, after, []);
expect(result).toBe(after); // unverändert (Referenzgleichheit)
});
it("keine Änderung (before === after) ist ein No-op", () => {
const p = project([g1]);
expect(addNewElementsToActiveGroup(p, p, ["g1"])).toBe(p);
});
it("mehrere neue Elemente auf einmal (z. B. Feld/Array) werden alle Mitglied", () => {
const before = project([g1]);
const after: Project = { ...before, drawings2d: [line("d1"), line("d2"), line("d3")] };
const result = addNewElementsToActiveGroup(before, after, ["g1"]);
const g = result.groups!.find((x) => x.id === "g1")!;
expect(g.drawingIds.sort()).toEqual(["d1", "d2", "d3"]);
});
it("verschachtelt: neues Element landet in der INNERSTEN (aktiven) Gruppe, nicht der äusseren", () => {
const inner: Group = { id: "g1", wallIds: [], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
const outer: Group = { id: "g2", wallIds: [], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] };
const before = project([inner, outer]);
const after: Project = { ...before, drawings2d: [line("d1")] };
const result = addNewElementsToActiveGroup(before, after, ["g2", "g1"]);
expect(result.groups!.find((x) => x.id === "g1")!.drawingIds).toEqual(["d1"]);
expect(result.groups!.find((x) => x.id === "g2")!.drawingIds).toEqual([]);
});
});
describe("ungroup", () => {
it("löst genau EIN Level auf; Untergruppen bleiben als eigene Gruppen bestehen", () => {
const inner: Group = { id: "g1", wallIds: ["w1"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: [] };
const outer: Group = { id: "g2", wallIds: ["w2"], drawingIds: [], ceilingIds: [], roomIds: [], stairIds: [], columnIds: [], roofIds: [], openingIds: [], groupIds: ["g1"] };
const p = project([inner, outer]);
const res = ungroup(p, "g2");
expect(res).not.toBeNull();
expect(res!.project.groups!.map((g) => g.id)).toEqual(["g1"]); // g2 weg, g1 bleibt
expect(res!.members.wallIds).toEqual(["w2"]);
expect(res!.subGroupIds).toEqual(["g1"]);
});
it("unbekannte ID liefert null", () => {
expect(ungroup(project([]), "missing")).toBeNull();
});
});
+328
View File
@@ -0,0 +1,328 @@
// Gruppen (Vectorworks-artig, s. `model/types.ts::Group`): reine Funktionen
// über Mitgliedschaft/Verschachtelung + Erzeugen/Auflösen. Keine React-/Store-
// Abhängigkeit — testbar ohne Mock, wird von `useGroupHandlers.ts` (Klick-/
// Tastatur-Verdrahtung) und `PlanView.tsx` (Dimm-Rendering) konsumiert.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Group, Project } from "../model/types";
import { uniqueId } from "../tools/types";
import type { Primitive } from "../plan/generatePlan/shared";
/** Mitglieds-IDs je Elementart — dieselbe Form wie `Group`, ohne `id`/`name`/`groupIds`. */
export interface GroupMembership {
wallIds: string[];
drawingIds: string[];
ceilingIds: string[];
roomIds: string[];
stairIds: string[];
columnIds: string[];
roofIds: string[];
openingIds: string[];
}
export type MemberKind = keyof GroupMembership;
export const EMPTY_MEMBERSHIP: GroupMembership = {
wallIds: [],
drawingIds: [],
ceilingIds: [],
roomIds: [],
stairIds: [],
columnIds: [],
roofIds: [],
openingIds: [],
};
const KEYS: MemberKind[] = [
"wallIds",
"drawingIds",
"ceilingIds",
"roomIds",
"stairIds",
"columnIds",
"roofIds",
"openingIds",
];
export function mergeMembership(a: GroupMembership, b: GroupMembership): GroupMembership {
const out = { ...a };
for (const k of KEYS) out[k] = [...new Set([...a[k], ...b[k]])];
return out;
}
/**
* Transitive Mitgliedschaft einer Gruppe: löst verschachtelte Gruppen
* (`groupIds`) rekursiv auf und vereinigt alle Mitglieds-IDs. Zyklenschutz
* über `seen` (sollte nie vorkommen — `groupSelection` verhindert das beim
* Erzeugen —, ist aber billig und schützt gegen manuell kaputte Projekte).
*/
export function transitiveMembership(
project: Project,
groupId: string,
seen: Set<string> = new Set(),
): GroupMembership {
if (seen.has(groupId)) return EMPTY_MEMBERSHIP;
seen.add(groupId);
const g = (project.groups ?? []).find((x) => x.id === groupId);
if (!g) return EMPTY_MEMBERSHIP;
let out: GroupMembership = {
wallIds: [...g.wallIds],
drawingIds: [...g.drawingIds],
ceilingIds: [...g.ceilingIds],
roomIds: [...g.roomIds],
stairIds: [...g.stairIds],
columnIds: [...g.columnIds],
roofIds: [...g.roofIds],
openingIds: [...g.openingIds],
};
for (const sub of g.groupIds) out = mergeMembership(out, transitiveMembership(project, sub, seen));
return out;
}
/** IDs aller Gruppen, die als Untergruppe in irgendeiner ANDEREN Gruppe stehen. */
function nestedGroupIds(project: Project): Set<string> {
const s = new Set<string>();
for (const g of project.groups ?? []) for (const id of g.groupIds) s.add(id);
return s;
}
/** Top-Level-Gruppen (nicht Untergruppe einer anderen Gruppe) — Kandidaten ausserhalb jeder Isolation. */
export function topLevelGroups(project: Project): Group[] {
const nested = nestedGroupIds(project);
return (project.groups ?? []).filter((g) => !nested.has(g.id));
}
/**
* Die „sichtbare" Gruppe, die ein Element enthält, relativ zum aktuellen
* Isolations-Kontext `groupPath` (leer = nicht isoliert):
* • nicht isoliert: sucht unter den TOP-LEVEL-Gruppen (verschachtelte
* Untergruppen sind dabei „unsichtbar", man sieht nur die äusserste Hülle).
* • isoliert (aktive Gruppe = letztes Element von `groupPath`): sucht NUR
* unter den DIREKTEN Untergruppen der aktiven Gruppe — direkte Mitglieder
* der aktiven Gruppe selbst zählen NICHT als „von einer Gruppe enthalten"
* (die sind ja gerade einzeln bearbeitbar, weil man in der Gruppe ist).
* `null` = das Element gehört zu keiner (in diesem Kontext sichtbaren) Gruppe.
*/
export function containingGroupId(
project: Project,
kind: MemberKind,
id: string,
groupPath: string[],
): string | null {
let candidates: Group[];
if (groupPath.length === 0) {
candidates = topLevelGroups(project);
} else {
const active = (project.groups ?? []).find((g) => g.id === groupPath[groupPath.length - 1]);
if (!active) return null;
candidates = active.groupIds
.map((gid) => (project.groups ?? []).find((g) => g.id === gid))
.filter((g): g is Group => !!g);
}
for (const g of candidates) {
if (transitiveMembership(project, g.id)[kind].includes(id)) return g.id;
}
return null;
}
/**
* Ob ein Element im aktuellen Isolations-Kontext editierbar ist: ohne aktive
* Gruppe (`groupPath` leer) immer `true`; isoliert nur, wenn das Element
* TRANSITIV zur aktiven (innersten) Gruppe gehört — alles andere ist „grau
* und gesperrt", analog zum bestehenden Ebenen-Sperren-Modus.
*/
export function isInsideActiveGroup(
project: Project,
kind: MemberKind,
id: string,
groupPath: string[],
): boolean {
if (groupPath.length === 0) return true;
const active = groupPath[groupPath.length - 1];
return transitiveMembership(project, active)[kind].includes(id);
}
/**
* Erweitert eine rohe Treffer-Mitgliedschaft (aus Einzelklick ODER Marquee,
* beliebig viele IDs je Art) um Gruppen: jede ID, die zu einer im aktuellen
* Isolations-Kontext (`groupPath`) SICHTBAREN Gruppe gehört (s.
* `containingGroupId`), wird durch die TRANSITIVE Mitgliedschaft dieser
* Gruppe ersetzt; IDs ohne Gruppe bleiben einzeln. Liefert zusätzlich die
* Menge der dabei „getroffenen" Gruppen-IDs (für `selectedGroupIds`/die
* Bounding-Box-Anzeige). Deckt Einzelklick (eine ID) UND Marquee (viele IDs
* über mehrere Arten) mit derselben Logik ab.
*/
export function expandToGroups(
project: Project,
raw: GroupMembership,
groupPath: string[],
): { expanded: GroupMembership; groupIds: string[] } {
let expanded: GroupMembership = { ...EMPTY_MEMBERSHIP };
const groupIds = new Set<string>();
for (const k of KEYS) {
for (const id of raw[k]) {
const gid = containingGroupId(project, k, id, groupPath);
if (gid) {
groupIds.add(gid);
expanded = mergeMembership(expanded, transitiveMembership(project, gid));
} else {
expanded = mergeMembership(expanded, { ...EMPTY_MEMBERSHIP, [k]: [id] });
}
}
}
return { expanded, groupIds: [...groupIds] };
}
/** Primitiv-ID-Feld → zugehörige `MemberKind` (Reihenfolge irrelevant). */
const ID_FIELD_TO_KIND: [string, MemberKind][] = [
["wallId", "wallIds"],
["drawingId", "drawingIds"],
["ceilingId", "ceilingIds"],
["roomId", "roomIds"],
["stairId", "stairIds"],
["columnId", "columnIds"],
["roofId", "roofIds"],
["openingId", "openingIds"],
];
/**
* Dimmt (setzt `greyed: true`) jedes Primitiv, das zu einem ECHTEN Element
* (trägt eines der bekannten ID-Felder) gehört, das NICHT transitiv zur
* aktiven (innersten) Gruppe von `groupPath` gehört — die visuelle Seite der
* Isolation (Vectorworks „Gruppe betreten"), analog zum Sperren beim Klicken
* (`isInsideActiveGroup`). Primitive OHNE jede solche ID (Raster, Massstabs-
* leiste o. Ä.) bleiben unverändert. `groupPath` leer → unverändert (kein
* Isolations-Kontext, alles normal sichtbar).
*/
export function applyGroupDimming(
primitives: Primitive[],
project: Project,
groupPath: string[],
): Primitive[] {
if (groupPath.length === 0) return primitives;
const active = groupPath[groupPath.length - 1];
const mem = transitiveMembership(project, active);
const sets: Record<MemberKind, Set<string>> = {
wallIds: new Set(mem.wallIds),
drawingIds: new Set(mem.drawingIds),
ceilingIds: new Set(mem.ceilingIds),
roomIds: new Set(mem.roomIds),
stairIds: new Set(mem.stairIds),
columnIds: new Set(mem.columnIds),
roofIds: new Set(mem.roofIds),
openingIds: new Set(mem.openingIds),
};
return primitives.map((p) => {
const rec = p as unknown as Record<string, unknown>;
let hasId = false;
let inside = false;
for (const [field, kind] of ID_FIELD_TO_KIND) {
const v = rec[field];
if (typeof v !== "string") continue;
hasId = true;
if (sets[kind].has(v)) inside = true;
}
return hasId && !inside ? { ...p, greyed: true } : p;
});
}
/** IDs, die in `after` neu hinzugekommen sind (in `before` noch nicht vorhanden). */
function newIds(before: { id: string }[], after: { id: string }[]): string[] {
const beforeIds = new Set(before.map((x) => x.id));
return after.filter((x) => !beforeIds.has(x.id)).map((x) => x.id);
}
/**
* Zeichnet man WÄHREND man eine Gruppe betreten hat (Isolation) ein neues
* Element, wird es automatisch Mitglied dieser (innersten) Gruppe — man
* zeichnet ja SICHTLICH „in" ihr (Nutzer-Wunsch, Vectorworks-Verhalten).
* Vergleicht `before`/`after` je Elementart auf neu hinzugekommene IDs (Diff
* über die commit-Mutation) und hängt sie an die aktive Gruppe an. `before`
* === `after` (keine Änderung) oder `groupPath` leer → `after` unverändert.
*/
export function addNewElementsToActiveGroup(
before: Project,
after: Project,
groupPath: string[],
): Project {
if (groupPath.length === 0 || before === after) return after;
const activeId = groupPath[groupPath.length - 1];
const g = (after.groups ?? []).find((x) => x.id === activeId);
if (!g) return after;
const added: GroupMembership = {
wallIds: newIds(before.walls, after.walls),
drawingIds: newIds(before.drawings2d, after.drawings2d),
ceilingIds: newIds(before.ceilings ?? [], after.ceilings ?? []),
roomIds: newIds(before.rooms ?? [], after.rooms ?? []),
stairIds: newIds(before.stairs ?? [], after.stairs ?? []),
columnIds: newIds(before.columns ?? [], after.columns ?? []),
roofIds: newIds(before.roofs ?? [], after.roofs ?? []),
openingIds: newIds(before.openings ?? [], after.openings ?? []),
};
if (KEYS.every((k) => added[k].length === 0)) return after;
const merged: Group = { ...g, ...mergeMembership(g, added) };
return {
...after,
groups: (after.groups ?? []).map((x) => (x.id === activeId ? merged : x)),
};
}
/**
* Erzeugt eine neue Gruppe aus einer Mengen-Auswahl (+ optional bereits
* gewählte Gruppen, die dabei als Untergruppen verschachtelt werden — „zwei
* Gruppen + Cmd+G" fasst sie in einer neuen äusseren Gruppe zusammen, statt
* ihre Mitglieder platt zu vermischen). Leere Auswahl → `null` (nichts zu tun).
*/
export function groupSelection(
project: Project,
sel: GroupMembership,
selectedGroupIds: string[],
): { project: Project; groupId: string } | null {
const totalCount =
KEYS.reduce((n, k) => n + sel[k].length, 0) + selectedGroupIds.length;
if (totalCount < 2) return null; // Gruppieren braucht ≥ 2 Elemente (Vectorworks-Konvention)
const id = uniqueId("grp");
const g: Group = {
id,
wallIds: sel.wallIds,
drawingIds: sel.drawingIds,
ceilingIds: sel.ceilingIds,
roomIds: sel.roomIds,
stairIds: sel.stairIds,
columnIds: sel.columnIds,
roofIds: sel.roofIds,
openingIds: sel.openingIds,
groupIds: [...selectedGroupIds],
};
return { project: { ...project, groups: [...(project.groups ?? []), g] }, groupId: id };
}
/**
* Löst EINE Gruppenhülle auf (ein Level, Vectorworks-Ungroup): Untergruppen
* bleiben als eigene Gruppen bestehen (werden nicht rekursiv aufgelöst). Die
* direkten Mitglieder (Elemente + Untergruppen-IDs) der aufgelösten Gruppe
* kommen als Ergebnis zurück, damit der Aufrufer sie als neue Auswahl setzen
* kann. `null`, wenn die ID keine Gruppe ist.
*/
export function ungroup(
project: Project,
groupId: string,
): { project: Project; members: GroupMembership; subGroupIds: string[] } | null {
const g = (project.groups ?? []).find((x) => x.id === groupId);
if (!g) return null;
return {
project: { ...project, groups: (project.groups ?? []).filter((x) => x.id !== groupId) },
members: {
wallIds: g.wallIds,
drawingIds: g.drawingIds,
ceilingIds: g.ceilingIds,
roomIds: g.roomIds,
stairIds: g.stairIds,
columnIds: g.columnIds,
roofIds: g.roofIds,
openingIds: g.openingIds,
},
subGroupIds: g.groupIds,
};
}
+17 -4
View File
@@ -40,9 +40,9 @@ beforeEach(() => {
});
describe("drawingVertices — Kreis/Bogen", () => {
it("Kreis liefert einen Radius-Griff auf der Ost-Seite", () => {
it("Kreis liefert zwei Griffe (Ost/Nord) — wie eine Ellipse", () => {
const v = drawingVertices(circleDrawing);
expect(v).toEqual([{ x: 3, y: 3 }]);
expect(v).toEqual([{ x: 3, y: 3 }, { x: 2, y: 4 }]);
});
it("Bogen liefert Start-/End-/Mittel-Winkel-Griffe", () => {
@@ -55,8 +55,21 @@ describe("drawingVertices — Kreis/Bogen", () => {
});
describe("moveGripOf — Kreis/Bogen", () => {
it("Kreis-Griff ziehen ändert nur den Radius, nicht das Zentrum", () => {
getState().moveGripOf("d-circle", null, 0, { x: 5, y: 3 }); // 3m vom Zentrum
it("Kreis-Ost-Griff auf denselben Radius gezogen bleibt ein Kreis", () => {
getState().moveGripOf("d-circle", null, 0, { x: 3, y: 3 }); // weiterhin 1m Ost
const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom;
expect(g).toMatchObject({ shape: "circle", center: { x: 2, y: 3 }, r: 1 });
});
it("Kreis-Ost-Griff auf einen ANDEREN Radius gezogen wird zur Ellipse (Nutzer-Wunsch: Kreis kann beim Bearbeiten zur Ellipse werden)", () => {
getState().moveGripOf("d-circle", null, 0, { x: 5, y: 3 }); // 3m Ost statt 1m
const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom;
expect(g).toMatchObject({ shape: "ellipse", center: { x: 2, y: 3 }, rx: 3, ry: 1, rotation: 0 });
});
it("Ellipse-Griff zurück auf (fast) gleiche Halbachsen gezogen wird wieder zum Kreis", () => {
getState().moveGripOf("d-circle", null, 0, { x: 5, y: 3 }); // → Ellipse rx=3, ry=1
getState().moveGripOf("d-circle", null, 1, { x: 2, y: 6 }); // Nord-Griff auf ry=3
const g = getState().project.drawings2d.find((d) => d.id === "d-circle")!.geom;
expect(g).toMatchObject({ shape: "circle", center: { x: 2, y: 3 }, r: 3 });
});
+139
View File
@@ -0,0 +1,139 @@
/**
* Kanten-Griff ziehen (`moveEdgeOf`/`moveRoomEdge` in `projectSlice.ts`) bei
* Vielecken mit nicht rechtwinkligen Anschlüssen. Nutzer-Report: die
* Nachbarkanten der verschobenen Seite kippten bisher im WINKEL statt sich
* nur zu verlängern/verkürzen — der Eckpunkt wurde stur um `delta` versetzt,
* statt entlang seiner unveränderten Nachbarkante zu wandern. Fix:
* `movePolygonEdge`/`edgeMoveVertex` schneiden die verschobene Kanten-Linie
* mit der (unendlich verlängerten) Nachbarkanten-Linie durch den fixen
* Nachbarpunkt — Nachbarkante behält ihre Richtung, nur ihre Länge ändert
* sich.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { getState, setState } from "./appStore";
import { sampleProject } from "../model/sampleProject";
import type { Drawing2D, Room } from "../model/types";
// Fünfeck (Haus-Form): P0(0,0) P1(4,0) P2(4,3) P3(2,5) P4(0,3), geschlossen.
const pentagon: Drawing2D = {
id: "d-pentagon",
type: "drawing2d",
levelId: "eg",
categoryCode: "00",
geom: {
shape: "polyline",
closed: true,
pts: [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 3 },
{ x: 2, y: 5 },
{ x: 0, y: 3 },
],
},
};
function makeRoom(over: Partial<Room> = {}): Room {
return {
id: "r-pentagon",
type: "room",
floorId: "eg",
categoryCode: "45",
siaCategory: "HNF",
name: "Wohnen",
boundary: [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 3 },
{ x: 2, y: 5 },
{ x: 0, y: 3 },
],
color: "#123456",
...over,
};
}
beforeEach(() => {
setState({
project: { ...sampleProject, drawings2d: [pentagon], rooms: [makeRoom()] },
undoStack: [],
redoStack: [],
canUndo: false,
canRedo: false,
});
});
describe("moveEdgeOf — Nachbarkanten winkeltreu (nicht rechtwinklige Ecke)", () => {
it("verschiebt die slanted Kante P2-P3 und erhält die Richtung der Nachbarkanten", () => {
// Kante (Index 2,3) = P2(4,3)→P3(2,5) um (1,1) verschieben.
getState().moveEdgeOf("d-pentagon", null, 2, 3, { x: 1, y: 1 });
const g = getState().project.drawings2d.find((d) => d.id === "d-pentagon")!.geom;
expect(g.shape).toBe("polyline");
if (g.shape !== "polyline") return;
// P0/P1/P4 unverändert (nicht Teil der gezogenen Kante).
expect(g.pts[0]).toEqual({ x: 0, y: 0 });
expect(g.pts[1]).toEqual({ x: 4, y: 0 });
expect(g.pts[4]).toEqual({ x: 0, y: 3 });
// P2/P3 wandern entlang ihrer FIXEN Nachbarkanten (P1-P2 senkrecht,
// P4-P3 diagonal 45°), nicht stur um (1,1) versetzt.
expect(g.pts[2].x).toBeCloseTo(4, 9);
expect(g.pts[2].y).toBeCloseTo(5, 9);
expect(g.pts[3].x).toBeCloseTo(3, 9);
expect(g.pts[3].y).toBeCloseTo(6, 9);
// Nachbarkante P1-P2' behält ihre Richtung (senkrecht, Δx=0) — nur die
// Länge ändert sich (3 → 5).
const p1 = g.pts[1];
const p2 = g.pts[2];
expect(p2.x - p1.x).toBeCloseTo(0, 9);
expect(p2.y - p1.y).toBeCloseTo(5, 9);
// Nachbarkante P4-P3' behält ihre Richtung (Steigung 1:1) — nur die
// Länge ändert sich (√8 → √18).
const p4 = g.pts[4];
const p3 = g.pts[3];
const dir = { x: p3.x - p4.x, y: p3.y - p4.y };
expect(dir.x).toBeCloseTo(dir.y, 9); // 45°-Steigung erhalten
expect(Math.hypot(dir.x, dir.y)).toBeGreaterThan(Math.hypot(2, 2)); // länger als vorher
});
it("Rechteck (4 rechtwinklige Ecken) bleibt reine Parallel-Verschiebung (Regressionsschutz)", () => {
const rect: Drawing2D = {
id: "d-rect",
type: "drawing2d",
levelId: "eg",
categoryCode: "00",
geom: {
shape: "polyline",
closed: true,
pts: [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 3 },
{ x: 0, y: 3 },
],
},
};
setState({ project: { ...getState().project, drawings2d: [rect] } });
// Rechte Kante (Index 1,2) um (2,0) nach außen verschieben.
getState().moveEdgeOf("d-rect", null, 1, 2, { x: 2, y: 0 });
const g = getState().project.drawings2d.find((d) => d.id === "d-rect")!.geom;
expect(g.shape).toBe("polyline");
if (g.shape !== "polyline") return;
expect(g.pts[0]).toEqual({ x: 0, y: 0 });
expect(g.pts[1]).toEqual({ x: 6, y: 0 });
expect(g.pts[2]).toEqual({ x: 6, y: 3 });
expect(g.pts[3]).toEqual({ x: 0, y: 3 });
});
});
describe("moveRoomEdge — dieselbe Winkeltreue über movePolygonEdge", () => {
it("verschiebt die slanted Kante eines Raum-Umrisses winkeltreu", () => {
getState().moveRoomEdge("r-pentagon", 2, 3, { x: 1, y: 1 });
const r = getState().project.rooms!.find((x) => x.id === "r-pentagon")!;
expect(r.boundary[0]).toEqual({ x: 0, y: 0 });
expect(r.boundary[2].x).toBeCloseTo(4, 9);
expect(r.boundary[2].y).toBeCloseTo(5, 9);
expect(r.boundary[3].x).toBeCloseTo(3, 9);
expect(r.boundary[3].y).toBeCloseTo(6, 9);
});
});
+215 -18
View File
@@ -23,6 +23,7 @@ import type {
Project,
Vec2,
} from "../model/types";
import { lineIntersect } from "../model/geometry";
import { commitTransform } from "../tools/transform";
import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform";
import { uniqueId } from "../tools/types";
@@ -908,9 +909,7 @@ export function createProjectSlice(
setProject(
(p) => mapCeiling(p, ceilingId, (c) => ({
...c,
outline: c.outline.map((v, i) =>
i === aIndex || i === bIndex ? { x: v.x + delta.x, y: v.y + delta.y } : v,
),
outline: movePolygonEdge(c.outline, aIndex, bIndex, delta, true),
})),
`moveCeilingEdge:${ceilingId}:${aIndex}:${bIndex}`,
),
@@ -1030,11 +1029,7 @@ export function createProjectSlice(
(p) =>
mapRoom(p, roomId, (r) => ({
...r,
boundary: r.boundary.map((v, i) =>
i === aIndex || i === bIndex
? { x: v.x + delta.x, y: v.y + delta.y }
: v,
),
boundary: movePolygonEdge(r.boundary, aIndex, bIndex, delta, true),
})),
`moveRoomEdge:${roomId}:${aIndex}:${bIndex}`,
),
@@ -1412,6 +1407,28 @@ function resizeGeom(
};
return { ...g, center, r };
}
case "ellipse": {
// bbox: rotation=0 angenommen (das Werkzeug erzeugt nur achsparallele
// Ellipsen); rx/ry unabhängig skaliert, Mittelpunkt um den Anker verschoben.
const box = {
minX: g.center.x - g.rx,
minY: g.center.y - g.ry,
maxX: g.center.x + g.rx,
maxY: g.center.y + g.ry,
};
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
const center: Vec2 = {
x: ax + (g.center.x - ax) * sx,
y: ay + (g.center.y - ay) * sy,
};
return { ...g, center, rx: Math.max(1e-3, g.rx * sx), ry: Math.max(1e-3, g.ry * sy) };
}
case "bezier":
case "spline": {
const { ax, ay, sx, sy } = scaleAround(bbox2(g.pts), w, h, anchor);
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
return { ...g, pts: g.pts.map(f) };
}
case "text": {
// Text: nur die Einfügeposition um den Anker schieben (Höhe bleibt).
const box = { minX: g.at.x, minY: g.at.y, maxX: g.at.x, maxY: g.at.y };
@@ -1435,6 +1452,24 @@ function resizeGeom(
}
}
/**
* Kreis, solange rx≈ry (relative Toleranz 0.5%) — sonst Ellipse (rotation 0).
* Trägt den Kreis-zu-Ellipse-Formwandel beim Griff-Ziehen (Nutzer-Wunsch:
* „Kreis wird wie bisher erstellt, kann beim Bearbeiten zur Ellipse werden");
* symmetrisch nutzbar auch für die Rückrichtung (Ellipse → Kreis, wenn man
* die Halbachsen wieder aneinander annähert).
*/
function ellipseOrCircle(
center: Vec2,
rx: number,
ry: number,
): import("../model/types").Drawing2DGeom {
if (Math.abs(rx - ry) <= 0.005 * Math.max(rx, ry)) {
return { shape: "circle", center, r: (rx + ry) / 2 };
}
return { shape: "ellipse", center, rx, ry, rotation: 0 };
}
// ── Editier-Griffe (verschoben aus App.tsx, 1:1) ─────────────────────────────
/**
@@ -1455,8 +1490,16 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
{ x: g.min.x, y: g.max.y },
];
}
// Kreis: zwei Griffe (Ost/Nord, wie bei der Ellipse) — zieht man EINEN der
// beiden unabhängig, wird der Kreis zur Ellipse (s. `moveGrip`). So bleibt
// ein Kreis ein Kreis, solange man ihn nicht gezielt verzerrt (Nutzer-
// Wunsch: „Kreis erstellen wie bisher, beim Bearbeiten kann er zur Ellipse
// werden").
if (g.shape === "circle") {
return [{ x: g.center.x + g.r, y: g.center.y }];
return [
{ x: g.center.x + g.r, y: g.center.y },
{ x: g.center.x, y: g.center.y + g.r },
];
}
if (g.shape === "arc") {
const mid = (g.a0 + g.a1) / 2;
@@ -1466,6 +1509,30 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
{ x: g.center.x + g.r * Math.cos(mid), y: g.center.y + g.r * Math.sin(mid) },
];
}
// Ellipse: zwei Griffe (Index 0 = rx-Achse, Index 1 = ry-Achse), gedreht um
// `rotation` (0 beim heutigen Werkzeug) — siehe `moveGrip`.
if (g.shape === "ellipse") {
const c = Math.cos(g.rotation), s = Math.sin(g.rotation);
return [
{ x: g.center.x + g.rx * c, y: g.center.y + g.rx * s },
{ x: g.center.x - g.ry * s, y: g.center.y + g.ry * c },
];
}
// Bezier/Spline: alle Kontroll-/Wegpunkte als Griffe (wie polyline).
if (g.shape === "bezier" || g.shape === "spline") return [...g.pts];
// Bemassung: Griff 0..N-1 = jeder Punkt der Kette (wie polyline); EIN
// zusätzlicher Griff (Index N) sitzt AUF der Masslinie (Mitte der Kette) —
// ihn zu ziehen ändert nur `offset`, nicht die Punkte (s. moveGrip).
if (g.shape === "dimension") {
const a = g.pts[0], b = g.pts[g.pts.length - 1];
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const nx = (-dy / len) * g.offset, ny = (dx / len) * g.offset;
const mid: Vec2 = { x: (a.x + b.x) / 2 + nx, y: (a.y + b.y) / 2 + ny };
return [...g.pts, mid];
}
// Kote: EIN Griff an der Dreieck-Spitze (zum Verschieben; siehe moveGrip).
if (g.shape === "kote") return [g.at];
// Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip).
if (g.shape === "text") return [g.at];
return [];
@@ -1523,10 +1590,26 @@ function moveGrip(
if (g.shape === "line") {
return { ...d, geom: index === 0 ? { ...g, a: pt } : { ...g, b: pt } };
}
if (g.shape === "polyline") {
if (g.shape === "polyline" || g.shape === "bezier" || g.shape === "spline") {
const pts = g.pts.map((p, i) => (i === index ? pt : p));
return { ...d, geom: { ...g, pts } };
}
// Bemassung: Griff 0..N-1 verschiebt den jeweiligen Kettenpunkt
// (Masszahlen folgen automatisch, s. Rendering); der LETZTE Griff
// (auf der Masslinie) ändert nur `offset` — Normalabstand von `pt`
// zur (unveränderten) Punktreihe.
if (g.shape === "dimension") {
if (index < g.pts.length) {
const pts = g.pts.map((p, i) => (i === index ? pt : p));
return { ...d, geom: { ...g, pts } };
}
const a = g.pts[0], b = g.pts[g.pts.length - 1];
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len;
const offset = (pt.x - a.x) * nx + (pt.y - a.y) * ny;
return { ...d, geom: { ...g, offset } };
}
if (g.shape === "rect" || g.shape === "image") {
const corners = [
g.min,
@@ -1541,10 +1624,16 @@ function moveGrip(
}
// Text: der einzige Griff (Index 0) sitzt am Ankerpunkt → verschiebt ihn.
if (g.shape === "text") return { ...d, geom: { ...g, at: pt } };
// Kreis: der einzige Griff (Index 0) ändert nur den Radius.
// Kote: der einzige Griff (Index 0) sitzt an der Dreieck-Spitze.
if (g.shape === "kote") return { ...d, geom: { ...g, at: pt } };
// Kreis: Griff 0 (Ost) bzw. 1 (Nord) — zieht man die beiden Achsen
// unabhängig auseinander, wird der Kreis zur Ellipse (rotation 0);
// bleiben rx/ry (fast) gleich, bleibt es ein Kreis (s. `ellipseOrCircle`).
if (g.shape === "circle") {
const r = Math.max(1e-3, Math.hypot(pt.x - g.center.x, pt.y - g.center.y));
return { ...d, geom: { ...g, r } };
const dx = pt.x - g.center.x, dy = pt.y - g.center.y;
const rx = index === 0 ? Math.max(1e-3, Math.abs(dx)) : g.r;
const ry = index === 0 ? g.r : Math.max(1e-3, Math.abs(dy));
return { ...d, geom: ellipseOrCircle(g.center, rx, ry) };
}
// Bogen: Griff 0/1 (Start-/End-Winkel, s. drawingVertices) ändert a0/a1;
// Griff 2 (Bogenmitte) ändert nur den Radius (Winkel bleiben fest).
@@ -1556,6 +1645,22 @@ function moveGrip(
const ang = Math.atan2(pt.y - g.center.y, pt.x - g.center.x);
return { ...d, geom: index === 0 ? { ...g, a0: ang } : { ...g, a1: ang } };
}
// Ellipse: Griff 0 (rx-Achse) bzw. 1 (ry-Achse) — der gezogene Punkt wird
// ins lokale (gedrehte) Achsensystem projiziert, nur die jeweils
// betroffene Halbachse ändert sich.
if (g.shape === "ellipse") {
const c = Math.cos(g.rotation), s = Math.sin(g.rotation);
const dx = pt.x - g.center.x, dy = pt.y - g.center.y;
const lx = dx * c + dy * s;
const ly = -dx * s + dy * c;
const rx = index === 0 ? Math.max(1e-3, Math.abs(lx)) : g.rx;
const ry = index === 0 ? g.ry : Math.max(1e-3, Math.abs(ly));
// Nur bei rotation 0 kann eine (fast) gleichseitige Ellipse wieder
// zum Kreis kollabieren — eine gedrehte Ellipse mit rx≈ry ist optisch
// bereits ein Kreis, „Drehung" wäre dann aber wieder bedeutungslos.
if (g.rotation === 0) return { ...d, geom: ellipseOrCircle(g.center, rx, ry) };
return { ...d, geom: { ...g, rx, ry } };
}
return d;
}),
};
@@ -1595,7 +1700,14 @@ function moveElementBy(
return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
case "circle":
case "arc":
case "ellipse":
return { ...d, geom: { ...g, center: mv(g.center) } };
case "bezier":
case "spline":
case "dimension":
return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "kote":
return { ...d, geom: { ...g, at: mv(g.at) } };
case "text":
return { ...d, geom: { ...g, at: mv(g.at) } };
default:
@@ -1615,12 +1727,97 @@ function moveElementBy(
return project;
}
/**
* Neue Position eines Eckpunkts, dessen EINE Kante parallel um `delta`
* verschoben wird: der Punkt bleibt NICHT einfach delta-verschoben, sondern
* wandert entlang seiner ANDEREN (unverschobenen) Nachbarkante — deren
* Richtung bleibt exakt erhalten (nur ihre Länge ändert sich), statt sich mit
* zu drehen. Nutzer-Report: bei Vielecken mit mehr als 4/5 Ecken (nicht
* rechtwinklige Anschlüsse) kippten die Nachbarkanten bisher im Winkel, weil
* der Eckpunkt stur um `delta` versetzt wurde (korrekt nur bei rechtwinkligen
* Anschlüssen — Zufall bei Rechtecken, da rect ohnehin nur achsparallele
* Ecken kennt). Berechnet als Schnittpunkt der verschobenen Kanten-Linie mit
* der (unendlich verlängerten) Nachbarkanten-Linie durch den fixen
* Nachbarpunkt — dasselbe Prinzip wie die Wandstoß-Gehrung in `model/
* joins.ts`. Fällt bei fehlendem Nachbarn (offenes Kettenende) oder (fast)
* paralleler Nachbarkante (kein/instabiler Schnittpunkt) auf die einfache
* Delta-Verschiebung zurück.
*/
function edgeMoveVertex(
vertexOrig: Vec2,
neighborFixed: Vec2 | null,
shiftedLinePoint: Vec2,
edgeDir: Vec2,
delta: Vec2,
): Vec2 {
const fallback: Vec2 = { x: vertexOrig.x + delta.x, y: vertexOrig.y + delta.y };
if (!neighborFixed) return fallback;
const neighborDir: Vec2 = {
x: vertexOrig.x - neighborFixed.x,
y: vertexOrig.y - neighborFixed.y,
};
if (Math.hypot(neighborDir.x, neighborDir.y) < 1e-9) return fallback; // entartete Nachbarkante
return lineIntersect(neighborFixed, neighborDir, shiftedLinePoint, edgeDir) ?? fallback;
}
/**
* Nachbar-Index in einem Vertex-Ring/einer -Kette in Richtung `dir`. `null`,
* wenn `i` am offenen Ende liegt und `closed` falsch ist (kein Nachbar dort)
* ODER der Nachbar auf die andere Kanten-Ecke selbst fiele (entartetes
* 2-Punkt-„Polygon" — kein sinnvoller dritter Bezugspunkt).
*/
function ringNeighbor(
i: number,
n: number,
closed: boolean,
dir: 1 | -1,
excluding: number,
): number | null {
const j = i + dir;
const k = j >= 0 && j < n ? j : closed ? ((j % n) + n) % n : null;
return k !== null && k !== excluding ? k : null;
}
/**
* Verschiebt die Kante (aIndex,bIndex) eines Vertex-Rings/einer -Kette um
* `delta`, winkeltreu an den beiden Nachbar-Ecken (s. `edgeMoveVertex`).
* `closed`: Ring (Raum/Decke/geschlossene Polylinie) oder offene Kette
* (offene Polylinie).
*/
function movePolygonEdge(
pts: Vec2[],
aIndex: number,
bIndex: number,
delta: Vec2,
closed: boolean,
): Vec2[] {
const n = pts.length;
const A = pts[aIndex];
const B = pts[bIndex];
const shiftedA: Vec2 = { x: A.x + delta.x, y: A.y + delta.y };
const edgeDir: Vec2 = { x: B.x - A.x, y: B.y - A.y };
const prevOfA = ringNeighbor(aIndex, n, closed, -1, bIndex);
const nextOfB = ringNeighbor(bIndex, n, closed, 1, aIndex);
return pts.map((p, i) => {
if (i === aIndex) {
return edgeMoveVertex(A, prevOfA != null ? pts[prevOfA] : null, shiftedA, edgeDir, delta);
}
if (i === bIndex) {
return edgeMoveVertex(B, nextOfB != null ? pts[nextOfB] : null, shiftedA, edgeDir, delta);
}
return p;
});
}
/**
* Verschiebt eine SEITE (Kante) des selektierten Elements: beide Vertices der
* Kante um `delta` (immutabel). Für 2D-Elemente:
* Kante um `delta` (immutabel), winkeltreu an den Nachbarkanten (s. o.). Für
* 2D-Elemente:
* • rect: die zwei betroffenen Ecken (per Index) verschieben und min/max neu
* normalisieren — so wächst/schrumpft das Rechteck an genau dieser Seite.
* • polyline: die zwei Vertices `aIndex`/`bIndex` um `delta` verschieben.
* normalisieren — so wächst/schrumpft das Rechteck an genau dieser Seite
* (immer rechtwinklig, `movePolygonEdge` nicht nötig).
* • polyline: die zwei Vertices `aIndex`/`bIndex` um `delta` verschieben,
* winkeltreu über `movePolygonEdge`.
* • line: beide Endpunkte verschieben (das gesamte Segment senkrecht).
* Für die Wand: start+end gemeinsam (die ganze Wand senkrecht zur Achse).
*/
@@ -1643,8 +1840,8 @@ function moveEdge(
// Linie hat nur eine Kante [0,1] → das ganze Segment verschieben.
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
}
if (g.shape === "polyline") {
const pts = g.pts.map((p, i) => (i === aIndex || i === bIndex ? mv(p) : p));
if (g.shape === "polyline" || g.shape === "spline") {
const pts = movePolygonEdge(g.pts, aIndex, bIndex, delta, !!g.closed);
return { ...d, geom: { ...g, pts } };
}
if (g.shape === "rect" || g.shape === "image") {
+144 -5
View File
@@ -43,6 +43,12 @@ import type {
WallReferenceLine,
} from "../model/types";
import { localToLv95 } from "../io/lv95";
import {
resolveForeground,
resolveBackground,
resolveHatchId,
resolveStrokeWeight,
} from "../plan/generatePlan/shared";
import { columnVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
import { evaluateRoom, polygonArea } from "../geometry/roomArea";
@@ -413,6 +419,18 @@ export interface Selection {
strokeWeightSource?: AttributeSource;
/** Quelle der Schraffur, wenn kein `fillHatchId` gesetzt ist. */
hatchSource?: AttributeSource;
/**
* EFFEKTIV aufgelöster Vordergrund (Override ?? Ebene ?? Bauteil, s.
* `resolveForeground` — dieselbe Kette wie beim Rendern), für die IMMER
* sichtbare Vorschau im Attribut-Panel (Nutzer-Wunsch: „man sollte immer
* sehen welche Farbe [gilt]"), unabhängig von der aktuellen Quelle.
* `undefined` = kein Wert an keiner Stelle der Kette (echtes „Nach System").
*/
effectiveForeground?: string;
/** Effektiv aufgelöster Hintergrund, analog zu {@link Selection.effectiveForeground}. */
effectiveBackground?: string;
/** Effektiv aufgelöste Schraffur-ID, analog zu {@link Selection.effectiveForeground}. */
effectiveHatchId?: string | null;
/** Ob die Form geschlossen ist (rect/circle/closed polyline) — nur Drawing2D. */
closed?: boolean;
/**
@@ -476,7 +494,9 @@ function isClosedGeom(g: Drawing2DGeom): boolean {
return (
g.shape === "rect" ||
g.shape === "circle" ||
(g.shape === "polyline" && g.closed)
g.shape === "ellipse" ||
(g.shape === "polyline" && g.closed) ||
(g.shape === "spline" && g.closed)
);
}
@@ -510,6 +530,32 @@ function geomBBox(g: Drawing2DGeom): SelectionBBox {
acc(g.center.x - g.r, g.center.y - g.r);
acc(g.center.x + g.r, g.center.y + g.r);
break;
case "ellipse": {
// Präzise Bounding-Box unter Berücksichtigung der Drehung (rotation=0
// beim heutigen Werkzeug, Feld existiert für spätere Quellen).
const c = Math.cos(g.rotation), s = Math.sin(g.rotation);
const hw = Math.hypot(g.rx * c, g.ry * s);
const hh = Math.hypot(g.rx * s, g.ry * c);
acc(g.center.x - hw, g.center.y - hh);
acc(g.center.x + hw, g.center.y + hh);
break;
}
case "bezier":
case "spline":
for (const p of g.pts) acc(p.x, p.y);
break;
case "dimension": {
for (const p of g.pts) acc(p.x, p.y);
if (g.pts.length >= 2) {
const a = g.pts[0], b = g.pts[g.pts.length - 1];
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const nx = (-dy / len) * g.offset, ny = (dx / len) * g.offset;
for (const p of g.pts) acc(p.x + nx, p.y + ny);
}
break;
}
case "kote":
case "text":
acc(g.at.x, g.at.y);
break;
@@ -533,6 +579,26 @@ function wallSelection(project: Project, wall: Wall): Selection {
// Wand-Attribute auflösen (Referenzlinie, Aufbau, Referenzgeschoss, UK/OK).
const wt = getWallType(project, wall);
const thickness = wallTypeThickness(wt);
// Effektive Vordergrund-/Hintergrund-/Schraffur-Vorschau fürs Attribut-Panel
// (Nutzer-Wunsch: immer den TATSÄCHLICHEN Wert sehen, nicht nur ein leeres
// Override-Feld) — dieselbe Resolve-Kette wie beim Rendern (resolveForeground
// & Co.), als Bauteil-Repräsentant die ÄUSSERSTE Schicht (wie `abbrevOf`
// unten). Bei mehrschichtigem Aufbau nur eine Annäherung (jede Schicht kann
// im Schnitt ihre EIGENE Bauteilfarbe zeigen) — für die Panel-Vorschau
// ausreichend, der Override selbst wirkt ohnehin einheitlich auf alle Schichten.
const outerComponent = getComponent(project, wt.layers[0]?.componentId);
const effectiveForeground = outerComponent
? resolveForeground(outerComponent, wall.foreground, cat, wall.foregroundSource)
: wall.foreground;
const effectiveBackground = outerComponent
? resolveBackground(outerComponent, wall.background, cat, wall.backgroundSource)
: wall.background;
const effectiveHatchId = resolveHatchId(
wall.hatchId,
wall.hatchSource,
cat,
outerComponent?.hatchId,
);
const floor = project.drawingLevels.find((z) => z.id === wall.floorId);
const { zBottom, zTop } = wallVerticalExtent(project, wall);
const above = nextFloorAbove(project, wall);
@@ -606,6 +672,9 @@ function wallSelection(project: Project, wall: Wall): Selection {
background: wall.background,
foregroundSource: wall.foregroundSource,
backgroundSource: wall.backgroundSource,
effectiveForeground,
effectiveBackground,
effectiveHatchId,
strokeWeightOverride: wall.strokeWeight,
strokeWeightSource: wall.strokeWeightSource,
hatchSource: wall.hatchSource,
@@ -623,6 +692,21 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
const box = outlineBBox(ceiling.outline);
const wt = getCeilingType(project, ceiling);
const thickness = ceilingThickness(project, ceiling);
// Effektive Vordergrund-/Hintergrund-/Schraffur-Vorschau — s. Kommentar in
// wallSelection (dieselbe Kette, äusserste Schicht als Bauteil-Repräsentant).
const outerComponent = getComponent(project, wt.layers[0]?.componentId);
const effectiveForeground = outerComponent
? resolveForeground(outerComponent, ceiling.foreground, cat, ceiling.foregroundSource)
: ceiling.foreground;
const effectiveBackground = outerComponent
? resolveBackground(outerComponent, ceiling.background, cat, ceiling.backgroundSource)
: ceiling.background;
const effectiveHatchId = resolveHatchId(
ceiling.hatchId,
ceiling.hatchSource,
cat,
outerComponent?.hatchId,
);
const floor = project.drawingLevels.find((z) => z.id === ceiling.floorId);
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
const ceilingInfo: CeilingInfo = {
@@ -666,6 +750,9 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
background: ceiling.background,
foregroundSource: ceiling.foregroundSource,
backgroundSource: ceiling.backgroundSource,
effectiveForeground,
effectiveBackground,
effectiveHatchId,
strokeWeightOverride: ceiling.strokeWeight,
strokeWeightSource: ceiling.strokeWeightSource,
hatchSource: ceiling.hatchSource,
@@ -931,13 +1018,42 @@ function columnSelection(project: Project, col: Column): Selection {
zBottom,
zTop,
};
// Effektive Vordergrund-/Hintergrund-/Schraffur-/Strichstärke-Vorschau — s.
// Kommentar in wallSelection (dieselbe Kette, das zugewiesene Bauteil als
// Repräsentant statt der äussersten Schicht, da Column nur EIN componentId
// trägt statt eines geschichteten Aufbaus).
// `getComponent` wirft bei unbekannter ID (z. B. gelöschtes Bauteil) — die
// Panel-Vorschau soll dabei nicht abstürzen (analog dem try/catch in
// structures.ts addColumnPoche), einfach auf "kein Bauteil" zurückfallen.
const comp = col.componentId
? project.components.find((c) => c.id === col.componentId)
: undefined;
const effectiveForeground = comp
? resolveForeground(comp, col.foreground, category, col.foregroundSource)
: col.foreground;
const effectiveBackground = comp
? resolveBackground(comp, col.background, category, col.backgroundSource)
: col.background;
const effectiveHatchId = resolveHatchId(col.hatchId, col.hatchSource, category, comp?.hatchId);
const weightMm = resolveStrokeWeight(col.strokeWeight, col.strokeWeightSource, category, WALL_FALLBACK_MM);
return {
kind: "column",
id: col.id,
categoryCode: col.categoryCode,
floorId: col.floorId,
color: col.color ?? category?.color ?? COLUMN_COLOR,
weightMm: WALL_FALLBACK_MM,
weightMm,
fillHatchId: col.hatchId,
foreground: col.foreground,
background: col.background,
foregroundSource: col.foregroundSource,
backgroundSource: col.backgroundSource,
effectiveForeground,
effectiveBackground,
effectiveHatchId,
strokeWeightOverride: col.strokeWeight,
strokeWeightSource: col.strokeWeightSource,
hatchSource: col.hatchSource,
bbox: { minX, minY, maxX, maxY },
column: info,
};
@@ -998,15 +1114,35 @@ function drawingSelection(project: Project, d: Drawing2D): Selection {
const cat = findCategory(project.layers, d.categoryCode);
const color = d.color ?? ls?.color ?? cat?.color ?? DRAW_FALLBACK_COLOR;
// Effektive Strichstärke, dieselbe Kette wie generatePlan/addDrawing2D
// (resolveStrokeWeight): Override zuerst, "layer" erzwingt die Kategorie
// (umgeht das LineStyle-Gewicht), sonst die heutige LineStyle-/Kategorie-Kette.
// (resolveStrokeWeight): Override zuerst, Source ≠ "object" (also "layer"
// ODER fehlend, s. AttributeSource-Default) erzwingt die Kategorie (umgeht
// das LineStyle-Gewicht), sonst die LineStyle-/Kategorie-Kette.
const weightMm =
d.weightMm ??
(d.strokeWeightSource === "layer" ? cat?.lw : undefined) ??
(d.strokeWeightSource !== "object" ? cat?.lw : undefined) ??
ls?.weight ??
cat?.lw ??
WALL_FALLBACK_MM;
const closed = isClosedGeom(d.geom);
// Effektive Vordergrund-/Hintergrund-/Schraffur-Vorschau — s. Kommentar in
// wallSelection. Ein Drawing2D hat KEIN eigenes Bauteil (daher auch keine
// „Nach Bauteil"-Option im Panel, s. AttributesPanel.tsx) — als Platzhalter
// ein leeres Bauteil, dessen foreground/background/hatchId immer fehlen,
// sodass die Kette bei einem (aus Alt-Daten) expliziten `source:"object"`
// korrekt auf „kein Wert" statt einer erfundenen Bauteilfarbe fällt. Anders
// als bei Wand/Decke (Default „Nach Bauteil") gilt bei Drawing2D „Nach
// Ebene" als Default — daher hier explizit `?? "layer"` VOR dem Resolver
// (der selbst weiterhin generisch „Nach Bauteil" als Default kennt).
const noComponent = { id: "", name: "", color, hatchId: "", joinPriority: 0 };
const effectiveForeground = closed
? resolveForeground(noComponent, d.foreground, cat, d.foregroundSource ?? "layer")
: undefined;
const effectiveBackground = closed
? resolveBackground(noComponent, d.background, cat, d.backgroundSource ?? "layer")
: undefined;
const effectiveHatchId = closed
? resolveHatchId(d.hatchId, d.hatchSource ?? "layer", cat, undefined)
: null;
return {
kind: "drawing2d",
id: d.id,
@@ -1020,6 +1156,9 @@ function drawingSelection(project: Project, d: Drawing2D): Selection {
background: closed ? d.background : undefined,
foregroundSource: closed ? d.foregroundSource : undefined,
backgroundSource: closed ? d.backgroundSource : undefined,
effectiveForeground,
effectiveBackground,
effectiveHatchId,
strokeWeightOverride: d.weightMm,
strokeWeightSource: d.strokeWeightSource,
hatchSource: closed ? d.hatchSource : undefined,
+21
View File
@@ -30,6 +30,16 @@ export interface SelectionSlice {
// Gewählte Schnitt-/Ansichtslinie (DrawingLevel-Id) — EINZELauswahl (eine Linie
// zur Zeit), getrennt von den übrigen Kanälen. `null` = keine Linie gewählt.
selectedSectionLineId: string | null;
// Gewählte Gruppen (Vectorworks-artig) als MENGE von IDs — rein informativ/
// fürs UI (Bounding-Box, Doppelklick-Ziel); die eigentlichen Mitglieder
// stehen zusätzlich in den übrigen `selected…Ids`-Kanälen (Verschieben/
// Löschen/etc. brauchen so keine Gruppen-Sonderbehandlung), s. `groupActions.ts`.
selectedGroupIds: string[];
// Isolations-Pfad (Vectorworks „Gruppe betreten"): Stapel der Gruppen-IDs,
// in die per Doppelklick navigiert wurde — leer = nicht isoliert (normal),
// letztes Element = die AKTIVE (innerste) Gruppe. Alles ausserhalb ihrer
// transitiven Mitgliedschaft wird gedimmt/gesperrt (s. `groupActions.ts`).
groupPath: string[];
setSelectedWallIds: (ids: string[]) => void;
setSelectedDrawingIds: (ids: string[]) => void;
@@ -42,6 +52,8 @@ export interface SelectionSlice {
setSelectedRoofIds: (ids: string[]) => void;
setSelectedContextObjectIds: (ids: string[]) => void;
setSelectedSectionLineId: (id: string | null) => void;
setSelectedGroupIds: (ids: string[]) => void;
setGroupPath: (path: string[]) => void;
/** Auswahl (Wände + Decken + Öffnungen + Treppen + Dächer + 2D-Elemente) leeren. */
clearSelection: () => void;
}
@@ -62,6 +74,8 @@ export function createSelectionSlice(
selectedRoofIds: [],
selectedContextObjectIds: [],
selectedSectionLineId: null,
selectedGroupIds: [],
groupPath: [],
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
@@ -73,6 +87,8 @@ export function createSelectionSlice(
setSelectedRoofIds: (ids) => set({ selectedRoofIds: ids }),
setSelectedContextObjectIds: (ids) => set({ selectedContextObjectIds: ids }),
setSelectedSectionLineId: (id) => set({ selectedSectionLineId: id }),
setSelectedGroupIds: (ids) => set({ selectedGroupIds: ids }),
setGroupPath: (path) => set({ groupPath: path }),
clearSelection: () =>
set({
selectedWallIds: [],
@@ -86,6 +102,11 @@ export function createSelectionSlice(
selectedRoofIds: [],
selectedContextObjectIds: [],
selectedSectionLineId: null,
selectedGroupIds: [],
// groupPath (Isolation) bleibt bewusst erhalten — Leerraum-Klick INNERHALB
// einer betretenen Gruppe soll nur deren Auswahl leeren, nicht aus der
// Gruppe hinauswerfen (Vectorworks-Verhalten). Verlassen ist ein
// expliziter Schritt (Knopf/Escape, s. `useGroupHandlers.ts`).
}),
};
}
+602 -144
View File
@@ -104,6 +104,32 @@ export function useGripEditing(opts: {
return add(st.start, scale(d, st.runLength / 2));
};
// Welchem Element + lokalem Vertex-Index ein Griff im flachen `grips`-Array
// gehört (Mehrfachauswahl, Nutzer-Report: Griffe verschwanden komplett,
// sobald 2 Elemente gewählt waren — die alte Logik kannte nur GENAU EIN
// selektiertes Element). Leer (`{}`) bei den exklusiven Einzel-Fällen
// (Öffnung/Treppe/Decke/Raum), die weiterhin nur einzeln editierbar sind.
interface GripOwner {
drawingId?: string;
wallId?: string;
localIndex: number;
}
const gripOwners: GripOwner[] = [];
// [ersterFlachIndex, letzterFlachIndex] DESSELBEN Elements — für die Anker-
// Auflösung (Shift-Ortho, Tab-Feld-Controller): der "Nachbar-Griff" muss aus
// demselben Element kommen, nicht irgendeinem anderen der Mehrfachauswahl.
const gripOwnerRange: [number, number][] = [];
// Bei Mehrfachauswahl fehlt `selectedDrawingIds` manchmal trotzdem (nur die
// Einzel-ID `selectedDrawingId` gesetzt, s. useSelectionState-Konvention) —
// derselbe Fallback wie an anderen Stellen im Projekt.
const effectiveDrawingIds =
selectedDrawingIds.length > 0
? selectedDrawingIds
: selectedDrawingId
? [selectedDrawingId]
: [];
const grips: Vec2[] = (() => {
// Öffnung (Fenster/Tür): EIN Mittelpunkt-Griff auf der Wandachse. Ziehen
// verschiebt die Öffnung entlang der Wirts-Wand (siehe applyGrip →
@@ -113,7 +139,10 @@ export function useGripEditing(opts: {
const w = op && project.walls.find((x) => x.id === op.hostWallId);
if (op && w) {
const axis = normalize(sub(w.end, w.start));
return [add(w.start, scale(axis, op.position + op.width / 2))];
const pt = add(w.start, scale(axis, op.position + op.width / 2));
gripOwners.push({ localIndex: 0 });
gripOwnerRange.push([0, 0]);
return [pt];
}
return [];
}
@@ -121,34 +150,94 @@ export function useGripEditing(opts: {
// ganze Treppe (applyGrip → moveStairBy). Auswahl exklusiv.
if (selectedStairId) {
const st = (project.stairs ?? []).find((s) => s.id === selectedStairId);
return st ? [stairMoveHandle(st)] : [];
if (!st) return [];
gripOwners.push({ localIndex: 0 });
gripOwnerRange.push([0, 0]);
return [stairMoveHandle(st)];
}
if (selectedDrawing) return drawingVertices(selectedDrawing);
if (selectedWallIds.length === 1 && !selectedDrawingId) {
const w = project.walls.find((x) => x.id === selectedWallIds[0]);
if (w) return [w.start, w.end];
}
// Decke: die Umriss-Vertices als Griffe (nur bei genau einer gewählten Decke).
if (selectedCeiling && !selectedDrawingId && selectedWallIds.length === 0) {
return [...selectedCeiling.outline];
// Decke: die Umriss-Vertices als Griffe (nur bei genau einer gewählten Decke,
// solange kein 2D-Element/Wand mitgewählt ist — kein Multi-Select für Decken).
if (selectedCeiling && effectiveDrawingIds.length === 0 && selectedWallIds.length === 0) {
const pts = [...selectedCeiling.outline];
pts.forEach((_, i) => { gripOwners.push({ localIndex: i }); gripOwnerRange.push([0, pts.length - 1]); });
return pts;
}
// Raum: die Umriss-Vertices als Griffe (nur bei genau einem gewählten Raum).
if (
selectedRoom &&
!selectedDrawingId &&
effectiveDrawingIds.length === 0 &&
selectedWallIds.length === 0 &&
!selectedCeiling
) {
return [...selectedRoom.boundary];
const pts = [...selectedRoom.boundary];
pts.forEach((_, i) => { gripOwners.push({ localIndex: i }); gripOwnerRange.push([0, pts.length - 1]); });
return pts;
}
return [];
// Mehrfachauswahl (Regel- UND Standardfall): je gewähltem 2D-Element bzw.
// je gewählter Wand ein eigener, in sich zusammenhängender Vertex-Block.
// Wand UND 2D-Element schliessen sich weiterhin gegenseitig aus (wie schon
// bei der Auswahl selbst — s. die vielen `!selectedDrawingId`-Wächter, die
// dieses Modul vorher hatte).
const out: Vec2[] = [];
const pushGroup = (pts: Vec2[], owner: { drawingId?: string; wallId?: string }) => {
if (pts.length === 0) return;
const start = out.length;
pts.forEach((p, i) => {
out.push(p);
gripOwners.push({ ...owner, localIndex: i });
});
const end = out.length - 1;
for (let i = start; i <= end; i++) gripOwnerRange.push([start, end]);
};
for (const id of effectiveDrawingIds) {
const d = project.drawings2d.find((x) => x.id === id);
if (d) pushGroup(drawingVertices(d), { drawingId: id });
}
if (effectiveDrawingIds.length === 0) {
for (const id of selectedWallIds) {
const w = project.walls.find((x) => x.id === id);
if (w) pushGroup([w.start, w.end], { wallId: id });
}
}
return out;
})();
// Pro Seite des selektierten Elements ein Griff: Kanten-Mittelpunkt + nach
// AUSSEN zeigende Einheitsnormale + die beiden Vertex-Indizes (passend zur
// `grips`-/`drawingVertices`-Reihenfolge). Die Außenrichtung wird über das
// Vorzeichen des Skalarprodukts der Kanten-Normale mit (mid − centroid)
// gewählt (vom Polygon-Schwerpunkt weg).
// Bezier-Hilfslinien (Anker → Kontrollpunkt-Griff), gestrichelt gerendert,
// damit sichtbar/nachvollziehbar ist, welcher Griff welchen Kurvenabschnitt
// steuert (Nutzer-Wunsch: „Hilfslinien sollten schon sichtbar sein um sie
// zu steuern" — wie bei Illustrator/Inkscape üblich). `pts` folgt dem Muster
// Anker·Griff·Griff·Anker·… (s. `Drawing2DGeom["bezier"]`); jeder Anker
// bekommt eine Linie zu seinem/seinen benachbarten Griff(en).
const gripHandleLines: { a: Vec2; b: Vec2 }[] = (() => {
const out: { a: Vec2; b: Vec2 }[] = [];
for (const id of effectiveDrawingIds) {
const d = project.drawings2d.find((x) => x.id === id);
const g = d?.geom;
if (!g || g.shape !== "bezier") continue;
for (let i = 0; i + 3 < g.pts.length; i += 3) {
out.push({ a: g.pts[i], b: g.pts[i + 1] }); // Anker → Griff 1
out.push({ a: g.pts[i + 3], b: g.pts[i + 2] }); // nächster Anker → Griff 2
}
}
return out;
})();
// Nachbar-Griff-Index INNERHALB desselben Elements (für Anker-Auflösung).
// null = kein Nachbar möglich (Einzelgriff-Element wie Kreis/Öffnung/Treppe).
const neighborGripIndex = (index: number): number | null => {
const range = gripOwnerRange[index];
if (!range) return null;
const [start, end] = range;
if (index > start) return index - 1;
if (end > start) return start + 1;
return null;
};
// Pro Seite JEDES gewählten Elements ein Griff: Kanten-Mittelpunkt + nach
// AUSSEN zeigende Einheitsnormale + die beiden lokalen Vertex-Indizes +
// `owner` (welchem Element diese Kante gehört — Mehrfachauswahl). Die
// Außenrichtung wird über das Vorzeichen des Skalarprodukts der Kanten-
// Normale mit (mid − centroid) gewählt (vom Polygon-Schwerpunkt weg).
// • rect: 4 Kanten (geschlossen).
// • polyline mit `closed`: Kanten zwischen aufeinanderfolgenden Vertices
// inkl. Schlusskante (letzter → erster).
@@ -160,7 +249,7 @@ export function useGripEditing(opts: {
// Verschieben EINER Kante einer komplexen Polylinie, §Task B).
const edgeGrips: EdgeGrip[] = (() => {
// Geschlossener Ring aus Vertices → Kanten-Griffe mit Außennormale.
const ringEdges = (verts: Vec2[]): EdgeGrip[] => {
const ringEdges = (verts: Vec2[], owner: { drawingId?: string; wallId?: string }): EdgeGrip[] => {
const n = verts.length;
if (n < 3) return [];
// Schwerpunkt (Mittel der Eckpunkte) als Innenbezug.
@@ -177,7 +266,7 @@ export function useGripEditing(opts: {
// Nach außen orientieren: Normale soll von centroid → mid weisen.
const outward = sub(mid, centroid);
if (nrm.x * outward.x + nrm.y * outward.y < 0) nrm = scale(nrm, -1);
out.push({ mid, normal: nrm, aIndex: i, bIndex: (i + 1) % n });
out.push({ mid, normal: nrm, aIndex: i, bIndex: (i + 1) % n, owner });
}
return out;
};
@@ -185,7 +274,7 @@ export function useGripEditing(opts: {
// Schlusskante. `normal` dient nur der Griff-Darstellung (leftNormal der
// Kante); die Bewegung ist frei (volles Delta), nicht auf die Normale
// beschränkt — daher `free: true`.
const openEdges = (verts: Vec2[]): EdgeGrip[] => {
const openEdges = (verts: Vec2[], owner: { drawingId?: string; wallId?: string }): EdgeGrip[] => {
const n = verts.length;
if (n < 2) return [];
const out: EdgeGrip[] = [];
@@ -194,48 +283,60 @@ export function useGripEditing(opts: {
const b = verts[i + 1];
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const nrm = normalize(leftNormal(sub(b, a)));
out.push({ mid, normal: nrm, aIndex: i, bIndex: i + 1, free: true });
out.push({ mid, normal: nrm, aIndex: i, bIndex: i + 1, free: true, owner });
}
return out;
};
if (selectedDrawing) {
const g = selectedDrawing.geom;
if (g.shape === "rect" || g.shape === "image") return ringEdges(grips);
if (g.shape === "polyline" && g.closed) return ringEdges(grips);
if (g.shape === "polyline" && !g.closed) return openEdges(grips);
if (g.shape === "line") return openEdges(grips);
return [];
}
if (selectedWallIds.length === 1 && !selectedDrawingId) {
const w = project.walls.find((x) => x.id === selectedWallIds[0]);
if (w) {
const mid: Vec2 = { x: (w.start.x + w.end.x) / 2, y: (w.start.y + w.end.y) / 2 };
const nrm = normalize(leftNormal(sub(w.end, w.start)));
return [{ mid, normal: nrm, aIndex: 0, bIndex: 1 }];
}
}
// Decke: geschlossener Ring aus dem Umriss.
if (selectedCeiling && !selectedDrawingId && selectedWallIds.length === 0) {
return ringEdges(grips);
// Decke: geschlossener Ring aus dem Umriss (Auswahl exklusiv, wie bei grips).
if (selectedCeiling && effectiveDrawingIds.length === 0 && selectedWallIds.length === 0) {
return ringEdges(selectedCeiling.outline, {});
}
// Raum: geschlossener Ring aus dem Umriss.
if (
selectedRoom &&
!selectedDrawingId &&
effectiveDrawingIds.length === 0 &&
selectedWallIds.length === 0 &&
!selectedCeiling
) {
return ringEdges(grips);
return ringEdges(selectedRoom.boundary, {});
}
return [];
const out: EdgeGrip[] = [];
for (const id of effectiveDrawingIds) {
const d = project.drawings2d.find((x) => x.id === id);
if (!d) continue;
const verts = drawingVertices(d);
const g = d.geom;
const owner = { drawingId: id };
if (g.shape === "rect" || g.shape === "image") out.push(...ringEdges(verts, owner));
else if (g.shape === "polyline" && g.closed) out.push(...ringEdges(verts, owner));
else if (g.shape === "polyline" && !g.closed) out.push(...openEdges(verts, owner));
else if (g.shape === "spline" && g.closed) out.push(...ringEdges(verts, owner));
else if (g.shape === "spline" && !g.closed) out.push(...openEdges(verts, owner));
else if (g.shape === "line") out.push(...openEdges(verts, owner));
}
if (effectiveDrawingIds.length === 0) {
for (const id of selectedWallIds) {
const w = project.walls.find((x) => x.id === id);
if (!w) continue;
const mid: Vec2 = { x: (w.start.x + w.end.x) / 2, y: (w.start.y + w.end.y) / 2 };
const nrm = normalize(leftNormal(sub(w.end, w.start)));
out.push({ mid, normal: nrm, aIndex: 0, bIndex: 1, owner: { wallId: id } });
}
}
return out;
})();
// Zuletzt angewandter Kanten-Offset während eines Edge-Grip-Drags (Anker).
// null = kein laufender Drag; der erste Move-Event verankert nur (kein Sprung).
const edgeDragOffsetRef = useRef<number | null>(null);
// Freies Kanten-Ziehen (offene Polylinie/Linie): letzter roher Cursor, gegen den
// das inkrementelle 2D-Delta gebildet wird. null = Drag noch nicht verankert.
const edgeFreeLastRef = useRef<Vec2 | null>(null);
// Bereits angewandtes 2D-Delta seit Grab-Beginn eines Kanten-Drags, relativ
// zum ORIGINALEN Kanten-Mittelpunkt (`edge.mid` — die beim Grab erfasste
// Momentaufnahme, ändert sich während des Drags nicht). null = noch kein
// Move-Event verarbeitet (erstes Event verankert nur, kein Sprung). Ein
// ABSOLUTER Zielpunkt (aus Maus ODER gelocktem Feldwert) wird jeden Frame neu
// berechnet; angewandt wird nur die Differenz zum zuletzt angewandten Delta
// (moveEdgeOf/moveRoomEdge/moveCeilingEdge sind additiv). Dieselbe Formel
// trägt sowohl den geführten (Normalen-)Fall als auch den freien Fall — vorher
// zwei getrennte, rein inkrementelle Mechanismen ohne Bezug zum Ursprung, was
// eine getippte Ziel-Distanz (s. edgeEdit unten) unmöglich gemacht hätte.
const edgeAppliedDeltaRef = useRef<Vec2 | null>(null);
// Körper-Verschieben (2D): koinzidente Endpunkte ANDERER gewählter Elemente, die
// an einer Ecke des verschobenen Elements hängen, werden um dasselbe Delta
// mitgeführt (gemeinsame Ecke bleibt verbunden). Lazy beim ersten Move-Event
@@ -317,6 +418,12 @@ export function useGripEditing(opts: {
// Letzter roher Cursor des laufenden Vertex-Drags (für die Lock-Auflösung beim
// Tippen, wenn gerade kein Move-Event kommt). Plus der gezogene Griff-Index.
const gripDragInfoRef = useRef<{ index: number; raw: Vec2 } | null>(null);
// Position des gezogenen Griffs VOR dem Drag (beim ersten Move-Event nach dem
// Grab verankert) — Bezugspunkt für die Flucht-Kandidaten unten (die
// Nachbarkanten müssen an ihrer URSPRÜNGLICHEN, nicht der schon verschobenen
// Position gemessen werden, sonst würde die Richtung während des Ziehens
// mitdriften).
const gripOriginRef = useRef<Vec2 | null>(null);
// Endpunkt aus Anker + (gelockten/Live-)Länge/Winkel — wie lineEndFromFields,
// hier für den Vertex-Drag. Ungelockte Größen folgen dem rohen Cursor `raw`.
@@ -361,39 +468,136 @@ export function useGripEditing(opts: {
];
};
// Fixer Bezug (Anker) für Länge/Winkel eines gezogenen Punkt-Griffs. Normal
// die BENACHBARTE Ecke DESSELBEN Elements (Polylinien-Semantik) — bei Kreis/
// Bogen ist das falsch (deren Griffe liegen auf dem Ring, "Länge" muss der
// RADIUS sein, also relativ zum ZENTRUM). Ohne diesen Sonderfall blieb der
// Kreis-Radius-Griff sogar ganz ohne Anker (nur 1 Griff, kein Nachbar).
// Gemeinsam genutzt vom Tab-Feld-Controller UND dem Shift-Ortho-Bezug UND
// dem Cursor-HUD (Länge/Winkel-Anzeige neben der Maus).
const resolveGripAnchor = (index: number): Vec2 | null => {
// Bei Mehrfachauswahl gehört der gezogene Griff evtl. NICHT dem einzeln
// selektierten `selectedDrawing`, sondern einem anderen Element der
// Auswahl — das eigentliche Element über `gripOwners` auflösen.
const owner = gripOwners[index];
const ownerDrawing = owner?.drawingId
? project.drawings2d.find((d) => d.id === owner.drawingId)
: selectedDrawing;
const g = ownerDrawing?.geom;
if (g && (g.shape === "circle" || g.shape === "arc" || g.shape === "ellipse")) return g.center;
const refIndex = neighborGripIndex(index);
return refIndex != null ? grips[refIndex] : null;
};
// Die beiden (fixen) Polygon-Nachbarn eines gezogenen Punkt-Griffs — für die
// Flucht-Kandidaten unten. Anders als `neighborGripIndex` (EIN Nachbar,
// reicht für Länge/Winkel-Anker) werden hier BEIDE gebraucht: ein Vielecks-
// Eckpunkt hängt an ZWEI Kanten, jede liefert eine eigene mögliche Flucht-
// Richtung. Kennt geschlossene Ringe (rect/geschlossene Polylinie/Raum/
// Decke — Nachbar 0/n-1 wickelt um) vs. offene Ketten (offene Polylinie/
// Linie — Enden haben nur einen Nachbarn). Kreis/Bogen/Wand/Öffnung/Treppe
// haben keine Polygon-Nachbarn (kein Vielecks-Eckpunkt) → {null,null}.
const ringNeighborPts = (
pts: Vec2[],
i: number,
closed: boolean,
): { prev: Vec2 | null; next: Vec2 | null } => {
const n = pts.length;
const prevIdx = i > 0 ? i - 1 : closed ? n - 1 : null;
const nextIdx = i < n - 1 ? i + 1 : closed ? 0 : null;
return {
prev: prevIdx != null && prevIdx !== i ? pts[prevIdx] : null,
next: nextIdx != null && nextIdx !== i && nextIdx !== prevIdx ? pts[nextIdx] : null,
};
};
const resolvePolygonNeighbors = (index: number): { prev: Vec2 | null; next: Vec2 | null } => {
const owner = gripOwners[index];
const none = { prev: null, next: null };
if (!owner?.drawingId && !owner?.wallId) {
// Raum/Decke (exklusiv) — kein Owner, `localIndex` fehlt dann auch, also
// ist der flache `index` selbst der Ring-Index.
const pts = selectedCeiling
? selectedCeiling.outline
: selectedRoom
? selectedRoom.boundary
: null;
return pts ? ringNeighborPts(pts, owner?.localIndex ?? index, true) : none;
}
if (owner.wallId) return none; // Wand: nur 2 Punkte, kein Vielecks-Nachbar
const d = project.drawings2d.find((x) => x.id === owner.drawingId);
const g = d?.geom;
if (!g) return none;
if (g.shape === "polyline" || g.shape === "spline")
return ringNeighborPts(g.pts, owner.localIndex, !!g.closed);
if (g.shape === "rect" || g.shape === "image") {
const corners = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
return ringNeighborPts(corners, owner.localIndex, true);
}
if (g.shape === "line") return ringNeighborPts([g.a, g.b], owner.localIndex, false);
return none; // circle/arc: kein Vielecks-Eckpunkt
};
// Rohen Cursor `raw` auf die nächstliegende von mehreren Kandidaten-Achsen
// projizieren (jeweils Stützpunkt + Richtung) — "nächstliegend" = kleinster
// senkrechter Abstand von `raw` zur (unendlich verlängerten) Achse. Deckt
// Shift-Ortho (Welt-H/V) UND Flucht-Snap (Richtung anliegender, fixer
// Kanten) in einem einheitlichen Mechanismus ab.
const closestAxisPoint = (raw: Vec2, candidates: { point: Vec2; dir: Vec2 }[]): Vec2 | null => {
let best: Vec2 | null = null;
let bestDist = Infinity;
for (const c of candidates) {
const len = Math.hypot(c.dir.x, c.dir.y);
if (len < 1e-9) continue; // entartete Richtung (koinzidenter Nachbar) → überspringen
const u = { x: c.dir.x / len, y: c.dir.y / len };
const rel = sub(raw, c.point);
const t = rel.x * u.x + rel.y * u.y;
const proj = add(c.point, scale(u, t));
const dist = Math.hypot(raw.x - proj.x, raw.y - proj.y);
if (dist < bestDist) {
bestDist = dist;
best = proj;
}
}
return best;
};
// Stabiler Ref auf cycleGripEditField (der globale Tab-Handler hat leere Deps).
const cycleGripEditFieldRef = useRef<() => void>(() => {});
// Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle). Der
// Anker ist normalerweise die BENACHBARTE Ecke (Polylinien-Semantik: Länge/
// Winkel relativ zum Nachbarpunkt) — bei Kreis/Bogen ist das falsch (deren
// Griffe liegen auf dem Ring, "Länge" muss der RADIUS sein, also relativ
// zum ZENTRUM). Ohne diese Sonderfälle blieb der Kreis-Radius-Griff sogar
// ganz ohne Anker (nur 1 Griff, kein Nachbar) → Tab tat nichts.
// Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle).
// • ÖFFNEN (noch kein gripEdit): friert BEIDE Werte an der aktuellen
// Position ein (`locks` sofort vollständig befüllt statt leer) — die
// Maus hat ab jetzt keinen Einfluss mehr, bis man explizit wieder in ein
// Feld tabbt (verhindert, dass ein Zittern der Hand beim Greifen zur
// Tastatur den Punkt noch verschiebt).
// • ZYKELN (gripEdit schon offen): wechselt zum anderen Feld und LÖST
// dessen Lock (frei für die Maus) — das Feld, das man verlässt, bleibt
// exakt auf seinem letzten Wert fixiert (getippt oder eingefroren).
// Nutzer-Wunsch: nach dem ersten getippten Wert soll das ANDERE Feld
// nicht sofort wieder der Maus folgen, sondern erst nach explizitem Tab
// dorthin — sonst „wird der Winkel nie richtig fix".
const cycleGripEditField = () => {
const info = gripDragInfoRef.current;
if (!info) return;
const g = selectedDrawing?.geom;
const anchor =
g && (g.shape === "circle" || g.shape === "arc")
? g.center
: (() => {
const refIndex = info.index > 0 ? info.index - 1 : grips.length > 1 ? 1 : -1;
return refIndex >= 0 ? grips[refIndex] : null;
})();
const anchor = resolveGripAnchor(info.index);
if (!anchor) return; // ohne Anker keine polare Eingabe möglich
setGripEdit((prev) =>
prev
? { ...prev, active: prev.active === "length" ? "angle" : "length" }
: { anchor, active: "length", locks: {} },
);
setGripEdit((prev) => {
if (!prev) {
const frozen = grips[info.index];
const dx = frozen.x - anchor.x;
const dy = frozen.y - anchor.y;
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { anchor, active: "length", locks: { length: Math.hypot(dx, dy), angle } };
}
const active = prev.active === "length" ? "angle" : "length";
const locks = { ...prev.locks };
delete locks[active];
return { ...prev, active, locks };
});
};
cycleGripEditFieldRef.current = cycleGripEditField;
// Globaler Tab-Handler (Feld-Zyklus/Zahlen-Tippen) — siehe state/useKeyboardShortcuts.ts.
useCommandTabShortcut(engineRef, commandLineRef, gripDragInfoRef, gripEditRef, cycleGripEditFieldRef);
// Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken und
// den Zielpunkt sofort mit dem gelockten Wert neu setzen.
// Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken
// (das andere Feld bleibt unverändert fixiert, s. o.).
const submitGripEditValue = (value: number) => {
const ge = gripEditRef.current;
const info = gripDragInfoRef.current;
@@ -406,6 +610,149 @@ export function useGripEditing(opts: {
applyGrip(info.index, pt);
};
// Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile, s.
// App.tsx) — beendet NICHT den ganzen Griff-Drag (der bleibt bewaffnet,
// Maus steuert wieder normal wie vor dem ersten Tab).
const closeGripEditField = () => {
setGripEdit(null);
};
// Dasselbe Feld-Controller-Muster wie oben (gripEdit), jetzt für einen Kanten-
// Griff (Dreieck-Anfasser, „Seite parallel verschieben"). Ein Feld genügt für
// geführte Kanten (rect/geschlossene Polylinie/Wand — Bewegung ist auf die
// Normale beschränkt, "Länge" ist der vorzeichenbehaftete Abstand); freie
// Kanten (offene Polylinie/Linie) bekommen wie ein Punkt-Griff Länge+Winkel.
const [edgeEdit, setEdgeEdit] = useState<{
/** Kanten-Mittelpunkt bei Grab-Beginn (fix für die ganze Dauer des Drags). */
anchor: Vec2;
active: "length" | "angle";
locks: { length?: number; angle?: number };
} | null>(null);
const edgeEditRef = useRef<typeof edgeEdit>(null);
edgeEditRef.current = edgeEdit;
// Letzter roher Cursor + der gezogene Kanten-Griff (Momentaufnahme vom Grab).
const edgeDragInfoRef = useRef<{ edge: EdgeGrip; raw: Vec2 } | null>(null);
// Zielpunkt aus Anker + (gelockten/Live-)Werten — bei freien Kanten wie ein
// Punkt-Griff (polar), bei geführten Kanten nur entlang der festen Normale.
const edgeEditPoint = (
edge: EdgeGrip,
anchor: Vec2,
locks: { length?: number; angle?: number },
raw: Vec2,
): Vec2 => {
if (edge.free) return gripEditPoint(anchor, locks, raw);
const live = dot(sub(raw, anchor), edge.normal);
const len = locks.length != null ? locks.length : live;
return add(anchor, scale(edge.normal, len));
};
// Felder fürs Befehlsfeld während des Kanten-Drags. Geführte Kanten zeigen
// NUR "Länge" (kein Winkel — der ist durch die feste Normale vorgegeben).
const edgeEditFields = (): GripEditFieldsState[] => {
const ee = edgeEdit;
const info = edgeDragInfoRef.current;
if (!ee || !info) return [];
const { edge, raw } = info;
if (!edge.free) {
const live = dot(sub(raw, ee.anchor), edge.normal);
return [
{
id: "length",
labelKey: "cmd.field.length",
value: ee.locks.length ?? live,
locked: ee.locks.length != null,
active: true,
},
];
}
const dx = raw.x - ee.anchor.x;
const dy = raw.y - ee.anchor.y;
const liveLen = Math.hypot(dx, dy);
const liveAng = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 360) + 360) % 360;
return [
{
id: "length",
labelKey: "cmd.field.length",
value: ee.locks.length ?? liveLen,
locked: ee.locks.length != null,
active: ee.active === "length",
},
{
id: "angle",
labelKey: "cmd.field.angle",
value: ee.locks.angle ?? liveAng,
locked: ee.locks.angle != null,
active: ee.active === "angle",
},
];
};
const cycleEdgeEditFieldRef = useRef<() => void>(() => {});
// Tab im Kanten-Drag: Feld-Controller öffnen/zyklen — dasselbe Muster wie
// cycleGripEditField oben (Öffnen friert BEIDE Werte ein, Zykeln löst NUR
// das neu aktive Feld). Geführte Kanten haben nur ein Feld ("Länge" =
// Abstand entlang der Normale) → Zykeln tut dort nichts, das eine Feld
// bleibt beim Öffnen ohnehin schon eingefroren/fixiert.
const cycleEdgeEditField = () => {
const info = edgeDragInfoRef.current;
if (!info) return;
const { edge, raw } = info;
setEdgeEdit((prev) => {
if (!prev) {
const anchor = edge.mid;
const frozen = edgeEditPoint(edge, anchor, {}, raw);
const dx = frozen.x - anchor.x;
const dy = frozen.y - anchor.y;
if (edge.free) {
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { anchor, active: "length", locks: { length: Math.hypot(dx, dy), angle } };
}
const offset = dx * edge.normal.x + dy * edge.normal.y;
return { anchor, active: "length", locks: { length: offset } };
}
if (!edge.free) return prev; // geführte Kante: nur ein Feld, nichts zu zykeln
const active = prev.active === "length" ? "angle" : "length";
const locks = { ...prev.locks };
delete locks[active];
return { ...prev, active, locks };
});
};
cycleEdgeEditFieldRef.current = cycleEdgeEditField;
// Globaler Tab-Handler (Feld-Zyklus/Zahlen-Tippen, Punkt- UND Kanten-Griff) —
// siehe state/useKeyboardShortcuts.ts.
useCommandTabShortcut(
engineRef,
commandLineRef,
gripDragInfoRef,
gripEditRef,
cycleGripEditFieldRef,
edgeDragInfoRef,
edgeEditRef,
cycleEdgeEditFieldRef,
);
// Getippte Zahl im Kanten-Drag: Feld locken, bei freien Kanten aufs nächste
// Feld vorrücken, Zielpunkt sofort mit dem gelockten Wert neu anwenden.
const submitEdgeEditValue = (value: number) => {
const ee = edgeEditRef.current;
const info = edgeDragInfoRef.current;
if (!ee || !info) return;
const { edge, raw } = info;
const locks = { ...ee.locks, [ee.active]: value };
const next = edge.free ? (ee.active === "length" ? "angle" : "length") : ee.active;
setEdgeEdit({ ...ee, locks, active: next });
const pt = edgeEditPoint(edge, ee.anchor, locks, raw);
applyEdgeToward(edge, ee.anchor, pt);
};
// Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile) — beendet
// NICHT den Kanten-Drag (der bleibt bewaffnet, Maus steuert wieder normal).
const closeEdgeEditField = () => {
setEdgeEdit(null);
};
// Editier-Dispatch: bei gewählter Decke auf die Decken-Aktionen umleiten, sonst
// auf die Wand/2D-Aktionen. So teilen Plan- UND 3D-Griffe denselben Pfad.
const applyGrip = (index: number, pt: Vec2) => {
@@ -438,45 +785,132 @@ export function useGripEditing(opts: {
}
if (selectedRoomId) moveRoomGrip(selectedRoomId, index, pt);
else if (selectedCeilingId) moveCeilingGrip(selectedCeilingId, index, pt);
else moveGripOf(selectedDrawingId, selectedWallId, index, pt);
else {
// Mehrfachauswahl: welchem Element dieser Griff gehört, kommt aus
// `gripOwners` (nicht mehr aus den singulären selectedDrawingId/-WallId).
const owner = gripOwners[index];
moveGripOf(owner?.drawingId ?? selectedDrawingId, owner?.wallId ?? selectedWallId, owner?.localIndex ?? index, pt);
}
};
const applyEdge = (aIndex: number, bIndex: number, delta: Vec2) => {
if (selectedRoomId) moveRoomEdge(selectedRoomId, aIndex, bIndex, delta);
else if (selectedCeilingId) moveCeilingEdge(selectedCeilingId, aIndex, bIndex, delta);
else moveEdgeOf(selectedDrawingId, selectedWallId, aIndex, bIndex, delta);
const applyEdge = (edge: EdgeGrip, delta: Vec2) => {
if (selectedRoomId) moveRoomEdge(selectedRoomId, edge.aIndex, edge.bIndex, delta);
else if (selectedCeilingId) moveCeilingEdge(selectedCeilingId, edge.aIndex, edge.bIndex, delta);
else moveEdgeOf(edge.owner?.drawingId ?? selectedDrawingId, edge.owner?.wallId ?? selectedWallId, edge.aIndex, edge.bIndex, delta);
};
// Kante auf einen ABSOLUTEN Zielpunkt (relativ zum ORIGINALEN Anker) bewegen —
// moveEdge* ist additiv, daher nur die Differenz zum zuletzt angewandten Delta
// anwenden (s. edgeAppliedDeltaRef). Trägt sowohl Maus-geführte Bewegung als
// auch gelockte Feldwerte UND das Verwerfen (Ziel = Anker selbst → Delta 0,
// die Differenz zum vorherigen Delta macht die ganze Verschiebung rückgängig).
const applyEdgeToward = (edge: EdgeGrip, anchor: Vec2, pt: Vec2) => {
const desired = sub(pt, anchor);
const prev = edgeAppliedDeltaRef.current;
edgeAppliedDeltaRef.current = desired;
if (prev === null) return; // erster Event verankert nur (kein Sprung beim Greifen)
const inc = sub(desired, prev);
if (inc.x === 0 && inc.y === 0) return;
applyEdge(edge, inc);
};
// Maß-/Winkel-HUD am Cursor für einen gezogenen Punkt-Griff — dieselbe
// Darstellung wie beim Zeichnen (DraftHud, VW-Stil). Ohne Anker (Einzelgriff-
// Elemente wie Öffnung/Treppe) gibt es keine sinnvolle Länge/Winkel-Anzeige.
const hudFor = (index: number, pt: Vec2): NonNullable<ToolDraft["hud"]> | undefined => {
const anchor = resolveGripAnchor(index);
if (!anchor) return undefined;
const dx = pt.x - anchor.x;
const dy = pt.y - anchor.y;
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { at: pt, length: Math.hypot(dx, dy), angleDeg };
};
// Dasselbe für einen gezogenen Kanten-Griff: freie Kanten zeigen Länge+Winkel
// (wie ein Punkt-Griff), geführte Kanten nur EINEN Wert (Abstand entlang der
// Normale) — dafür reicht das HUD-Schema nicht (length+angleDeg gehören
// zusammen), daher als freier Text `Δ: …m`.
const edgeHudFor = (edge: EdgeGrip, anchor: Vec2, pt: Vec2): NonNullable<ToolDraft["hud"]> => {
const dx = pt.x - anchor.x;
const dy = pt.y - anchor.y;
if (edge.free) {
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { at: pt, length: Math.hypot(dx, dy), angleDeg };
}
const offset = dx * edge.normal.x + dy * edge.normal.y;
return { at: pt, text: `Δ: ${offset.toFixed(3)}m` };
};
// Aufräumen nach Abschluss/Verwerfen eines Griff- ODER Kanten-Drags (geteilt
// von onGripEnd/onGripCancel/onEdgeEnd/onEdgeCancel — nie beide gleichzeitig
// aktiv, ein gemeinsames Aufräumen ist daher unschädlich und einfacher).
const endEditing = () => {
setDraft(null);
edgeAppliedDeltaRef.current = null; // Kanten-Drag beendet → Anker zurücksetzen
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen
gripOriginRef.current = null; // Flucht-Bezugspunkt verwerfen
setGripEdit(null); // (c) Feld-Controller schließen
edgeDragInfoRef.current = null; // Kanten-Drag-Info verwerfen
setEdgeEdit(null); // Kanten-Feld-Controller schließen
};
const gripHandlers = {
onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
// (c) Vertex-Drag mit aktivem Feld-Controller + Locks: den Zielpunkt polar
// aus Anker + gelockter Länge/Winkel bilden (ungelockt folgt dem Cursor).
// Erster Move-Event nach dem Grab (gripDragInfoRef noch leer): die
// AKTUELLE — hier noch unveränderte — Position als Flucht-Bezugspunkt
// verankern (s. Kommentar an gripOriginRef).
if (gripDragInfoRef.current === null) gripOriginRef.current = grips[index];
// (c) Vertex-Drag mit aktivem Feld-Controller: `locks` ist ab dem
// ÖFFNEN (Tab) immer für BEIDE Felder befüllt (s. cycleGripEditField),
// die Maus wirkt also erst wieder, sobald ein Feld per Tab explizit
// gelöst wurde — kein separater „eingefroren"-Zustand mehr nötig.
const ge = gripEditRef.current;
gripDragInfoRef.current = { index, raw };
if (ge && (ge.locks.length != null || ge.locks.angle != null)) {
const pt = gripEditPoint(ge.anchor, ge.locks, raw);
applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: null });
setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
return;
}
// Mehrfachauswahl: das Element, dem DIESER Griff gehört (nicht zwingend
// das singuläre selectedDrawingId/selectedWallId) — von den eigenen
// Snap-Kandidaten ausschließen (sonst fängt der Punkt an sich selbst).
const owner = gripOwners[index];
const snapProject: Project = selectedRoomId
? { ...project, rooms: (project.rooms ?? []).filter((r) => r.id !== selectedRoomId) }
: selectedCeilingId
? { ...project, ceilings: (project.ceilings ?? []).filter((c) => c.id !== selectedCeilingId) }
: selectedDrawingId
? { ...project, drawings2d: project.drawings2d.filter((d) => d.id !== selectedDrawingId) }
: { ...project, walls: project.walls.filter((w) => w.id !== selectedWallId) };
// Bezugspunkt für Shift-Ortho: ein Nachbar-Griff (festes Ende), relativ zu
// dem die Bewegung auf H/V eingerastet wird.
const refIndex = index > 0 ? index - 1 : grips.length > 1 ? 1 : -1;
const anchor = refIndex >= 0 ? grips[refIndex] : null;
// Shift = Ortho-Lock: den gezogenen Punkt auf H/V gegenüber dem FIXEN Anker
// zwingen (Achse = die nähere; applyAngleConstraint mit 90°, wie Ortho beim
// Zeichnen). Der Lock dominiert (kein Snap), damit die Endkoordinate
// garantiert eine Achse mit dem Anker teilt — identisch zum 3D-Vertex-Drag.
: owner?.drawingId
? { ...project, drawings2d: project.drawings2d.filter((d) => d.id !== owner.drawingId) }
: owner?.wallId
? { ...project, walls: project.walls.filter((w) => w.id !== owner.wallId) }
: project;
// Bezugspunkt für Shift-Ortho: ein Nachbar-Griff DESSELBEN Elements
// (festes Ende), relativ zu dem die Bewegung auf H/V eingerastet wird.
const refIndex = neighborGripIndex(index);
const anchor = refIndex != null ? grips[refIndex] : null;
// Shift = Achsen-Lock: den gezogenen Punkt auf eine von mehreren
// Kandidaten-Achsen zwingen (welche gewinnt, s. closestAxisPoint) — Welt-
// H/V UND, bei einem Vielecks-Eckpunkt (zwei anliegende Kanten), zusätzlich
// die FLUCHT der beiden fixen Nachbarkanten in ihrer ursprünglichen
// Richtung. Nutzer-Wunsch: „wenn ein Punkt aus zwei Linien besteht, die
// nicht in 0/90° liegen, sollte Shift auch in DIESE Richtung snappen" —
// vorher war Shift stur auf Welt-H/V beschränkt, bei schrägen Anschlüssen
// also nutzlos für „entlang der bestehenden Kante weiterziehen". Der Lock
// dominiert (kein Snap), damit die Endkoordinate garantiert eine Achse
// trifft — identisch zum bisherigen Ortho-Verhalten.
if (mods.shift && anchor) {
const pt = applyAngleConstraint(anchor, raw, 90);
const candidates: { point: Vec2; dir: Vec2 }[] = [
{ point: anchor, dir: { x: 1, y: 0 } },
{ point: anchor, dir: { x: 0, y: 1 } },
];
const origin = gripOriginRef.current;
if (origin) {
const { prev, next } = resolvePolygonNeighbors(index);
if (prev) candidates.push({ point: prev, dir: sub(origin, prev) });
if (next) candidates.push({ point: next, dir: sub(next, origin) });
}
const pt = closestAxisPoint(raw, candidates) ?? applyAngleConstraint(anchor, raw, 90);
applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: null });
setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
return;
}
// Snap gegen das Projekt OHNE das gerade gezogene Element (sonst fängt der
@@ -496,7 +930,7 @@ export function useGripEditing(opts: {
});
const pt = snapResult?.point ?? raw;
applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: snapResult });
setDraft({ preview: [], vertices: [], snap: snapResult, hud: hudFor(index, pt) });
},
onMoveBody: (
delta: Vec2,
@@ -542,47 +976,46 @@ export function useGripEditing(opts: {
moveGripOf(c.drawingId, c.wallId, c.index, c.pos);
}
},
// Kanten-Griff ziehen. Zwei Verhalten:
// Kanten-Griff ziehen. Zwei Verhalten (Zielpunkt jeweils ABSOLUT relativ
// zum ORIGINALEN Anker `edge.mid`, angewandt über applyEdgeToward — s. dort):
// • free (offene Polylinie/Linie, §Task B): das Segment folgt dem VOLLEN
// Cursor-Delta — beide Endpunkte wandern gleich, die geteilten Ecken ziehen
// die Nachbar-Segmente nach. Shift = Ortho (H/V) auf das Gesamt-Delta.
// die Nachbar-Segmente nach. Shift = Ortho (H/V) auf den Gesamt-Vektor.
// • sonst (rect/geschlossene Polylinie/Wand): NUR die Komponente senkrecht
// zur Kante (Seite verschiebt sich parallel nach außen/innen). Fängt an
// eingeschalteten Geometrie-Snaps (Endpunkt/Mittelpunkt/Schnittpunkt/…);
// ohne Geometrie-Snap wird der Offset wie bisher aufs Raster gerundet.
onEdgeMove: (edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
edgeDragInfoRef.current = { edge, raw };
const anchor = edge.mid; // Momentaufnahme vom Grab, ändert sich während des Drags nicht
const ee = edgeEditRef.current;
// Feld-Controller mit Lock aktiv: Zielpunkt aus Anker + gelocktem Wert.
// `locks` ist ab dem Öffnen (Tab) immer befüllt (s. cycleEdgeEditField),
// die Maus wirkt also erst wieder, sobald ein Feld per Tab gelöst wurde.
if (ee && (ee.locks.length != null || ee.locks.angle != null)) {
const pt = edgeEditPoint(edge, anchor, ee.locks, raw);
applyEdgeToward(edge, anchor, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
return;
}
if (edge.free) {
// Freier Fall arbeitet mit einem inkrementellen Cursor-DELTA (nicht
// einem absoluten Punkt) — eine Geometrie-Snap-Projektion auf die
// Normale ergibt hier keinen Sinn, da es keine feste Kanten-Normale
// gibt, an die sich ein gefangener Punkt anlehnen könnte. Bleibt beim
// bisherigen Raster-Delta-Verhalten.
//
// Inkrementelles 2D-Delta gegen den letzten rohen Cursor. Erster Event
// verankert nur (kein Sprung, da der Griff am Kanten-Mittelpunkt sitzt).
const last = edgeFreeLastRef.current;
edgeFreeLastRef.current = raw;
if (last === null) return;
let dx = raw.x - last.x;
let dy = raw.y - last.y;
// Raster-Schritt auf das Delta (Snap aktiv, nicht per Ctrl unterdrückt).
// Keine Geometrie-Snap-Projektion (keine feste Kanten-Normale, an die
// sich ein gefangener Punkt anlehnen könnte) — der Cursor bewegt den
// Zielpunkt direkt, nur Ortho/Raster wirken.
let pt = raw;
if (mods.shift) {
const dx = raw.x - anchor.x;
const dy = raw.y - anchor.y;
pt = Math.abs(dx) >= Math.abs(dy) ? { x: raw.x, y: anchor.y } : { x: anchor.x, y: raw.y };
}
const gs = snap.gridSize;
if (!mods.ctrl && snap.enabled && snap.grid && gs > 0) {
dx = Math.round(dx / gs) * gs;
dy = Math.round(dy / gs) * gs;
if (dx === 0 && dy === 0) {
edgeFreeLastRef.current = last; // noch kein voller Rasterschritt → warten
return;
}
const dx = Math.round((pt.x - anchor.x) / gs) * gs;
const dy = Math.round((pt.y - anchor.y) / gs) * gs;
pt = { x: anchor.x + dx, y: anchor.y + dy };
}
// Shift = Ortho: das Delta auf die dominante Achse (H/V) zwingen.
if (mods.shift) {
if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
else dx = 0;
}
if (dx === 0 && dy === 0) return;
applyEdge(edge.aIndex, edge.bIndex, { x: dx, y: dy });
setDraft({ preview: [], vertices: [], snap: null });
applyEdgeToward(edge, anchor, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
return;
}
// Cursor zuerst durch die Snap-Engine schicken (Ctrl unterdrückt Snap
@@ -593,8 +1026,8 @@ export function useGripEditing(opts: {
const snapResult = snapFor(raw, pxPerMeter, mods);
const isGeometrySnap = !!snapResult && snapResult.kind !== "grid" && snapResult.kind !== "ortho";
const cursor = isGeometrySnap ? snapResult!.point : raw;
// Senkrechte Projektion von (Cursor − Grab-Mittelpunkt) auf die Außennormale.
const rel = sub(cursor, edge.mid);
// Senkrechte Projektion von (Cursor − Anker) auf die Außennormale.
const rel = sub(cursor, anchor);
let offset = rel.x * edge.normal.x + rel.y * edge.normal.y;
// Raster-Schritt (Snap) nur, wenn kein Geometrie-Snap gefangen wurde —
// sonst gewinnt der gefangene Punkt.
@@ -602,29 +1035,40 @@ export function useGripEditing(opts: {
if (!isGeometrySnap && !mods.ctrl && snap.enabled && snap.grid && step > 0) {
offset = Math.round(offset / step) * step;
}
// INKREMENTELL + Grab-Anker: moveEdge verschiebt additiv, daher nur die
// Änderung seit dem letzten Event anwenden. Erster Event verankert nur
// (kein Sprung beim Greifen, da das Dreieck am Mittelpunkt sitzt).
const prev = edgeDragOffsetRef.current;
edgeDragOffsetRef.current = offset;
if (prev === null) return;
const inc = offset - prev;
if (inc === 0) return;
applyEdge(edge.aIndex, edge.bIndex, scale(edge.normal, inc));
setDraft({ preview: [], vertices: [], snap: isGeometrySnap ? snapResult : null });
const pt = add(anchor, scale(edge.normal, offset));
applyEdgeToward(edge, anchor, pt);
setDraft({
preview: [],
vertices: [],
snap: isGeometrySnap ? snapResult : null,
hud: edgeHudFor(edge, anchor, pt),
});
},
onGripEnd: () => {
setDraft(null);
edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen
edgeFreeLastRef.current = null; // freien Kanten-Drag-Anker zurücksetzen
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen
setGripEdit(null); // (c) Feld-Controller schließen
endEditing();
},
// Bewaffneten Punkt-Griff verwerfen (Escape): exakt auf `origin`
// zurücksetzen — ohne Snap/Ortho-Neuauswertung, ein reiner Revert — und
// danach wie onGripEnd aufräumen.
onGripCancel: (index: number, origin: Vec2) => {
applyGrip(index, origin);
endEditing();
},
onEdgeEnd: () => {
endEditing();
},
// Bewaffneten Kanten-Griff verwerfen (Escape): Ziel = Anker selbst (Delta
// 0) — applyEdgeToward macht dadurch die komplette bisherige Verschiebung
// in einem Schritt rückgängig (negatives Delta zum zuletzt angewandten).
onEdgeCancel: (edge: EdgeGrip, anchor: Vec2) => {
applyEdgeToward(edge, anchor, anchor);
endEditing();
},
};
return {
grips,
gripHandleLines,
edgeGrips,
gripHandlers,
gripEdit,
@@ -632,5 +1076,19 @@ export function useGripEditing(opts: {
gripEditFields,
cycleGripEditField,
submitGripEditValue,
closeGripEditField,
edgeEdit,
setEdgeEdit,
edgeEditFields,
cycleEdgeEditField,
submitEdgeEditValue,
closeEdgeEditField,
// Für useToolNumberShortcuts: solange ein Punkt- ODER Kanten-Griff
// bewaffnet ist, dürfen Zifferntasten NICHT als Werkzeug-Wechsel
// interpretiert werden (auch nicht, wenn das Befehlsfeld fokussiert und
// leer ist — die Zeichen-Engine selbst läuft ja nicht, daher greift
// dieser Wächter sonst nicht).
gripDragInfoRef,
edgeDragInfoRef,
};
}
+78
View File
@@ -20,6 +20,8 @@ import type { CommandLineHandle } from "../ui/CommandLine";
import type { Drawing2D, Project, Vec2 } from "../model/types";
import { differenceDrawings, intersectionDrawings, unionDrawings } from "../editors/booleanOps";
import type { BooleanResult } from "../editors/booleanOps";
import { groupSelection, mergeMembership, ungroup, EMPTY_MEMBERSHIP } from "./groupActions";
import type { GroupMembership } from "./groupActions";
/** Aktive Transformation (Bewegen/Spiegeln/Drehen) — Form aus useDrawingToolState. */
type ActiveTransform = {
@@ -69,6 +71,12 @@ export function useCommandTabShortcut(
locks: { length?: number; angle?: number };
} | null>,
cycleGripEditFieldRef: RefObject<() => void>,
// Dasselbe für einen Kanten-Griff (Seite parallel verschieben) — separater
// Drag-Kanal, aber identisches Tab-Verhalten (öffnen/zyklen + Befehlsfeld
// fokussieren).
edgeDragInfoRef?: RefObject<{ edge: unknown; raw: Vec2 } | null>,
edgeEditRef?: RefObject<unknown>,
cycleEdgeEditFieldRef?: RefObject<() => void>,
): void {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -105,6 +113,13 @@ export function useCommandTabShortcut(
if (!wasOpen) commandLineRef.current?.focus();
return;
}
// Dasselbe für einen laufenden Kanten-Drag (Seite parallel verschieben).
if (edgeDragInfoRef?.current) {
const wasOpen = edgeEditRef?.current != null;
cycleEdgeEditFieldRef?.current?.();
if (!wasOpen) commandLineRef.current?.focus();
return;
}
commandLineRef.current?.focus();
};
window.addEventListener("keydown", onKey);
@@ -124,6 +139,8 @@ export function useToolNumberShortcuts(
onSelectTool: (id: ToolId) => void,
isFloor: boolean,
engineRef: RefObject<CommandEngine | null>,
gripDragInfoRef?: RefObject<{ index: number; raw: Vec2 } | null>,
edgeDragInfoRef?: RefObject<unknown>,
): void {
const toolShortcutRef = useRef({ onSelectTool, floor: isFloor });
toolShortcutRef.current = { onSelectTool, floor: isFloor };
@@ -142,6 +159,14 @@ export function useToolNumberShortcuts(
};
const onKey = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
// Ein Punkt-Griff ist bewaffnet (s. useGripEditing.ts) — Zifferntasten
// gehören dann IMMER dessen Länge/Winkel-Feld, nie dem Werkzeug-Wechsel.
// Die `eng?.hasFields()`-Prüfung unten greift hier nicht, weil während
// der Griff-Bearbeitung gar kein Zeichenbefehl der Engine läuft (Bug:
// getippter Wert schaltete auf das per Ziffer gemappte Zeichenwerkzeug
// um, sobald das leere, fokussierte Befehlsfeld erkannt wurde).
if (gripDragInfoRef?.current) return;
if (edgeDragInfoRef?.current) return;
const el = e.target as HTMLElement | null;
const isInput =
!!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
@@ -477,6 +502,7 @@ export function useTransformShortcuts(
) => void,
selectedWallIds: string[],
selectedDrawingId: string | null,
selectedDrawingIds: string[],
selectedExtrudedSolidId: string | null,
selectedColumnId: string | null,
engine: CommandEngine,
@@ -518,6 +544,7 @@ export function useTransformShortcuts(
const hasSel =
selectedWallIds.length > 0 ||
!!selectedDrawingId ||
selectedDrawingIds.length > 0 ||
!!selectedExtrudedSolidId ||
!!selectedColumnId;
if (!hasSel) return;
@@ -542,8 +569,59 @@ export function useTransformShortcuts(
activeTransform,
selectedWallIds,
selectedDrawingId,
selectedDrawingIds,
selectedExtrudedSolidId,
selectedColumnId,
engine,
]);
}
/**
* Ctrl/Cmd+G = Auswahl gruppieren (Vectorworks-artig); Ctrl/Cmd+Shift+G =
* gewählte Gruppe(n) auflösen (EIN Level — Untergruppen bleiben bestehen).
* `applySelection` setzt danach alle betroffenen `selected…Ids`-Kanäle +
* `selectedGroupIds` auf einen Schlag (s. App.tsx, dieselbe Form wie die
* Gruppen-Erweiterung beim Klicken in `useMouseSelectionHandlers.ts`).
*/
export function useGroupShortcut(
project: Project,
setProject: (updater: (p: Project) => Project) => void,
currentSelection: GroupMembership,
selectedGroupIds: string[],
applySelection: (mem: GroupMembership, groupIds: string[]) => void,
): void {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.key.toLowerCase() !== "g") return;
if (isEditableTarget(e)) return;
e.preventDefault();
if (e.shiftKey) {
// Ungruppieren: für jede gewählte Gruppe eine Hülle auflösen; ihre
// direkten Mitglieder (+ Untergruppen) werden die neue Auswahl.
if (selectedGroupIds.length === 0) return;
let p = project;
let members: GroupMembership = { ...EMPTY_MEMBERSHIP };
const newGroupIds: string[] = [];
for (const gid of selectedGroupIds) {
const res = ungroup(p, gid);
if (!res) continue;
p = res.project;
members = mergeMembership(members, res.members);
newGroupIds.push(...res.subGroupIds);
}
setProject(() => p);
applySelection(members, newGroupIds);
return;
}
// Gruppieren: aktuelle Auswahl (+ bereits gewählte Gruppen als Untergruppen).
const res = groupSelection(project, currentSelection, selectedGroupIds);
if (!res) return;
setProject(() => res.project);
applySelection(currentSelection, [res.groupId]);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project, currentSelection, selectedGroupIds]);
}
+96 -7
View File
@@ -16,6 +16,26 @@ import { plainText } from "../text/richText";
import { hitTestWall } from "../viewport/planHitTest";
import type { useSelectionState } from "./useSelectionState";
import type { useContextMenuState } from "../menus/useContextMenuState";
import {
EMPTY_MEMBERSHIP,
expandToGroups,
isInsideActiveGroup,
type GroupMembership,
type MemberKind,
} from "./groupActions";
/** `PlanSelection`s einzelnes Treffer-Feld als (Art, ID) — oder `null` (Leerraum). */
function selHit(sel: PlanSelection): { kind: MemberKind; id: string } | null {
if (sel.wallId) return { kind: "wallIds", id: sel.wallId };
if (sel.drawingId) return { kind: "drawingIds", id: sel.drawingId };
if (sel.ceilingId) return { kind: "ceilingIds", id: sel.ceilingId };
if (sel.openingId) return { kind: "openingIds", id: sel.openingId };
if (sel.stairId) return { kind: "stairIds", id: sel.stairId };
if (sel.roomId) return { kind: "roomIds", id: sel.roomId };
if (sel.columnId) return { kind: "columnIds", id: sel.columnId };
if (sel.roofId) return { kind: "roofIds", id: sel.roofId };
return null;
}
export function useMouseSelectionHandlers(opts: {
project: Project;
@@ -48,6 +68,9 @@ export function useMouseSelectionHandlers(opts: {
| "setSelectedContextObjectIds"
| "selectedSectionLineId"
| "setSelectedSectionLineId"
| "selectedGroupIds"
| "setSelectedGroupIds"
| "groupPath"
>;
contextMenuState: Pick<
ReturnType<typeof useContextMenuState>,
@@ -79,6 +102,8 @@ export function useMouseSelectionHandlers(opts: {
setSelectedContextObjectIds,
selectedSectionLineId,
setSelectedSectionLineId,
setSelectedGroupIds,
groupPath,
} = selectionState;
const { setMenu, setStampEditorRoomId, editTextId, setEditTextId, setEditTextMinHeightM } = contextMenuState;
@@ -187,6 +212,11 @@ export function useMouseSelectionHandlers(opts: {
// Klick wird ignoriert, als läge dort nichts.
const hitCode = categoryCodeOfSelection(sel);
if (hitCode !== undefined && categoryDisplay(hitCode).locked) return;
// Gruppen-Isolation (Vectorworks „Gruppe betreten"): ausserhalb der
// aktiven Gruppe ist alles gesperrt (gedimmt gerendert, s. PlanView) —
// derselbe Ignorier-Kurzschluss wie beim normalen Sperren oben.
const hit = selHit(sel);
if (hit && !isInsideActiveGroup(project, hit.kind, hit.id, groupPath)) return;
// Shift+Klick: Mehrfach-Auswahl — getroffenes Element zu-/abschalten, den
// Rest behalten (Wände und 2D-Zeichnungen unabhängig, gemischte Auswahl
// erlaubt). Leerraum-Klick mit Shift lässt die Auswahl unverändert.
@@ -271,6 +301,34 @@ export function useMouseSelectionHandlers(opts: {
}
return;
}
// Gruppen-Erweiterung: gehört das getroffene Element zu einer (im
// aktuellen Isolations-Kontext sichtbaren) Gruppe, wird die GANZE Gruppe
// gewählt statt nur dieses eine Element (Vectorworks: eine Gruppe
// verhält sich wie ein einzelnes Objekt, bis man per Doppelklick
// hineingeht — s. `onEnterGroup`/`groupPath`).
if (hit) {
const { expanded, groupIds } = expandToGroups(
project,
{ ...EMPTY_MEMBERSHIP, [hit.kind]: [hit.id] },
groupPath,
);
if (groupIds.length > 0) {
setSelectedWallIds(expanded.wallIds);
setSelectedDrawingIds(expanded.drawingIds);
setSelectedCeilingIds(expanded.ceilingIds);
setSelectedOpeningIds(expanded.openingIds);
setSelectedStairIds(expanded.stairIds);
setSelectedRoomIds(expanded.roomIds);
setSelectedColumnIds(expanded.columnIds);
setSelectedRoofIds(expanded.roofIds);
setSelectedExtrudedSolidIds([]);
setSelectedContextObjectIds([]);
setSelectedSectionLineId(null);
setSelectedGroupIds(groupIds);
return;
}
}
setSelectedGroupIds([]);
// Ohne Shift: ersetzen. Öffnung hat Vorrang (ihr Symbol liegt in der Lücke),
// dann Wand, dann 2D-Element, dann Decke; sonst leeren. Die Kanäle schließen
// sich gegenseitig aus.
@@ -329,14 +387,45 @@ export function useMouseSelectionHandlers(opts: {
// Links-Drag (Marquee) im Grundriss: die getroffene Wand-MENGE übernehmen
// (nach CAD-Konvention von PlanView bestimmt). Ersetzt die bisherige Auswahl.
const onPlanMarquee = (sel: MarqueeSelection) => {
setSelectedWallIds(sel.wallIds);
setSelectedDrawingIds(sel.drawingIds ?? []);
// Decken/Öffnungen/Treppen/Räume/Extrusionen/Dächer sind (noch) nicht Teil
// des Marquee → beim Aufziehen abwählen.
setSelectedCeilingIds([]);
const raw: GroupMembership = {
wallIds: sel.wallIds,
drawingIds: sel.drawingIds ?? [],
ceilingIds: sel.ceilingIds ?? [],
roomIds: sel.roomIds ?? [],
stairIds: sel.stairIds ?? [],
columnIds: [],
roofIds: [],
openingIds: [],
};
// Gruppen-Isolation: ausserhalb der aktiven Gruppe getroffene Elemente
// sind gesperrt (werden ignoriert), wie beim Einzelklick.
const filtered: GroupMembership =
groupPath.length === 0
? raw
: {
wallIds: raw.wallIds.filter((id) => isInsideActiveGroup(project, "wallIds", id, groupPath)),
drawingIds: raw.drawingIds.filter((id) => isInsideActiveGroup(project, "drawingIds", id, groupPath)),
ceilingIds: raw.ceilingIds.filter((id) => isInsideActiveGroup(project, "ceilingIds", id, groupPath)),
roomIds: raw.roomIds.filter((id) => isInsideActiveGroup(project, "roomIds", id, groupPath)),
stairIds: raw.stairIds.filter((id) => isInsideActiveGroup(project, "stairIds", id, groupPath)),
columnIds: [],
roofIds: [],
openingIds: [],
};
// Gruppen-Erweiterung: jedes getroffene Element, das zu einer sichtbaren
// Gruppe gehört, zieht deren ganze (transitive) Mitgliedschaft nach.
const { expanded, groupIds } = expandToGroups(project, filtered, groupPath);
setSelectedWallIds(expanded.wallIds);
setSelectedDrawingIds(expanded.drawingIds);
setSelectedCeilingIds(expanded.ceilingIds);
setSelectedStairIds(expanded.stairIds);
setSelectedRoomIds(expanded.roomIds);
setSelectedGroupIds(groupIds);
// Öffnungen/Extrusionen/Stützen/Dächer/Kontext-Objekte sind (noch) nicht
// Teil des Marquee (Nutzer-Report betraf nur Decken/Räume/Treppen; Stütze/
// Dach liessen sich mit derselben `marqueeHitByField`-Mechanik ergänzen,
// falls gewünscht) → beim Aufziehen abwählen.
setSelectedOpeningIds([]);
setSelectedStairIds([]);
setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]);
setSelectedRoofIds([]);
+14 -1
View File
@@ -49,7 +49,15 @@ function drawingsBBox(
else if (g.shape === "circle" || g.shape === "arc") {
acc(g.center.x - g.r, g.center.y - g.r);
acc(g.center.x + g.r, g.center.y + g.r);
} else if (g.shape === "text") acc(g.at.x, g.at.y);
} else if (g.shape === "ellipse") {
acc(g.center.x - g.rx, g.center.y - g.ry);
acc(g.center.x + g.rx, g.center.y + g.ry);
} else if (g.shape === "bezier" || g.shape === "spline") {
for (const p of g.pts) acc(p.x, p.y);
} else if (g.shape === "dimension") {
for (const p of g.pts) acc(p.x, p.y);
} else if (g.shape === "kote") acc(g.at.x, g.at.y);
else if (g.shape === "text") acc(g.at.x, g.at.y);
}
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
}
@@ -64,6 +72,11 @@ function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D {
case "rect": return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
case "circle": return { ...d, geom: { ...g, center: mv(g.center) } };
case "arc": return { ...d, geom: { ...g, center: mv(g.center) } };
case "ellipse": return { ...d, geom: { ...g, center: mv(g.center) } };
case "bezier": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "spline": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "dimension": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "kote": return { ...d, geom: { ...g, at: mv(g.at) } };
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
default: return d;
}
+10
View File
@@ -74,6 +74,12 @@ export function useSelectionState() {
selectedColumnIds.length === 1 ? selectedColumnIds[0] : null;
const selectedContextObjectIds = useStore((s) => s.selectedContextObjectIds);
const setSelectedContextObjectIds = useStore((s) => s.setSelectedContextObjectIds);
// Gruppen (Vectorworks-artig): gewählte Gruppen-IDs + Isolations-Pfad
// ("Gruppe betreten"), s. `groupActions.ts`.
const selectedGroupIds = useStore((s) => s.selectedGroupIds);
const setSelectedGroupIds = useStore((s) => s.setSelectedGroupIds);
const groupPath = useStore((s) => s.groupPath);
const setGroupPath = useStore((s) => s.setGroupPath);
return {
selectedWallIds,
@@ -115,5 +121,9 @@ export function useSelectionState() {
selectedColumnId,
selectedContextObjectIds,
setSelectedContextObjectIds,
selectedGroupIds,
setSelectedGroupIds,
groupPath,
setGroupPath,
};
}
+75
View File
@@ -2110,6 +2110,23 @@ body {
vector-effect: non-scaling-stroke;
}
/* Gruppen-Auswahl (Vectorworks-artig): statt jedes Mitglieds-Umrisses nur eine
gestrichelte Bounding-Box mit vier Eckpunkt-Griffen — dieselbe Akzentfarbe
wie die normale Auswahl, aber gestrichelt (sofort als "Gruppe" erkennbar). */
.plan-svg .plan-group-bbox {
fill: none;
stroke: var(--accent);
stroke-width: 1.5;
stroke-dasharray: 6 4;
vector-effect: non-scaling-stroke;
}
.plan-svg .plan-group-bbox-corner {
fill: var(--accent);
stroke: #fff;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
/* Hervorhebung selektierter 2D-Zeichenelemente (Mehrfach/Marquee): Akzentkontur
über der Geometrie, keine Füllung. */
.plan-svg .plan-sel-draw {
@@ -2119,6 +2136,54 @@ body {
vector-effect: non-scaling-stroke;
}
/* Gruppen-Isolation (Vectorworks „Gruppe betreten"): halbtransparenter
Orange-Rahmen (Band) am Rand der ganzen Ansicht, solange eine Gruppe
betreten ist — dieselbe visuelle Sprache wie Vectorworks' "Ansichtsbereich"-
Rahmen (Screenshot-Referenz, 2026-08-22). EIGENES Overlay-Element, NACH
PlanView im DOM gerendert (nicht `box-shadow` auf `.pane` selbst — das läge
HINTER der eigenen Kind-SVG von PlanView und wäre unsichtbar, s. Nutzer-
Report "ich seh es nicht"). `pointer-events: none`, damit Klicks/Griffe im
Plan weiterhin durchgehen. */
.pane-isolation-frame {
position: absolute;
/* Oben/links um die Lineal-Dicke eingerückt (TH=20 Bildschirm-px in
PlanRulers.tsx) — der Rahmen soll NUR den eigentlichen Zeichenbereich
umranden, nicht das Lineal überdecken (Nutzer-Wunsch: „respektiere das
Lineal, wirklich nur Viewport selber"). Rechts/unten gibt es kein
Lineal → bis an den Rand. */
top: 20px;
left: 20px;
right: 0;
bottom: 0;
z-index: 5;
pointer-events: none;
box-shadow: inset 0 0 0 8px rgba(230, 126, 34, 0.35);
}
/* "Gruppe verlassen": Knopf oben RECHTS in der Ansicht, sichtbar nur solange
eine Gruppe betreten ist. `top` unter dem Lineal (TH=20px, s. PlanRulers.tsx)
UND unter dem Isolations-Rahmenband (8px ab dessen 20px-Einrückung) —
Nutzer-Report: Pill sass vorher zu hoch, überlappte Lineal-Beschriftung. */
.pane-leave-group-btn {
position: absolute;
top: 36px;
right: 12px;
z-index: 6;
padding: 5px 11px;
border-radius: 6px;
border: 1px solid var(--accent-border);
background: var(--accent-dim);
color: var(--muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
}
.pane-leave-group-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
/* Auswahl-Rechteck (Marquee, Links-Drag): gestrichelte Akzentkontur, leichte
Füllung. Strichstärke + Strichmuster papierkonstant (non-scaling-stroke). Die
Default-Variante (links→rechts, „einschließend") ist durchgezogen; die
@@ -2143,6 +2208,16 @@ body {
vector-effect: non-scaling-stroke;
}
/* Bezier-Hilfslinien (Anker → Kontrollpunkt-Griff): dünn, gestrichelt, deutlich
zurückhaltender als der Kurven-Umriss selbst — reine Steuerungshilfe. */
.plan-svg .plan-grip-line {
stroke: var(--accent-light);
stroke-width: 1;
stroke-dasharray: 3 3;
vector-effect: non-scaling-stroke;
fill: none;
}
/* Kanten-/Seiten-Griffe (Edge Grips): dreieckiger Anfasser je Seite, Spitze
nach außen. Gleiche Akzent-/Rand-Farben wie die Eckpunkt-Griffe. */
.plan-svg .plan-edge-grip {
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import { ellipsePts, sampleBezierChain, sampleCatmullRom } from "./curves";
describe("ellipsePts", () => {
it("liegt auf der Ellipse (rotation 0): Ost/Nord-Punkte bei rx/ry", () => {
const pts = ellipsePts({ x: 0, y: 0 }, 4, 2, 0, 4);
// t=0 → Ost (rx,0); t=π/2 → Nord (0,ry)
expect(pts[0].x).toBeCloseTo(4, 6);
expect(pts[0].y).toBeCloseTo(0, 6);
expect(pts[1].x).toBeCloseTo(0, 6);
expect(pts[1].y).toBeCloseTo(2, 6);
});
it("Drehung um 90° vertauscht die Achsen", () => {
const pts = ellipsePts({ x: 0, y: 0 }, 4, 2, Math.PI / 2, 4);
// Der ehemalige Ost-Punkt (rx,0) liegt jetzt bei (0, rx).
expect(pts[0].x).toBeCloseTo(0, 6);
expect(pts[0].y).toBeCloseTo(4, 6);
});
it("Mittelpunkt-Verschiebung wird respektiert", () => {
const pts = ellipsePts({ x: 10, y: 5 }, 3, 3, 0, 8);
for (const p of pts) {
expect(Math.hypot(p.x - 10, p.y - 5)).toBeCloseTo(3, 6);
}
});
});
describe("sampleBezierChain", () => {
it("weniger als 4 Punkte: Rohpunkte unverändert (Gummiband-Vorschau)", () => {
const pts = [{ x: 0, y: 0 }, { x: 1, y: 1 }];
expect(sampleBezierChain(pts)).toEqual(pts);
});
it("ein Segment: startet bei P0, endet bei P3", () => {
const p0 = { x: 0, y: 0 }, p1 = { x: 1, y: 3 }, p2 = { x: 3, y: 3 }, p3 = { x: 4, y: 0 };
const out = sampleBezierChain([p0, p1, p2, p3], 10);
expect(out[0]).toEqual(p0);
const last = out[out.length - 1];
expect(last.x).toBeCloseTo(p3.x, 6);
expect(last.y).toBeCloseTo(p3.y, 6);
});
it("t=0.5 folgt der De-Casteljau-Formel (symmetrischer Fall)", () => {
const p0 = { x: 0, y: 0 }, p1 = { x: 0, y: 4 }, p2 = { x: 4, y: 4 }, p3 = { x: 4, y: 0 };
const out = sampleBezierChain([p0, p1, p2, p3], 2); // t = 0.5 genau bei Index 1
// Symmetrische Kontrollpunkte → Kurvenmitte liegt exakt bei (2, 3).
expect(out[1].x).toBeCloseTo(2, 6);
expect(out[1].y).toBeCloseTo(3, 6);
});
it("zwei Segmente: verbindet am gemeinsamen Anker P3=P4", () => {
const pts = [
{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 0 },
{ x: 4, y: -1 }, { x: 5, y: -1 }, { x: 6, y: 0 },
];
const out = sampleBezierChain(pts, 5);
expect(out[0]).toEqual(pts[0]);
const last = out[out.length - 1];
expect(last.x).toBeCloseTo(6, 6);
expect(last.y).toBeCloseTo(0, 6);
});
});
describe("sampleCatmullRom", () => {
it("weniger als 3 Punkte: Rohpunkte unverändert", () => {
const pts = [{ x: 0, y: 0 }, { x: 1, y: 1 }];
expect(sampleCatmullRom(pts, false)).toEqual(pts);
});
it("offen: läuft durch den ersten und letzten Wegpunkt", () => {
const pts = [{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 5, y: 1 }, { x: 8, y: 4 }];
const out = sampleCatmullRom(pts, false, 8);
expect(out[0]).toEqual(pts[0]);
const last = out[out.length - 1];
expect(last.x).toBeCloseTo(8, 6);
expect(last.y).toBeCloseTo(4, 6);
});
it("Kollineare Punkte ergeben eine Gerade (kein Überschwingen)", () => {
const pts = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }, { x: 3, y: 0 }];
const out = sampleCatmullRom(pts, false, 4);
for (const p of out) expect(p.y).toBeCloseTo(0, 6);
});
it("geschlossen: liefert keinen doppelten Schlusspunkt (Aufrufer schliesst selbst)", () => {
const pts = [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }, { x: 0, y: 4 }];
const out = sampleCatmullRom(pts, true, 6);
expect(out.length).toBe(pts.length * 6);
});
});
+104
View File
@@ -0,0 +1,104 @@
// Geometrie-Helfer für Ellipse/Bezier/Spline — reine Sample-/Tessellierungs-
// Funktionen, gemeinsam genutzt von den Zeichen-Werkzeugen (Vorschau), dem
// Grundriss-Generator (finales Rendering als Linien-/Polygon-Primitive) und
// dem Snapping (Kandidaten-Strecken).
import type { Vec2 } from "../model/types";
/** Punkte einer (ggf. gedrehten) Ellipse, N gleichverteilte Parameterschritte. */
export function ellipsePts(
center: Vec2,
rx: number,
ry: number,
rotation: number,
n = 64,
): Vec2[] {
const pts: Vec2[] = [];
const cosR = Math.cos(rotation);
const sinR = Math.sin(rotation);
for (let i = 0; i < n; i++) {
const t = (i / n) * Math.PI * 2;
const lx = rx * Math.cos(t);
const ly = ry * Math.sin(t);
pts.push({ x: center.x + lx * cosR - ly * sinR, y: center.y + lx * sinR + ly * cosR });
}
return pts;
}
/** Punkt auf einer kubischen Bezier-Kurve (De-Casteljau-Formel) bei Parameter `t` ∈ [0,1]. */
function cubicPoint(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const d = t * t * t;
return {
x: a * p0.x + b * p1.x + c * p2.x + d * p3.x,
y: a * p0.y + b * p1.y + c * p2.y + d * p3.y,
};
}
/**
* Tessellierte Punkte einer kubischen Bezier-Kette. `pts` folgt dem Muster
* Anker·Griff·Griff·Anker·… (`pts.length` ≡ 1 mod 3). Mit weniger als 4
* Punkten (noch im Bau) werden die Rohpunkte unverändert zurückgegeben
* (Gummiband-Vorschau).
*/
export function sampleBezierChain(pts: Vec2[], segsPerCurve = 24): Vec2[] {
if (pts.length < 4) return [...pts];
const out: Vec2[] = [pts[0]];
for (let i = 0; i + 3 < pts.length; i += 3) {
const p0 = pts[i], p1 = pts[i + 1], p2 = pts[i + 2], p3 = pts[i + 3];
for (let s = 1; s <= segsPerCurve; s++) out.push(cubicPoint(p0, p1, p2, p3, s / segsPerCurve));
}
return out;
}
/** Kontrollpunkt-Zugriff für Catmull-Rom mit Rand-Klemmung bzw. Ring-Umlauf. */
function crPoint(pts: Vec2[], i: number, closed: boolean): Vec2 {
const n = pts.length;
if (closed) return pts[((i % n) + n) % n];
if (i < 0) return pts[0];
if (i >= n) return pts[n - 1];
return pts[i];
}
/**
* Tessellierte Punkte einer glatten Kurve DURCH `pts` (uniformer Catmull-
* Rom-Spline). Mit weniger als 3 Punkten (noch im Bau bzw. entartet) werden
* die Rohpunkte unverändert zurückgegeben (Gummiband-Vorschau bzw. Gerade).
* `closed` schliesst den Ring OHNE expliziten Doppelpunkt am Ende — Aufrufer
* schliessen wie bei `polyline` selbst mit einem letzten Segment zurück zu
* `pts[0]`.
*/
export function sampleCatmullRom(pts: Vec2[], closed: boolean, segsPerSpan = 16): Vec2[] {
if (pts.length < 3) return [...pts];
const out: Vec2[] = [];
const spans = closed ? pts.length : pts.length - 1;
for (let i = 0; i < spans; i++) {
const p0 = crPoint(pts, i - 1, closed);
const p1 = crPoint(pts, i, closed);
const p2 = crPoint(pts, i + 1, closed);
const p3 = crPoint(pts, i + 2, closed);
for (let s = 0; s < segsPerSpan; s++) {
const t = s / segsPerSpan;
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1.x +
(-p0.x + p2.x) * t +
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
const y =
0.5 *
(2 * p1.y +
(-p0.y + p2.y) * t +
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
out.push({ x, y });
}
}
if (!closed) out.push(pts[pts.length - 1]);
return out;
}
+46 -1
View File
@@ -9,7 +9,7 @@
* Wandschicht statt nur an Achse/Aussenkante enden kann.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { computeSnap, snapCommonAngle, wallLayerBoundarySegments } from "./snapping";
import { DEFAULT_SNAP } from "./types";
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
@@ -368,3 +368,48 @@ function wall2d(start: Vec2, end: Vec2): Wall {
height: 2.6,
};
}
// ── Teilpunkte (divide) — nur fangbar nach Verweilen ────────────────────────
// computeSnap hält den Verweil-Zustand modulweit (mehrere, unabhängige
// Aufrufer teilen ihn sich, s. Kommentar dort) — Date.now()-basiert, daher
// hier mit Fake-Timern gesteuert statt echter Wartezeit.
describe("computeSnap — Teilpunkte (divide) nach Verweilen", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("fängt NICHT auf dem Drittelpunkt, solange der Cursor noch nicht lange genug verweilt hat", () => {
const project = drawProject([{ shape: "line", a: { x: 0, y: 0 }, b: { x: 9, y: 0 } }]);
const settings = { ...DEFAULT_SNAP, hoverDivideDelayMs: 300, hoverDivideCount: 3 };
const result = snapAt(project, { x: 3, y: 0.02 }, settings);
expect(result?.kind).not.toBe("divide");
});
it("fängt auf dem Drittelpunkt, sobald der Cursor lange genug auf derselben Linie verweilt hat", () => {
const project = drawProject([{ shape: "line", a: { x: 0, y: 0 }, b: { x: 9, y: 0 } }]);
const settings = { ...DEFAULT_SNAP, hoverDivideDelayMs: 300, hoverDivideCount: 3 };
const raw: Vec2 = { x: 3, y: 0.02 };
snapAt(project, raw, settings); // erster Aufruf verankert nur den Verweil-Beginn
vi.advanceTimersByTime(350);
const result = snapAt(project, raw, settings);
expect(result?.kind).toBe("divide");
expect(result?.point.x).toBeCloseTo(3, 6); // 1/3 von 9
expect(result?.point.y).toBeCloseTo(0, 6);
});
it("verwirft den Verweil, sobald der Cursor zwischenzeitlich auf eine ANDERE Linie wechselt", () => {
const project = drawProject([
{ shape: "line", a: { x: 0, y: 0 }, b: { x: 9, y: 0 } },
{ shape: "line", a: { x: 0, y: 5 }, b: { x: 9, y: 5 } },
]);
const settings = { ...DEFAULT_SNAP, hoverDivideDelayMs: 300, hoverDivideCount: 3 };
snapAt(project, { x: 3, y: 0.02 }, settings);
vi.advanceTimersByTime(350);
snapAt(project, { x: 3, y: 5.02 }, settings); // andere Linie dazwischen
const result = snapAt(project, { x: 3, y: 0.02 }, settings);
expect(result?.kind).not.toBe("divide");
});
});
+87 -4
View File
@@ -12,6 +12,7 @@ import { add, leftNormal, lineIntersect, normalize, scale, sub } from "../model/
import { wallReferenceOffset } from "../model/wall";
import { centroid as roomCentroid } from "../geometry/roomArea";
import type { SnapKind, SnapResult, SnapSettings } from "./types";
import { sampleBezierChain, sampleCatmullRom } from "./curves";
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
const mid = (a: Vec2, b: Vec2): Vec2 => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
@@ -28,6 +29,7 @@ const PRIORITY: Record<SnapKind, number> = {
ortho: 0,
extension: 0,
angle: 0,
divide: 3,
};
export interface SnapInput {
@@ -100,6 +102,14 @@ function collectSegments(input: SnapInput): Seg[] {
const c1 = g.min, c3 = g.max;
const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y };
segs.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]);
} else if (g.shape === "bezier") {
const pts = sampleBezierChain(g.pts);
for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]);
} else if (g.shape === "spline") {
const closed = g.closed && g.pts.length > 2;
const pts = sampleCatmullRom(g.pts, closed);
for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]);
if (closed) segs.push([pts[pts.length - 1], pts[0]]);
}
}
// Bereits gesetzte Draft-Segmente (Polylinie/Wand im Bau).
@@ -141,6 +151,58 @@ function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
return { x: a.x + dx * t, y: a.y + dy * t };
}
// ── Verweil-Erkennung fürs Teilpunkte-Snapping ("divide") ───────────────────
// Modul-weiter Zustand (wie `lastCoalesceKey` in projectSlice.ts) statt in
// SnapInput/computeSnap-Parametern: computeSnap wird von mehreren, völlig
// unabhängigen Stellen aufgerufen (Zeichnen über die Command-Engine UND die
// älteren Legacy-Werkzeuge, Griff-Ziehen, Transformieren) — der Verweil-
// Zustand muss all diese Aufrufer TEILEN, damit "verweilt der Cursor lange
// genug auf derselben Linie" aufrufer-übergreifend gilt (Nutzer-Wunsch:
// "wenn der Snappunkt eines neuen Elements auch [z. B. 200ms] auf der Linie
// ist, sollten die Teilpunkte auch snapbar sein — aber NUR dann").
let dwellSegKey: string | null = null;
let dwellSince = 0;
let dwellRevealedSeg: Seg | null = null;
const segKey = (seg: Seg): string =>
`${seg[0].x},${seg[0].y}|${seg[1].x},${seg[1].y}`;
/** Nächstgelegene Strecke zu `raw` innerhalb der Fangtoleranz (Meter), oder null. */
function nearestSegment(raw: Vec2, segs: Seg[], tolM: number): Seg | null {
let best: Seg | null = null;
let bestD = tolM;
for (const seg of segs) {
const d = dist(perpFoot(raw, seg[0], seg[1]), raw);
if (d < bestD) {
bestD = d;
best = seg;
}
}
return best;
}
/**
* Verankert/verwirft den Verweil-Zustand für den aktuellen Cursor und liefert
* die "freigegebene" Strecke (Teilpunkte fangbar), sobald der Cursor lange
* genug (`delayMs`) auf DERSELBEN Strecke geblieben ist — sonst `null`. Ein
* Streckenwechsel (auch zu "keine Strecke") setzt die Verweildauer zurück.
*/
function updateDwell(raw: Vec2, segs: Seg[], delayMs: number, tolM: number): Seg | null {
const seg = nearestSegment(raw, segs, tolM);
const key = seg ? segKey(seg) : null;
const now = Date.now();
if (key !== dwellSegKey) {
dwellSegKey = key;
dwellSince = now;
dwellRevealedSeg = null;
return null;
}
if (seg && dwellRevealedSeg === null && now - dwellSince >= delayMs) {
dwellRevealedSeg = seg;
}
return dwellRevealedSeg;
}
/**
* Bestimmt den effektiven Snap für den rohen Cursor-Punkt (oder null).
* Punkt-Snaps haben Vorrang vor Ortho und Raster (Prioritäts-Bonus).
@@ -164,10 +226,22 @@ export function computeSnap(input: SnapInput): SnapResult | null {
}
};
const segs =
settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint
? collectSegments(input)
: [];
const needSegs =
settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint;
const segs = needSegs ? collectSegments(input) : [];
// Teilpunkt-Verweilerkennung: braucht dieselben Strecken wie oben. Ohne sie
// (alle vier Objekt-Snap-Arten aus) bleibt "divide" ebenfalls inaktiv —
// konsistent dazu, dass dann ohnehin keine Linien-bezogenen Snaps gelten.
const revealed = updateDwell(raw, segs, settings.hoverDivideDelayMs, tolM);
if (revealed) {
const [a, b] = revealed;
const n = Math.max(2, Math.round(settings.hoverDivideCount));
for (let i = 1; i < n; i++) {
const t = i / n;
consider({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }, "divide");
}
}
// endpoint (Strecken-Endpunkte + Draft-Knoten + Raum-Stempel-Anker).
if (settings.endpoint) {
@@ -233,6 +307,15 @@ export function computeSnap(input: SnapInput): SnapResult | null {
consider(onCircle(c, c.arc.a1), "endpoint");
}
}
// Ellipse: nur der Mittelpunkt (keine Quadranten — bei gedrehter Ellipse
// wären die "Achsenpunkte" ohnehin keine Kreis-Quadranten mehr).
if (settings.center) {
for (const d of input.project.drawings2d) {
if (d.levelId !== input.levelId) continue;
if (!input.visibleCodes.has(d.categoryCode)) continue;
if (d.geom.shape === "ellipse") consider(d.geom.center, "center");
}
}
}
if (best) return best;
+70
View File
@@ -554,6 +554,71 @@ const arcTool: Tool = {
onCancel: (s) => [s, { draft: null, done: true }],
};
// Ellipse: Platzhalter-Werkzeug, gekoppelt an den `ellipse`-Befehl (zwei
// Bounding-Box-Ecken). Wie Kreis/Bogen nicht floorOnly.
const ellipseTool: Tool = {
id: "ellipse",
labelKey: "tool.ellipse",
hintKey: () => "tool.ellipse.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// Spline: Platzhalter-Werkzeug, gekoppelt an den `spline`-Befehl (Wegpunkte,
// wie Polylinie, aber geglättet). Nicht floorOnly.
const splineTool: Tool = {
id: "spline",
labelKey: "tool.spline",
hintKey: () => "tool.spline.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// Bezier: Platzhalter-Werkzeug, gekoppelt an den `bezier`-Befehl (manuelle
// Kontrollpunkt-Kette). Nicht floorOnly.
const bezierTool: Tool = {
id: "bezier",
labelKey: "tool.bezier",
hintKey: () => "tool.bezier.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// Bemassung: Platzhalter-Werkzeug, gekoppelt an den `dimension`-Befehl (zwei
// Punkte + Lage der Masslinie). Nicht floorOnly.
const dimensionTool: Tool = {
id: "dimension",
labelKey: "tool.dimension",
hintKey: () => "tool.dimension.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// Höhenkote: Platzhalter-Werkzeug, gekoppelt an den `kote`-Befehl (Punkt →
// getippter Höhenwert). Nicht floorOnly.
const koteTool: Tool = {
id: "kote",
labelKey: "tool.kote",
hintKey: () => "tool.kote.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// Text: Platzhalter-Werkzeug, gekoppelt an den `text`-Befehl (Ankerpunkt →
// getippte Zeile). Wie Kreis/Bogen nicht floorOnly (freies 2D-Element).
const textTool: Tool = {
@@ -611,6 +676,11 @@ const TOOLS: Record<ToolId, Tool> = {
rect: rectTool,
circle: circleTool,
arc: arcTool,
ellipse: ellipseTool,
spline: splineTool,
bezier: bezierTool,
dimension: dimensionTool,
kote: koteTool,
text: textTool,
textbox: textboxTool,
image: imageTool,
+82
View File
@@ -0,0 +1,82 @@
/**
* `commitTransform`/`transformPreview` mit MEHREREN gewählten 2D-Elementen
* (`TransformSelection.drawingIds`) — Nutzer-Report: "wenn ich zwei Linien
* anwähle, dass ich dann m usw nicht verwenden kann". Vorher trug
* `TransformSelection` nur ein einzelnes `drawingId`, das bei einer
* Mehrfachauswahl (2+ Elemente) `null` war → Bewegen/Kopieren/Spiegeln/
* Drehen/Feld griffen gar nicht.
*/
import { describe, it, expect } from "vitest";
import { commitTransform } from "./transform";
import type { TransformSelection } from "./transform";
import type { Drawing2D, Project } from "../model/types";
function project(drawings: Drawing2D[]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [],
layers: [],
walls: [],
doors: [],
drawings2d: drawings,
};
}
function line(id: string, x: number): Drawing2D {
return {
id,
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "line", a: { x, y: 0 }, b: { x: x + 1, y: 0 } },
};
}
describe("commitTransform — Mehrfachauswahl (drawingIds)", () => {
it("move: verschiebt ALLE gewählten 2D-Elemente, nicht nur eines", () => {
const p = project([line("d1", 0), line("d2", 5), line("d3", 10)]);
const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] };
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 2, y: 3 }], "move", 1);
const d1 = next.drawings2d.find((d) => d.id === "d1")!.geom;
const d2 = next.drawings2d.find((d) => d.id === "d2")!.geom;
const d3 = next.drawings2d.find((d) => d.id === "d3")!.geom;
if (d1.shape === "line") expect(d1.a).toEqual({ x: 2, y: 3 });
if (d2.shape === "line") expect(d2.a).toEqual({ x: 7, y: 3 }); // 5+2
if (d3.shape === "line") expect(d3.a).toEqual({ x: 10, y: 0 }); // nicht gewählt, unverändert
});
it("copy: dupliziert ALLE gewählten 2D-Elemente (Original bleibt)", () => {
const p = project([line("d1", 0), line("d2", 5)]);
const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] };
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "copy", 1);
expect(next.drawings2d.length).toBe(4); // 2 Original + 2 Kopien
});
it("array: dupliziert ALLE gewählten 2D-Elemente N-mal", () => {
const p = project([line("d1", 0), line("d2", 5)]);
const sel: TransformSelection = { wallIds: [], drawingId: null, drawingIds: ["d1", "d2"] };
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "array", 3);
expect(next.drawings2d.length).toBe(8); // 2 Original + 2×3 Kopien
});
it("weiterhin abwärtskompatibel: einzelnes `drawingId` (ohne drawingIds) funktioniert unverändert", () => {
const p = project([line("d1", 0)]);
const sel: TransformSelection = { wallIds: [], drawingId: "d1" };
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 3, y: 0 }], "move", 1);
const d1 = next.drawings2d[0].geom;
if (d1.shape === "line") expect(d1.a).toEqual({ x: 3, y: 0 });
});
it("drawingId + drawingIds zusammen werden dedupliziert (kein Doppel-Verschieben)", () => {
const p = project([line("d1", 0), line("d2", 5)]);
const sel: TransformSelection = { wallIds: [], drawingId: "d1", drawingIds: ["d1", "d2"] };
const next = commitTransform(p, sel, "move", [{ x: 0, y: 0 }, { x: 1, y: 0 }], "copy", 1);
expect(next.drawings2d.length).toBe(4); // je EINE Kopie pro Element, nicht zwei für d1
});
});
+29 -6
View File
@@ -30,13 +30,27 @@ export type CopyMode = "move" | "copy" | "array" | "distribute"; // U / I / O /
/** Welche Auswahl transformiert wird. */
export interface TransformSelection {
wallIds: string[];
/**
* Einzeln gewähltes 2D-Element (Legacy-Feld, s. `CommandSelection.drawingId`).
* `drawingIds` (Mehrfachauswahl) wird bevorzugt ausgewertet; beide zusammen
* ergeben die MENGE der zu transformierenden 2D-Elemente (Union, dedupliziert).
*/
drawingId: string | null;
/** ALLE gewählten 2D-Elemente (Mehrfachauswahl) — s. `CommandSelection.drawingIds`. */
drawingIds?: string[];
/** Gewählter extrudierter Körper (truck-Integration); nur move.ts setzt ihn. */
extrudedSolidId?: string | null;
/** Gewählte Stütze (Tragwerk); von move/copy/mirror gesetzt. */
columnId?: string | null;
}
/** Vereinigt `drawingId`/`drawingIds` zu einer eindeutigen ID-Menge (dedupliziert). */
function drawingIdSet(sel: TransformSelection): Set<string> {
const ids = new Set(sel.drawingIds ?? []);
if (sel.drawingId) ids.add(sel.drawingId);
return ids;
}
/** Wie viele Klickpunkte die Operation braucht (Drehen = 3, sonst 2). */
export const requiredPoints = (op: TransformOp): number => (op === "rotate" ? 3 : 2);
@@ -116,10 +130,8 @@ function gatherItems(project: Project, sel: TransformSelection): AnyItem[] {
const items: AnyItem[] = [];
const ids = new Set(sel.wallIds);
for (const w of project.walls) if (ids.has(w.id)) items.push({ kind: "wall", wall: w });
if (sel.drawingId) {
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
if (d) items.push({ kind: "drawing", drawing: d });
}
const drawIds = drawingIdSet(sel);
for (const d of project.drawings2d) if (drawIds.has(d.id)) items.push({ kind: "drawing", drawing: d });
if (sel.extrudedSolidId) {
const s = (project.extrudedSolids ?? []).find((x) => x.id === sel.extrudedSolidId);
if (s) items.push({ kind: "extrudedSolid", solid: s });
@@ -179,6 +191,16 @@ const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => {
return { ...geom, center: fn(geom.center) };
case "arc":
return { ...geom, center: fn(geom.center) };
case "ellipse":
return { ...geom, center: fn(geom.center) };
case "bezier":
return { ...geom, pts: geom.pts.map(fn) };
case "spline":
return { ...geom, pts: geom.pts.map(fn) };
case "dimension":
return { ...geom, pts: geom.pts.map(fn) };
case "kote":
return { ...geom, at: fn(geom.at) };
case "text":
return { ...geom, at: fn(geom.at) };
case "image": {
@@ -282,9 +304,10 @@ export function commitTransform(
if (movesOriginal(mode)) {
const fn = transformFn(op, pts, 1);
walls = walls.map((w) => (wallIds.has(w.id) ? { ...w, start: fn(w.start), end: fn(w.end) } : w));
if (sel.drawingId) {
const moveDrawIds = drawingIdSet(sel);
if (moveDrawIds.size > 0) {
drawings2d = drawings2d.map((d) =>
d.id === sel.drawingId ? { ...d, geom: mvGeom(d.geom, fn) } : d,
moveDrawIds.has(d.id) ? { ...d, geom: mvGeom(d.geom, fn) } : d,
);
}
if (sel.extrudedSolidId) {
+35 -3
View File
@@ -21,6 +21,11 @@ export type ToolId =
| "rect"
| "circle"
| "arc"
| "ellipse"
| "spline"
| "bezier"
| "dimension"
| "kote"
| "text"
| "textbox"
| "image";
@@ -37,7 +42,15 @@ export type SnapKind =
| "grid"
| "ortho"
| "extension"
| "angle";
| "angle"
/**
* Teilpunkt einer Linie, NUR fangbar, solange der Cursor lange genug (s.
* `SnapSettings.hoverDivideDelayMs`) auf DERSELBEN Linie verweilt hat (s.
* `computeSnap`s Verweil-Erkennung) — bewusst NICHT dauerhaft aktiv wie die
* übrigen Snap-Arten, sonst würde jede N-tel-Position jeder Linie den
* Fang-Kandidatenraum überfluten.
*/
| "divide";
export interface SnapResult {
/** Gefangener Punkt (Meter). */
@@ -71,15 +84,32 @@ export interface SnapSettings {
commonAngles: boolean;
/** Toleranz (Grad) für das weiche Winkel-Einrasten. */
commonAngleTolerance: number;
/**
* Verweilt der Cursor länger als diese Zeit (ms) ruhig auf einer Linie
* (2D-Element, kein aktiver Zug/Werkzeug), werden ihre Teilpunkte (s.
* `hoverDivideCount`) kurz mit Akzentfarbe eingeblendet — reine
* Sichtbarkeits-Hilfe, kein zusätzliches Snap-Ziel.
*/
hoverDivideDelayMs: number;
/**
* In wie viele gleiche Teile die gehoverte Linie dabei angezeigt wird
* (2 = nur der Mittelpunkt, 3 = zwei Drittelpunkte, usw.).
*/
hoverDivideCount: number;
}
/** Default-Snap-Einstellungen (endpoint/midpoint/intersection/onEdge + grid). */
/**
* Default-Snap-Einstellungen (endpoint/midpoint/intersection/onEdge/center +
* grid). `center` (Kreis-/Bogen-Mittelpunkt + Quadranten 0/90/180/270°) war
* lange im Modell vorhanden, aber ohne UI-Schalter unerreichbar (Default
* `false`) — auf Nutzer-Wunsch jetzt an UND im Footer (StatusBar) umschaltbar.
*/
export const DEFAULT_SNAP: SnapSettings = {
enabled: true,
endpoint: true,
midpoint: true,
intersection: true,
center: false,
center: true,
onEdge: true,
grid: true,
gridSize: 0.1,
@@ -88,6 +118,8 @@ export const DEFAULT_SNAP: SnapSettings = {
tolerancePx: 12,
commonAngles: true,
commonAngleTolerance: 2.5,
hoverDivideDelayMs: 300,
hoverDivideCount: 2,
};
// ── Werkzeug-Schnittstelle ──────────────────────────────────────────────────
+38
View File
@@ -172,6 +172,15 @@ function SnapControl({
/>
<span>{t("snap.midpoint")}</span>
</label>
<label className="sb-snap-row">
<input
type="checkbox"
checked={snap.center}
disabled={!snap.enabled}
onChange={(e) => onSnapChange({ ...snap, center: e.target.checked })}
/>
<span>{t("snap.center")}</span>
</label>
<label className="sb-snap-row">
<input
type="checkbox"
@@ -245,6 +254,35 @@ function SnapControl({
}
/>
</label>
{/* Hover-Teilpunkte (rein visuell, unabhängig vom Fang selbst — daher
NICHT an snap.enabled gekoppelt): Ruht der Cursor auf einer Linie,
erscheinen nach der Verzögerung ihre Teilpunkte mit Akzentfarbe. */}
<label className="sb-snap-row sb-snap-row-num">
<span>{t("snap.hoverDivideDelayMs")}</span>
<input
type="number"
step={50}
min={0}
max={3000}
value={snap.hoverDivideDelayMs}
onChange={(e) =>
onSnapChange({ ...snap, hoverDivideDelayMs: Number(e.target.value) })
}
/>
</label>
<label className="sb-snap-row sb-snap-row-num">
<span>{t("snap.hoverDivideCount")}</span>
<input
type="number"
step={1}
min={2}
max={12}
value={snap.hoverDivideCount}
onChange={(e) =>
onSnapChange({ ...snap, hoverDivideCount: Number(e.target.value) })
}
/>
</label>
</div>
)}
</span>
+9
View File
@@ -36,6 +36,15 @@ export function CommandIcon({ name }: { name: string }) {
<rect x="5.5" y="5.5" width="8" height="8" />
</svg>
);
case "array":
// Drei Kopien eines Quadrats entlang eines Richtungspfeils (Feld).
return (
<svg {...common}>
<rect x="1.5" y="10.5" width="3.6" height="3.6" />
<rect x="6.2" y="6" width="3.6" height="3.6" strokeDasharray="1.6 1.2" />
<rect x="10.9" y="1.5" width="3.6" height="3.6" strokeDasharray="1.6 1.2" />
</svg>
);
case "measure":
// Lineal/Massband: Strecke mit Endmarken + kleiner Skala.
return (
+3 -2
View File
@@ -24,6 +24,7 @@ export function drawingGripVertices(d: Drawing2D): Vec2[] {
{ x: g.min.x, y: g.max.y },
];
}
if (g.shape === "bezier" || g.shape === "spline" || g.shape === "dimension") return [...g.pts];
return [];
}
@@ -44,7 +45,7 @@ export function drawingMoveAnchor(d: Drawing2D, verts: Vec2[]): Vec2 | null {
return { x: sx / verts.length, y: sy / verts.length };
}
const g = d.geom;
if (g.shape === "circle" || g.shape === "arc") return g.center;
if (g.shape === "text") return g.at;
if (g.shape === "circle" || g.shape === "arc" || g.shape === "ellipse") return g.center;
if (g.shape === "text" || g.shape === "kote") return g.at;
return null;
}
+136 -30
View File
@@ -9,7 +9,6 @@
import { useEffect, useMemo, useState } from "react";
import { t } from "../i18n";
import { formatM } from "../model/types";
import type {
Ceiling,
Drawing2D,
@@ -28,9 +27,11 @@ import type { SectionOutput } from "../plan/toSection";
import { generateElevationPlan } from "../plan/toElevation";
import { pushNativeScene, pushNativeWalls } from "../plan/nativeSync";
import { selectionHighlightLines } from "../plan/toWalls3d";
import { applyGroupDimming } from "../state/groupActions";
import { PlanView } from "../plan/PlanView";
import type {
GripHandlers,
GroupableHit,
HudFieldsState,
MarqueeSelection,
PlanSelection,
@@ -62,6 +63,8 @@ export function Content({
hairline,
marqueeColor,
snapColor,
hoverDivideDelayMs,
hoverDivideCount,
renderMode,
view3d,
fov,
@@ -84,6 +87,7 @@ export function Content({
selectedStairIds,
selectedExtrudedSolidIds,
selectedColumnIds,
selectedGroupIds,
selectedRoomIds,
selectedRoofIds,
selectedContextObjectIds,
@@ -92,6 +96,9 @@ export function Content({
onStampMove,
onStampEdit,
onEditDrawingText,
onEnterGroup,
groupPath,
onLeaveGroup,
editTextId,
editTextMinHeightM,
onCommitTextEdit,
@@ -115,6 +122,7 @@ export function Content({
toolHandlers,
hudFields,
grips,
gripHandleLines,
edgeGrips,
selectedDrawingId,
selectedDrawingIds,
@@ -141,6 +149,8 @@ export function Content({
marqueeColor: string;
/** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */
snapColor: string;
hoverDivideDelayMs?: number;
hoverDivideCount?: number;
renderMode: RenderMode;
/** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */
view3d: View3d;
@@ -174,6 +184,7 @@ export function Content({
selectedStairIds: string[];
selectedExtrudedSolidIds: string[];
selectedColumnIds: string[];
selectedGroupIds: string[];
selectedRoomIds: string[];
selectedRoofIds: string[];
selectedContextObjectIds: string[];
@@ -182,6 +193,9 @@ export function Content({
onStampMove: (roomId: string, delta: Vec2) => void;
onStampEdit: (roomId: string) => void;
onEditDrawingText?: (drawingId: string) => void;
onEnterGroup?: (hit: GroupableHit) => boolean;
groupPath: string[];
onLeaveGroup?: () => void;
editTextId?: string | null;
editTextMinHeightM?: number | null;
onCommitTextEdit?: (doc: RichTextDoc) => void;
@@ -214,6 +228,7 @@ export function Content({
toolHandlers: ToolHandlers;
hudFields: HudFieldsState | null;
grips: Vec2[];
gripHandleLines: { a: Vec2; b: Vec2 }[];
edgeGrips: EdgeGrip[];
selectedDrawingId: string | null;
selectedDrawingIds?: string[];
@@ -291,6 +306,8 @@ export function Content({
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
hoverDivideDelayMs={hoverDivideDelayMs}
hoverDivideCount={hoverDivideCount}
mono={mono}
planViewRef={planViewRef}
onCursor={onCursor}
@@ -301,6 +318,7 @@ export function Content({
selectedStairIds={selectedStairIds}
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
selectedColumnIds={selectedColumnIds}
selectedGroupIds={selectedGroupIds}
selectedRoomIds={selectedRoomIds}
selectedRoofIds={selectedRoofIds}
selectedSectionLineId={selectedSectionLineId}
@@ -308,6 +326,9 @@ export function Content({
onStampMove={onStampMove}
onStampEdit={onStampEdit}
onEditDrawingText={onEditDrawingText}
onEnterGroup={onEnterGroup}
groupPath={groupPath}
onLeaveGroup={onLeaveGroup}
editTextId={editTextId}
editTextMinHeightM={editTextMinHeightM}
onCommitTextEdit={onCommitTextEdit}
@@ -322,6 +343,7 @@ export function Content({
toolHandlers={toolHandlers}
hudFields={hudFields}
grips={grips}
gripHandleLines={gripHandleLines}
edgeGrips={edgeGrips}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
@@ -353,6 +375,8 @@ export function Content({
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
hoverDivideDelayMs={hoverDivideDelayMs}
hoverDivideCount={hoverDivideCount}
mono={mono}
planViewRef={planViewRef}
onCursor={onCursor}
@@ -363,6 +387,7 @@ export function Content({
selectedStairIds={selectedStairIds}
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
selectedColumnIds={selectedColumnIds}
selectedGroupIds={selectedGroupIds}
selectedRoomIds={selectedRoomIds}
selectedRoofIds={selectedRoofIds}
selectedContextObjectIds={selectedContextObjectIds}
@@ -371,6 +396,9 @@ export function Content({
onStampMove={onStampMove}
onStampEdit={onStampEdit}
onEditDrawingText={onEditDrawingText}
onEnterGroup={onEnterGroup}
groupPath={groupPath}
onLeaveGroup={onLeaveGroup}
editTextId={editTextId}
editTextMinHeightM={editTextMinHeightM}
onCommitTextEdit={onCommitTextEdit}
@@ -385,6 +413,7 @@ export function Content({
toolHandlers={toolHandlers}
hudFields={hudFields}
grips={grips}
gripHandleLines={gripHandleLines}
edgeGrips={edgeGrips}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
@@ -458,6 +487,8 @@ export function Content({
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
hoverDivideDelayMs={hoverDivideDelayMs}
hoverDivideCount={hoverDivideCount}
mono={mono}
planViewRef={planViewRef}
onCursor={onCursor}
@@ -468,6 +499,7 @@ export function Content({
selectedStairIds={selectedStairIds}
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
selectedColumnIds={selectedColumnIds}
selectedGroupIds={selectedGroupIds}
selectedRoomIds={selectedRoomIds}
selectedRoofIds={selectedRoofIds}
selectedContextObjectIds={selectedContextObjectIds}
@@ -476,6 +508,9 @@ export function Content({
onStampMove={onStampMove}
onStampEdit={onStampEdit}
onEditDrawingText={onEditDrawingText}
onEnterGroup={onEnterGroup}
groupPath={groupPath}
onLeaveGroup={onLeaveGroup}
editTextId={editTextId}
editTextMinHeightM={editTextMinHeightM}
onCommitTextEdit={onCommitTextEdit}
@@ -490,6 +525,7 @@ export function Content({
toolHandlers={toolHandlers}
hudFields={hudFields}
grips={grips}
gripHandleLines={gripHandleLines}
edgeGrips={edgeGrips}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
@@ -511,6 +547,8 @@ function LevelPlanView({
hairline,
marqueeColor,
snapColor,
hoverDivideDelayMs,
hoverDivideCount,
mono,
planViewRef,
onCursor,
@@ -521,6 +559,7 @@ function LevelPlanView({
selectedStairIds,
selectedExtrudedSolidIds,
selectedColumnIds,
selectedGroupIds,
selectedRoomIds,
selectedRoofIds,
selectedContextObjectIds,
@@ -529,6 +568,9 @@ function LevelPlanView({
onStampMove,
onStampEdit,
onEditDrawingText,
onEnterGroup,
groupPath,
onLeaveGroup,
editTextId,
editTextMinHeightM,
onCommitTextEdit,
@@ -543,6 +585,7 @@ function LevelPlanView({
toolHandlers,
hudFields,
grips,
gripHandleLines,
edgeGrips,
selectedDrawingId,
selectedDrawingIds,
@@ -557,6 +600,8 @@ function LevelPlanView({
hairline: boolean;
marqueeColor: string;
snapColor: string;
hoverDivideDelayMs?: number;
hoverDivideCount?: number;
mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void;
@@ -567,6 +612,7 @@ function LevelPlanView({
selectedStairIds: string[];
selectedExtrudedSolidIds: string[];
selectedColumnIds: string[];
selectedGroupIds: string[];
selectedRoomIds: string[];
selectedRoofIds: string[];
selectedContextObjectIds?: string[];
@@ -575,6 +621,9 @@ function LevelPlanView({
onStampMove: (roomId: string, delta: Vec2) => void;
onStampEdit: (roomId: string) => void;
onEditDrawingText?: (drawingId: string) => void;
onEnterGroup?: (hit: GroupableHit) => boolean;
groupPath: string[];
onLeaveGroup?: () => void;
editTextId?: string | null;
editTextMinHeightM?: number | null;
onCommitTextEdit?: (doc: RichTextDoc) => void;
@@ -593,6 +642,7 @@ function LevelPlanView({
toolHandlers: ToolHandlers;
hudFields: HudFieldsState | null;
grips: Vec2[];
gripHandleLines: { a: Vec2; b: Vec2 }[];
edgeGrips: EdgeGrip[];
selectedDrawingId: string | null;
selectedDrawingIds?: string[];
@@ -600,19 +650,31 @@ function LevelPlanView({
}) {
// Detailgrad + Referenzlinien fließen in die Plan-Erzeugung ein (Symbole,
// Schraffuren/Linienfeinheit bzw. Wand-Achslinien).
const plan = useMemo(
() =>
generatePlan(
project,
level.id,
visibleCodes,
categoryDisplay,
detail,
referenceLines,
mono,
),
[project, level.id, visibleCodes, categoryDisplay, detail, referenceLines, mono],
);
const plan = useMemo(() => {
const p = generatePlan(
project,
level.id,
visibleCodes,
categoryDisplay,
detail,
referenceLines,
mono,
);
// Gruppen-Isolation ("Gruppe betreten"): alles ausserhalb der aktiven
// Gruppe gedimmt, s. state/groupActions.ts::applyGroupDimming.
return groupPath.length === 0
? p
: { ...p, primitives: applyGroupDimming(p.primitives, project, groupPath) };
}, [
project,
level.id,
visibleCodes,
categoryDisplay,
detail,
referenceLines,
mono,
groupPath,
]);
// Live-Spiegelung in die nativen wgpu-Fenster (No-op ohne Tauri, debounced):
// EXAKT der sichtbare Plan (gleiches Geschoss, gleicher Detailgrad) fürs
@@ -624,22 +686,18 @@ function LevelPlanView({
pushNativeWalls(project);
}, [project]);
// Bei einer freien Zeichnung gibt es keine Schnitthöhe → eigene Legende/Titel.
const isDrawing = level.kind === "drawing";
const caption = isDrawing
? t("content.drawingLegend")
: t("content.grundrissLegend", {
name: level.name,
cutHeight: formatM(level.cutHeight ?? 0),
});
return (
<section className="pane">
<div className="pane-title">
{isDrawing
? t("content.drawingTitle", { name: level.name })
: t("content.grundrissTitle", { name: level.name })}
</div>
{groupPath.length > 0 && (
<button
type="button"
className="pane-leave-group-btn"
onClick={onLeaveGroup}
title={t("group.leave.hint")}
>
{t("group.leave")}
</button>
)}
<PlanView
ref={planViewRef}
plan={plan}
@@ -652,6 +710,7 @@ function LevelPlanView({
selectedStairIds={selectedStairIds}
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
selectedColumnIds={selectedColumnIds}
selectedGroupIds={selectedGroupIds}
selectedRoomIds={selectedRoomIds}
selectedRoofIds={selectedRoofIds}
selectedContextObjectIds={selectedContextObjectIds}
@@ -660,6 +719,7 @@ function LevelPlanView({
onStampMove={onStampMove}
onStampEdit={onStampEdit}
onEditDrawingText={onEditDrawingText}
onEnterGroup={onEnterGroup}
editTextId={editTextId}
editTextMinHeightM={editTextMinHeightM}
onCommitTextEdit={onCommitTextEdit}
@@ -674,15 +734,20 @@ function LevelPlanView({
toolHandlers={toolHandlers}
hudFields={hudFields}
grips={grips}
gripHandleLines={gripHandleLines}
edgeGrips={edgeGrips}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
hoverDivideDelayMs={hoverDivideDelayMs}
hoverDivideCount={hoverDivideCount}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers}
/>
<div className="plan-legend">{caption}</div>
{/* NACH PlanView gerendert (nicht als box-shadow auf .pane), damit der
Isolations-Rahmen über der PlanView-SVG sichtbar bleibt. */}
{groupPath.length > 0 && <div className="pane-isolation-frame" />}
</section>
);
}
@@ -703,6 +768,8 @@ function SectionPlanView({
hairline,
marqueeColor,
snapColor,
hoverDivideDelayMs,
hoverDivideCount,
mono,
planViewRef,
onCursor,
@@ -713,6 +780,7 @@ function SectionPlanView({
selectedStairIds,
selectedExtrudedSolidIds,
selectedColumnIds,
selectedGroupIds,
selectedRoomIds,
selectedRoofIds,
selectedSectionLineId,
@@ -720,6 +788,9 @@ function SectionPlanView({
onStampMove,
onStampEdit,
onEditDrawingText,
onEnterGroup,
groupPath,
onLeaveGroup,
editTextId,
editTextMinHeightM,
onCommitTextEdit,
@@ -734,6 +805,7 @@ function SectionPlanView({
toolHandlers,
hudFields,
grips,
gripHandleLines,
edgeGrips,
selectedDrawingId,
selectedDrawingIds,
@@ -744,6 +816,8 @@ function SectionPlanView({
hairline: boolean;
marqueeColor: string;
snapColor: string;
hoverDivideDelayMs?: number;
hoverDivideCount?: number;
mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void;
@@ -754,6 +828,7 @@ function SectionPlanView({
selectedStairIds: string[];
selectedExtrudedSolidIds: string[];
selectedColumnIds: string[];
selectedGroupIds: string[];
selectedRoomIds: string[];
selectedRoofIds: string[];
selectedSectionLineId: string | null;
@@ -761,6 +836,9 @@ function SectionPlanView({
onStampMove: (roomId: string, delta: Vec2) => void;
onStampEdit: (roomId: string) => void;
onEditDrawingText?: (drawingId: string) => void;
onEnterGroup?: (hit: GroupableHit) => boolean;
groupPath: string[];
onLeaveGroup?: () => void;
editTextId?: string | null;
editTextMinHeightM?: number | null;
onCommitTextEdit?: (doc: RichTextDoc) => void;
@@ -775,6 +853,7 @@ function SectionPlanView({
toolHandlers: ToolHandlers;
hudFields: HudFieldsState | null;
grips: Vec2[];
gripHandleLines: { a: Vec2; b: Vec2 }[];
edgeGrips: EdgeGrip[];
selectedDrawingId: string | null;
selectedDrawingIds?: string[];
@@ -828,7 +907,16 @@ function SectionPlanView({
: null,
[isElevation, output, mono, level.hiddenLines],
);
const plan = isElevation ? elevationPlan : sectionPlan;
const rawPlan = isElevation ? elevationPlan : sectionPlan;
// Gruppen-Isolation ("Gruppe betreten") gilt auch für Schnitt-/Ansichts-
// Pläne, s. state/groupActions.ts::applyGroupDimming.
const plan = useMemo(
() =>
rawPlan && groupPath.length > 0
? { ...rawPlan, primitives: applyGroupDimming(rawPlan.primitives, project, groupPath) }
: rawPlan,
[rawPlan, project, groupPath],
);
const effStatus = isElevation ? (elevationPlan ? "ready" : "empty") : status;
const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation");
@@ -853,6 +941,16 @@ function SectionPlanView({
<div className="pane-title">
{t("content.sectionTitle", { name: level.name, kind: kindLabel })}
</div>
{groupPath.length > 0 && (
<button
type="button"
className="pane-leave-group-btn"
onClick={onLeaveGroup}
title={t("group.leave.hint")}
>
{t("group.leave")}
</button>
)}
<PlanView
ref={planViewRef}
plan={plan}
@@ -865,6 +963,7 @@ function SectionPlanView({
selectedStairIds={selectedStairIds}
selectedExtrudedSolidIds={selectedExtrudedSolidIds}
selectedColumnIds={selectedColumnIds}
selectedGroupIds={selectedGroupIds}
selectedRoomIds={selectedRoomIds}
selectedRoofIds={selectedRoofIds}
selectedSectionLineIds={selectedSectionLineId ? [selectedSectionLineId] : []}
@@ -872,6 +971,7 @@ function SectionPlanView({
onStampMove={onStampMove}
onStampEdit={onStampEdit}
onEditDrawingText={onEditDrawingText}
onEnterGroup={onEnterGroup}
editTextId={editTextId}
editTextMinHeightM={editTextMinHeightM}
onCommitTextEdit={onCommitTextEdit}
@@ -886,14 +986,20 @@ function SectionPlanView({
toolHandlers={toolHandlers}
hudFields={hudFields}
grips={grips}
gripHandleLines={gripHandleLines}
edgeGrips={edgeGrips}
hairline={hairline}
marqueeColor={marqueeColor}
snapColor={snapColor}
hoverDivideDelayMs={hoverDivideDelayMs}
hoverDivideCount={hoverDivideCount}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers}
/>
{/* NACH PlanView gerendert (nicht als box-shadow auf .pane), damit der
Isolations-Rahmen über der PlanView-SVG sichtbar bleibt. */}
{groupPath.length > 0 && <div className="pane-isolation-frame" />}
<div className="plan-legend">{t("section.legend", { kind: kindLabel })}</div>
</section>
);