diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b79b71f1..4be0b2c7b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## Unreleased
+
+### Features
+
+* Add `Highlighter.isolation` for selection isolation with adjustable context opacity, streamed-material support, and restoration of original material settings.
+
## [3.4.0](https://github.com/ThatOpen/engine_components/compare/v3.3.2...v3.4.0) (2026-04-09)
diff --git a/package.json b/package.json
index 8d162e953..c4528567c 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"Pablo Aguilar (https://github.com/pabloaguilarv"
],
"scripts": {
+ "test:isolation": "node tests/run-isolation.mjs",
"dev": "vite --host",
"build-core": "yarn workspace @thatopen/components build",
"build-front": "yarn workspace @thatopen/components-front build",
diff --git a/packages/front/src/fragments/Highlighter/examples/ContextIsolation/example.html b/packages/front/src/fragments/Highlighter/examples/ContextIsolation/example.html
new file mode 100644
index 000000000..41348be71
--- /dev/null
+++ b/packages/front/src/fragments/Highlighter/examples/ContextIsolation/example.html
@@ -0,0 +1,88 @@
+
+
+
+
+
+ Selection context opacity
+
+
+
+
+
+
+
+
diff --git a/packages/front/src/fragments/Highlighter/examples/ContextIsolation/example.ts b/packages/front/src/fragments/Highlighter/examples/ContextIsolation/example.ts
new file mode 100644
index 000000000..0edc27260
--- /dev/null
+++ b/packages/front/src/fragments/Highlighter/examples/ContextIsolation/example.ts
@@ -0,0 +1,105 @@
+/* MD
+ ## Selection with translucent context
+ Isolate highlighted items while retaining the surrounding model at a chosen opacity.
+ `highlighter.isolation.isolate(0.15)` shows context at 15% of its original opacity;
+ `setOpacity(0)` hides it and `reset()` restores original materials and shows all items.
+ Isolation follows the select style, including Ctrl-click changes. Clearing selection
+ exits isolation. Configure a non-null select material so selected items remain distinct.
+ Translucent isolation rejects a selection covered by a higher-priority style; clear
+ the overlapping items or adjust that style's priority explicitly before isolating.
+ If such a conflict appears while isolation is active, isolation resets and reports
+ the error. Style memberships and priorities are preserved.
+ Disposal cancels pending isolation updates and restores materials synchronously.
+ If retaining the scene, await `isolation.reset()` before disposing the Highlighter
+ to restore item visibility as well.
+ Call `isolation.refresh()` after loading more models while isolation is active.
+
+ The component owns item visibility while active; reset shows all items, as Hider does.
+ Positive slider changes modify existing graphics materials without enumerating model IDs.
+ Streamed materials inherit the same factor. Original glass transparency is multiplied,
+ not replaced. Independent styles retain their colors and participate in the context.
+*/
+import * as THREE from "three";
+import * as OBC from "@thatopen/components";
+import { Highlighter } from "../../index";
+
+const container = document.getElementById("container")!;
+const status = document.getElementById("status")!;
+const slider = document.getElementById("opacity") as HTMLInputElement;
+const value = document.getElementById("value")!;
+const components = new OBC.Components();
+const world = components
+ .get(OBC.Worlds)
+ .create();
+world.scene = new OBC.SimpleScene(components);
+world.scene.setup();
+world.scene.three.background = new THREE.Color("#171b22");
+world.renderer = new OBC.SimpleRenderer(components, container);
+world.camera = new OBC.OrthoPerspectiveCamera(components);
+await world.camera.controls.setLookAt(68, 23, -8.5, 21.5, -5.5, 23);
+components.init();
+const fragments = components.get(OBC.FragmentsManager);
+const highlighter = components.get(Highlighter);
+const report = (error: unknown) => {
+ status.textContent = String(error);
+};
+const update = () => fragments.core.update().catch(report);
+
+try {
+ const worker = await fetch(
+ "https://thatopen.github.io/engine_fragment/resources/worker.mjs",
+ );
+ if (!worker.ok) throw new Error(`Worker download failed: ${worker.status}`);
+ const workerUrl = URL.createObjectURL(
+ new Blob([await worker.text()], { type: "text/javascript" }),
+ );
+ fragments.init(workerUrl);
+ world.camera.controls.addEventListener("update", update);
+ fragments.list.onItemSet.add(({ value: model }) => {
+ model.useCamera(world.camera.three);
+ world.scene.three.add(model.object);
+ });
+ const file = await fetch(
+ "https://thatopen.github.io/engine_components/resources/frags/school_arq.frag",
+ );
+ if (!file.ok) throw new Error(`Model download failed: ${file.status}`);
+ const model = await fragments.core.load(await file.arrayBuffer(), {
+ modelId: "school",
+ });
+ highlighter.setup({ world });
+ await fragments.core.update(true);
+ const ids = await model.getItemsIdsWithGeometry();
+ const run = async (operation: () => Promise) => {
+ try {
+ await operation();
+ status.textContent = highlighter.isolation.active
+ ? `Isolated · context ${Math.round(highlighter.isolation.opacity * 100)}%`
+ : "All items visible";
+ } catch (error) {
+ report(error);
+ }
+ };
+ document.getElementById("sample")!.onclick = () =>
+ run(() =>
+ highlighter.highlightByID("select", { school: new Set(ids.slice(0, 3)) }),
+ );
+ document.getElementById("isolate")!.onclick = () =>
+ run(() => highlighter.isolation.isolate(Number(slider.value) / 100));
+ document.getElementById("reset")!.onclick = () =>
+ run(() => highlighter.isolation.reset());
+ document.getElementById("clear")!.onclick = () =>
+ run(() => highlighter.clear("select"));
+ slider.oninput = () => {
+ value.textContent = `${slider.value}%`;
+ void run(() =>
+ highlighter.isolation.setOpacity(Number(slider.value) / 100),
+ );
+ };
+ for (const control of document.querySelectorAll<
+ HTMLButtonElement | HTMLInputElement
+ >("button, input"))
+ control.disabled = false;
+ status.textContent = `Ready · ${ids.length} geometric items`;
+} catch (error) {
+ report(error);
+}
diff --git a/packages/front/src/fragments/Highlighter/index.ts b/packages/front/src/fragments/Highlighter/index.ts
index 12a244fa5..3d0872c5b 100644
--- a/packages/front/src/fragments/Highlighter/index.ts
+++ b/packages/front/src/fragments/Highlighter/index.ts
@@ -4,6 +4,7 @@ import * as OBC from "@thatopen/components";
import { DataMap } from "@thatopen/fragments";
import * as FRAGS from "@thatopen/fragments";
import { HighlighterConfig, HighlightEvents, HighlightStyle } from "./src";
+import { HighlighterIsolation } from "./src/isolation";
/**
* This component allows highlighting and selecting fragments in a 3D scene. 📕 [Tutorial](https://docs.thatopen.com/Tutorials/Components/Front/Highlighter). 📘 [API](https://docs.thatopen.com/api/@thatopen/components-front/classes/Highlighter).
@@ -36,6 +37,9 @@ export class Highlighter
/** {@link OBC.Component.enabled} */
enabled = true;
+ /** Isolates the select style with adjustable context opacity from 0 (hidden) to 1. */
+ readonly isolation: HighlighterIsolation;
+
/** Stores the events triggered by the Highlighter. */
events: HighlightEvents = {};
@@ -113,6 +117,12 @@ export class Highlighter
constructor(components: OBC.Components) {
super(components);
+ this.isolation = new HighlighterIsolation(
+ () => this.components.get(OBC.FragmentsManager).core,
+ () => this.selection[this.config.selectName] ?? {},
+ () => this.styles.get(this.config.selectName) ? this.config.selectName : null,
+ (selection) => this.validateIsolationSelection(selection),
+ );
this.components.add(Highlighter.uuid, this);
this.eventManager.list.add(this.onSetup);
this.eventManager.list.add(this.onDisposed);
@@ -147,6 +157,7 @@ export class Highlighter
/** {@link Disposable.dispose} */
async dispose() {
+ this.isolation.dispose();
this.setupEvents(false);
this.onBeforeUpdate.reset();
this.onAfterUpdate.reset();
@@ -402,6 +413,11 @@ export class Highlighter
// priority is ours, not part of the material definition fragments expects.
const { priority: _priority, ...material } = definition;
+ // Keep the rendered style identity so selection stays opaque while context fades.
+ if (material.preserveOriginalMaterial) {
+ material._explicitProps = [...new Set([...(material._explicitProps ?? []), "customId"])];
+ }
+
promises.push(
fragments.highlight({ ...material, customId: style }, map),
);
@@ -411,6 +427,7 @@ export class Highlighter
promises.push(fragments.core.update(true));
}
await Promise.allSettled(promises);
+ await this.isolation.refresh();
}
/**
@@ -427,6 +444,24 @@ export class Highlighter
});
}
+ private validateIsolationSelection(selection: OBC.ModelIdMap) {
+ for (const style of this.getStylesByPriority()) {
+ if (style === this.config.selectName) return;
+ if (!this.styles.get(style)) continue;
+ for (const [modelId, ids] of Object.entries(selection)) {
+ const other = this.selection[style]?.[modelId];
+ if (!other) continue;
+ const smaller = ids.size < other.size ? ids : other;
+ const larger = smaller === ids ? other : ids;
+ for (const id of smaller) {
+ if (larger.has(id)) {
+ throw new Error(`Cannot fade context: style "${style}" has priority over selected items. Clear its overlapping items or change its priority first.`);
+ }
+ }
+ }
+ }
+ }
+
private getPriority(style: string) {
const priority = this.styles.get(style)?.priority;
if (priority !== undefined) return priority;
diff --git a/packages/front/src/fragments/Highlighter/src/index.ts b/packages/front/src/fragments/Highlighter/src/index.ts
index eea524d65..68ff41734 100644
--- a/packages/front/src/fragments/Highlighter/src/index.ts
+++ b/packages/front/src/fragments/Highlighter/src/index.ts
@@ -1 +1,2 @@
export * from "./types";
+export * from "./isolation";
diff --git a/packages/front/src/fragments/Highlighter/src/isolation-materials.ts b/packages/front/src/fragments/Highlighter/src/isolation-materials.ts
new file mode 100644
index 000000000..ba5f3370f
--- /dev/null
+++ b/packages/front/src/fragments/Highlighter/src/isolation-materials.ts
@@ -0,0 +1,47 @@
+import type { FragmentsModels } from "@thatopen/fragments";
+import type { Material } from "three";
+
+type Materials = FragmentsModels["models"]["materials"]["list"];
+type Original = Pick;
+
+export class IsolationMaterials {
+ private originals = new WeakMap();
+ private opacity = 1;
+
+ constructor(
+ private materials: Materials,
+ private getStyle: () => string | null,
+ ) {}
+
+ setOpacity(opacity: number) {
+ this.opacity = opacity;
+ this.materials.onItemSet.remove(this.update);
+ if (opacity < 1) this.materials.onItemSet.add(this.update);
+ for (const value of this.materials.values()) this.update({ value });
+ }
+
+ dispose() {
+ this.setOpacity(1);
+ }
+
+ private update = ({ value }: { value: Material }) => {
+ const opacity =
+ value.userData.customId === this.getStyle() ? 1 : this.opacity;
+ let original = this.originals.get(value);
+ if (!original) {
+ if (opacity === 1) return;
+ original = {
+ opacity: value.opacity,
+ transparent: value.transparent,
+ depthWrite: value.depthWrite,
+ };
+ this.originals.set(value, original);
+ }
+ const transparent = opacity < 1 || original.transparent;
+ if (value.transparent !== transparent) value.needsUpdate = true;
+ value.opacity = original.opacity * opacity;
+ value.transparent = transparent;
+ value.depthWrite = opacity < 1 ? false : original.depthWrite;
+ if (opacity === 1) this.originals.delete(value);
+ };
+}
diff --git a/packages/front/src/fragments/Highlighter/src/isolation.ts b/packages/front/src/fragments/Highlighter/src/isolation.ts
new file mode 100644
index 000000000..18fd092ca
--- /dev/null
+++ b/packages/front/src/fragments/Highlighter/src/isolation.ts
@@ -0,0 +1,155 @@
+import type { FragmentsModels } from "@thatopen/fragments";
+import type { ModelIdMap } from "@thatopen/components";
+import { IsolationMaterials } from "./isolation-materials";
+
+type State = { selection: ModelIdMap | null; opacity: number };
+
+/** Isolates the active selection with an adjustable, streamed material context. */
+export class HighlighterIsolation {
+ private desired: State = { selection: null, opacity: 1 };
+ private applied: State | null = null;
+ private running: Promise | null = null;
+ private materials: IsolationMaterials | null = null;
+ private core: FragmentsModels | null = null;
+ private disposed = false;
+
+ /** Whether a non-empty selection is currently isolated. */
+ get active() {
+ return this.desired.selection !== null;
+ }
+
+ /** Context opacity in the range 0–1; zero hides context geometry. */
+ get opacity() {
+ return this.desired.opacity;
+ }
+
+ constructor(
+ private getCore: () => FragmentsModels,
+ private getSelection: () => ModelIdMap,
+ private getStyle: () => string | null,
+ private validateSelection: (selection: ModelIdMap) => void = () => {},
+ ) {}
+
+ /** Isolates the selection; resetting or clearing it shows all loaded items. */
+ async isolate(opacity = 0) {
+ this.validate(opacity);
+ const source = this.getSelection();
+ const selection: ModelIdMap = {};
+ for (const [modelId, ids] of Object.entries(source)) {
+ if (ids.size) selection[modelId] = new Set(ids);
+ }
+ if (!Object.keys(selection).length) {
+ await this.reset();
+ return;
+ }
+ this.core ??= this.getCore();
+ this.validateStyle(opacity);
+ if (opacity > 0) this.validateSelection(selection);
+ for (const modelId of Object.keys(selection)) {
+ if (!this.core.models.list.has(modelId))
+ throw new Error(`Unknown isolation model: ${modelId}`);
+ }
+ this.desired = { selection, opacity };
+ await this.schedule();
+ }
+
+ /** Changes context opacity without enumerating model items or creating worker materials. */
+ async setOpacity(opacity: number) {
+ this.validate(opacity);
+ if (!this.active) return;
+ this.validateStyle(opacity);
+ if (this.opacity === 0 && opacity > 0)
+ this.validateSelection(this.desired.selection!);
+ this.desired = { ...this.desired, opacity };
+ await this.schedule();
+ }
+
+ /** Refreshes isolation after selection changes or loading another model. */
+ async refresh() {
+ if (!this.active) return;
+ try {
+ await this.isolate(this.opacity);
+ } catch (error) {
+ // An invalid selection/style must not leave selected items faded.
+ await this.reset();
+ throw error;
+ }
+ }
+
+ /** Restores original material properties and shows all loaded items. */
+ async reset() {
+ if (this.disposed || (!this.active && !this.running)) return;
+ this.desired = { selection: null, opacity: 1 };
+ await this.schedule();
+ }
+
+ /** Cancels pending updates and restores materials synchronously; call reset first to restore visibility in a retained scene. */
+ dispose() {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.desired = { selection: null, opacity: 1 };
+ this.materials?.dispose();
+ this.materials = null;
+ this.core = null;
+ }
+
+ private validate(opacity: number) {
+ if (this.disposed) throw new Error("Isolation has been disposed.");
+ if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) {
+ throw new Error("Context opacity must be between 0 and 1.");
+ }
+ }
+
+ private validateStyle(opacity: number) {
+ if (opacity > 0 && this.getStyle() === null) {
+ throw new Error(
+ "A non-null select material is required for translucent context.",
+ );
+ }
+ }
+
+ private schedule() {
+ if (!this.running) {
+ this.running = Promise.resolve().then(async () => {
+ try {
+ while (!this.disposed && this.applied !== this.desired)
+ await this.apply(this.desired);
+ } catch (error) {
+ this.applied = null;
+ throw error;
+ } finally {
+ // Clear within the drain: a request arriving as its promise settles
+ // must start a new drain instead of joining an already-finished one.
+ this.running = null;
+ }
+ });
+ }
+ return this.running;
+ }
+
+ private async apply(state: State) {
+ const core = this.core!;
+ const hidden = state.selection !== null && state.opacity === 0;
+ const wasHidden =
+ this.applied?.selection !== null && this.applied?.opacity === 0;
+ // Positive opacity changes touch only GPU materials: O(materials), no IFC ID scan.
+ if (!this.applied || hidden || wasHidden || !state.selection) {
+ for (const [modelId, model] of core.models.list) {
+ if (this.disposed) return;
+ await model.setVisible(undefined, !hidden);
+ if (this.disposed) return;
+ const ids = state.selection?.[modelId];
+ if (hidden && ids?.size) await model.setVisible([...ids], true);
+ }
+ if (this.disposed) return;
+ if (core.models.list.size) await core.update(true);
+ }
+ if (this.disposed) return;
+ this.materials ??= new IsolationMaterials(
+ core.models.materials.list,
+ this.getStyle,
+ );
+ this.materials.setOpacity(state.selection && !hidden ? state.opacity : 1);
+ this.applied = state;
+ }
+}
diff --git a/tests/highlighter-isolation.test.ts b/tests/highlighter-isolation.test.ts
new file mode 100644
index 000000000..f9fd38c8f
--- /dev/null
+++ b/tests/highlighter-isolation.test.ts
@@ -0,0 +1,268 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { DataMap, type FragmentsModels } from "@thatopen/fragments";
+import { MeshLambertMaterial } from "three";
+import { HighlighterIsolation } from "../packages/front/src/fragments/Highlighter/src/isolation";
+import { Highlighter } from "../packages/front/src/fragments/Highlighter";
+import * as OBC from "@thatopen/components";
+import { Color } from "three";
+
+function harness() {
+ const visibility: unknown[] = [];
+ const materials = new DataMap();
+ const models = new DataMap();
+ models.set("a", {
+ setVisible: async (...args: unknown[]) => {
+ visibility.push(["a", ...args]);
+ },
+ });
+ models.set("b", {
+ setVisible: async (...args: unknown[]) => {
+ visibility.push(["b", ...args]);
+ },
+ });
+ let selection = { a: new Set([7]) } as Record>;
+ let updates = 0;
+ const core = {
+ models: { list: models, materials: { list: materials } },
+ update: async () => {
+ updates++;
+ },
+ };
+ const isolation = new HighlighterIsolation(
+ () => core as unknown as FragmentsModels,
+ () => selection,
+ () => "select",
+ );
+ return {
+ isolation,
+ materials,
+ models,
+ visibility,
+ updates: () => updates,
+ select: (map: Record>) => {
+ selection = map;
+ },
+ };
+}
+
+test("fades original and newly streamed context materials, preserves selection, and restores originals", async () => {
+ const h = harness();
+ const glass = new MeshLambertMaterial({
+ opacity: 0.4,
+ transparent: true,
+ depthWrite: true,
+ });
+ const selected = new MeshLambertMaterial({
+ userData: { customId: "select" },
+ });
+ h.materials.set(1, glass);
+ h.materials.set(2, selected);
+ await h.isolation.isolate(0.25);
+ assert.equal(glass.opacity, 0.1);
+ assert.equal(glass.depthWrite, false);
+ assert.equal(selected.opacity, 1);
+ const streamed = new MeshLambertMaterial();
+ h.materials.set(3, streamed);
+ assert.equal(streamed.opacity, 0.25);
+ await h.isolation.reset();
+ assert.equal(glass.opacity, 0.4);
+ assert.equal(glass.transparent, true);
+ assert.equal(glass.depthWrite, true);
+ assert.equal(streamed.opacity, 1);
+ assert.equal(streamed.transparent, false);
+});
+
+test("zero hides context in every model and selection changes follow the active isolation", async () => {
+ const h = harness();
+ await h.isolation.isolate(0);
+ assert.deepEqual(h.visibility, [
+ ["a", undefined, false],
+ ["a", [7], true],
+ ["b", undefined, false],
+ ]);
+ h.select({ b: new Set([9]) });
+ await h.isolation.refresh();
+ assert.deepEqual(h.visibility.slice(-3), [
+ ["a", undefined, false],
+ ["b", undefined, false],
+ ["b", [9], true],
+ ]);
+ h.select({});
+ await h.isolation.refresh();
+ assert.equal(h.isolation.active, false);
+ assert.deepEqual(h.visibility.slice(-2), [
+ ["a", undefined, true],
+ ["b", undefined, true],
+ ]);
+});
+
+test("positive slider changes require no item lookup, visibility RPCs or additional materials", async () => {
+ const h = harness();
+ h.materials.set(1, new MeshLambertMaterial());
+ await h.isolation.isolate(0.1);
+ const calls = h.visibility.length;
+ for (let i = 1; i <= 100; i++) await h.isolation.setOpacity(i / 100);
+ assert.equal(h.visibility.length, calls);
+ assert.equal(h.materials.size, 1);
+ assert.equal(h.materials.get(1)!.opacity, 1);
+});
+
+test("invalid values do not modify visibility or materials", async () => {
+ const h = harness();
+ for (const value of [NaN, Infinity, -0.1, 1.1])
+ await assert.rejects(h.isolation.isolate(value));
+ assert.deepEqual(h.visibility, []);
+});
+
+test("concurrent slider updates settle at the latest value and reset wins", async () => {
+ const h = harness();
+ const mat = new MeshLambertMaterial();
+ h.materials.set(1, mat);
+ await Promise.all([
+ h.isolation.isolate(0),
+ h.isolation.setOpacity(0.2),
+ h.isolation.setOpacity(0.7),
+ ]);
+ assert.equal(mat.opacity, 0.7);
+ await Promise.all([h.isolation.setOpacity(0), h.isolation.reset()]);
+ assert.equal(mat.opacity, 1);
+ assert.equal(h.isolation.active, false);
+ assert.deepEqual(h.visibility.slice(-2), [
+ ["a", undefined, true],
+ ["b", undefined, true],
+ ]);
+});
+
+test("disposing restores materials and stops streaming effects, including after model removal", async () => {
+ const h = harness();
+ const mat = new MeshLambertMaterial();
+ h.materials.set(1, mat);
+ await h.isolation.isolate(0.2);
+ h.models.clear();
+ await h.isolation.dispose();
+ assert.equal(mat.opacity, 1);
+ const late = new MeshLambertMaterial();
+ h.materials.set(2, late);
+ assert.equal(late.opacity, 1);
+ await assert.rejects(h.isolation.isolate(0.3), /disposed/);
+});
+
+test("a slider change arriving as the previous update completes is not lost", async () => {
+ const h = harness();
+ const mat = new MeshLambertMaterial();
+ h.materials.set(1, mat);
+ await h.isolation.isolate(0.1);
+ const first = h.isolation.setOpacity(0.2);
+ const last = (async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await h.isolation.setOpacity(0.7);
+ })();
+ await Promise.all([first, last]);
+ assert.equal(h.isolation.opacity, 0.7);
+ assert.equal(mat.opacity, 0.7);
+});
+
+test("a worker rejection remains visible and reset can recover", async () => {
+ const h = harness();
+ const model = h.models.get("a");
+ const setVisible = model.setVisible;
+ model.setVisible = async () => {
+ throw new Error("Worker disconnected");
+ };
+ await assert.rejects(h.isolation.isolate(0), /Worker disconnected/);
+ model.setVisible = setVisible;
+ await h.isolation.reset();
+ assert.equal(h.isolation.active, false);
+});
+
+test("disposal takes effect synchronously and cancels an in-flight visibility update", async () => {
+ const h = harness();
+ const material = new MeshLambertMaterial();
+ h.materials.set(1, material);
+ await h.isolation.isolate(0.25);
+ let release!: () => void;
+ let started!: () => void;
+ const inFlight = new Promise((resolve) => {
+ started = resolve;
+ });
+ h.models.get("a").setVisible = async () => {
+ started();
+ await new Promise((resolve) => {
+ release = resolve;
+ });
+ };
+ const pending = h.isolation.setOpacity(0);
+ await inFlight;
+ const disposal = h.isolation.dispose();
+ assert.equal(material.opacity, 1);
+ await assert.rejects(h.isolation.isolate(0.7), /disposed/);
+ const calls = h.visibility.length;
+ release();
+ await pending;
+ await disposal;
+ assert.equal(h.visibility.length, calls);
+ const streamed = new MeshLambertMaterial();
+ h.materials.set(2, streamed);
+ assert.equal(streamed.opacity, 1);
+});
+
+test("Highlighter preserves style metadata and selection-driven isolation through its public API", async () => {
+ const components = new OBC.Components();
+ const manager = components.get(OBC.FragmentsManager);
+ const h = harness();
+ const definitions: any[] = [];
+ for (const model of h.models.values()) {
+ model.highlight = async (_ids: number[], definition: unknown) => {
+ definitions.push(definition);
+ };
+ model.resetHighlight = async () => {};
+ }
+ const core = {
+ models: { list: h.models, materials: { list: h.materials } },
+ update: async () => {},
+ };
+ Object.defineProperty(manager, "core", { get: () => core });
+ const highlighter = new Highlighter(components);
+ highlighter.styles.set("select", {
+ color: new Color("yellow"),
+ opacity: 1,
+ transparent: false,
+ renderedFaces: 0,
+ preserveOriginalMaterial: true,
+ _explicitProps: ["color"],
+ });
+ await highlighter.highlightByID("select", { a: new Set([7]) });
+ assert.ok(definitions.at(-1)._explicitProps.includes("customId"));
+ assert.equal(definitions.at(-1).customId, "select");
+ await highlighter.isolation.isolate(0);
+ await highlighter.highlightByID("select", { b: new Set([9]) });
+ assert.deepEqual(h.visibility.slice(-3), [
+ ["a", undefined, false],
+ ["b", undefined, false],
+ ["b", [9], true],
+ ]);
+ await highlighter.clear("select");
+ assert.equal(highlighter.isolation.active, false);
+ const selectStyle = highlighter.styles.get("select")!;
+ highlighter.styles.set("override", { ...selectStyle, priority: Infinity });
+ await highlighter.highlightByID("select", { a: new Set([7]) });
+ await highlighter.highlightByID("override", { a: new Set([7]) });
+ await assert.rejects(highlighter.isolation.isolate(0.2), /priority/);
+ assert.deepEqual([...highlighter.selection.override.a], [7]);
+ await highlighter.highlightByID("override", { b: new Set([9]) });
+ await highlighter.isolation.isolate(0.2);
+ await assert.rejects(
+ highlighter.highlightByID("override", { a: new Set([7]) }),
+ /priority/,
+ );
+ assert.equal(highlighter.isolation.active, false);
+ await highlighter.isolation.reset();
+ highlighter.styles.set("select", null);
+ await highlighter.highlightByID("select", { a: new Set([7]) });
+ await assert.rejects(
+ highlighter.isolation.isolate(0.2),
+ /non-null select material/,
+ );
+});
diff --git a/tests/run-isolation.mjs b/tests/run-isolation.mjs
new file mode 100644
index 000000000..cb667fd5b
--- /dev/null
+++ b/tests/run-isolation.mjs
@@ -0,0 +1,31 @@
+import { build } from "esbuild";
+import { spawnSync } from "node:child_process";
+import { mkdir, rm } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+
+const root = fileURLToPath(new URL("../", import.meta.url));
+const output = new URL(
+ "../node_modules/.cache/isolation-tests/",
+ import.meta.url,
+);
+await mkdir(output, { recursive: true });
+const filename = fileURLToPath(new URL("test.mjs", output));
+try {
+ await build({
+ absWorkingDir: root,
+ entryPoints: ["tests/highlighter-isolation.test.ts"],
+ bundle: true,
+ platform: "node",
+ format: "esm",
+ external: ["three", "@thatopen/*"],
+ outfile: filename,
+ });
+ const result = spawnSync(process.execPath, ["--test", filename], {
+ stdio: "inherit",
+ timeout: 30000,
+ });
+ if (result.error) throw result.error;
+ process.exitCode = result.status ?? 1;
+} finally {
+ await rm(filename, { force: true });
+}