feat(3d): refine scene controls and camera views
Keep the control panel geometry stable when toggling building context, and use accessible switch controls to prevent focus-driven movement. Add browser coverage for camera framing, opacity, responsive layout, and the default 6x pipe scale.
This commit is contained in:
@@ -10,7 +10,7 @@ const state: SceneRuntimeState = {
|
||||
roofVisible: true,
|
||||
displayMode: "global",
|
||||
style: {
|
||||
scale: 8,
|
||||
scale: 6,
|
||||
mode: "uniform",
|
||||
color: "#098ed0",
|
||||
missingColor: "#89949d",
|
||||
@@ -30,6 +30,7 @@ const state: SceneRuntimeState = {
|
||||
appearance: {
|
||||
preset: "day",
|
||||
exposure: 0.95,
|
||||
contextOpacity: 0.4,
|
||||
shadows: true,
|
||||
effects: true,
|
||||
quality: "standard",
|
||||
@@ -49,6 +50,48 @@ const state: SceneRuntimeState = {
|
||||
};
|
||||
|
||||
describe("ThreeDimensionalControls", () => {
|
||||
it("toggles the active toolbar panel and opens a different panel", () => {
|
||||
const onOpenChange = jest.fn();
|
||||
const onTabChange = jest.fn();
|
||||
const { rerender } = render(
|
||||
<ThreeDimensionalControls
|
||||
open
|
||||
activeTab="scene"
|
||||
ready
|
||||
state={state}
|
||||
selection={null}
|
||||
timelineOpen
|
||||
onOpenChange={onOpenChange}
|
||||
onTimelineOpenChange={jest.fn()}
|
||||
onTabChange={onTabChange}
|
||||
onCommand={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "场景与视角" }));
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(false);
|
||||
expect(onTabChange).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<ThreeDimensionalControls
|
||||
open={false}
|
||||
activeTab="scene"
|
||||
ready
|
||||
state={state}
|
||||
selection={null}
|
||||
timelineOpen
|
||||
onOpenChange={onOpenChange}
|
||||
onTimelineOpenChange={jest.fn()}
|
||||
onTabChange={onTabChange}
|
||||
onCommand={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "管网样式" }));
|
||||
expect(onTabChange).toHaveBeenLastCalledWith("style");
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it("sends typed scene commands from platform controls", () => {
|
||||
const onCommand = jest.fn();
|
||||
const onTimelineOpenChange = jest.fn();
|
||||
@@ -69,10 +112,49 @@ describe("ThreeDimensionalControls", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "站房精细版" }));
|
||||
expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" });
|
||||
fireEvent.change(screen.getByRole("slider", { name: "建筑背景透明度 60%" }), {
|
||||
target: { value: "0.35" },
|
||||
});
|
||||
expect(onCommand).toHaveBeenCalledWith({
|
||||
name: "set-appearance",
|
||||
patch: { contextOpacity: 0.65 },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("switch", { name: "建筑背景" }));
|
||||
expect(onCommand).toHaveBeenCalledWith({ name: "toggle-context" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "时间轴" }));
|
||||
expect(onTimelineOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("uses the glass listbox instead of a native select", () => {
|
||||
const onCommand = jest.fn();
|
||||
render(
|
||||
<ThreeDimensionalControls
|
||||
open
|
||||
activeTab="scene"
|
||||
ready
|
||||
state={state}
|
||||
selection={null}
|
||||
timelineOpen
|
||||
onOpenChange={jest.fn()}
|
||||
onTimelineOpenChange={jest.fn()}
|
||||
onTabChange={jest.fn()}
|
||||
onCommand={onCommand}
|
||||
/>,
|
||||
);
|
||||
|
||||
const displayMode = screen.getByRole("combobox", {
|
||||
name: "管网展示比例",
|
||||
});
|
||||
expect(displayMode.tagName).not.toBe("SELECT");
|
||||
|
||||
fireEvent.mouseDown(displayMode);
|
||||
fireEvent.click(screen.getByRole("option", { name: "泵房协调比例" }));
|
||||
expect(onCommand).toHaveBeenCalledWith({
|
||||
name: "set-display-mode",
|
||||
mode: "coordinated",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows selected asset fields and linked assets in the property tab", () => {
|
||||
const onCommand = jest.fn();
|
||||
render(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Select from "@mui/material/Select";
|
||||
import clsx from "clsx";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import {
|
||||
FiBox,
|
||||
FiCamera,
|
||||
FiCheck,
|
||||
FiChevronDown,
|
||||
FiChevronRight,
|
||||
FiClock,
|
||||
FiCrosshair,
|
||||
@@ -40,6 +44,90 @@ const glassSurface =
|
||||
"bg-[linear-gradient(135deg,rgba(255,255,255,0.50),rgba(224,239,250,0.28))] [backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [-webkit-backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-1px_0_rgba(112,145,168,0.18),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/55";
|
||||
const fieldClass =
|
||||
"h-10 w-full rounded-lg border border-slate-300/70 bg-white/55 px-3 text-sm text-slate-800 outline-none transition focus:border-blue-500 focus:bg-white/80 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
const selectSx = {
|
||||
height: 40,
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "rgba(255,255,255,0.55)",
|
||||
color: "#1e293b",
|
||||
fontSize: "0.875rem",
|
||||
transition: "background-color 150ms ease, box-shadow 150ms ease",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255,255,255,0.78)",
|
||||
},
|
||||
"& .MuiSelect-select": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
minHeight: "0 !important",
|
||||
padding: "8px 38px 8px 12px !important",
|
||||
},
|
||||
"& .MuiSelect-icon": {
|
||||
right: 12,
|
||||
color: "#64748b",
|
||||
fontSize: 16,
|
||||
transition: "transform 150ms ease",
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(148,163,184,0.7)",
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(59,130,246,0.65)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "rgba(255,255,255,0.82)",
|
||||
boxShadow: "0 0 0 3px rgba(59,130,246,0.16)",
|
||||
},
|
||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "#3b82f6",
|
||||
borderWidth: 1,
|
||||
},
|
||||
};
|
||||
const selectMenuProps = {
|
||||
disableScrollLock: true,
|
||||
PaperProps: {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
mt: 0.75,
|
||||
overflow: "hidden",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid rgba(255,255,255,0.72)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(244,249,252,0.94), rgba(225,239,248,0.88))",
|
||||
backdropFilter: "blur(24px) saturate(155%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(155%)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 rgba(255,255,255,0.92), 0 16px 42px rgba(15,43,69,0.22)",
|
||||
},
|
||||
},
|
||||
MenuListProps: {
|
||||
sx: {
|
||||
p: 0.75,
|
||||
},
|
||||
},
|
||||
};
|
||||
const selectMenuItemSx = {
|
||||
minHeight: 40,
|
||||
gap: 1,
|
||||
borderRadius: "0.5rem",
|
||||
px: 1.25,
|
||||
color: "#334155",
|
||||
fontSize: "0.875rem",
|
||||
transition: "background-color 120ms ease, color 120ms ease",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255,255,255,0.68)",
|
||||
color: "#1d4ed8",
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: "#2563eb",
|
||||
color: "#fff",
|
||||
},
|
||||
"&.Mui-selected:hover": {
|
||||
backgroundColor: "#1d4ed8",
|
||||
},
|
||||
"&.Mui-focusVisible": {
|
||||
outline: "2px solid rgba(59,130,246,0.42)",
|
||||
outlineOffset: -2,
|
||||
},
|
||||
};
|
||||
|
||||
export type ControlTab = "scene" | "style" | "properties";
|
||||
|
||||
@@ -71,6 +159,10 @@ export function ThreeDimensionalControls({
|
||||
const [cameraLabel, setCameraLabel] = useState("");
|
||||
|
||||
const selectTab = (tab: ControlTab) => {
|
||||
if (open && activeTab === tab) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
onTabChange(tab);
|
||||
onOpenChange(true);
|
||||
};
|
||||
@@ -150,7 +242,7 @@ export function ThreeDimensionalControls({
|
||||
aria-label="三维场景控制面板"
|
||||
className={clsx(
|
||||
glassSurface,
|
||||
"absolute inset-x-2 bottom-2 z-30 flex max-h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-4 md:right-4 md:top-4 md:h-auto md:max-h-[760px] md:w-96",
|
||||
"absolute inset-x-2 bottom-2 z-30 flex h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-auto md:right-4 md:top-4 md:h-[min(760px,calc(100%-2rem))] md:w-96",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-14 items-center gap-3 border-b border-white/45 bg-white/10 px-4">
|
||||
@@ -175,7 +267,7 @@ export function ThreeDimensionalControls({
|
||||
<div
|
||||
aria-disabled={!ready}
|
||||
className={clsx(
|
||||
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 [scrollbar-color:rgba(100,116,139,.45)_transparent] [scrollbar-width:thin]",
|
||||
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 [overflow-anchor:none] [scrollbar-color:rgba(100,116,139,.45)_transparent] [scrollbar-width:thin]",
|
||||
!ready && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -255,6 +347,15 @@ export function ThreeDimensionalControls({
|
||||
|
||||
<ControlSection title="场景显示">
|
||||
<SwitchRow label="建筑背景" checked={state.contextVisible} onChange={() => onCommand({ name: "toggle-context" })} />
|
||||
<LabeledSlider
|
||||
label={`建筑背景透明度 ${Math.round((1 - state.appearance.contextOpacity) * 100)}%`}
|
||||
value={1 - state.appearance.contextOpacity}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
disabled={!state.contextVisible}
|
||||
onChange={(value) => onCommand({ name: "set-appearance", patch: { contextOpacity: 1 - value } })}
|
||||
/>
|
||||
<SwitchRow label="屋盖与吊顶" checked={state.roofVisible} onChange={() => onCommand({ name: "toggle-roof" })} />
|
||||
<SelectField
|
||||
label="管网展示比例"
|
||||
@@ -386,31 +487,64 @@ function ControlSection({ title, description, children }: { title: string; descr
|
||||
}
|
||||
|
||||
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) {
|
||||
const labelId = useId();
|
||||
const selected = options.find((option) => option.value === value);
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-500">{label}</span>
|
||||
<select className={fieldClass} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="block">
|
||||
<span id={labelId} className="mb-1 block text-xs text-slate-500">{label}</span>
|
||||
<Select
|
||||
fullWidth
|
||||
labelId={labelId}
|
||||
value={value}
|
||||
renderValue={() => selected?.label ?? value}
|
||||
MenuProps={selectMenuProps}
|
||||
IconComponent={FiChevronDown}
|
||||
onChange={(event) => onChange(String(event.target.value))}
|
||||
sx={selectSx}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const optionSelected = option.value === value;
|
||||
return (
|
||||
<MenuItem key={option.value} value={option.value} sx={selectMenuItemSx}>
|
||||
<span>{option.label}</span>
|
||||
{optionSelected && <FiCheck aria-hidden="true" className="ml-auto text-base" />}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex min-h-10 cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className="flex min-h-10 w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<input type="checkbox" className="peer sr-only" checked={checked} onChange={(event) => onChange(event.target.checked)} />
|
||||
<span aria-hidden="true" className="relative h-6 w-11 shrink-0 rounded-full bg-slate-300/80 transition peer-checked:bg-blue-600 peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500/40 peer-focus-visible:ring-offset-2 after:absolute after:left-1 after:top-1 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-5" />
|
||||
</label>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
"relative h-6 w-11 shrink-0 rounded-full transition-colors after:absolute after:left-1 after:top-1 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform",
|
||||
checked
|
||||
? "bg-blue-600 after:translate-x-5"
|
||||
: "bg-slate-300/80",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledSlider({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
|
||||
function LabeledSlider({ label, value, min, max, step, disabled = false, onChange }: { label: string; value: number; min: number; max: number; step: number; disabled?: boolean; onChange: (value: number) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs tabular-nums text-slate-500">{label}</span>
|
||||
<input type="range" aria-label={label} className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-300/75 accent-blue-600" value={value} min={min} max={max} step={step} onChange={(event) => onChange(Number(event.target.value))} />
|
||||
<label className={clsx("block", disabled && "opacity-45")}>
|
||||
<span className="mb-1.5 flex items-center justify-between text-xs tabular-nums text-slate-500">{label}</span>
|
||||
<input type="range" aria-label={label} className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-300/75 accent-blue-600 disabled:cursor-not-allowed" value={value} min={min} max={max} step={step} disabled={disabled} onChange={(event) => onChange(Number(event.target.value))} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
type SceneRuntimeState,
|
||||
} from "./sceneProtocol";
|
||||
|
||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2";
|
||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity";
|
||||
const SCENE_LOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
const initialRuntimeState: SceneRuntimeState = {
|
||||
@@ -48,7 +48,7 @@ const initialRuntimeState: SceneRuntimeState = {
|
||||
roofVisible: true,
|
||||
displayMode: "global",
|
||||
style: {
|
||||
scale: 8,
|
||||
scale: 6,
|
||||
mode: "uniform",
|
||||
color: "#098ed0",
|
||||
missingColor: "#89949d",
|
||||
@@ -68,6 +68,7 @@ const initialRuntimeState: SceneRuntimeState = {
|
||||
appearance: {
|
||||
preset: "day",
|
||||
exposure: 0.95,
|
||||
contextOpacity: 0.4,
|
||||
shadows: true,
|
||||
effects: true,
|
||||
quality: "standard",
|
||||
@@ -128,7 +129,7 @@ export default function ThreeDimensionalScene() {
|
||||
const [runtimeState, setRuntimeState] =
|
||||
useState<SceneRuntimeState>(initialRuntimeState);
|
||||
const [selection, setSelection] = useState<SceneAssetSelection | null>(null);
|
||||
const [controlsOpen, setControlsOpen] = useState(false);
|
||||
const [controlsOpen, setControlsOpen] = useState(true);
|
||||
const [controlTab, setControlTab] = useState<ControlTab>("scene");
|
||||
const [timelineOpen, setTimelineOpen] = useState(true);
|
||||
const [pressureDevices, setPressureDevices] = useState<PressureDevice[]>([]);
|
||||
|
||||
@@ -38,6 +38,7 @@ export type SceneNetworkStyle = {
|
||||
export type SceneAppearance = {
|
||||
preset: SceneLightingPreset;
|
||||
exposure: number;
|
||||
contextOpacity: number;
|
||||
shadows: boolean;
|
||||
effects: boolean;
|
||||
quality: SceneQuality;
|
||||
|
||||
Reference in New Issue
Block a user