Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"examples:generate": "bun ../examples/web-examples/generate.mjs",
"examples:generate-crane-shell": "bun ../examples/web-examples/generate-crane-shell.mjs",
"examples:generate-plate-hole-shell": "bun ../examples/web-examples/generate-plate-hole-shell.mjs",
"test": "bun ../examples/validation/run.mjs && bun ../examples/validation/dof-constraint.test.mjs && bun ../examples/validation/prescribed-displacement.test.mjs && bun tests/test_wall_bracket.mjs 20.0 && bun tests/test_multibody.mjs && bun tests/test_tie.mjs && bun tests/test_coupling.mjs && bun tests/test_face_pick.mjs && bun tests/test_edge_pick.mjs && bun tests/test_moment_load.mjs && bun tests/test_shellize_mpc.mjs && bun tests/test_body_highlight.mjs && bun tests/test_midsurface_junction.mjs && bun tests/test_coupled_materials.mjs && playwright test",
"test": "bun ../examples/validation/run.mjs && bun ../examples/validation/dof-constraint.test.mjs && bun ../examples/validation/prescribed-displacement.test.mjs && bun tests/test_wall_bracket.mjs 20.0 && bun tests/test_multibody.mjs && bun tests/test_tie.mjs && bun tests/test_coupling.mjs && bun tests/test_reference_point.mjs && bun tests/test_face_pick.mjs && bun tests/test_edge_pick.mjs && bun tests/test_moment_load.mjs && bun tests/test_shellize_mpc.mjs && bun tests/test_body_highlight.mjs && bun tests/test_midsurface_junction.mjs && bun tests/test_coupled_materials.mjs && playwright test",
"test:coverage": "rm -rf .nyc_output coverage && COVERAGE=1 playwright test && bun run coverage:report",
"coverage:report": "nyc report && bun scripts/coverage-dead-code.ts",
"test:ui": "playwright test --ui",
Expand Down
134 changes: 119 additions & 15 deletions web/src/components/panel/BcLoadFormControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

