Schnitt/Ansicht: SectionOutput aus render3d an die Zeichenebenen anbinden

- render3d/web.rs: GPU-freies WASM-Binding cut_section_json(model_json, p, n)
  → JSON {cutPolygons, visibleEdges, hiddenEdges} in (u,v)-Metern.
- src/plan/toSection.ts (neu): SectionOutput-Typen, sectionPlaneFromLevel()
  leitet die Schnittebene aus linePoints/directionSign ab, computeSection()
  flacht über projectToModel3d ab und ruft das Binding.
- src/engine/engine3d.ts (neu): geteilter memoisierter pkg3d-Loader, damit
  3D-Viewport und Schnittpfad EINE mod.default()-Init teilen.
- generatePlan.ts: generateSectionPlan() übersetzt SectionOutput in
  bestehende Primitive (Cut = polygon mit Poché-Schraffur, sichtbare Kante =
  line solid, verdeckte Kante = line dashed) — keine neue Primitive-Art nötig.
- App.tsx: SectionPlanView ersetzt den Stub für Ebenen vom Typ Schnitt/Ansicht;
  rendert dasselbe PlanView, mit lokalisierten Hinweisen (lädt/keine Linie/Fehler).
- i18n de/en: section.*-Schlüssel.
- Öffnungen erscheinen korrekt (Brüstung/Sturz-Teilrechtecke) über die
  projectToModel3d-Teilkörper-Zerlegung.

