Merge: Schnittfunktion ausgebaut (Schnittlinie, Editieren im Schnitt, Tiefe)
- Befehl 'sectionline'/'viewline' (Aliase schnittlinie/ansichtslinie): zwei Klicks setzen die Schnitt-/Ansichtslinie, neue Ebene bei Bedarf - Schnittführungs-Symbol im Grundriss (Strichpunkt, Endmarken, Richtungs- pfeile, Label) + Pick-Band, Auswahl + Attribut-Sektion (Name, Richtung umkehren, Endpunkte, Tiefe, Löschen) - Editieren IM Schnitt: Cut-Polygone tragen die Quell-Element-Id (durch Schicht-Zerlegung/Terminierung/Dominanz propagiert) → Klick wählt das Bauteil, Panels editieren, Schnitt rechnet neu - Schnitt-Tiefe (DrawingLevel.depth): Ansichtskanten ferner Bauteile werden ausgeblendet (Owner-Distanz-Näherung, dokumentiert) # Conflicts: # src/model/types.ts
This commit is contained in:
+74
-1
@@ -582,6 +582,9 @@ export default function App() {
|
|||||||
const selectedRoofIds = useStore((s) => s.selectedRoofIds);
|
const selectedRoofIds = useStore((s) => s.selectedRoofIds);
|
||||||
const setSelectedRoofIds = useStore((s) => s.setSelectedRoofIds);
|
const setSelectedRoofIds = useStore((s) => s.setSelectedRoofIds);
|
||||||
const selectedRoofId = selectedRoofIds.length === 1 ? selectedRoofIds[0] : null;
|
const selectedRoofId = selectedRoofIds.length === 1 ? selectedRoofIds[0] : null;
|
||||||
|
// Schnitt-/Ansichtslinien-Auswahl (Einzel-DrawingLevel-Id oder null).
|
||||||
|
const selectedSectionLineId = useStore((s) => s.selectedSectionLineId);
|
||||||
|
const setSelectedSectionLineId = useStore((s) => s.setSelectedSectionLineId);
|
||||||
const updateRoof = useStore((s) => s.updateRoof);
|
const updateRoof = useStore((s) => s.updateRoof);
|
||||||
const moveRoofGrip = useStore((s) => s.moveRoofGrip);
|
const moveRoofGrip = useStore((s) => s.moveRoofGrip);
|
||||||
const moveRoofBy = useStore((s) => s.moveRoofBy);
|
const moveRoofBy = useStore((s) => s.moveRoofBy);
|
||||||
@@ -635,6 +638,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
setMenu(null);
|
setMenu(null);
|
||||||
setEditor(null);
|
setEditor(null);
|
||||||
}, [activeLevelId]);
|
}, [activeLevelId]);
|
||||||
@@ -1853,6 +1857,13 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||||
if (activeTransform) return; // während einer Transformation nicht löschen
|
if (activeTransform) return; // während einer Transformation nicht löschen
|
||||||
|
// Schnittlinie: Entf löscht NUR ihre Linie (linePoints), die Ebene bleibt.
|
||||||
|
if (selectedSectionLineId) {
|
||||||
|
patchLevel(selectedSectionLineId, { linePoints: undefined });
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
selectedDrawingIds.length ||
|
selectedDrawingIds.length ||
|
||||||
selectedWallIds.length ||
|
selectedWallIds.length ||
|
||||||
@@ -1898,6 +1909,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1914,6 +1926,7 @@ export default function App() {
|
|||||||
selectedExtrudedSolidIds,
|
selectedExtrudedSolidIds,
|
||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
selectedRoofIds,
|
selectedRoofIds,
|
||||||
|
selectedSectionLineId,
|
||||||
project.walls,
|
project.walls,
|
||||||
project.drawings2d,
|
project.drawings2d,
|
||||||
project.ceilings,
|
project.ceilings,
|
||||||
@@ -2707,6 +2720,21 @@ export default function App() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Gewählte Schnitt-/Ansichtslinie (DrawingLevel) — separat von `selection`, da
|
||||||
|
// eine Schnittlinie KEIN Projekt-Bauteil ist. Speist die Schnittlinien-Sektion
|
||||||
|
// des Object-Info-Panels.
|
||||||
|
const sectionLine = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedSectionLineId
|
||||||
|
? project.drawingLevels.find(
|
||||||
|
(z) =>
|
||||||
|
z.id === selectedSectionLineId &&
|
||||||
|
(z.kind === "section" || z.kind === "elevation"),
|
||||||
|
) ?? null
|
||||||
|
: null,
|
||||||
|
[project.drawingLevels, selectedSectionLineId],
|
||||||
|
);
|
||||||
|
|
||||||
// ── DXF/DWG-Import (Dialog) ───────────────────────────────────────────────
|
// ── DXF/DWG-Import (Dialog) ───────────────────────────────────────────────
|
||||||
// Eine Datei (Button ODER Drop) öffnet den modalen Import-Dialog. DXF wird als
|
// Eine Datei (Button ODER Drop) öffnet den modalen Import-Dialog. DXF wird als
|
||||||
// Text geparst, DWG als ArrayBuffer über LibreDWG-WASM (lazy, asynchron); beide
|
// Text geparst, DWG als ArrayBuffer über LibreDWG-WASM (lazy, asynchron); beide
|
||||||
@@ -3319,6 +3347,14 @@ export default function App() {
|
|||||||
onSetRoofPatch: (patch) => {
|
onSetRoofPatch: (patch) => {
|
||||||
if (selection?.kind === "roof") updateRoof(selection.id, patch);
|
if (selection?.kind === "roof") updateRoof(selection.id, patch);
|
||||||
},
|
},
|
||||||
|
// ── Schnitt-/Ansichtslinie (nur bei selektierter Linie) ────────────────
|
||||||
|
sectionLine,
|
||||||
|
onSetSectionLinePatch: (patch) => {
|
||||||
|
if (selectedSectionLineId) patchLevel(selectedSectionLineId, patch);
|
||||||
|
},
|
||||||
|
onDeleteSectionLine: () => {
|
||||||
|
if (selectedSectionLineId) patchLevel(selectedSectionLineId, { linePoints: undefined });
|
||||||
|
},
|
||||||
// ── Treppen-Attribute (nur bei selektierter Treppe) ────────────────────
|
// ── Treppen-Attribute (nur bei selektierter Treppe) ────────────────────
|
||||||
onSetStairShape: (shape) => {
|
onSetStairShape: (shape) => {
|
||||||
if (selection?.kind === "stair") updateStair(selection.id, { shape });
|
if (selection?.kind === "stair") updateStair(selection.id, { shape });
|
||||||
@@ -3475,6 +3511,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "Wand":
|
case "Wand":
|
||||||
setSelectedWallIds([id]);
|
setSelectedWallIds([id]);
|
||||||
@@ -3848,6 +3885,8 @@ export default function App() {
|
|||||||
detail,
|
detail,
|
||||||
scaleDenominator,
|
scaleDenominator,
|
||||||
selection,
|
selection,
|
||||||
|
sectionLine,
|
||||||
|
selectedSectionLineId,
|
||||||
selectedViewSnapshotId,
|
selectedViewSnapshotId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -4334,6 +4373,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
setStampEditorRoomId(roomId);
|
setStampEditorRoomId(roomId);
|
||||||
};
|
};
|
||||||
// Doppelklick auf ein Text-2D-Element: selektieren + Inhalts-Editor öffnen.
|
// Doppelklick auf ein Text-2D-Element: selektieren + Inhalts-Editor öffnen.
|
||||||
@@ -4349,6 +4389,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
setEditTextId(drawingId);
|
setEditTextId(drawingId);
|
||||||
};
|
};
|
||||||
// Bearbeiteten Text-Inhalt übernehmen (immutabel; nur bei Text-Geometrie).
|
// Bearbeiteten Text-Inhalt übernehmen (immutabel; nur bei Text-Geometrie).
|
||||||
@@ -4373,6 +4414,13 @@ export default function App() {
|
|||||||
// Rest behalten (Wände und 2D-Zeichnungen unabhängig, gemischte Auswahl
|
// Rest behalten (Wände und 2D-Zeichnungen unabhängig, gemischte Auswahl
|
||||||
// erlaubt). Leerraum-Klick mit Shift lässt die Auswahl unverändert.
|
// erlaubt). Leerraum-Klick mit Shift lässt die Auswahl unverändert.
|
||||||
if (sel.shift) {
|
if (sel.shift) {
|
||||||
|
// Schnittlinie ist Einzelauswahl — Shift+Klick togglet sie an/aus.
|
||||||
|
if (sel.sectionLineId) {
|
||||||
|
setSelectedSectionLineId(
|
||||||
|
selectedSectionLineId === sel.sectionLineId ? null : sel.sectionLineId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (sel.wallId) {
|
if (sel.wallId) {
|
||||||
const id = sel.wallId;
|
const id = sel.wallId;
|
||||||
setSelectedWallIds(
|
setSelectedWallIds(
|
||||||
@@ -4454,8 +4502,12 @@ export default function App() {
|
|||||||
if (keep !== "extrudedSolid") setSelectedExtrudedSolidIds([]);
|
if (keep !== "extrudedSolid") setSelectedExtrudedSolidIds([]);
|
||||||
if (keep !== "column") setSelectedColumnIds([]);
|
if (keep !== "column") setSelectedColumnIds([]);
|
||||||
if (keep !== "roof") setSelectedRoofIds([]);
|
if (keep !== "roof") setSelectedRoofIds([]);
|
||||||
|
if (keep !== "sectionLine") setSelectedSectionLineId(null);
|
||||||
};
|
};
|
||||||
if (sel.openingId) {
|
if (sel.sectionLineId) {
|
||||||
|
setSelectedSectionLineId(sel.sectionLineId);
|
||||||
|
clearOthers("sectionLine");
|
||||||
|
} else if (sel.openingId) {
|
||||||
setSelectedOpeningIds([sel.openingId]);
|
setSelectedOpeningIds([sel.openingId]);
|
||||||
clearOthers("opening");
|
clearOthers("opening");
|
||||||
} else if (sel.wallId) {
|
} else if (sel.wallId) {
|
||||||
@@ -4500,6 +4552,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
};
|
};
|
||||||
// Rechts-Klick im Grundriss: Wand-Kontextmenü. Trifft der Klick eine Wand, die
|
// Rechts-Klick im Grundriss: Wand-Kontextmenü. Trifft der Klick eine Wand, die
|
||||||
// NICHT bereits in der Auswahl ist, wird die Auswahl auf sie gesetzt (damit die
|
// NICHT bereits in der Auswahl ist, wird die Auswahl auf sie gesetzt (damit die
|
||||||
@@ -4526,6 +4579,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Links-Klick im 3D: per Raycast getroffene Treppe wählen (Einzelauswahl).
|
// Links-Klick im 3D: per Raycast getroffene Treppe wählen (Einzelauswahl).
|
||||||
@@ -4540,6 +4594,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Links-Klick im 3D: per Raycast getroffene Decke wählen (Einzelauswahl).
|
// Links-Klick im 3D: per Raycast getroffene Decke wählen (Einzelauswahl).
|
||||||
@@ -4554,6 +4609,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Links-Klick im 3D: per Raycast getroffene Öffnung wählen (Einzelauswahl).
|
// Links-Klick im 3D: per Raycast getroffene Öffnung wählen (Einzelauswahl).
|
||||||
@@ -4568,6 +4624,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Links-KLICK im Nordstern-3D-Viewport (WASM, TS-Raycast): getroffenes Bauteil
|
// Links-KLICK im Nordstern-3D-Viewport (WASM, TS-Raycast): getroffenes Bauteil
|
||||||
@@ -4587,6 +4644,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Öffnung (Fenster/Tür) und Treppe sind jetzt auch im WASM-Viewport pickbar
|
// Öffnung (Fenster/Tür) und Treppe sind jetzt auch im WASM-Viewport pickbar
|
||||||
@@ -4602,6 +4660,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hit.kind === "stair") {
|
if (hit.kind === "stair") {
|
||||||
@@ -4614,6 +4673,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hit.kind === "roof") {
|
if (hit.kind === "roof") {
|
||||||
@@ -4646,6 +4706,7 @@ export default function App() {
|
|||||||
setSelectedExtrudedSolidIds([]);
|
setSelectedExtrudedSolidIds([]);
|
||||||
setSelectedColumnIds([]);
|
setSelectedColumnIds([]);
|
||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
|
setSelectedSectionLineId(null);
|
||||||
};
|
};
|
||||||
// Rechts-Klick im 3D: Wand-Kontextmenü an der Cursorposition.
|
// Rechts-Klick im 3D: Wand-Kontextmenü an der Cursorposition.
|
||||||
const onViewportContextMenu = (info: ViewportContextInfo) => {
|
const onViewportContextMenu = (info: ViewportContextInfo) => {
|
||||||
@@ -4941,6 +5002,7 @@ export default function App() {
|
|||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
|
selectedSectionLineId={selectedSectionLineId}
|
||||||
roomStamp={roomStamp}
|
roomStamp={roomStamp}
|
||||||
onStampMove={moveRoomStamp}
|
onStampMove={moveRoomStamp}
|
||||||
onStampEdit={onOpenStampEditor}
|
onStampEdit={onOpenStampEditor}
|
||||||
@@ -6074,6 +6136,7 @@ function Content({
|
|||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
selectedRoomIds,
|
selectedRoomIds,
|
||||||
selectedRoofIds,
|
selectedRoofIds,
|
||||||
|
selectedSectionLineId,
|
||||||
roomStamp,
|
roomStamp,
|
||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
@@ -6152,6 +6215,7 @@ function Content({
|
|||||||
selectedColumnIds: string[];
|
selectedColumnIds: string[];
|
||||||
selectedRoomIds: string[];
|
selectedRoomIds: string[];
|
||||||
selectedRoofIds: string[];
|
selectedRoofIds: string[];
|
||||||
|
selectedSectionLineId: string | null;
|
||||||
roomStamp: { roomId: string; anchor: Vec2 } | null;
|
roomStamp: { roomId: string; anchor: Vec2 } | null;
|
||||||
onStampMove: (roomId: string, delta: Vec2) => void;
|
onStampMove: (roomId: string, delta: Vec2) => void;
|
||||||
onStampEdit: (roomId: string) => void;
|
onStampEdit: (roomId: string) => void;
|
||||||
@@ -6269,6 +6333,7 @@ function Content({
|
|||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
|
selectedSectionLineId={selectedSectionLineId}
|
||||||
roomStamp={roomStamp}
|
roomStamp={roomStamp}
|
||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
@@ -6324,6 +6389,7 @@ function Content({
|
|||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
|
selectedSectionLineId={selectedSectionLineId}
|
||||||
roomStamp={roomStamp}
|
roomStamp={roomStamp}
|
||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
@@ -6419,6 +6485,7 @@ function Content({
|
|||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
|
selectedSectionLineId={selectedSectionLineId}
|
||||||
roomStamp={roomStamp}
|
roomStamp={roomStamp}
|
||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
@@ -6464,6 +6531,7 @@ function LevelPlanView({
|
|||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
selectedRoomIds,
|
selectedRoomIds,
|
||||||
selectedRoofIds,
|
selectedRoofIds,
|
||||||
|
selectedSectionLineId,
|
||||||
roomStamp,
|
roomStamp,
|
||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
@@ -6502,6 +6570,7 @@ function LevelPlanView({
|
|||||||
selectedColumnIds: string[];
|
selectedColumnIds: string[];
|
||||||
selectedRoomIds: string[];
|
selectedRoomIds: string[];
|
||||||
selectedRoofIds: string[];
|
selectedRoofIds: string[];
|
||||||
|
selectedSectionLineId: string | null;
|
||||||
roomStamp: { roomId: string; anchor: Vec2 } | null;
|
roomStamp: { roomId: string; anchor: Vec2 } | null;
|
||||||
onStampMove: (roomId: string, delta: Vec2) => void;
|
onStampMove: (roomId: string, delta: Vec2) => void;
|
||||||
onStampEdit: (roomId: string) => void;
|
onStampEdit: (roomId: string) => void;
|
||||||
@@ -6579,6 +6648,7 @@ function LevelPlanView({
|
|||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
|
selectedSectionLineIds={selectedSectionLineId ? [selectedSectionLineId] : []}
|
||||||
roomStamp={roomStamp}
|
roomStamp={roomStamp}
|
||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
@@ -6632,6 +6702,7 @@ function SectionPlanView({
|
|||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
selectedRoomIds,
|
selectedRoomIds,
|
||||||
selectedRoofIds,
|
selectedRoofIds,
|
||||||
|
selectedSectionLineId,
|
||||||
roomStamp,
|
roomStamp,
|
||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
@@ -6666,6 +6737,7 @@ function SectionPlanView({
|
|||||||
selectedColumnIds: string[];
|
selectedColumnIds: string[];
|
||||||
selectedRoomIds: string[];
|
selectedRoomIds: string[];
|
||||||
selectedRoofIds: string[];
|
selectedRoofIds: string[];
|
||||||
|
selectedSectionLineId: string | null;
|
||||||
roomStamp: { roomId: string; anchor: Vec2 } | null;
|
roomStamp: { roomId: string; anchor: Vec2 } | null;
|
||||||
onStampMove: (roomId: string, delta: Vec2) => void;
|
onStampMove: (roomId: string, delta: Vec2) => void;
|
||||||
onStampEdit: (roomId: string) => void;
|
onStampEdit: (roomId: string) => void;
|
||||||
@@ -6767,6 +6839,7 @@ function SectionPlanView({
|
|||||||
selectedColumnIds={selectedColumnIds}
|
selectedColumnIds={selectedColumnIds}
|
||||||
selectedRoomIds={selectedRoomIds}
|
selectedRoomIds={selectedRoomIds}
|
||||||
selectedRoofIds={selectedRoofIds}
|
selectedRoofIds={selectedRoofIds}
|
||||||
|
selectedSectionLineIds={selectedSectionLineId ? [selectedSectionLineId] : []}
|
||||||
roomStamp={roomStamp}
|
roomStamp={roomStamp}
|
||||||
onStampMove={onStampMove}
|
onStampMove={onStampMove}
|
||||||
onStampEdit={onStampEdit}
|
onStampEdit={onStampEdit}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Schnittlinien-Befehl: der reine Datenpfad (commitLine/nextLevelName) — setzt
|
||||||
|
// die Grundriss-Schnittlinie auf die richtige Ziel-Ebene bzw. legt eine neue an.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { _test } from "./sectionline";
|
||||||
|
import type { DrawingLevel, Project } from "../../model/types";
|
||||||
|
|
||||||
|
const { commitLine, nextLevelName } = _test;
|
||||||
|
|
||||||
|
function projectWith(levels: DrawingLevel[]): Project {
|
||||||
|
return {
|
||||||
|
id: "p",
|
||||||
|
name: "T",
|
||||||
|
layers: [],
|
||||||
|
drawingLevels: levels,
|
||||||
|
walls: [],
|
||||||
|
drawings2d: [],
|
||||||
|
} as unknown as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = { x: 0, y: 0 };
|
||||||
|
const b = { x: 5, y: 0 };
|
||||||
|
|
||||||
|
describe("sectionline — commitLine", () => {
|
||||||
|
it("belegt die EINE Platzhalter-Schnittebene ohne Linie", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false },
|
||||||
|
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
const out = commitLine(p, a, b, "section");
|
||||||
|
const s = out.drawingLevels.find((z) => z.id === "s1")!;
|
||||||
|
expect(s.linePoints).toEqual([a, b]);
|
||||||
|
expect(s.directionSign).toBe(1);
|
||||||
|
// Keine neue Ebene angelegt.
|
||||||
|
expect(out.drawingLevels).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legt eine NEUE Schnittebene an, wenn keine Platzhalter-Ebene existiert", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false },
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
name: "Schnitt A",
|
||||||
|
kind: "section",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
linePoints: [{ x: 1, y: 1 }, { x: 2, y: 2 }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const out = commitLine(p, a, b, "section");
|
||||||
|
expect(out.drawingLevels).toHaveLength(3);
|
||||||
|
const created = out.drawingLevels[out.drawingLevels.length - 1];
|
||||||
|
expect(created.kind).toBe("section");
|
||||||
|
expect(created.linePoints).toEqual([a, b]);
|
||||||
|
// Zweite Ebene → Buchstabe B.
|
||||||
|
expect(created.name).toContain("B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legt bei ZWEI Platzhaltern (mehrdeutig) eine neue Ebene an", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
|
||||||
|
{ id: "s2", name: "Schnitt B", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
const out = commitLine(p, a, b, "section");
|
||||||
|
expect(out.drawingLevels).toHaveLength(3);
|
||||||
|
// Die bestehenden Platzhalter bleiben unangetastet.
|
||||||
|
expect(out.drawingLevels[0].linePoints).toBeUndefined();
|
||||||
|
expect(out.drawingLevels[1].linePoints).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft entartete (zu kurze) Linien", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
const out = commitLine(p, a, { x: 0.02, y: 0 }, "section");
|
||||||
|
expect(out).toBe(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trennt Schnitt- und Ansichts-Ebenen (kind)", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
// Ansichtslinie darf die Schnitt-Platzhalter-Ebene NICHT belegen.
|
||||||
|
const out = commitLine(p, a, b, "elevation");
|
||||||
|
expect(out.drawingLevels).toHaveLength(2);
|
||||||
|
expect(out.drawingLevels[0].linePoints).toBeUndefined();
|
||||||
|
expect(out.drawingLevels[1].kind).toBe("elevation");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sectionline — nextLevelName", () => {
|
||||||
|
it("zählt Buchstaben je Art hoch (A, B, C …)", () => {
|
||||||
|
const p0 = projectWith([]);
|
||||||
|
expect(nextLevelName(p0, "section")).toContain("A");
|
||||||
|
const p1 = projectWith([
|
||||||
|
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
expect(nextLevelName(p1, "section")).toContain("B");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Schnittlinie — setzt die Grundriss-Schnitt-/Ansichtslinie einer Zeichnungsebene
|
||||||
|
// (DrawingLevel, kind "section"/"elevation") per zwei Klicks (Start → Ende). Die
|
||||||
|
// Linie steuert `sectionPlaneFromLevel` (plan/toSection.ts): senkrecht auf ihr
|
||||||
|
// steht die Blickrichtung (directionSign, Voreinstellung +1).
|
||||||
|
//
|
||||||
|
// Schritte:
|
||||||
|
// 1) „Schnitt-Startpunkt:" → Punkt
|
||||||
|
// 2) „Schnitt-Endpunkt:" → Live-Linie + Richtungspfeil; Klick committet.
|
||||||
|
//
|
||||||
|
// Ziel-Ebene (im Commit bestimmt): existiert GENAU EINE Ebene der gewählten Art
|
||||||
|
// OHNE gesetzte Linie (frisch angelegter Platzhalter), wird deren Linie gesetzt;
|
||||||
|
// sonst wird eine NEUE Ebene angelegt (Name „Schnitt B/C…" bzw. „Ansicht …").
|
||||||
|
//
|
||||||
|
// Die Art (Schnitt/Ansicht) ist als Inline-Option umschaltbar — dieselbe Mechanik
|
||||||
|
// für beide, nur `kind` unterscheidet sich (Alias `viewline` startet mit
|
||||||
|
// „elevation"). Bezeichner englisch, sichtbarer Text via t().
|
||||||
|
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import type { DrawingLevel, DrawingLevelKind } from "../../model/types";
|
||||||
|
import { leftNormal, normalize, sub } from "../../model/geometry";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
CmdOption,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/** Nur Schnitt/Ansicht tragen eine Linie. */
|
||||||
|
type LineKind = "section" | "elevation";
|
||||||
|
|
||||||
|
/** Wählbare Art (Inline-Optionen der Befehlszeile). */
|
||||||
|
const KIND_OPTIONS: CmdOption[] = [
|
||||||
|
{ id: "section", labelKey: "cmd.sectionline.kind.section" },
|
||||||
|
{ id: "elevation", labelKey: "cmd.sectionline.kind.elevation" },
|
||||||
|
];
|
||||||
|
const KIND_IDS = new Set(KIND_OPTIONS.map((o) => o.id));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Richtungspfeil-Vorschau: kurze Linie vom Mittelpunkt der Schnittlinie entlang
|
||||||
|
* der Blicknormalen (leftNormal der Linie, per directionSign gedreht) plus zwei
|
||||||
|
* Flügel-Segmente — zeigt, wohin der Schnitt „blickt".
|
||||||
|
*/
|
||||||
|
function directionArrow(a: Vec2, b: Vec2, sign: 1 | -1): DraftShape[] {
|
||||||
|
const dir = sub(b, a);
|
||||||
|
const len = Math.hypot(dir.x, dir.y);
|
||||||
|
if (len < 1e-6) return [];
|
||||||
|
const u = normalize(dir);
|
||||||
|
const n = leftNormal(u); // (-uy, ux)
|
||||||
|
const nx = n.x * sign;
|
||||||
|
const ny = n.y * sign;
|
||||||
|
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||||
|
// Pfeillänge ~ min(1 m, Viertel der Linienlänge), damit die Vorschau lesbar bleibt.
|
||||||
|
const arrowLen = Math.min(1, len / 4);
|
||||||
|
const tip = { x: mid.x + nx * arrowLen, y: mid.y + ny * arrowLen };
|
||||||
|
const wing = arrowLen * 0.35;
|
||||||
|
// Flügel: vom Spitzenpunkt zurück, je halb entlang −u und +u geneigt.
|
||||||
|
const back = { x: tip.x - nx * wing * 2, y: tip.y - ny * wing * 2 };
|
||||||
|
const w1 = { x: back.x + u.x * wing, y: back.y + u.y * wing };
|
||||||
|
const w2 = { x: back.x - u.x * wing, y: back.y - u.y * wing };
|
||||||
|
return [
|
||||||
|
{ kind: "line", a: mid, b: tip },
|
||||||
|
{ kind: "line", a: tip, b: w1 },
|
||||||
|
{ kind: "line", a: tip, b: w2 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau: Schnittlinie + Richtungspfeil + Länge im HUD. */
|
||||||
|
function lineDraft(a: Vec2, cursor: Vec2 | null, sign: 1 | -1): ToolDraft {
|
||||||
|
if (!cursor) return { preview: [{ kind: "poly", pts: [a] }], vertices: [a] };
|
||||||
|
const preview: DraftShape[] = [
|
||||||
|
{ kind: "line", a, b: cursor },
|
||||||
|
...directionArrow(a, cursor, sign),
|
||||||
|
];
|
||||||
|
const draft: ToolDraft = { preview, vertices: [a] };
|
||||||
|
draft.hud = {
|
||||||
|
at: cursor,
|
||||||
|
text: `${Math.hypot(cursor.x - a.x, cursor.y - a.y).toFixed(2)} m`,
|
||||||
|
};
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nächster freier Ebenen-Name der Art (A, B, C … über die Anzahl bestehender). */
|
||||||
|
function nextLevelName(p: Project, kind: LineKind): string {
|
||||||
|
const count = p.drawingLevels.filter((z) => z.kind === kind).length;
|
||||||
|
// Buchstaben-Suffix (A, B, …, Z, dann AA…) — klassische Schnitt-Benennung.
|
||||||
|
let n = count;
|
||||||
|
let letters = "";
|
||||||
|
do {
|
||||||
|
letters = String.fromCharCode(65 + (n % 26)) + letters;
|
||||||
|
n = Math.floor(n / 26) - 1;
|
||||||
|
} while (n >= 0);
|
||||||
|
const key = kind === "section" ? "cmd.sectionline.newSection" : "cmd.sectionline.newElevation";
|
||||||
|
return t(key, { letter: letters });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Schnittlinie [a, b] auf die Ziel-Ebene (immutabel). Existiert GENAU
|
||||||
|
* EINE Ebene der Art ohne Linie, wird sie belegt; sonst wird eine neue angelegt.
|
||||||
|
* Entartete (zu kurze) Linien werden verworfen.
|
||||||
|
*/
|
||||||
|
function commitLine(p: Project, a: Vec2, b: Vec2, kind: LineKind): Project {
|
||||||
|
if (Math.hypot(b.x - a.x, b.y - a.y) < 0.1) return p;
|
||||||
|
const line: [Vec2, Vec2] = [a, b];
|
||||||
|
const orphans = p.drawingLevels.filter((z) => z.kind === kind && !z.linePoints);
|
||||||
|
if (orphans.length === 1) {
|
||||||
|
const targetId = orphans[0].id;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
drawingLevels: p.drawingLevels.map((z) =>
|
||||||
|
z.id === targetId ? { ...z, linePoints: line, directionSign: z.directionSign ?? 1 } : z,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const level: DrawingLevel = {
|
||||||
|
id: `${kind}-${Date.now()}`,
|
||||||
|
name: nextLevelName(p, kind),
|
||||||
|
kind: kind as DrawingLevelKind,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
linePoints: line,
|
||||||
|
directionSign: 1,
|
||||||
|
};
|
||||||
|
return { ...p, drawingLevels: [...p.drawingLevels, level] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineIdle extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
kind: LineKind;
|
||||||
|
}
|
||||||
|
interface LineEnd extends CommandState {
|
||||||
|
phase: "end";
|
||||||
|
a: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
kind: LineKind;
|
||||||
|
}
|
||||||
|
type LineState = LineIdle | LineEnd;
|
||||||
|
|
||||||
|
const DIRECTION_SIGN: 1 = 1;
|
||||||
|
|
||||||
|
function makeCommand(name: string, startKind: LineKind): Command {
|
||||||
|
const idle = (kind: LineKind): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", kind, lastPoint: null } as LineIdle,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
labelKey: "cmd.sectionline.label",
|
||||||
|
// Nicht floorOnly: eine Schnittlinie zieht man IM Grundriss, wo Bauteile
|
||||||
|
// liegen — die aktive Ebene ist dann ein Geschoss. Aber der Befehl soll auch
|
||||||
|
// auf freien Zeichnungsebenen nutzbar bleiben; die Prüfung übernimmt der
|
||||||
|
// Grundriss-Kontext (keine harte Sperre nötig).
|
||||||
|
floorOnly: true,
|
||||||
|
prompt: (s) => ((s as LineState).phase === "end" ? "cmd.sectionline.end" : "cmd.sectionline.start"),
|
||||||
|
accepts: () => ["point", "option"],
|
||||||
|
options: () => KIND_OPTIONS,
|
||||||
|
init: (): LineIdle => ({ phase: "start", kind: startKind, lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input): [CommandState, CommandResult] => {
|
||||||
|
const s = state as LineState;
|
||||||
|
if (input.kind === "option" && KIND_IDS.has(input.id)) {
|
||||||
|
const ns = { ...s, kind: input.id as LineKind } as LineState;
|
||||||
|
const draft =
|
||||||
|
ns.phase === "end" ? lineDraft(ns.a, ns.cursor, DIRECTION_SIGN) : null;
|
||||||
|
return [ns, { draft }];
|
||||||
|
}
|
||||||
|
if (input.kind !== "point") {
|
||||||
|
return [
|
||||||
|
s,
|
||||||
|
{ draft: s.phase === "end" ? lineDraft(s.a, s.cursor, DIRECTION_SIGN) : null },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (s.phase !== "end") {
|
||||||
|
const ns: LineEnd = {
|
||||||
|
phase: "end",
|
||||||
|
a: input.point,
|
||||||
|
cursor: input.point,
|
||||||
|
kind: s.kind,
|
||||||
|
lastPoint: input.point,
|
||||||
|
};
|
||||||
|
return [ns, { draft: lineDraft(input.point, input.point, DIRECTION_SIGN) }];
|
||||||
|
}
|
||||||
|
const a = s.a;
|
||||||
|
const b = input.point;
|
||||||
|
const kind = s.kind;
|
||||||
|
return [
|
||||||
|
{ phase: "start", kind, lastPoint: null } as LineIdle,
|
||||||
|
{ draft: null, done: true, commit: (p) => commitLine(p, a, b, kind) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as LineState;
|
||||||
|
if (s.phase !== "end") return [s, { draft: null }];
|
||||||
|
const ns: LineEnd = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: lineDraft(s.a, point, DIRECTION_SIGN) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (state): [CommandState, CommandResult] => idle((state as LineState).kind),
|
||||||
|
onCancel: (state): [CommandState, CommandResult] => idle((state as LineState).kind),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schnittlinie (startet als „section"). */
|
||||||
|
export const sectionLineCommand: Command = makeCommand("sectionline", "section");
|
||||||
|
/** Ansichtslinie (startet als „elevation"; identische Mechanik). */
|
||||||
|
export const viewLineCommand: Command = makeCommand("viewline", "elevation");
|
||||||
|
|
||||||
|
// Für Tests: die reinen Datenpfad-Helfer.
|
||||||
|
export const _test = { commitLine, nextLevelName, directionArrow };
|
||||||
@@ -29,6 +29,7 @@ import { trimCommand } from "./cmds/trim";
|
|||||||
import { importCommand } from "./cmds/import";
|
import { importCommand } from "./cmds/import";
|
||||||
import { terrainCommand } from "./cmds/terrain";
|
import { terrainCommand } from "./cmds/terrain";
|
||||||
import { measureCommand } from "./cmds/measure";
|
import { measureCommand } from "./cmds/measure";
|
||||||
|
import { sectionLineCommand, viewLineCommand } from "./cmds/sectionline";
|
||||||
|
|
||||||
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
|
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
|
||||||
export const COMMANDS: Record<string, Command> = {
|
export const COMMANDS: Record<string, Command> = {
|
||||||
@@ -59,6 +60,8 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
import: importCommand,
|
import: importCommand,
|
||||||
terrain: terrainCommand,
|
terrain: terrainCommand,
|
||||||
measure: measureCommand,
|
measure: measureCommand,
|
||||||
|
sectionline: sectionLineCommand,
|
||||||
|
viewline: viewLineCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +108,8 @@ export const ALIASES: Record<string, string> = {
|
|||||||
tr: "trim",
|
tr: "trim",
|
||||||
imp: "import",
|
imp: "import",
|
||||||
ter: "terrain",
|
ter: "terrain",
|
||||||
|
schnittlinie: "sectionline",
|
||||||
|
ansichtslinie: "viewline",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
|
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export const de = {
|
|||||||
"ribbon.group.draw": "Zeichnen",
|
"ribbon.group.draw": "Zeichnen",
|
||||||
"ribbon.group.modify": "Ändern",
|
"ribbon.group.modify": "Ändern",
|
||||||
"ribbon.group.components": "Bauteile",
|
"ribbon.group.components": "Bauteile",
|
||||||
|
"ribbon.group.sections": "Schnitte",
|
||||||
"ribbon.group.custom": "Eigene Leiste",
|
"ribbon.group.custom": "Eigene Leiste",
|
||||||
"ribbon.custom.add": "+ Hinzufügen",
|
"ribbon.custom.add": "+ Hinzufügen",
|
||||||
"ribbon.custom.empty": "Elemente über + Hinzufügen wählen",
|
"ribbon.custom.empty": "Elemente über + Hinzufügen wählen",
|
||||||
@@ -504,6 +505,18 @@ export const de = {
|
|||||||
"objinfo.room.refFloor": "Referenzgeschoss",
|
"objinfo.room.refFloor": "Referenzgeschoss",
|
||||||
"objinfo.room.editStamp": "Stempel bearbeiten …",
|
"objinfo.room.editStamp": "Stempel bearbeiten …",
|
||||||
// ── Extrusions-Attribute (truck-Integration, Object-Info-Panel) ─────────
|
// ── Extrusions-Attribute (truck-Integration, Object-Info-Panel) ─────────
|
||||||
|
"objinfo.sectionLine.section": "Schnittlinie",
|
||||||
|
"objinfo.sectionLine.kind.section": "Schnitt",
|
||||||
|
"objinfo.sectionLine.kind.elevation": "Ansicht",
|
||||||
|
"objinfo.sectionLine.name": "Name",
|
||||||
|
"objinfo.sectionLine.flip": "Blickrichtung umkehren",
|
||||||
|
"objinfo.sectionLine.startX": "Start X",
|
||||||
|
"objinfo.sectionLine.startY": "Start Y",
|
||||||
|
"objinfo.sectionLine.endX": "Ende X",
|
||||||
|
"objinfo.sectionLine.endY": "Ende Y",
|
||||||
|
"objinfo.sectionLine.depth": "Schnitt-Tiefe (m)",
|
||||||
|
"objinfo.sectionLine.depthHint": "leer = unbegrenzt",
|
||||||
|
"objinfo.sectionLine.delete": "Schnittlinie löschen",
|
||||||
"objinfo.extrudedSolid.section": "Extrusion",
|
"objinfo.extrudedSolid.section": "Extrusion",
|
||||||
"objinfo.extrudedSolid.height": "Höhe",
|
"objinfo.extrudedSolid.height": "Höhe",
|
||||||
"objinfo.extrudedSolid.taper": "Verjüngung",
|
"objinfo.extrudedSolid.taper": "Verjüngung",
|
||||||
@@ -978,6 +991,13 @@ export const de = {
|
|||||||
"cmd.roof.shape.mansarde": "Mansarddach",
|
"cmd.roof.shape.mansarde": "Mansarddach",
|
||||||
"cmd.roof.shape.zelt": "Zeltdach",
|
"cmd.roof.shape.zelt": "Zeltdach",
|
||||||
"cmd.roof.shape.flach": "Flachdach",
|
"cmd.roof.shape.flach": "Flachdach",
|
||||||
|
"cmd.sectionline.label": "Schnittlinie",
|
||||||
|
"cmd.sectionline.start": "Schnitt-Startpunkt:",
|
||||||
|
"cmd.sectionline.end": "Schnitt-Endpunkt:",
|
||||||
|
"cmd.sectionline.kind.section": "Schnitt",
|
||||||
|
"cmd.sectionline.kind.elevation": "Ansicht",
|
||||||
|
"cmd.sectionline.newSection": "Schnitt {letter}",
|
||||||
|
"cmd.sectionline.newElevation": "Ansicht {letter}",
|
||||||
"cmd.opening.label": "Öffnung",
|
"cmd.opening.label": "Öffnung",
|
||||||
"cmd.opening.windowLabel": "Fenster",
|
"cmd.opening.windowLabel": "Fenster",
|
||||||
"cmd.opening.doorLabel": "Tür",
|
"cmd.opening.doorLabel": "Tür",
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ribbon.group.draw": "Draw",
|
"ribbon.group.draw": "Draw",
|
||||||
"ribbon.group.modify": "Modify",
|
"ribbon.group.modify": "Modify",
|
||||||
"ribbon.group.components": "Components",
|
"ribbon.group.components": "Components",
|
||||||
|
"ribbon.group.sections": "Sections",
|
||||||
"ribbon.group.custom": "Custom bar",
|
"ribbon.group.custom": "Custom bar",
|
||||||
"ribbon.custom.add": "+ Add",
|
"ribbon.custom.add": "+ Add",
|
||||||
"ribbon.custom.empty": "Pick elements via “+ Add”",
|
"ribbon.custom.empty": "Pick elements via “+ Add”",
|
||||||
@@ -501,6 +502,18 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.room.refFloor": "Reference floor",
|
"objinfo.room.refFloor": "Reference floor",
|
||||||
"objinfo.room.editStamp": "Edit stamp …",
|
"objinfo.room.editStamp": "Edit stamp …",
|
||||||
// ── Extruded solid attributes (truck integration, object info panel) ────
|
// ── Extruded solid attributes (truck integration, object info panel) ────
|
||||||
|
"objinfo.sectionLine.section": "Section line",
|
||||||
|
"objinfo.sectionLine.kind.section": "Section",
|
||||||
|
"objinfo.sectionLine.kind.elevation": "Elevation",
|
||||||
|
"objinfo.sectionLine.name": "Name",
|
||||||
|
"objinfo.sectionLine.flip": "Flip view direction",
|
||||||
|
"objinfo.sectionLine.startX": "Start X",
|
||||||
|
"objinfo.sectionLine.startY": "Start Y",
|
||||||
|
"objinfo.sectionLine.endX": "End X",
|
||||||
|
"objinfo.sectionLine.endY": "End Y",
|
||||||
|
"objinfo.sectionLine.depth": "Section depth (m)",
|
||||||
|
"objinfo.sectionLine.depthHint": "empty = unlimited",
|
||||||
|
"objinfo.sectionLine.delete": "Delete section line",
|
||||||
"objinfo.extrudedSolid.section": "Extrusion",
|
"objinfo.extrudedSolid.section": "Extrusion",
|
||||||
"objinfo.extrudedSolid.height": "Height",
|
"objinfo.extrudedSolid.height": "Height",
|
||||||
"objinfo.extrudedSolid.taper": "Taper",
|
"objinfo.extrudedSolid.taper": "Taper",
|
||||||
@@ -968,6 +981,13 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.roof.shape.mansarde": "Mansard roof",
|
"cmd.roof.shape.mansarde": "Mansard roof",
|
||||||
"cmd.roof.shape.zelt": "Pyramid roof",
|
"cmd.roof.shape.zelt": "Pyramid roof",
|
||||||
"cmd.roof.shape.flach": "Flat roof",
|
"cmd.roof.shape.flach": "Flat roof",
|
||||||
|
"cmd.sectionline.label": "Section line",
|
||||||
|
"cmd.sectionline.start": "Section start point:",
|
||||||
|
"cmd.sectionline.end": "Section end point:",
|
||||||
|
"cmd.sectionline.kind.section": "Section",
|
||||||
|
"cmd.sectionline.kind.elevation": "Elevation",
|
||||||
|
"cmd.sectionline.newSection": "Section {letter}",
|
||||||
|
"cmd.sectionline.newElevation": "Elevation {letter}",
|
||||||
"cmd.opening.label": "Opening",
|
"cmd.opening.label": "Opening",
|
||||||
"cmd.opening.windowLabel": "Window",
|
"cmd.opening.windowLabel": "Window",
|
||||||
"cmd.opening.doorLabel": "Door",
|
"cmd.opening.doorLabel": "Door",
|
||||||
|
|||||||
@@ -770,6 +770,13 @@ export interface DrawingLevel {
|
|||||||
* die dahinterliegende Fassade.
|
* die dahinterliegende Fassade.
|
||||||
*/
|
*/
|
||||||
shadows?: boolean;
|
shadows?: boolean;
|
||||||
|
/**
|
||||||
|
* Schnitt-Tiefe in Metern (nur Schnitt/Ansicht): Bauteile weiter als `depth`
|
||||||
|
* HINTER der Schnittebene (in Blickrichtung) werden ausgeblendet. Fehlt der
|
||||||
|
* Wert, ist die Tiefe unbegrenzt (das gesamte Modell wird projiziert — heutiges
|
||||||
|
* Verhalten).
|
||||||
|
*/
|
||||||
|
depth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
RoomSection,
|
RoomSection,
|
||||||
ExtrudedSolidSection,
|
ExtrudedSolidSection,
|
||||||
ColumnSection,
|
ColumnSection,
|
||||||
|
SectionLineSection,
|
||||||
} from "./ObjectInfoPanel";
|
} from "./ObjectInfoPanel";
|
||||||
|
|
||||||
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
|
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
|
||||||
@@ -92,10 +93,15 @@ export function AttributesPanel() {
|
|||||||
// Leerzustand: der Typ-Picker (falls ein Bauteil-Werkzeug aktiv ist), sonst ein
|
// Leerzustand: der Typ-Picker (falls ein Bauteil-Werkzeug aktiv ist), sonst ein
|
||||||
// kompakter Hinweis.
|
// kompakter Hinweis.
|
||||||
if (sel === null) {
|
if (sel === null) {
|
||||||
|
// Schnitt-/Ansichtslinie ist kein Projekt-Bauteil (kein `selection`) — sie
|
||||||
|
// kommt über host.sectionLine und wird hier im „Leerzustand" bearbeitet.
|
||||||
return (
|
return (
|
||||||
<div className="attr-panel">
|
<div className="attr-panel">
|
||||||
{typeSection}
|
{typeSection}
|
||||||
{!typeSection && <div className="attr-empty">{t("attr.empty")}</div>}
|
{host.sectionLine && <SectionLineSection level={host.sectionLine} host={host} />}
|
||||||
|
{!typeSection && !host.sectionLine && (
|
||||||
|
<div className="attr-empty">{t("attr.empty")}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import { t } from "../i18n";
|
|||||||
import type { TranslationKey } from "../i18n";
|
import type { TranslationKey } from "../i18n";
|
||||||
import { flattenCategories, formatM } from "../model/types";
|
import { flattenCategories, formatM } from "../model/types";
|
||||||
import type {
|
import type {
|
||||||
|
DrawingLevel,
|
||||||
RoofShape,
|
RoofShape,
|
||||||
SiaCategory,
|
SiaCategory,
|
||||||
SliceTermination,
|
SliceTermination,
|
||||||
StairShape,
|
StairShape,
|
||||||
|
Vec2,
|
||||||
VerticalAnchor,
|
VerticalAnchor,
|
||||||
WallReferenceLine,
|
WallReferenceLine,
|
||||||
} from "../model/types";
|
} from "../model/types";
|
||||||
@@ -314,6 +316,118 @@ export function RoomSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Schnitt-/Ansichtslinien-Abschnitt (Object-Info; nur die gewählte Linie) ──
|
||||||
|
// Name (editierbar), Blickrichtung umkehren (directionSign-Flip), Endpunkt-
|
||||||
|
// Koordinaten (editierbar) + Schnitt-Tiefe; Löschen setzt linePoints zurück
|
||||||
|
// (die Ebene bleibt bestehen). Eine Schnittlinie ist KEIN Projekt-Bauteil,
|
||||||
|
// darum kommt sie über `host.sectionLine` (nicht über `selection`).
|
||||||
|
export function SectionLineSection({
|
||||||
|
level,
|
||||||
|
host,
|
||||||
|
}: {
|
||||||
|
level: DrawingLevel;
|
||||||
|
host: ReturnType<typeof usePanelHost>;
|
||||||
|
}) {
|
||||||
|
const line = level.linePoints;
|
||||||
|
const kindLabel =
|
||||||
|
level.kind === "elevation"
|
||||||
|
? t("objinfo.sectionLine.kind.elevation")
|
||||||
|
: t("objinfo.sectionLine.kind.section");
|
||||||
|
const patchPoint = (which: 0 | 1, axis: "x" | "y", value: number) => {
|
||||||
|
if (!line) return;
|
||||||
|
const next: [Vec2, Vec2] = [{ ...line[0] }, { ...line[1] }];
|
||||||
|
next[which] = { ...next[which], [axis]: value };
|
||||||
|
host.onSetSectionLinePatch({ linePoints: next });
|
||||||
|
};
|
||||||
|
const coordField = (labelKey: TranslationKey, value: number, on: (v: number) => void) => (
|
||||||
|
<div className="objinfo-field">
|
||||||
|
<span className="objinfo-flabel">{t(labelKey)}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="objinfo-text"
|
||||||
|
step={0.1}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
if (Number.isFinite(v)) on(v);
|
||||||
|
}}
|
||||||
|
title={t(labelKey)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="objinfo-sep" />
|
||||||
|
<div className="objinfo-section-label">
|
||||||
|
{t("objinfo.sectionLine.section")} · {kindLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name (editierbar). */}
|
||||||
|
<div className="objinfo-field">
|
||||||
|
<span className="objinfo-flabel">{t("objinfo.sectionLine.name")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="objinfo-text"
|
||||||
|
value={level.name}
|
||||||
|
onChange={(e) => host.onSetSectionLinePatch({ name: e.target.value })}
|
||||||
|
title={t("objinfo.sectionLine.name")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Blickrichtung umkehren (directionSign-Flip). */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="objinfo-btn"
|
||||||
|
onClick={() =>
|
||||||
|
host.onSetSectionLinePatch({
|
||||||
|
directionSign: (level.directionSign ?? 1) === 1 ? -1 : 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("objinfo.sectionLine.flip")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Endpunkt-Koordinaten (nur wenn eine Linie gesetzt ist). */}
|
||||||
|
{line && (
|
||||||
|
<>
|
||||||
|
{coordField("objinfo.sectionLine.startX", line[0].x, (v) => patchPoint(0, "x", v))}
|
||||||
|
{coordField("objinfo.sectionLine.startY", line[0].y, (v) => patchPoint(0, "y", v))}
|
||||||
|
{coordField("objinfo.sectionLine.endX", line[1].x, (v) => patchPoint(1, "x", v))}
|
||||||
|
{coordField("objinfo.sectionLine.endY", line[1].y, (v) => patchPoint(1, "y", v))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Schnitt-Tiefe (leer = unbegrenzt). */}
|
||||||
|
<div className="objinfo-field">
|
||||||
|
<span className="objinfo-flabel">{t("objinfo.sectionLine.depth")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="objinfo-text"
|
||||||
|
step={0.5}
|
||||||
|
min={0}
|
||||||
|
value={level.depth ?? ""}
|
||||||
|
placeholder={t("objinfo.sectionLine.depthHint")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.trim();
|
||||||
|
if (raw === "") {
|
||||||
|
host.onSetSectionLinePatch({ depth: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const v = Number(raw);
|
||||||
|
if (Number.isFinite(v) && v > 0) host.onSetSectionLinePatch({ depth: v });
|
||||||
|
}}
|
||||||
|
title={t("objinfo.sectionLine.depth")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Löschen: linePoints zurücksetzen (die Ebene bleibt bestehen). */}
|
||||||
|
<button type="button" className="objinfo-btn" onClick={() => host.onDeleteSectionLine()}>
|
||||||
|
{t("objinfo.sectionLine.delete")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Extrusions-Attribut-Abschnitt (truck-Integration) ───────────────────────
|
// ── Extrusions-Attribut-Abschnitt (truck-Integration) ───────────────────────
|
||||||
// Höhe editierbar (löst Re-Extrusion aus); Grundfläche + Geschoss read-only.
|
// Höhe editierbar (löst Re-Extrusion aus); Grundfläche + Geschoss read-only.
|
||||||
export function ExtrudedSolidSection({
|
export function ExtrudedSolidSection({
|
||||||
|
|||||||
@@ -330,6 +330,25 @@ export interface PanelHostValue {
|
|||||||
*/
|
*/
|
||||||
onSetRoofPatch: (patch: Partial<import("../model/types").Roof>) => void;
|
onSetRoofPatch: (patch: Partial<import("../model/types").Roof>) => void;
|
||||||
|
|
||||||
|
// ── Schnitt-/Ansichtslinie (Object-Info-Panel; nur die selektierte Linie) ──
|
||||||
|
/**
|
||||||
|
* Die aktuell gewählte Schnitt-/Ansichtsebene (DrawingLevel, kind
|
||||||
|
* "section"/"elevation"), oder `null`. Das Object-Info-Panel zeigt ihre
|
||||||
|
* Attribute (Name, Blickrichtung, Endpunkte, Tiefe), auch wenn `selection`
|
||||||
|
* null ist (eine Schnittlinie ist KEIN Projekt-Bauteil).
|
||||||
|
*/
|
||||||
|
sectionLine: DrawingLevel | null;
|
||||||
|
/**
|
||||||
|
* Immutable Änderung einer Schnitt-/Ansichtslinie (Name/directionSign/
|
||||||
|
* linePoints/depth) — wirkt NUR auf die selektierte Linie.
|
||||||
|
*/
|
||||||
|
onSetSectionLinePatch: (patch: Partial<DrawingLevel>) => void;
|
||||||
|
/**
|
||||||
|
* Löscht die Schnittlinie der gewählten Ebene (`linePoints` = undefined); die
|
||||||
|
* Ebene selbst bleibt bestehen (der Schnitt zeigt dann wieder den Hinweis).
|
||||||
|
*/
|
||||||
|
onDeleteSectionLine: () => void;
|
||||||
|
|
||||||
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
|
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
|
||||||
/** Setzt die Grundform (gerade/L/Wendel). */
|
/** Setzt die Grundform (gerade/L/Wendel). */
|
||||||
onSetStairShape: (shape: StairShape) => void;
|
onSetStairShape: (shape: StairShape) => void;
|
||||||
|
|||||||
@@ -197,6 +197,12 @@ export interface PlanSelection {
|
|||||||
* Element höherer Priorität (alle anderen gehen vor); sonst `null`.
|
* Element höherer Priorität (alle anderen gehen vor); sonst `null`.
|
||||||
*/
|
*/
|
||||||
roofId: string | null;
|
roofId: string | null;
|
||||||
|
/**
|
||||||
|
* ID der getroffenen Schnitt-/Ansichtslinie (DrawingLevel), wenn der Klick ihr
|
||||||
|
* dünnes Pick-Band traf; sonst `null`. Höchste Priorität (das schmale Band liegt
|
||||||
|
* über allem — ein Klick genau darauf meint die Linie).
|
||||||
|
*/
|
||||||
|
sectionLineId: string | null;
|
||||||
/**
|
/**
|
||||||
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
|
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
|
||||||
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
|
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
|
||||||
@@ -287,6 +293,11 @@ export interface PlanViewProps {
|
|||||||
selectedRoomIds?: string[];
|
selectedRoomIds?: string[];
|
||||||
/** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */
|
/** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */
|
||||||
selectedRoofIds?: string[];
|
selectedRoofIds?: string[];
|
||||||
|
/**
|
||||||
|
* Aktuell ausgewählte Schnitt-/Ansichtslinien-IDs (DrawingLevel; von App). Die
|
||||||
|
* Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur.
|
||||||
|
*/
|
||||||
|
selectedSectionLineIds?: string[];
|
||||||
/**
|
/**
|
||||||
* Aktuell ausgewählte IDs extrudierter Körper (truck-Integration; kontrolliert
|
* Aktuell ausgewählte IDs extrudierter Körper (truck-Integration; kontrolliert
|
||||||
* von App). Die Ansicht markiert den Footprint-Umriss JEDES dieser Körper mit
|
* von App). Die Ansicht markiert den Footprint-Umriss JEDES dieser Körper mit
|
||||||
@@ -416,6 +427,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
selectedStairIds,
|
selectedStairIds,
|
||||||
selectedRoomIds,
|
selectedRoomIds,
|
||||||
selectedRoofIds,
|
selectedRoofIds,
|
||||||
|
selectedSectionLineIds,
|
||||||
selectedExtrudedSolidIds,
|
selectedExtrudedSolidIds,
|
||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
roomStamp,
|
roomStamp,
|
||||||
@@ -1007,6 +1019,20 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Getroffene Schnitt-/Ansichtslinie: ihr schmales, unsichtbares Pick-Band
|
||||||
|
// (Polygon mit sectionLineId) per Punkt-in-Polygon.
|
||||||
|
const pickSectionLine = (clientX: number, clientY: number): string | null => {
|
||||||
|
const v = clientToView(clientX, clientY, view);
|
||||||
|
const m = viewToModel(v.x, v.y);
|
||||||
|
for (let i = plan.primitives.length - 1; i >= 0; i--) {
|
||||||
|
const p = plan.primitives[i];
|
||||||
|
if (p.kind === "polygon" && p.sectionLineId && pointInPolygon(m, p.pts)) {
|
||||||
|
return p.sectionLineId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
|
// Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
|
||||||
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
|
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
|
||||||
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
|
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
|
||||||
@@ -1518,6 +1544,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
const hit = hitIndex !== null ? plan.primitives[hitIndex] : undefined;
|
const hit = hitIndex !== null ? plan.primitives[hitIndex] : undefined;
|
||||||
// Öffnung (Fenster/Tür) zuerst: ihr Symbol sitzt in der ausgesparten Lücke
|
// Öffnung (Fenster/Tür) zuerst: ihr Symbol sitzt in der ausgesparten Lücke
|
||||||
// bzw. schlägt über die Wand — ein Klick nahe am Symbol wählt die Öffnung.
|
// bzw. schlägt über die Wand — ein Klick nahe am Symbol wählt die Öffnung.
|
||||||
|
// Schnitt-/Ansichtslinie zuerst: ihr schmales Band liegt über allem, ein
|
||||||
|
// Klick genau darauf meint die Linie (nicht die darunterliegende Wand) —
|
||||||
|
// dann sofort melden (kein anderes Element konkurriert).
|
||||||
|
const sectionLineId = pickSectionLine(e.clientX, e.clientY);
|
||||||
|
if (sectionLineId) {
|
||||||
|
onSelect({
|
||||||
|
model: viewToModel(v.x, v.y),
|
||||||
|
hitIndex,
|
||||||
|
wallId: null,
|
||||||
|
drawingId: null,
|
||||||
|
ceilingId: null,
|
||||||
|
openingId: null,
|
||||||
|
stairId: null,
|
||||||
|
roomId: null,
|
||||||
|
extrudedSolidId: null,
|
||||||
|
columnId: null,
|
||||||
|
roofId: null,
|
||||||
|
sectionLineId,
|
||||||
|
shift: e.shiftKey,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const openingId = pickOpening(e.clientX, e.clientY);
|
const openingId = pickOpening(e.clientX, e.clientY);
|
||||||
const wallId =
|
const wallId =
|
||||||
openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null;
|
openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null;
|
||||||
@@ -1581,6 +1629,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
extrudedSolidId,
|
extrudedSolidId,
|
||||||
columnId,
|
columnId,
|
||||||
roofId,
|
roofId,
|
||||||
|
sectionLineId: null,
|
||||||
shift: e.shiftKey,
|
shift: e.shiftKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1780,6 +1829,20 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
);
|
);
|
||||||
}, [plan, selectedRoofIds]);
|
}, [plan, selectedRoofIds]);
|
||||||
|
|
||||||
|
// Hervorhebung der selektierten Schnitt-/Ansichtslinie: das (unsichtbare)
|
||||||
|
// Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen.
|
||||||
|
const highlightSectionLinePolys = useMemo(() => {
|
||||||
|
const ids =
|
||||||
|
selectedSectionLineIds && selectedSectionLineIds.length
|
||||||
|
? new Set(selectedSectionLineIds)
|
||||||
|
: null;
|
||||||
|
if (!ids) return [];
|
||||||
|
return plan.primitives.filter(
|
||||||
|
(p): p is Extract<Primitive, { kind: "polygon" }> =>
|
||||||
|
p.kind === "polygon" && p.sectionLineId != null && ids.has(p.sectionLineId),
|
||||||
|
);
|
||||||
|
}, [plan, selectedSectionLineIds]);
|
||||||
|
|
||||||
// Hervorhebung der selektierten extrudierten Körper (truck-Integration): der
|
// Hervorhebung der selektierten extrudierten Körper (truck-Integration): der
|
||||||
// Footprint-Umriss (Polygon mit extrudedSolidId) mit Akzentkontur überzeichnen
|
// Footprint-Umriss (Polygon mit extrudedSolidId) mit Akzentkontur überzeichnen
|
||||||
// — analog zur Raum-Auswahl.
|
// — analog zur Raum-Auswahl.
|
||||||
@@ -2024,6 +2087,18 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{/* Auswahl-Hervorhebung der gewählten Schnitt-/Ansichtslinie (Pick-Band). */}
|
||||||
|
{highlightSectionLinePolys.map((poly, i) => (
|
||||||
|
<polygon
|
||||||
|
key={`selsl-${i}`}
|
||||||
|
className="plan-selected"
|
||||||
|
points={poly.pts
|
||||||
|
.map(toScreen)
|
||||||
|
.map((s) => `${s.x},${s.y}`)
|
||||||
|
.join(" ")}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{/* Auswahl-Hervorhebung der gewählten extrudierten Körper (truck-Integration). */}
|
{/* Auswahl-Hervorhebung der gewählten extrudierten Körper (truck-Integration). */}
|
||||||
{highlightExtrudedSolidPolys.map((poly, i) => (
|
{highlightExtrudedSolidPolys.map((poly, i) => (
|
||||||
<polygon
|
<polygon
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Schnittführungs-Symbol im Grundriss (generatePlan → addSectionLines): jede
|
||||||
|
* Schnitt-/Ansichtsebene MIT `linePoints` erzeugt eine Strichpunkt-Linie
|
||||||
|
* (cls "section-line"), Endmarken + ein unsichtbares Pick-Band (Polygon mit
|
||||||
|
* `sectionLineId`). Ebenen OHNE Linie erzeugen nichts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { generatePlan } from "./generatePlan";
|
||||||
|
import type { DrawingLevel, Project } from "../model/types";
|
||||||
|
|
||||||
|
const visible = new Set(["20", "35"]);
|
||||||
|
|
||||||
|
function baseProject(levels: DrawingLevel[]): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: levels,
|
||||||
|
layers: [{ code: "20", name: "Wände", color: "#333", lw: 0.18, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
} as unknown as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
const floor: DrawingLevel = {
|
||||||
|
id: "eg",
|
||||||
|
name: "EG",
|
||||||
|
kind: "floor",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
floorHeight: 2.6,
|
||||||
|
cutHeight: 1.0,
|
||||||
|
baseElevation: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("generatePlan — Schnittführungs-Symbol", () => {
|
||||||
|
it("zeichnet Linie + Pick-Band für eine Schnittebene mit linePoints", () => {
|
||||||
|
const p = baseProject([
|
||||||
|
floor,
|
||||||
|
{
|
||||||
|
id: "s-a",
|
||||||
|
name: "Schnitt A",
|
||||||
|
kind: "section",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
linePoints: [{ x: 0, y: 2 }, { x: 6, y: 2 }],
|
||||||
|
directionSign: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const prims = generatePlan(p, "eg", visible).primitives;
|
||||||
|
// Strichpunkt-Hauptlinie vorhanden.
|
||||||
|
const lines = prims.filter((pr) => pr.kind === "line" && pr.cls === "section-line");
|
||||||
|
expect(lines.length).toBeGreaterThan(0);
|
||||||
|
// Pick-Band trägt die Level-Id.
|
||||||
|
const band = prims.find((pr) => pr.kind === "polygon" && pr.sectionLineId === "s-a");
|
||||||
|
expect(band).toBeTruthy();
|
||||||
|
// Label-Text (Kurz-Tag „A") an den Enden.
|
||||||
|
const labels = prims.filter((pr) => pr.kind === "drawingText" && pr.text === "A");
|
||||||
|
expect(labels.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeichnet NICHTS für eine Schnittebene ohne linePoints", () => {
|
||||||
|
const p = baseProject([
|
||||||
|
floor,
|
||||||
|
{ id: "s-b", name: "Schnitt B", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
const prims = generatePlan(p, "eg", visible).primitives;
|
||||||
|
expect(prims.some((pr) => pr.kind === "polygon" && pr.sectionLineId != null)).toBe(false);
|
||||||
|
expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behandelt Ansichtsebenen (elevation) gleich", () => {
|
||||||
|
const p = baseProject([
|
||||||
|
floor,
|
||||||
|
{
|
||||||
|
id: "e-a",
|
||||||
|
name: "Ansicht Süd",
|
||||||
|
kind: "elevation",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
linePoints: [{ x: 0, y: 0 }, { x: 4, y: 0 }],
|
||||||
|
directionSign: -1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const prims = generatePlan(p, "eg", visible).primitives;
|
||||||
|
expect(prims.some((pr) => pr.kind === "polygon" && pr.sectionLineId === "e-a")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -265,6 +265,12 @@ export type Primitive =
|
|||||||
columnId?: string;
|
columnId?: string;
|
||||||
/** ID des Dachs (Roof), falls dieses (unsichtbare) Polygon der Traufe-Umriss ist (Auswahl). */
|
/** ID des Dachs (Roof), falls dieses (unsichtbare) Polygon der Traufe-Umriss ist (Auswahl). */
|
||||||
roofId?: string;
|
roofId?: string;
|
||||||
|
/**
|
||||||
|
* ID der Schnitt-/Ansichtslinie (DrawingLevel), falls dieses (unsichtbare)
|
||||||
|
* Pick-Band das Schnittführungs-Symbol umhüllt (für die Links-Klick-Auswahl
|
||||||
|
* der Schnittlinie im Grundriss).
|
||||||
|
*/
|
||||||
|
sectionLineId?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "line";
|
kind: "line";
|
||||||
@@ -892,6 +898,10 @@ export function generatePlan(
|
|||||||
// die Reihenfolge unkritisch; wir hängen sie als Referenz an.
|
// die Reihenfolge unkritisch; wir hängen sie als Referenz an.
|
||||||
addContextContours(primitives, project);
|
addContextContours(primitives, project);
|
||||||
|
|
||||||
|
// Schnittführungs-Symbole (klassische Schnittlinien) aller Schnitt-/Ansichts-
|
||||||
|
// ebenen mit gesetzter Linie — projektglobale Referenzgeometrie über dem Plan.
|
||||||
|
addSectionLines(primitives, project);
|
||||||
|
|
||||||
// Schwarz-Weiss (mono): nachträglich alle Farben auf reine Tinte zwingen.
|
// Schwarz-Weiss (mono): nachträglich alle Farben auf reine Tinte zwingen.
|
||||||
// Element-/Kategorie-/Hatch-Farben werden ignoriert, Füllungen werden weiss,
|
// Element-/Kategorie-/Hatch-Farben werden ignoriert, Füllungen werden weiss,
|
||||||
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
|
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
|
||||||
@@ -985,6 +995,12 @@ export function generateSectionPlan(output: SectionOutput, mono = false): Plan {
|
|||||||
const monoFill = solid ? HATCH_INK : HATCH_PAPER;
|
const monoFill = solid ? HATCH_INK : HATCH_PAPER;
|
||||||
const monoHatch =
|
const monoHatch =
|
||||||
solid ? hatch : hatch.pattern !== "none" ? { ...hatch, color: SECTION_INK } : hatch;
|
solid ? hatch : hatch.pattern !== "none" ? { ...hatch, color: SECTION_INK } : hatch;
|
||||||
|
// Auswahl im Schnitt: das QUELL-Element (via cp.sourceId + component.kind) als
|
||||||
|
// wallId/ceilingId/roofId ans Polygon hängen — so wählt ein Klick auf das
|
||||||
|
// geschnittene Bauteil im Schnitt exakt jene Wand/Decke/Dach im Modell, und
|
||||||
|
// die bestehende PlanView-Pick-/Highlight-Logik greift ohne Sonderfall.
|
||||||
|
const kind = cp.component.kind;
|
||||||
|
const sourceId = cp.sourceId;
|
||||||
primitives.push({
|
primitives.push({
|
||||||
kind: "polygon",
|
kind: "polygon",
|
||||||
pts,
|
pts,
|
||||||
@@ -992,6 +1008,9 @@ export function generateSectionPlan(output: SectionOutput, mono = false): Plan {
|
|||||||
stroke: SECTION_INK,
|
stroke: SECTION_INK,
|
||||||
strokeWidthMm: SECTION_CUT_OUTLINE_MM,
|
strokeWidthMm: SECTION_CUT_OUTLINE_MM,
|
||||||
hatch: mono ? monoHatch : hatch,
|
hatch: mono ? monoHatch : hatch,
|
||||||
|
wallId: kind === "wall" ? sourceId : undefined,
|
||||||
|
ceilingId: kind === "slab" ? sourceId : undefined,
|
||||||
|
roofId: kind === "roof" ? sourceId : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2641,6 +2660,139 @@ function addRoof(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Schnittführungs-Symbol (klassische Schnittlinie im Grundriss) ───────────
|
||||||
|
/** Tinte der Schnittführungs-Linie/Marken (kräftiges Grau). */
|
||||||
|
const SECTION_LINE_INK = "#1f242c";
|
||||||
|
/** Haarlinie der Strichpunkt-Schnittlinie (mm Papier). */
|
||||||
|
const SECTION_LINE_MM = 0.18;
|
||||||
|
/** Kräftige Endmarken (Querstrich + Pfeil) (mm Papier). */
|
||||||
|
const SECTION_LINE_MARK_MM = 0.5;
|
||||||
|
/** Strichpunkt-Muster der Schnittlinie (lange Striche + Punkt, mm Papier). */
|
||||||
|
const SECTION_LINE_DASH: number[] = [6, 1.5, 0.4, 1.5];
|
||||||
|
/** Länge der Endmarken (Querstrich/Pfeil) in Modell-Metern. */
|
||||||
|
const SECTION_MARK_LEN = 0.6;
|
||||||
|
/** Halbe Breite des unsichtbaren Pick-Bands über der Linie (Modell-Meter). */
|
||||||
|
const SECTION_PICK_HALF = 0.3;
|
||||||
|
/** Schrifthöhe des Schnitt-Labels (Modell-Meter). */
|
||||||
|
const SECTION_LABEL_H = 0.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurz-Tag eines Schnitt-Labels: der letzte „Wort"-Teil des Ebenennamens (z. B.
|
||||||
|
* „Schnitt A" → „A"); enthält der Name kein Leerzeichen, der ganze Name. Klassisch
|
||||||
|
* wird der Schnitt an beiden Enden mit diesem Tag beschriftet (A–A).
|
||||||
|
*/
|
||||||
|
function sectionLabelTag(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
return parts.length > 1 ? parts[parts.length - 1] : name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet für jede Schnitt-/Ansichtsebene MIT gesetzter `linePoints` das
|
||||||
|
* klassische Schnittführungs-Symbol in den Grundriss: eine Strichpunkt-Haarlinie
|
||||||
|
* über die volle Länge, an beiden Enden einen kräftigen Querstrich + Richtungspfeil
|
||||||
|
* auf der Blickseite (`directionSign`) und das Schnitt-Label (Kurz-Tag) daneben.
|
||||||
|
* Zusätzlich ein unsichtbares Pick-Band (Polygon mit `sectionLineId`), damit die
|
||||||
|
* Linie im Grundriss anklickbar ist (analog dem Dach-Traufe-Pick-Polygon).
|
||||||
|
*/
|
||||||
|
function addSectionLines(out: Primitive[], project: Project): void {
|
||||||
|
for (const level of project.drawingLevels) {
|
||||||
|
if (level.kind !== "section" && level.kind !== "elevation") continue;
|
||||||
|
const line = level.linePoints;
|
||||||
|
if (!line) continue;
|
||||||
|
const [p0, p1] = line;
|
||||||
|
const axis = sub(p1, p0);
|
||||||
|
const len = Math.hypot(axis.x, axis.y);
|
||||||
|
if (len < 1e-6) continue;
|
||||||
|
const u = normalize(axis);
|
||||||
|
const n = leftNormal(u); // (-uy, ux)
|
||||||
|
const sign = level.directionSign ?? 1;
|
||||||
|
const bn = { x: n.x * sign, y: n.y * sign }; // Blicknormale
|
||||||
|
|
||||||
|
// Hauptlinie (Strichpunkt-Haarlinie).
|
||||||
|
out.push({
|
||||||
|
kind: "line",
|
||||||
|
a: p0,
|
||||||
|
b: p1,
|
||||||
|
cls: "section-line",
|
||||||
|
weightMm: SECTION_LINE_MM,
|
||||||
|
dash: SECTION_LINE_DASH,
|
||||||
|
color: SECTION_LINE_INK,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Endmarken (Querstrich + Richtungspfeil + Label) an beiden Enden.
|
||||||
|
const half = SECTION_MARK_LEN / 2;
|
||||||
|
for (const end of [p0, p1]) {
|
||||||
|
// Querstrich senkrecht zur Linie, mittig am Endpunkt.
|
||||||
|
const t0 = { x: end.x - n.x * half, y: end.y - n.y * half };
|
||||||
|
const t1 = { x: end.x + n.x * half, y: end.y + n.y * half };
|
||||||
|
out.push({
|
||||||
|
kind: "line",
|
||||||
|
a: t0,
|
||||||
|
b: t1,
|
||||||
|
cls: "section-line",
|
||||||
|
weightMm: SECTION_LINE_MARK_MM,
|
||||||
|
color: SECTION_LINE_INK,
|
||||||
|
});
|
||||||
|
// Richtungspfeil: vom Querstrich-Ende auf der Blickseite entlang bn.
|
||||||
|
const base = { x: end.x + bn.x * half, y: end.y + bn.y * half };
|
||||||
|
const tip = { x: base.x + bn.x * SECTION_MARK_LEN, y: base.y + bn.y * SECTION_MARK_LEN };
|
||||||
|
const wingBack = {
|
||||||
|
x: tip.x - bn.x * SECTION_MARK_LEN * 0.4,
|
||||||
|
y: tip.y - bn.y * SECTION_MARK_LEN * 0.4,
|
||||||
|
};
|
||||||
|
const w = SECTION_MARK_LEN * 0.25;
|
||||||
|
out.push({ kind: "line", a: base, b: tip, cls: "section-line", weightMm: SECTION_LINE_MARK_MM, color: SECTION_LINE_INK });
|
||||||
|
out.push({
|
||||||
|
kind: "line",
|
||||||
|
a: tip,
|
||||||
|
b: { x: wingBack.x + u.x * w, y: wingBack.y + u.y * w },
|
||||||
|
cls: "section-line",
|
||||||
|
weightMm: SECTION_LINE_MARK_MM,
|
||||||
|
color: SECTION_LINE_INK,
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
kind: "line",
|
||||||
|
a: tip,
|
||||||
|
b: { x: wingBack.x - u.x * w, y: wingBack.y - u.y * w },
|
||||||
|
cls: "section-line",
|
||||||
|
weightMm: SECTION_LINE_MARK_MM,
|
||||||
|
color: SECTION_LINE_INK,
|
||||||
|
});
|
||||||
|
// Label (Kurz-Tag) etwas hinter dem Pfeil, horizontal.
|
||||||
|
const labelAt = {
|
||||||
|
x: tip.x + bn.x * SECTION_MARK_LEN * 0.4 - SECTION_LABEL_H * 0.3,
|
||||||
|
y: tip.y + bn.y * SECTION_MARK_LEN * 0.4 + SECTION_LABEL_H * 0.5,
|
||||||
|
};
|
||||||
|
out.push({
|
||||||
|
kind: "drawingText",
|
||||||
|
at: labelAt,
|
||||||
|
text: sectionLabelTag(level.name),
|
||||||
|
heightM: SECTION_LABEL_H,
|
||||||
|
angle: 0,
|
||||||
|
color: SECTION_LINE_INK,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsichtbares Pick-Band (Rechteck entlang der Linie) — klickbar per
|
||||||
|
// Punkt-in-Polygon in PlanView, trägt die DrawingLevel-Id.
|
||||||
|
const off = { x: n.x * SECTION_PICK_HALF, y: n.y * SECTION_PICK_HALF };
|
||||||
|
out.push({
|
||||||
|
kind: "polygon",
|
||||||
|
pts: [
|
||||||
|
{ x: p0.x + off.x, y: p0.y + off.y },
|
||||||
|
{ x: p1.x + off.x, y: p1.y + off.y },
|
||||||
|
{ x: p1.x - off.x, y: p1.y - off.y },
|
||||||
|
{ x: p0.x - off.x, y: p0.y - off.y },
|
||||||
|
],
|
||||||
|
fill: "none",
|
||||||
|
stroke: "none",
|
||||||
|
strokeWidthMm: 0,
|
||||||
|
hatch: NO_HATCH,
|
||||||
|
sectionLineId: level.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente
|
* Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente
|
||||||
* Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren
|
* Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Auswahl im Schnitt-View: generateSectionPlan hängt jedem Cut-Polygon die
|
||||||
|
// Quell-Element-Id (cp.sourceId) je nach component.kind als wallId/ceilingId/
|
||||||
|
// roofId ans Plan-Polygon — damit ein Klick im Schnitt das Bauteil im Modell
|
||||||
|
// wählt (die bestehende PlanView-Pick-Logik greift dann ohne Sonderfall).
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { generateSectionPlan } from "./generatePlan";
|
||||||
|
import type { SectionCutPolygon, SectionOutput } from "./toSection";
|
||||||
|
|
||||||
|
function cut(kind: "wall" | "slab" | "roof", sourceId: string): SectionCutPolygon {
|
||||||
|
return {
|
||||||
|
component: { kind, index: 0 },
|
||||||
|
color: [0.5, 0.5, 0.5],
|
||||||
|
pts: [
|
||||||
|
[0, 0],
|
||||||
|
[1, 0],
|
||||||
|
[1, 2],
|
||||||
|
[0, 2],
|
||||||
|
],
|
||||||
|
sourceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: SectionOutput = {
|
||||||
|
cutPolygons: [cut("wall", "W1"), cut("slab", "C1"), cut("roof", "R1")],
|
||||||
|
visibleEdges: [],
|
||||||
|
hiddenEdges: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("generateSectionPlan — Auswahl-Ids am Cut-Polygon", () => {
|
||||||
|
it("schreibt sourceId je component.kind als wallId/ceilingId/roofId", () => {
|
||||||
|
const polys = generateSectionPlan(output).primitives.filter(
|
||||||
|
(p): p is Extract<typeof p, { kind: "polygon" }> => p.kind === "polygon",
|
||||||
|
);
|
||||||
|
const wall = polys.find((p) => p.wallId === "W1");
|
||||||
|
const ceiling = polys.find((p) => p.ceilingId === "C1");
|
||||||
|
const roof = polys.find((p) => p.roofId === "R1");
|
||||||
|
expect(wall).toBeTruthy();
|
||||||
|
expect(ceiling).toBeTruthy();
|
||||||
|
expect(roof).toBeTruthy();
|
||||||
|
// Kanäle schließen sich je Polygon aus (eine Wand trägt keine ceilingId).
|
||||||
|
expect(wall?.ceilingId).toBeUndefined();
|
||||||
|
expect(wall?.roofId).toBeUndefined();
|
||||||
|
expect(ceiling?.wallId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt die Ids weg, wenn cp.sourceId fehlt (unauflösbares Bauteil)", () => {
|
||||||
|
const noId: SectionOutput = {
|
||||||
|
cutPolygons: [{ ...cut("wall", "W1"), sourceId: undefined }],
|
||||||
|
visibleEdges: [],
|
||||||
|
hiddenEdges: [],
|
||||||
|
};
|
||||||
|
const poly = generateSectionPlan(noId).primitives.find(
|
||||||
|
(p): p is Extract<typeof p, { kind: "polygon" }> => p.kind === "polygon",
|
||||||
|
);
|
||||||
|
expect(poly?.wallId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Schnitt-Tiefe (DrawingLevel.depth): filterByDepth blendet Ansichtskanten aus,
|
||||||
|
// deren Quell-Wand weiter als `depth` hinter der Schnittebene (Blickrichtung)
|
||||||
|
// liegt. Owner-Distanz-Näherung (Wand-Mitte) — reiner TS-Datenpfad, ohne WASM.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { filterByDepth } from "./toSection";
|
||||||
|
import type { SectionOutput, SectionPlaneSpec } from "./toSection";
|
||||||
|
import type { Project, Wall } from "../model/types";
|
||||||
|
|
||||||
|
// Zwei Wände auf verschiedenen Tiefen: W_near bei y=1, W_far bei y=8.
|
||||||
|
function makeWall(id: string, y: number): Wall {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "wall",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
wallTypeId: "wt",
|
||||||
|
start: { x: 0, y },
|
||||||
|
end: { x: 4, y },
|
||||||
|
height: 2.6,
|
||||||
|
} as unknown as Wall;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = {
|
||||||
|
id: "p",
|
||||||
|
name: "T",
|
||||||
|
walls: [makeWall("W_near", 1), makeWall("W_far", 8)],
|
||||||
|
ceilings: [],
|
||||||
|
roofs: [],
|
||||||
|
openings: [],
|
||||||
|
doors: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false }],
|
||||||
|
} as unknown as Project;
|
||||||
|
|
||||||
|
// Schnittebene bei y=0, Blickrichtung +y (in die Szene). wallSegmentOwners
|
||||||
|
// nummeriert die Wände in project.walls-Reihenfolge: index 0 = W_near, 1 = W_far.
|
||||||
|
const plane: SectionPlaneSpec = { point: [0, 0, 0], normal: [0, 0, 1] };
|
||||||
|
|
||||||
|
function edge(index: number) {
|
||||||
|
return { component: { kind: "wall" as const, index }, a: [0, 0] as [number, number], b: [1, 1] as [number, number] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("filterByDepth", () => {
|
||||||
|
it("behält nahe Kanten, verwirft Kanten hinter der Tiefengrenze", () => {
|
||||||
|
const out: SectionOutput = {
|
||||||
|
cutPolygons: [],
|
||||||
|
visibleEdges: [edge(0), edge(1)],
|
||||||
|
hiddenEdges: [edge(1)],
|
||||||
|
};
|
||||||
|
filterByDepth(out, project, plane, 4); // Tiefe 4 m: W_near (1) bleibt, W_far (8) fällt weg.
|
||||||
|
expect(out.visibleEdges.map((e) => e.component.index)).toEqual([0]);
|
||||||
|
expect(out.hiddenEdges).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behält alles, wenn die Tiefe alle Bauteile umfasst", () => {
|
||||||
|
const out: SectionOutput = {
|
||||||
|
cutPolygons: [],
|
||||||
|
visibleEdges: [edge(0), edge(1)],
|
||||||
|
hiddenEdges: [],
|
||||||
|
};
|
||||||
|
filterByDepth(out, project, plane, 20);
|
||||||
|
expect(out.visibleEdges).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("depth <= 0 ist ein No-op", () => {
|
||||||
|
const out: SectionOutput = { cutPolygons: [], visibleEdges: [edge(0), edge(1)], hiddenEdges: [] };
|
||||||
|
filterByDepth(out, project, plane, 0);
|
||||||
|
expect(out.visibleEdges).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -112,6 +112,13 @@ describe("splitWallLayers: Aussenputz auf der Aussenseite, Wände spiegelverkehr
|
|||||||
expect(bands[3].width).toBeCloseTo(RENDER_EXT_T, 6);
|
expect(bands[3].width).toBeCloseTo(RENDER_EXT_T, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("propagiert sourceId (Quell-Element) auf ALLE Schicht-Bänder", () => {
|
||||||
|
const cpWithId: SectionCutPolygon = { ...cp, sourceId: "W2" };
|
||||||
|
const bands = splitWallLayers(cpWithId, sampleProject, aw, wall("W2"), uWorld);
|
||||||
|
expect(bands.length).toBeGreaterThan(1);
|
||||||
|
expect(bands.every((b) => b.sourceId === "W2")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("W2 und W4 liegen spiegelverkehrt (nicht identisch)", () => {
|
it("W2 und W4 liegen spiegelverkehrt (nicht identisch)", () => {
|
||||||
const b2 = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W2"), uWorld));
|
const b2 = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W2"), uWorld));
|
||||||
const b4 = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W4"), uWorld));
|
const b4 = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W4"), uWorld));
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ export interface SectionCutPolygon {
|
|||||||
* Wert (unauflösbares Bauteil), nimmt das Band an keiner Verschmelzung teil.
|
* Wert (unauflösbares Bauteil), nimmt das Band an keiner Verschmelzung teil.
|
||||||
*/
|
*/
|
||||||
componentId?: string;
|
componentId?: string;
|
||||||
|
/**
|
||||||
|
* ID des QUELL-Elements dieses Cut-Bands im Dokumentmodell (Wand/Decke/Dach) —
|
||||||
|
* aufgelöst in `attachCutStyles` über die Besitzer-Listen. Trägt die Auswahl im
|
||||||
|
* Schnitt-View: `generateSectionPlan` schreibt sie je nach `component.kind` als
|
||||||
|
* `wallId`/`ceilingId`/`roofId` ins Plan-Polygon, sodass ein Klick auf das
|
||||||
|
* geschnittene Bauteil im Schnitt exakt jenes Element im Grundriss-Modell wählt.
|
||||||
|
* Fehlt (unauflösbares Bauteil), bleibt das Band ohne Auswahlbezug.
|
||||||
|
*/
|
||||||
|
sourceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein projiziertes Liniensegment in (u, v)-Metern. */
|
/** Ein projiziertes Liniensegment in (u, v)-Metern. */
|
||||||
@@ -294,11 +303,13 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section
|
|||||||
// aufgelöst; keine Boolean-Dominanz (joinPriority undefined) — unverändert
|
// aufgelöst; keine Boolean-Dominanz (joinPriority undefined) — unverändert
|
||||||
// übernehmen. WICHTIG vor dem Slab-Zweig (sonst falscher owner-Lookup).
|
// übernehmen. WICHTIG vor dem Slab-Zweig (sonst falscher owner-Lookup).
|
||||||
if (ref.kind === "roof") {
|
if (ref.kind === "roof") {
|
||||||
|
cp.sourceId = (project.roofs ?? [])[ref.index]?.id;
|
||||||
bands.push(cp);
|
bands.push(cp);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (ref.kind === "wall") {
|
if (ref.kind === "wall") {
|
||||||
const owner = walls[ref.index];
|
const owner = walls[ref.index];
|
||||||
|
cp.sourceId = owner?.id;
|
||||||
// Wand: bei mehrschichtigem Wandtyp wird das EINE geschnittene Rechteck in
|
// Wand: bei mehrschichtigem Wandtyp wird das EINE geschnittene Rechteck in
|
||||||
// stehende Teil-Rechtecke je Schicht quer zur Dicke (u) aufgeteilt — das
|
// stehende Teil-Rechtecke je Schicht quer zur Dicke (u) aufgeteilt — das
|
||||||
// vertikale Gegenstück zur Decken-Zerlegung (dort quer zur Höhe v), siehe
|
// vertikale Gegenstück zur Decken-Zerlegung (dort quer zur Höhe v), siehe
|
||||||
@@ -327,6 +338,7 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section
|
|||||||
// Schnitt sichtbar (im Grundriss bleibt die Decke bewusst flächig, siehe
|
// Schnitt sichtbar (im Grundriss bleibt die Decke bewusst flächig, siehe
|
||||||
// `addCeilingPoche`).
|
// `addCeilingPoche`).
|
||||||
const owner = slabs[ref.index];
|
const owner = slabs[ref.index];
|
||||||
|
cp.sourceId = owner?.id;
|
||||||
if (owner) {
|
if (owner) {
|
||||||
const wt = getCeilingType(project, owner);
|
const wt = getCeilingType(project, owner);
|
||||||
if (wt.layers.length > 1) {
|
if (wt.layers.length > 1) {
|
||||||
@@ -727,6 +739,7 @@ function bandFromRect(src: SectionCutPolygon, r: Rect): SectionCutPolygon {
|
|||||||
hatch: src.hatch,
|
hatch: src.hatch,
|
||||||
joinPriority: src.joinPriority,
|
joinPriority: src.joinPriority,
|
||||||
componentId: src.componentId,
|
componentId: src.componentId,
|
||||||
|
sourceId: src.sourceId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,6 +792,7 @@ function splitSlabLayers(
|
|||||||
hatch,
|
hatch,
|
||||||
joinPriority: comp.joinPriority,
|
joinPriority: comp.joinPriority,
|
||||||
componentId: comp.id,
|
componentId: comp.id,
|
||||||
|
sourceId: cp.sourceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -865,6 +879,7 @@ export function splitWallLayers(
|
|||||||
hatch,
|
hatch,
|
||||||
joinPriority: comp.joinPriority,
|
joinPriority: comp.joinPriority,
|
||||||
componentId: comp.id,
|
componentId: comp.id,
|
||||||
|
sourceId: cp.sourceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -1033,6 +1048,72 @@ export function appendRoofSections(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mittelpunkt (Modell-2D) einer Punktliste (Outline-Schwerpunkt, naiv). */
|
||||||
|
function centroid2d(pts: Vec2[]): Vec2 | null {
|
||||||
|
if (pts.length === 0) return null;
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
for (const p of pts) {
|
||||||
|
x += p.x;
|
||||||
|
y += p.y;
|
||||||
|
}
|
||||||
|
return { x: x / pts.length, y: y / pts.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blendet Ansichtskanten (sichtbar/verdeckt) aus, deren QUELL-Bauteil weiter als
|
||||||
|
* `depth` Meter HINTER der Schnittebene (in Blickrichtung) liegt — die
|
||||||
|
* Schnitt-Tiefe (`DrawingLevel.depth`). Die Distanz wird über den REPRÄSENTATIVEN
|
||||||
|
* Bauteilpunkt (Wand-Mitte bzw. Outline-Schwerpunkt) gegen die Ebenennormale
|
||||||
|
* gemessen (in-place).
|
||||||
|
*
|
||||||
|
* NÄHERUNG (bewusst, kein Rust-Hack): der Rust-Extraktor liefert die Kanten nur in
|
||||||
|
* (u, v) OHNE echte Tiefe je Kante — die exakte Lösung bräuchte eine Tiefen-Achse
|
||||||
|
* aus `section.rs`. Bis dahin filtert dieser Owner-Distanz-Test grob nach dem
|
||||||
|
* Bauteil-Mittelpunkt: ein Bauteil wird GANZ ein- oder ausgeblendet (kein
|
||||||
|
* Teil-Clipping an der Tiefengrenze). Cut-Polygone liegen auf der Ebene (Distanz
|
||||||
|
* ~0) und bleiben immer erhalten. TODO: exakte per-Kante-Tiefe aus render3d
|
||||||
|
* (section.rs) zurückgeben, dann hier hart nach Kanten-Tiefe clippen.
|
||||||
|
*/
|
||||||
|
export function filterByDepth(
|
||||||
|
output: SectionOutput,
|
||||||
|
project: Project,
|
||||||
|
plane: SectionPlaneSpec,
|
||||||
|
depth: number,
|
||||||
|
): void {
|
||||||
|
if (!(depth > 0)) return;
|
||||||
|
// Blickrichtung + Ebenenaufpunkt in Modell-2D (x = world.x, y = world.z).
|
||||||
|
const blick = normalize({ x: plane.normal[0], y: plane.normal[2] });
|
||||||
|
const origin = { x: plane.point[0], y: plane.point[2] };
|
||||||
|
const walls = wallSegmentOwners(project);
|
||||||
|
const slabs = slabSegmentOwners(project);
|
||||||
|
const roofs = project.roofs ?? [];
|
||||||
|
|
||||||
|
const ownerPoint = (ref: SectionComponentRef): Vec2 | null => {
|
||||||
|
if (ref.kind === "wall") {
|
||||||
|
const w = walls[ref.index];
|
||||||
|
return w ? { x: (w.start.x + w.end.x) / 2, y: (w.start.y + w.end.y) / 2 } : null;
|
||||||
|
}
|
||||||
|
if (ref.kind === "slab") {
|
||||||
|
const c = slabs[ref.index];
|
||||||
|
return c ? centroid2d(c.outline) : null;
|
||||||
|
}
|
||||||
|
const r = roofs[ref.index];
|
||||||
|
return r ? centroid2d(r.outline) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keep = (e: SectionEdge): boolean => {
|
||||||
|
const p = ownerPoint(e.component);
|
||||||
|
if (!p) return true; // Unauflösbar: konservativ behalten.
|
||||||
|
// Distanz HINTER der Ebene entlang der Blickrichtung; ≤ depth ⇒ behalten.
|
||||||
|
const d = dot(sub(p, origin), blick);
|
||||||
|
return d <= depth + 1e-6;
|
||||||
|
};
|
||||||
|
|
||||||
|
output.visibleEdges = output.visibleEdges.filter(keep);
|
||||||
|
output.hiddenEdges = output.hiddenEdges.filter(keep);
|
||||||
|
}
|
||||||
|
|
||||||
export async function computeSection(
|
export async function computeSection(
|
||||||
project: Project,
|
project: Project,
|
||||||
level: DrawingLevel,
|
level: DrawingLevel,
|
||||||
@@ -1063,5 +1144,7 @@ export async function computeSection(
|
|||||||
// Dächer TS-seitig ergänzen (der Rust-Extraktor kennt nur Wände + Decken).
|
// Dächer TS-seitig ergänzen (der Rust-Extraktor kennt nur Wände + Decken).
|
||||||
appendRoofSections(output, project, plane);
|
appendRoofSections(output, project, plane);
|
||||||
attachCutStyles(output, project, plane);
|
attachCutStyles(output, project, plane);
|
||||||
|
// Schnitt-Tiefe: Kanten weiter als `level.depth` hinter der Ebene ausblenden.
|
||||||
|
if (level.depth !== undefined) filterByDepth(output, project, plane, level.depth);
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export interface SelectionSlice {
|
|||||||
selectedColumnIds: string[];
|
selectedColumnIds: string[];
|
||||||
// Gewählte Dächer als MENGE von IDs — getrennt von den übrigen Kanälen.
|
// Gewählte Dächer als MENGE von IDs — getrennt von den übrigen Kanälen.
|
||||||
selectedRoofIds: string[];
|
selectedRoofIds: string[];
|
||||||
|
// 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;
|
||||||
|
|
||||||
setSelectedWallIds: (ids: string[]) => void;
|
setSelectedWallIds: (ids: string[]) => void;
|
||||||
setSelectedDrawingIds: (ids: string[]) => void;
|
setSelectedDrawingIds: (ids: string[]) => void;
|
||||||
@@ -35,6 +38,7 @@ export interface SelectionSlice {
|
|||||||
setSelectedExtrudedSolidIds: (ids: string[]) => void;
|
setSelectedExtrudedSolidIds: (ids: string[]) => void;
|
||||||
setSelectedColumnIds: (ids: string[]) => void;
|
setSelectedColumnIds: (ids: string[]) => void;
|
||||||
setSelectedRoofIds: (ids: string[]) => void;
|
setSelectedRoofIds: (ids: string[]) => void;
|
||||||
|
setSelectedSectionLineId: (id: string | null) => void;
|
||||||
/** Auswahl (Wände + Decken + Öffnungen + Treppen + Dächer + 2D-Elemente) leeren. */
|
/** Auswahl (Wände + Decken + Öffnungen + Treppen + Dächer + 2D-Elemente) leeren. */
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
}
|
}
|
||||||
@@ -53,6 +57,7 @@ export function createSelectionSlice(
|
|||||||
selectedExtrudedSolidIds: [],
|
selectedExtrudedSolidIds: [],
|
||||||
selectedColumnIds: [],
|
selectedColumnIds: [],
|
||||||
selectedRoofIds: [],
|
selectedRoofIds: [],
|
||||||
|
selectedSectionLineId: null,
|
||||||
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
|
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
|
||||||
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
|
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
|
||||||
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
|
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
|
||||||
@@ -62,6 +67,7 @@ export function createSelectionSlice(
|
|||||||
setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }),
|
setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }),
|
||||||
setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }),
|
setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }),
|
||||||
setSelectedRoofIds: (ids) => set({ selectedRoofIds: ids }),
|
setSelectedRoofIds: (ids) => set({ selectedRoofIds: ids }),
|
||||||
|
setSelectedSectionLineId: (id) => set({ selectedSectionLineId: id }),
|
||||||
clearSelection: () =>
|
clearSelection: () =>
|
||||||
set({
|
set({
|
||||||
selectedWallIds: [],
|
selectedWallIds: [],
|
||||||
@@ -73,6 +79,7 @@ export function createSelectionSlice(
|
|||||||
selectedExtrudedSolidIds: [],
|
selectedExtrudedSolidIds: [],
|
||||||
selectedColumnIds: [],
|
selectedColumnIds: [],
|
||||||
selectedRoofIds: [],
|
selectedRoofIds: [],
|
||||||
|
selectedSectionLineId: null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ export const RIBBON_TABS: RibbonTab[] = [
|
|||||||
t("room"),
|
t("room"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
titleKey: "ribbon.group.sections",
|
||||||
|
items: [c("sectionline"), c("viewline")],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user