From 1f4a1320f11b341e0f1e791db837c3b90810edd8 Mon Sep 17 00:00:00 2001 From: maxkrut Date: Thu, 10 Sep 2026 00:35:35 +0300 Subject: [PATCH] feat: isolate highlighted selection with adjustable context opacity --- CHANGELOG.md | 6 + package.json | 1 + .../examples/ContextIsolation/example.html | 88 ++++++ .../examples/ContextIsolation/example.ts | 105 +++++++ .../front/src/fragments/Highlighter/index.ts | 35 +++ .../src/fragments/Highlighter/src/index.ts | 1 + .../Highlighter/src/isolation-materials.ts | 47 +++ .../fragments/Highlighter/src/isolation.ts | 155 ++++++++++ tests/highlighter-isolation.test.ts | 268 ++++++++++++++++++ tests/run-isolation.mjs | 31 ++ 10 files changed, 737 insertions(+) create mode 100644 packages/front/src/fragments/Highlighter/examples/ContextIsolation/example.html create mode 100644 packages/front/src/fragments/Highlighter/examples/ContextIsolation/example.ts create mode 100644 packages/front/src/fragments/Highlighter/src/isolation-materials.ts create mode 100644 packages/front/src/fragments/Highlighter/src/isolation.ts create mode 100644 tests/highlighter-isolation.test.ts create mode 100644 tests/run-isolation.mjs 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 }); +}