import type {
CouplingKind,
FaceSelection,
LoadKind,
PickGeometry,
ReferencePointOption,
TieExtent,
} from "../../store/modelStore";
import {
Expand All @@ -14,50 +17,62 @@ import {
} from "./bcFormUtils";
import styles from "./LeftPanel.module.css";

// Face / Edge picker for shell models: a flat shell sheet is one face-pick
// region, so grabbing its rim (an edge load or supported-edge BC) needs edge
// picking (#386). Offered only when the model has CTRIA3 elements.
const GEOMETRY_LABEL: Record<PickGeometry, string> = {
face: "Face",
edge: "Edge",
point: "Point",
};

// What a click selects. `options` is explicit because not every geometry suits
// every model or every condition: edge picking only means something on a shell
// (a closed solid boundary has no boundary polyline to walk), and a point pick
// is not offered where a single node would be the wrong thing to define.
// Rendered only when there is a genuine choice to make.
export function PickGeometryToggle({
value,
options,
onChange,
}: {
value: "face" | "edge";
onChange(geometry: "face" | "edge"): void;
value: PickGeometry;
options: PickGeometry[];
onChange(geometry: PickGeometry): void;
}) {
if (options.length < 2) return null;
return (
<div className={styles.segToggle} role="group" aria-label="Pick geometry">
{(["face", "edge"] as const).map((geometry) => (
{options.map((geometry) => (
<button
key={geometry}
type="button"
data-testid={`pick-geometry-${geometry}`}
className={`${styles.segBtn} ${value === geometry ? styles.segBtnActive : ""}`}
aria-pressed={value === geometry}
onClick={() => onChange(geometry)}
>
{geometry === "face" ? "Face" : "Edge"}
{GEOMETRY_LABEL[geometry]}
</button>
))}
</div>
);
}

const PICK_HINT: Record<PickGeometry, string> = {
face: "Click a face in the 3D viewport",
edge: "Click near a mesh edge in the 3D viewport",
point: "Click near a mesh node in the 3D viewport",
};

export function PickedFaceList({
faces,
onRemove,
geometry = "face",
}: {
faces: FaceSelection[];
onRemove(index: number): void;
geometry?: "face" | "edge";
geometry?: PickGeometry;
}) {
if (faces.length === 0) {
return (
<div className={styles.pickHint}>
{geometry === "edge"
? "Click near a mesh edge in the 3D viewport"
: "Click a face in the 3D viewport"}
</div>
);
return <div className={styles.pickHint}>{PICK_HINT[geometry]}</div>;
}
return (
<div>
Expand Down Expand Up @@ -229,3 +244,92 @@ export function DofCheckboxes({
</div>
);
}

// How a surface-to-point coupling ties its surface to its reference point. The
// two kinds are not interchangeable — see lib/coupling.ts — so the difference
// is spelled out under the select rather than left to the two words.
export function CouplingKindSelect({
value,
onChange,
}: {
value: CouplingKind;
onChange(kind: CouplingKind): void;
}) {
return (
<>
<div className={styles.formRow}>
<span className={styles.formLabel}>Kind</span>
<select
className={styles.formSelect}
data-testid="coupling-kind"
value={value}
onChange={(e) => onChange(e.target.value as CouplingKind)}
>
<option value="distributing">Distributing (RBE3)</option>
<option value="kinematic">Kinematic (RBE2)</option>
</select>
</div>
<div className={styles.pickNote}>
{value === "distributing"
? "the surface stays flexible and the point follows it — the point can be loaded, but not fixed"
: "the surface follows the point rigidly — the point can be fixed, loaded or coupled onward"}
</div>
</>
);
}

// Where the reference point sits: one of the positions derived from the picked
// surface (its centre, and the two ends of its axis when it is a cylinder), or
// coordinates typed in. Choosing a derived position fills the coordinate boxes,
// which stay editable — the derived positions are a starting point, not a
// constraint (KOF-208).
export function ReferencePointInputs({
options,
coords,
onCoordChange,
onPickOption,
}: {
options: ReferencePointOption[];
coords: [string, string, string];
onCoordChange(index: number, value: string): void;
onPickOption(option: ReferencePointOption): void;
}) {
return (
<>
{options.length > 0 && (
<div className={styles.formRow}>
<span className={styles.formLabel}>Place at</span>
<select
className={styles.formSelect}
data-testid="coupling-place-at"
value=""
onChange={(e) => {
const option = options[Number(e.target.value)];
if (option) onPickOption(option);
}}
>
<option value="">Custom coordinates</option>
{options.map((option, i) => (
<option key={option.label} value={i}>
{option.label}
</option>
))}
</select>
</div>
)}
{["X", "Y", "Z"].map((axis, i) => (
<div className={styles.formRow} key={axis}>
<span className={styles.formLabel}>{axis} (mm)</span>
<input
className={styles.formInput}
data-testid={`coupling-point-${axis.toLowerCase()}`}
type="number"
step="0.1"
value={coords[i]}
onChange={(e) => onCoordChange(i, e.target.value)}
/>
</div>
))}
</>
);
}
32 changes: 24 additions & 8 deletions web/src/components/panel/BcSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function BcSection({ onError }: { onError(msg: string | null): void }) {
const hasShells = useModelStore((s) =>
s.elements.some((el) => el.type === "CTRIA3"),
);
const couplingGroups = useModelStore((s) => s.couplingGroups);
const createBcGroup = useModelStore((s) => s.createBcGroup);
const addFaceToBcGroup = useModelStore((s) => s.addFaceToBcGroup);
const removeFaceFromBcGroup = useModelStore((s) => s.removeFaceFromBcGroup);
Expand All @@ -51,6 +52,15 @@ export function BcSection({ onError }: { onError(msg: string | null): void }) {
false,
]);
const [bcValue, setBcValue] = useState("0");
// Whether the pick has landed on a coupling's reference point — clicked in
// the viewport like anything else. Such a point carries six real DOFs
// (shell_core gives a coupling reference its rotations), so the rotational
// boxes become meaningful: fixing them is how a bolted or welded connection
// is restrained without clamping every node of the bore.
const refPoints = new Set(couplingGroups.map((c) => c.refNodeId));
const pickedReferencePoint = allPickedFaces.some((face) =>
face.nodeIds.every((id) => refPoints.has(id)),
);

const targetBcGroup =
pickTargetGroupId !== null
Expand All @@ -63,7 +73,7 @@ export function BcSection({ onError }: { onError(msg: string | null): void }) {
allPickedFaces,
// eslint-disable-next-line kofem/no-silent-fallback -- numbering offset for the new entries; a pick with no target group starts a fresh group, which has 0 faces
targetBcGroup?.faces.length ?? 0,
pickGeometry === "edge" ? "Edge" : "Face",
pickGeometry,
);
if (targetBcGroup) {
for (const faceEntry of faceEntries) {
Expand Down Expand Up @@ -109,12 +119,16 @@ export function BcSection({ onError }: { onError(msg: string | null): void }) {
</button>
</div>

{hasShells && (
<PickGeometryToggle
value={pickGeometry}
onChange={setPickGeometry}
/>
)}
{/* Point picking is offered so a coupling's REFERENCE POINT can be
fixed — clicked in the viewport like any other selection, which is
the only way to restrain a bolted hole without clamping every node
of its bore. It also allows a single mesh node, which is a
legitimate way to remove a rigid-body mode. */}
<PickGeometryToggle
value={pickGeometry}
options={hasShells ? ["face", "edge", "point"] : ["face", "point"]}
onChange={setPickGeometry}
/>

<PickedFaceList
faces={allPickedFaces}
Expand All @@ -124,9 +138,11 @@ export function BcSection({ onError }: { onError(msg: string | null): void }) {

{allPickedFaces.length > 0 && !targetBcGroup && (
<>
{/* A reference point carries six DOFs whichever elements the
model has, so Rx/Ry/Rz appear as soon as one is picked. */}
<DofCheckboxes
checkedDofs={checkedDofs}
showRotations={hasShells}
showRotations={hasShells || pickedReferencePoint}
onToggle={(index) =>
setCheckedDofs((prev) =>
prev.map((checked, i) =>
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/panel/BoundaryConditionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useModelStore } from "../../store/modelStore";
import { BcSection } from "./BcSection";
import { LoadSection } from "./LoadSection";
import { TieSection } from "./TieSection";
import { CouplingSection } from "./CouplingSection";
import styles from "./LeftPanel.module.css";

export function BoundaryConditionsPanel() {
Expand Down Expand Up @@ -33,6 +34,7 @@ export function BoundaryConditionsPanel() {
<BcSection onError={setError} />
<LoadSection onError={setError} />
<TieSection onError={setError} />
<CouplingSection onError={setError} />
</>
)}
</div>
Expand Down
Loading
Loading