Gates: tsc -b 0, build grün (WASM = eigener Lazy-Chunk), render3d cargo test 29/29,
wasm32 --features web check grün.
This commit is contained in:
2026-07-03 18:37:49 +02:00
parent 3402e6ac96
commit 056ca2c288
8 changed files with 514 additions and 30 deletions
+190 -12
View File
@@ -35,8 +35,10 @@ import type {
Vec2,
Wall,
} from "./model/types";
import { generatePlan } from "./plan/generatePlan";
import { generatePlan, generateSectionPlan } from "./plan/generatePlan";
import type { CategoryDisplayResolver } from "./plan/generatePlan";
import { computeSection } from "./plan/toSection";
import type { SectionOutput } from "./plan/toSection";
import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync";
import { parseDxf } from "./io/dxfParser";
import type { DxfImportResult } from "./io/dxfParser";
@@ -3836,7 +3838,40 @@ function Content({
onEdit3dEnd: () => void;
}) {
if (level.kind === "section" || level.kind === "elevation") {
return <StubView level={level} />;
return (
<main className="content">
<SectionPlanView
project={project}
level={level}
hairline={hairline}
mono={mono}
planViewRef={planViewRef}
onCursor={onCursor}
onZoom={onZoom}
onScale={onScale}
selectedWallIds={selectedWallIds}
selectedCeilingIds={selectedCeilingIds}
selectedOpeningIds={selectedOpeningIds}
selectedStairIds={selectedStairIds}
selectedRoomIds={selectedRoomIds}
roomStamp={roomStamp}
onStampMove={onStampMove}
onStampEdit={onStampEdit}
onPlanSelect={onPlanSelect}
onPlanMarquee={onPlanMarquee}
onSegmentCut={onSegmentCut}
onPlanContextMenu={onPlanContextMenu}
activeTool={activeTool}
toolInputActive={toolInputActive}
toolHandlers={toolHandlers}
grips={grips}
edgeGrips={edgeGrips}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers}
/>
</main>
);
}
// Freie Zeichnung: dieselbe Plan-Ansicht wie ein Geschoss (generatePlan filtert
@@ -4154,18 +4189,161 @@ function LevelPlanView({
);
}
/** Platzhalter für Schnitt/Ansicht (kommt in einer späteren Phase). */
function StubView({ level }: { level: DrawingLevel }) {
return (
<main className="content">
/**
* Schnitt-/Ansichts-Ebene: der render3d-Schnitt-Extraktor (WASM, plan/toSection)
* durchschneidet das Modell entlang der Schnittlinie der Ebene und übersetzt das
* Ergebnis (Cut-Polygone + sichtbare/verdeckte Kanten) über generateSectionPlan
* in Plan-Primitive, die dieselbe PlanView wie ein Grundriss zeichnet.
*
* Die Berechnung ist asynchron (WASM lazy geladen); bis das Ergebnis da ist,
* bzw. wenn die Ebene keine Schnittlinie trägt oder das WASM-Modul den Export
* (noch) nicht kennt, wird ein Hinweis eingeblendet.
*/
function SectionPlanView({
project,
level,
hairline,
mono,
planViewRef,
onCursor,
onZoom,
onScale,
selectedWallIds,
selectedCeilingIds,
selectedOpeningIds,
selectedStairIds,
selectedRoomIds,
roomStamp,
onStampMove,
onStampEdit,
onPlanSelect,
onPlanMarquee,
onSegmentCut,
onPlanContextMenu,
activeTool,
toolInputActive,
toolHandlers,
grips,
edgeGrips,
selectedDrawingId,
selectedDrawingIds,
gripHandlers,
}: {
project: Project;
level: DrawingLevel;
hairline: boolean;
mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void;
onZoom: (percent: number) => void;
onScale: (n: number) => void;
selectedWallIds: string[];
selectedCeilingIds: string[];
selectedOpeningIds: string[];
selectedStairIds: string[];
selectedRoomIds: string[];
roomStamp: { roomId: string; anchor: Vec2 } | null;
onStampMove: (roomId: string, delta: Vec2) => void;
onStampEdit: (roomId: string) => void;
onPlanSelect: (sel: PlanSelection) => void;
onPlanMarquee: (sel: MarqueeSelection) => void;
onSegmentCut: (drawingId: string, model: Vec2) => void;
onPlanContextMenu: (info: { clientX: number; clientY: number; model: Vec2 }) => void;
activeTool: ToolId;
toolInputActive: boolean;
toolHandlers: ToolHandlers;
grips: Vec2[];
edgeGrips: EdgeGrip[];
selectedDrawingId: string | null;
selectedDrawingIds?: string[];
gripHandlers: GripHandlers;
}) {
const [output, setOutput] = useState<SectionOutput | null>(null);
const [status, setStatus] = useState<"loading" | "ready" | "empty" | "error">(
"loading",
);
useEffect(() => {
let disposed = false;
setStatus("loading");
setOutput(null);
void (async () => {
try {
const out = await computeSection(project, level);
if (disposed) return;
if (!out) {
setStatus("empty");
return;
}
setOutput(out);
setStatus("ready");
} catch (e) {
console.warn("render3d-Schnitt fehlgeschlagen:", e);
if (!disposed) setStatus("error");
}
})();
return () => {
disposed = true;
};
}, [project, level]);
const plan = useMemo(
() => (output ? generateSectionPlan(output, mono) : null),
[output, mono],
);
const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation");
if (!plan || status !== "ready") {
const note =
status === "empty"
? t("section.empty", { kind: kindLabel })
: status === "error"
? t("section.error", { kind: kindLabel })
: t("section.loading", { kind: kindLabel });
return (
<div className="placeholder">
<strong>{level.name}</strong>
<span>
{t("stub.note", {
kind: level.kind === "section" ? t("stub.section") : t("stub.elevation"),
})}
</span>
<span>{note}</span>
</div>
</main>
);
}
return (
<section className="pane">
<div className="pane-title">
{t("content.sectionTitle", { name: level.name, kind: kindLabel })}
</div>
<PlanView
ref={planViewRef}
plan={plan}
resetKey={level.id}
onCursor={onCursor}
onZoom={onZoom}
onScale={onScale}
selectedWallIds={selectedWallIds}
selectedCeilingIds={selectedCeilingIds}
selectedOpeningIds={selectedOpeningIds}
selectedStairIds={selectedStairIds}
selectedRoomIds={selectedRoomIds}
roomStamp={roomStamp}
onStampMove={onStampMove}
onStampEdit={onStampEdit}
onSelect={onPlanSelect}
onMarquee={onPlanMarquee}
onSegmentCut={onSegmentCut}
onContextMenu={onPlanContextMenu}
activeTool={activeTool}
toolInputActive={toolInputActive}
toolHandlers={toolHandlers}
grips={grips}
edgeGrips={edgeGrips}
hairline={hairline}
selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers}
/>
<div className="plan-legend">{t("section.legend", { kind: kindLabel })}</div>
</section>
);
}